Effect Курс Горизонтальная композиция

Горизонтальная композиция

Объединение нескольких независимых Layer.

Теория

Горизонтальная vs вертикальная композиция

В предыдущей статье мы изучили вертикальную композицию (provide), где выход одного Layer подключается ко входу другого. Горизонтальная композиция (merge) работает иначе — она объединяет два независимых Layer в один, который предоставляет оба набора сервисов:

      Вертикальная (provide)           Горизонтальная (merge)
      ──────────────────────           ──────────────────────

         ┌──────────┐                  ┌──────────┐  ┌──────────┐
         │ Logger   │                  │  Config  │  │  Logger  │
         │ needs:   │                  │  (ROut)  │  │  (ROut)  │
         │  Config  │                  └──────────┘  └──────────┘
         └────┬─────┘                        │            │
              │                              └─────┬──────┘
         ┌────▼─────┐                         merge│
         │  Config  │                     ┌────────▼────────┐
         │ (source) │                     │ Config | Logger  │
         └──────────┘                     │    (combined)    │
                                          └─────────────────┘
      "Последовательно"                   "Параллельно"

Когда использовать merge

Merge используется когда:

  • Нужно объединить несколько независимых сервисов в один Layer
  • Несколько Layer имеют общие зависимости, но не зависят друг от друга
  • Нужно собрать «пакет» сервисов одного архитектурного слоя
  • Финальная сборка MainLive из нескольких разрешённых Layer

Концепция ФП

Merge как Product в категории

С точки зрения теории категорий, Layer.merge создаёт произведение (product) двух Layer:

Layer<A, E1, R1>  ×  Layer<B, E2, R2>  =  Layer<A | B, E1 | E2, R1 | R2>

Это аналог произведения типов (A & B или [A, B]), но на уровне контекстов:

  • Выходы объединяются: A | B (оба сервиса доступны)
  • Ошибки объединяются: E1 | E2 (любая из ошибок может возникнуть)
  • Зависимости объединяются: R1 | R2 (нужны все зависимости)

Ассоциативность и коммутативность

Layer.merge обладает важными алгебраическими свойствами:

Ассоциативность:
  merge(merge(A, B), C) ≡ merge(A, merge(B, C))

Коммутативность (по выходу):
  merge(A, B) производит A | B
  merge(B, A) производит B | A
  // A | B ≡ B | A в TypeScript, так что результат эквивалентен

Идентичность:
  merge(A, Layer.empty) ≡ A

Эти свойства гарантируют, что порядок объединения не влияет на результат.


Layer.merge

Сигнатура

function merge<ROut, E, RIn, ROut2, E2, RIn2>(
  self: Layer<ROut, E, RIn>,
  that: Layer<ROut2, E2, RIn2>
): Layer<ROut | ROut2, E | E2, RIn | RIn2>

Разбор типов:

self: Layer<ROut, E, RIn>       — первый Layer
that: Layer<ROut2, E2, RIn2>    — второй Layer

Результат:
  ROut  = ROut | ROut2      (объединение выходов)
  E     = E | E2            (объединение ошибок)
  RIn   = RIn | RIn2        (объединение зависимостей)

Базовые примеры


class Config extends Context.Tag("Config")<
  Config,
  { readonly port: number }
>() {}

class Logger extends Context.Tag("Logger")<
  Logger,
  { readonly log: (msg: string) => Effect.Effect<void> }
>() {}

class Metrics extends Context.Tag("Metrics")<
  Metrics,
  { readonly track: (event: string) => Effect.Effect<void> }
>() {}

// Три независимых Layer
const ConfigLive = Layer.succeed(Config, { port: 3000 })
const LoggerLive = Layer.succeed(Logger, { log: (msg) => Effect.log(msg) })
const MetricsLive = Layer.succeed(Metrics, { track: (event) => Effect.log(`[METRIC] ${event}`) })

// Merge двух Layer
const ConfigAndLogger = Layer.merge(ConfigLive, LoggerLive)
// Тип: Layer<Config | Logger, never, never>

// Программа может использовать оба сервиса
const program = Effect.gen(function* () {
  const config = yield* Config
  const logger = yield* Logger
  yield* logger.log(`Port: ${config.port}`)
})

Effect.runPromise(Effect.provide(program, ConfigAndLogger))

Merge Layer с зависимостями

Когда оба Layer имеют зависимости, они объединяются:


class Config extends Context.Tag("Config")<Config, { readonly env: string }>() {}
class Logger extends Context.Tag("Logger")<Logger, { readonly log: (msg: string) => Effect.Effect<void> }>() {}
class Database extends Context.Tag("Database")<Database, { readonly query: (sql: string) => Effect.Effect<unknown> }>() {}
class Cache extends Context.Tag("Cache")<Cache, { readonly get: (key: string) => Effect.Effect<string | null> }>() {}

// Logger зависит от Config
const LoggerLive: Layer.Layer<Logger, never, Config> = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    return { log: (msg) => Effect.log(`[${config.env}] ${msg}`) }
  })
)

// Cache зависит от Config
const CacheLive: Layer.Layer<Cache, never, Config> = Layer.effect(
  Cache,
  Effect.gen(function* () {
    const config = yield* Config
    return { get: (_key) => Effect.succeed(null) }
  })
)

// Merge: зависимости объединяются
const LoggerAndCache = Layer.merge(LoggerLive, CacheLive)
// Тип: Layer<Logger | Cache, never, Config>
//                                    ↑ общая зависимость

Merge с разными зависимостями


class Config extends Context.Tag("Config")<Config, { readonly url: string }>() {}
class Credentials extends Context.Tag("Credentials")<Credentials, { readonly token: string }>() {}
class ApiClient extends Context.Tag("ApiClient")<ApiClient, { readonly call: (endpoint: string) => Effect.Effect<unknown> }>() {}
class AuthClient extends Context.Tag("AuthClient")<AuthClient, { readonly verify: (token: string) => Effect.Effect<boolean> }>() {}

// ApiClient зависит от Config
const ApiClientLive: Layer.Layer<ApiClient, never, Config> = Layer.effect(
  ApiClient,
  Effect.gen(function* () {
    const config = yield* Config
    return { call: (endpoint) => Effect.succeed({ url: config.url, endpoint }) }
  })
)

// AuthClient зависит от Credentials
const AuthClientLive: Layer.Layer<AuthClient, never, Credentials> = Layer.effect(
  AuthClient,
  Effect.gen(function* () {
    const creds = yield* Credentials
    return { verify: (_token) => Effect.succeed(creds.token.length > 0) }
  })
)

// Merge: разные зависимости
const ClientsLive = Layer.merge(ApiClientLive, AuthClientLive)
// Тип: Layer<ApiClient | AuthClient, never, Config | Credentials>
//                                            ↑ ОБЕ зависимости

Pipe-стиль

// Функциональный стиль
const combined1 = Layer.merge(ConfigLive, LoggerLive)

// Pipe-стиль
const combined2 = ConfigLive.pipe(Layer.merge(LoggerLive))

// Цепочка merge через pipe
const combined3 = ConfigLive.pipe(
  Layer.merge(LoggerLive),
  Layer.merge(MetricsLive)
)
// Тип: Layer<Config | Logger | Metrics, never, never>

Layer.mergeAll

Сигнатура

function mergeAll<Layers extends ReadonlyArray<Layer.Layer<any, any, any>>>(
  ...layers: Layers
): Layer</* union of all ROut */, /* union of all E */, /* union of all RIn */>

mergeAll — variadic версия merge. Принимает произвольное количество Layer и объединяет их все в один.

Базовый пример


class Config extends Context.Tag("Config")<Config, { readonly port: number }>() {}
class Logger extends Context.Tag("Logger")<Logger, { readonly log: (msg: string) => Effect.Effect<void> }>() {}
class Metrics extends Context.Tag("Metrics")<Metrics, { readonly track: (event: string) => Effect.Effect<void> }>() {}
class Tracer extends Context.Tag("Tracer")<Tracer, { readonly span: (name: string) => Effect.Effect<void> }>() {}

const ConfigLive = Layer.succeed(Config, { port: 3000 })
const LoggerLive = Layer.succeed(Logger, { log: (msg) => Effect.log(msg) })
const MetricsLive = Layer.succeed(Metrics, { track: (event) => Effect.log(`[METRIC] ${event}`) })
const TracerLive = Layer.succeed(Tracer, { span: (name) => Effect.log(`[SPAN] ${name}`) })

// mergeAll вместо цепочки merge
const AllServices = Layer.mergeAll(ConfigLive, LoggerLive, MetricsLive, TracerLive)
// Тип: Layer<Config | Logger | Metrics | Tracer, never, never>

// Эквивалент цепочки merge:
const AllServices2 = ConfigLive.pipe(
  Layer.merge(LoggerLive),
  Layer.merge(MetricsLive),
  Layer.merge(TracerLive)
)

mergeAll с зависимостями


class Config extends Context.Tag("Config")<Config, { readonly env: string }>() {}
class S3 extends Context.Tag("S3")<S3, { readonly upload: (key: string) => Effect.Effect<void> }>() {}
class ElasticSearch extends Context.Tag("ElasticSearch")<ElasticSearch, { readonly index: (doc: unknown) => Effect.Effect<void> }>() {}
class Database extends Context.Tag("Database")<Database, { readonly query: (sql: string) => Effect.Effect<unknown> }>() {}

class S3Error extends Data.TaggedError("S3Error")<{ readonly key: string }> {}
class EsError extends Data.TaggedError("EsError")<{ readonly index: string }> {}
class DbError extends Data.TaggedError("DbError")<{ readonly sql: string }> {}

// Все три зависят от Config, каждый со своей ошибкой
const S3Live: Layer.Layer<S3, S3Error, Config> = Layer.effect(S3, Effect.gen(function* () {
  yield* Config
  return { upload: (key) => Effect.log(`[S3] Upload: ${key}`) }
}))

const ElasticSearchLive: Layer.Layer<ElasticSearch, EsError, Config> = Layer.effect(ElasticSearch, Effect.gen(function* () {
  yield* Config
  return { index: (doc) => Effect.log(`[ES] Index: ${JSON.stringify(doc)}`) }
}))

const DatabaseLive: Layer.Layer<Database, DbError, Config> = Layer.effect(Database, Effect.gen(function* () {
  yield* Config
  return { query: (sql) => Effect.succeed({ result: sql }) }
}))

// mergeAll объединяет всё
const InfrastructureLive = Layer.mergeAll(S3Live, ElasticSearchLive, DatabaseLive)
// Тип: Layer<S3 | ElasticSearch | Database, S3Error | EsError | DbError, Config>
//      ↑ все три выхода                     ↑ все три ошибки          ↑ общая зависимость

Поведение типов при merge

Автоматическая дедупликация зависимостей

TypeScript’s union type автоматически дедуплицирует одинаковые зависимости:


declare const layer1: Layer.Layer<"A", never, "Config" | "Logger">
declare const layer2: Layer.Layer<"B", never, "Config" | "Metrics">

const merged = Layer.merge(layer1, layer2)
// Тип: Layer<"A" | "B", never, "Config" | "Logger" | "Metrics">
//                                ↑ Config не дублируется

Перекрытие выходов

Если два Layer производят одинаковый сервис, последний «выигрывает» (правый приоритет):


class Config extends Context.Tag("Config")<
  Config,
  { readonly source: string }
>() {}

const ConfigFromFile = Layer.succeed(Config, { source: "file" })
const ConfigFromEnv = Layer.succeed(Config, { source: "env" })

// При merge двух Layer с одинаковым выходом — тип один
const merged = Layer.merge(ConfigFromFile, ConfigFromEnv)
// Тип: Layer<Config, never, never>

// ⚠️ Какой Config будет использован? Зависит от реализации.
// На практике лучше избегать таких ситуаций.

⚠️ Важно: избегайте merge Layer, которые производят один и тот же сервис. Это может привести к непредсказуемому поведению. Если нужно выбрать между реализациями, используйте Layer.orElse или Layer.catchAll.


Комбинирование с provide

Типичный паттерн: merge + provide

Самый распространённый паттерн — объединить несколько базовых Layer через merge, затем предоставить их как зависимости верхнеуровневому Layer:


class Config extends Context.Tag("Config")<Config, { readonly env: string }>() {}
class Logger extends Context.Tag("Logger")<Logger, { readonly log: (msg: string) => Effect.Effect<void> }>() {}
class Database extends Context.Tag("Database")<Database, { readonly query: (sql: string) => Effect.Effect<unknown> }>() {}

const ConfigLive = Layer.succeed(Config, { env: "production" })

const LoggerLive = Layer.effect(Logger, Effect.gen(function* () {
  const config = yield* Config
  return { log: (msg) => Effect.log(`[${config.env}] ${msg}`) }
}))

const DatabaseLive = Layer.effect(Database, Effect.gen(function* () {
  const config = yield* Config
  const logger = yield* Logger
  return {
    query: (sql) => Effect.gen(function* () {
      yield* logger.log(`SQL: ${sql}`)
      return { rows: [] }
    })
  }
}))

// Шаг 1: merge Config + Logger (после разрешения Config для Logger)
const BaseLive = Layer.merge(
  ConfigLive,
  LoggerLive.pipe(Layer.provide(ConfigLive))
)
// Тип: Layer<Config | Logger, never, never>

// Шаг 2: provide база для Database
const MainLive = DatabaseLive.pipe(
  Layer.provide(BaseLive)
)
// Тип: Layer<Database, never, never>

Паттерн: mergeAll для тестирования


class S3 extends Context.Tag("S3")<S3, { readonly createBucket: Effect.Effect<{ name: string }> }>() {}
class ElasticSearch extends Context.Tag("ElasticSearch")<ElasticSearch, { readonly createIndex: Effect.Effect<{ id: string }> }>() {}
class Database extends Context.Tag("Database")<Database, { readonly createEntry: (bucket: string, index: string) => Effect.Effect<{ id: string }> }>() {}

// Тестовые реализации
const S3Test = Layer.succeed(S3, {
  createBucket: Effect.succeed({ name: "test-bucket" })
})

const ElasticSearchTest = Layer.succeed(ElasticSearch, {
  createIndex: Effect.succeed({ id: "test-index" })
})

const DatabaseTest = Layer.succeed(Database, {
  createEntry: (bucket, index) => Effect.succeed({ id: `entry-${bucket}-${index}` })
})

// Все тестовые сервисы одной строкой
const TestLayer = Layer.mergeAll(S3Test, ElasticSearchTest, DatabaseTest)
// Тип: Layer<S3 | ElasticSearch | Database, never, never>

API Reference

Layer.merge [STABLE]

function merge<ROut, E, RIn, ROut2, E2, RIn2>(
  self: Layer<ROut, E, RIn>,
  that: Layer<ROut2, E2, RIn2>
): Layer<ROut | ROut2, E | E2, RIn | RIn2>

Объединяет два Layer. Результирующий Layer предоставляет сервисы обоих и требует зависимости обоих.

Layer.mergeAll [STABLE]

function mergeAll<Layers extends ReadonlyArray<Layer.Layer<any, any, any>>>(
  ...layers: Layers
): Layer</* union of all outputs */, /* union of all errors */, /* union of all inputs */>

Variadic merge — объединяет произвольное количество Layer.


Примеры

Пример: Сборка observability-стека


// === Observability Services ===

class Config extends Context.Tag("Config")<
  Config,
  {
    readonly serviceName: string
    readonly version: string
    readonly metricsPort: number
  }
>() {}

class Logger extends Context.Tag("Logger")<
  Logger,
  {
    readonly info: (msg: string) => Effect.Effect<void>
    readonly warn: (msg: string) => Effect.Effect<void>
    readonly error: (msg: string, err?: unknown) => Effect.Effect<void>
  }
>() {}

class Metrics extends Context.Tag("Metrics")<
  Metrics,
  {
    readonly counter: (name: string, labels?: Record<string, string>) => Effect.Effect<void>
    readonly histogram: (name: string, value: number) => Effect.Effect<void>
    readonly gauge: (name: string, value: number) => Effect.Effect<void>
  }
>() {}

class Tracer extends Context.Tag("Tracer")<
  Tracer,
  {
    readonly startSpan: (name: string) => Effect.Effect<{ readonly end: () => Effect.Effect<void> }>
    readonly addAttribute: (key: string, value: string) => Effect.Effect<void>
  }
>() {}

class HealthCheck extends Context.Tag("HealthCheck")<
  HealthCheck,
  {
    readonly check: () => Effect.Effect<{
      readonly status: "healthy" | "degraded" | "unhealthy"
      readonly checks: ReadonlyArray<{ name: string; status: string }>
    }>
  }
>() {}

// === Implementations ===

const ConfigLive = Layer.succeed(Config, {
  serviceName: "order-service",
  version: "1.2.3",
  metricsPort: 9090
})

const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    const prefix = `[${config.serviceName}@${config.version}]`
    return {
      info: (msg) => Effect.log(`${prefix} INFO: ${msg}`),
      warn: (msg) => Effect.log(`${prefix} WARN: ${msg}`),
      error: (msg, err) => Effect.log(`${prefix} ERROR: ${msg} ${err ?? ""}`)
    }
  })
)

const MetricsLive = Layer.effect(
  Metrics,
  Effect.gen(function* () {
    const config = yield* Config
    yield* Effect.log(`Metrics server starting on :${config.metricsPort}`)
    return {
      counter: (name, labels) =>
        Effect.log(`COUNTER ${name} +1 ${JSON.stringify(labels ?? {})}`),
      histogram: (name, value) =>
        Effect.log(`HISTOGRAM ${name} = ${value}`),
      gauge: (name, value) =>
        Effect.log(`GAUGE ${name} = ${value}`)
    }
  })
)

const TracerLive = Layer.effect(
  Tracer,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      startSpan: (name) =>
        Effect.gen(function* () {
          const start = Date.now()
          yield* Effect.log(`SPAN START: ${config.serviceName}/${name}`)
          return {
            end: () =>
              Effect.log(`SPAN END: ${config.serviceName}/${name} (${Date.now() - start}ms)`)
          }
        }),
      addAttribute: (key, value) =>
        Effect.log(`ATTR: ${key}=${value}`)
    }
  })
)

const HealthCheckLive = Layer.effect(
  HealthCheck,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      check: () =>
        Effect.succeed({
          status: "healthy" as const,
          checks: [
            { name: config.serviceName, status: "ok" },
            { name: "database", status: "ok" },
            { name: "cache", status: "ok" }
          ]
        })
    }
  })
)

// === Composition: merge всех observability-сервисов ===

// Все 4 зависят от Config → merge после предоставления
const ObservabilityLive = Layer.mergeAll(
  LoggerLive,
  MetricsLive,
  TracerLive,
  HealthCheckLive
).pipe(
  Layer.provideMerge(ConfigLive)
)
// Тип: Layer<Config | Logger | Metrics | Tracer | HealthCheck, never, never>

// === Использование ===

const program = Effect.gen(function* () {
  const logger = yield* Logger
  const metrics = yield* Metrics
  const tracer = yield* Tracer
  const health = yield* HealthCheck

  // Начинаем трейс
  const span = yield* tracer.startSpan("process-order")
  yield* tracer.addAttribute("orderId", "ORD-001")

  // Бизнес-логика
  yield* logger.info("Processing order ORD-001")
  yield* metrics.counter("orders.processed", { type: "standard" })
  yield* metrics.histogram("order.processing_time", 150)

  // Завершаем трейс
  yield* span.end()

  // Health check
  const status = yield* health.check()
  yield* logger.info(`Health: ${status.status}`)

  return status
})

Effect.runPromise(Effect.provide(program, ObservabilityLive)).then(console.log)

Пример: Feature flags через merge


// Feature flags как отдельные сервисы
class FeatureNewUI extends Context.Tag("FeatureNewUI")<FeatureNewUI, { readonly enabled: boolean }>() {}
class FeatureDarkMode extends Context.Tag("FeatureDarkMode")<FeatureDarkMode, { readonly enabled: boolean }>() {}
class FeatureBetaApi extends Context.Tag("FeatureBetaApi")<FeatureBetaApi, { readonly enabled: boolean }>() {}

// Каждый feature flag — отдельный Layer
const NewUIFlag = Layer.succeed(FeatureNewUI, { enabled: true })
const DarkModeFlag = Layer.succeed(FeatureDarkMode, { enabled: false })
const BetaApiFlag = Layer.succeed(FeatureBetaApi, { enabled: true })

// Все флаги одним пакетом
const FeatureFlagsLive = Layer.mergeAll(NewUIFlag, DarkModeFlag, BetaApiFlag)

// Программа проверяет флаги
const renderApp = Effect.gen(function* () {
  const newUI = yield* FeatureNewUI
  const darkMode = yield* FeatureDarkMode
  const betaApi = yield* FeatureBetaApi

  return {
    ui: newUI.enabled ? "v2" : "v1",
    theme: darkMode.enabled ? "dark" : "light",
    apiVersion: betaApi.enabled ? "beta" : "stable"
  }
})

Effect.runPromise(Effect.provide(renderApp, FeatureFlagsLive)).then(console.log)
// { ui: "v2", theme: "light", apiVersion: "beta" }

Упражнения

Упражнение

Объединение базовых сервисов

Легко

Создайте 4 простых сервиса (без зависимостей):

  1. Clock — метод now(): Effect&lt;Date&gt;
  2. Random — метод next(): Effect&lt;number&gt;
  3. Uuid — метод generate(): Effect&lt;string&gt;
  4. Hash — метод sha256(input: string): Effect&lt;string&gt;

Объедините их через mergeAll в UtilsLive.

Напишите программу, использующую все 4 сервиса.

Упражнение

merge с общими зависимостями

Средне

Три сервиса зависят от общего Config:

  • EmailSender (Config) — отправка email
  • SmsSender (Config) — отправка SMS
  • PushSender (Config) — push-уведомления
  1. Создайте mergeAll из всех трёх
  2. Предоставьте Config через provide
  3. Программа: отправить одно сообщение через все три канала
Упражнение

Динамический merge на основе конфигурации

Сложно

Создайте систему плагинов:

  • Plugin A: Analytics (optional)
  • Plugin B: ErrorTracking (optional)
  • Plugin C: FeatureFlags (optional)

Конфигурация определяет, какие плагины активны. Используйте merge для создания Layer только с активными плагинами. Неактивные плагины предоставляют no-op реализации.


🔗 Далее: Вертикальные пайплайны и граф зависимостей — полная картина композиции Layer