Effect Курс Вертикальные пайплайны

Вертикальные пайплайны

Полная картина композиции Layer.

Теория

Что такое вертикальный пайплайн

Вертикальный пайплайн — это последовательность Layer, где выход каждого предыдущего Layer подаётся на вход следующего. Это создаёт «конвейер» конструирования:

  Layer 1 (Foundation)        Layer 2 (Infrastructure)       Layer 3 (Domain)
  ┌─────────────────┐        ┌─────────────────────┐        ┌────────────────┐
  │                 │  Out   │                     │  Out   │                │
  │ In: nothing     ├───────►│ In: Config | Logger ├───────►│ In: DB | Cache │  Out
  │ Out: Config     │        │ Out: DB | Cache     │        │ Out: UserSvc   ├───────►
  │     | Logger    │        │                     │        │                │
  └─────────────────┘        └─────────────────────┘        └────────────────┘

Каждый «этаж» пайплайна разрешает зависимости следующего. В результате получается полностью разрешённый Layer с RIn = never.

Направление потока

Важно понимать направление потока в Layer:

Данные текут ВНИЗ (от Foundation к Domain):
  Config → Logger → Database → UserService

Зависимости описываются СНИЗУ ВВЕРХ:
  UserService needs Database
  Database needs Logger, Config
  Logger needs Config
  Config needs nothing

Layer.provide подключает СВЕРХУ ВНИЗ:
  UserService.pipe(
    Layer.provide(Database),    // подключаем Database
    Layer.provide(Logger),      // подключаем Logger
    Layer.provide(Config)       // подключаем Config
  )

Концепция ФП

Kleisli-композиция Layer

Если рассматривать Layer как стрелку в категории Kleisli:

Layer<A, E, R> : R ─E─► A

Композиция (provide):
  f : B ─E1─► A     (Layer<A, E1, B>)
  g : C ─E2─► B     (Layer<B, E2, C>)
  ──────────────────
  f ∘ g : C ─(E1|E2)─► A     (Layer<A, E1|E2, C>)

Это классическая Kleisli-композиция, где ошибки накапливаются через union.

Dag (Directed Acyclic Graph)

Граф зависимостей Layer — это всегда DAG (направленный ациклический граф). Циклы невозможны по конструкции:

  • Тип Layer<A, E, R> гарантирует, что A не может быть частью R (TypeScript не допустит)
  • Layer.provide устраняет зависимость из R, а не добавляет
  • Рекурсивные зависимости приведут к бесконечному типу, что TypeScript отвергнет

Это фундаментальное преимущество перед runtime DI-фреймворками (вроде Inversify), где циклы обнаруживаются только в рантайме.


Вертикальные пайплайны

Линейный пайплайн

Простейший случай — линейная цепочка зависимостей:


class Config extends Context.Tag("Config")<Config, { readonly dbUrl: 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 Repository extends Context.Tag("Repository")<Repository, { readonly findUser: (id: string) => Effect.Effect<unknown> }>() {}

// Config → Logger → Database → Repository

const ConfigLive = Layer.succeed(Config, { dbUrl: "postgres://localhost/app" })

const LoggerLive = Layer.effect(Logger, Effect.gen(function* () {
  yield* Config
  return { log: (msg) => Effect.log(msg) }
}))
// Layer<Logger, never, Config>

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(`[DB] ${sql}`)
      return { rows: [] }
    })
  }
}))
// Layer<Database, never, Config | Logger>

const RepositoryLive = Layer.effect(Repository, Effect.gen(function* () {
  const db = yield* Database
  const logger = yield* Logger
  return {
    findUser: (id) => Effect.gen(function* () {
      yield* logger.log(`Finding user ${id}`)
      return yield* db.query(`SELECT * FROM users WHERE id = '${id}'`)
    })
  }
}))
// Layer<Repository, never, Database | Logger>

// === Пайплайн: пошаговое разрешение ===

// Шаг 1: Config (корень)
const Step1 = ConfigLive
// Layer<Config, never, never>

// Шаг 2: + Logger
const Step2 = LoggerLive.pipe(Layer.provideMerge(Step1))
// Layer<Config | Logger, never, never>

// Шаг 3: + Database
const Step3 = DatabaseLive.pipe(Layer.provideMerge(Step2))
// Layer<Config | Logger | Database, never, never>

// Шаг 4: + Repository
const MainLive = RepositoryLive.pipe(Layer.provideMerge(Step3))
// Layer<Config | Logger | Database | Repository, never, never>

Разветвлённый пайплайн

Более реалистичный случай — дерево зависимостей:

                Config
               /      \
          Logger      Redis
         /    \         |
    Database  EventBus Cache
         \      |      /
          \     |     /
           UserService

// === Tags ===
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 Redis extends Context.Tag("Redis")<Redis, { readonly ping: () => Effect.Effect<string> }>() {}
class Database extends Context.Tag("Database")<Database, { readonly query: (sql: string) => Effect.Effect<unknown> }>() {}
class EventBus extends Context.Tag("EventBus")<EventBus, { readonly emit: (event: string) => Effect.Effect<void> }>() {}
class Cache extends Context.Tag("Cache")<Cache, { readonly get: (key: string) => Effect.Effect<string | null> }>() {}
class UserService extends Context.Tag("UserService")<UserService, { readonly find: (id: string) => Effect.Effect<unknown> }>() {}

// === Implementations ===
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 RedisLive = Layer.effect(Redis, Effect.gen(function* () {
  yield* Config
  return { ping: () => Effect.succeed("PONG") }
}))

const DatabaseLive = Layer.effect(Database, Effect.gen(function* () {
  yield* Logger
  return { query: (sql) => Effect.succeed({ sql, rows: [] }) }
}))

const EventBusLive = Layer.effect(EventBus, Effect.gen(function* () {
  const logger = yield* Logger
  return { emit: (event) => logger.log(`Event: ${event}`) }
}))

const CacheLive = Layer.effect(Cache, Effect.gen(function* () {
  yield* Redis
  return { get: (_key) => Effect.succeed(null) }
}))

const UserServiceLive = Layer.effect(UserService, Effect.gen(function* () {
  const db = yield* Database
  const bus = yield* EventBus
  const cache = yield* Cache
  return {
    find: (id) => Effect.gen(function* () {
      const cached = yield* cache.get(`user:${id}`)
      if (cached !== null) return cached
      const result = yield* db.query(`SELECT * FROM users WHERE id = '${id}'`)
      yield* bus.emit(`user.found:${id}`)
      return result
    })
  }
}))

// === Сборка графа ===

// Уровень 1: Foundations
const FoundationLive = Layer.mergeAll(
  ConfigLive,
  LoggerLive.pipe(Layer.provide(ConfigLive)),
  RedisLive.pipe(Layer.provide(ConfigLive))
)
// Layer<Config | Logger | Redis, never, never>

// Уровень 2: Infrastructure
const InfraLive = Layer.mergeAll(
  DatabaseLive,
  EventBusLive,
  CacheLive
).pipe(Layer.provideMerge(FoundationLive))
// Layer<Config | Logger | Redis | Database | EventBus | Cache, never, never>

// Уровень 3: Domain
const MainLive = UserServiceLive.pipe(Layer.provideMerge(InfraLive))
// Layer<Config | Logger | Redis | Database | EventBus | Cache | UserService, never, never>

Топология графа зависимостей

Топологическая сортировка

Effect автоматически выполняет топологическую сортировку при конструировании Layer. Это означает, что:

  1. Layer без зависимостей конструируются первыми
  2. Layer конструируются только когда все их зависимости уже готовы
  3. Независимые Layer могут конструироваться параллельно
Порядок конструирования для нашего графа:

  Шаг 1: Config                      (нет зависимостей)
  Шаг 2: Logger, Redis               (параллельно, оба зависят только от Config)
  Шаг 3: Database, EventBus, Cache   (параллельно, зависимости готовы)
  Шаг 4: UserService                 (все зависимости готовы)

Порядок финализации (обратный):
  Шаг 1: UserService
  Шаг 2: Database, EventBus, Cache
  Шаг 3: Logger, Redis
  Шаг 4: Config

Обнаружение ошибок на этапе компиляции

TypeScript выявляет неразрешённые зависимости:


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 LoggerLive: Layer.Layer<Logger, never, Config> = Layer.effect(Logger, Effect.gen(function* () {
  yield* Config
  return { log: (msg) => Effect.log(msg) }
}))

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

const program = Effect.gen(function* () {
  const db = yield* Database
  return yield* db.query("SELECT 1")
})

// ❌ ОШИБКА КОМПИЛЯЦИИ: Config и Logger не предоставлены
// const runnable = Effect.provide(program, DatabaseLive)
// Type 'Config | Logger' is not assignable to type 'never'

// ✅ Правильно: предоставить все зависимости
const ConfigLive = Layer.succeed(Config, { env: "dev" })
const MainLive = DatabaseLive.pipe(
  Layer.provide(LoggerLive),
  Layer.provide(ConfigLive)
)
const runnable = Effect.provide(program, MainLive)
// Тип: Effect<unknown, never, never> ← все зависимости разрешены

Diamond dependency и её решение

Проблема

Diamond dependency возникает когда два сервиса зависят от одного и того же третьего:

      Config
     /      \
  Logger   Cache
     \      /
    Database

Вопрос: создаётся ли Config один раз или дважды?

Ответ: автоматическая мемоизация

При глобальном предоставлении (через Effect.provide на уровне программы) — Layer мемоизируется по ссылочному равенству:


class Config extends Context.Tag("Config")<Config, { readonly id: string }>() {}
class Logger extends Context.Tag("Logger")<Logger, { readonly configId: string }>() {}
class Cache extends Context.Tag("Cache")<Cache, { readonly configId: string }>() {}
class Database extends Context.Tag("Database")<Database, { readonly info: string }>() {}

const ConfigLive = Layer.effect(
  Config,
  Effect.gen(function* () {
    const id = `cfg-${Date.now()}`
    yield* Effect.log(`>>> Config CREATED: ${id}`)
    return { id }
  })
)

const LoggerLive = Layer.effect(Logger, Effect.gen(function* () {
  const config = yield* Config
  return { configId: config.id }
}))

const CacheLive = Layer.effect(Cache, Effect.gen(function* () {
  const config = yield* Config
  return { configId: config.id }
}))

const DatabaseLive = Layer.effect(Database, Effect.gen(function* () {
  const logger = yield* Logger
  const cache = yield* Cache
  return { info: `logger(${logger.configId}), cache(${cache.configId})` }
}))

// === Правильная композиция (с мемоизацией) ===

const MainLive = DatabaseLive.pipe(
  Layer.provide(
    Layer.merge(
      LoggerLive.pipe(Layer.provide(ConfigLive)),
      CacheLive.pipe(Layer.provide(ConfigLive))
      //                            ↑ ОДИН И ТОТ ЖЕ ConfigLive (по ссылке)
    )
  )
)

const program = Effect.gen(function* () {
  const db = yield* Database
  yield* Effect.log(`Database info: ${db.info}`)
})

Effect.runPromise(Effect.provide(program, MainLive))
// Output:
// >>> Config CREATED: cfg-1234567890   ← ОДИН раз!
// Database info: logger(cfg-1234567890), cache(cfg-1234567890)

Ловушка: нарушение ссылочного равенства

// ⚠️ ОШИБКА: разные вызовы — разные ссылки!
const makeConfigLive = () => Layer.succeed(Config, { id: `cfg-${Date.now()}` })

const BadMainLive = DatabaseLive.pipe(
  Layer.provide(
    Layer.merge(
      LoggerLive.pipe(Layer.provide(makeConfigLive())),  // ← ссылка 1
      CacheLive.pipe(Layer.provide(makeConfigLive()))    // ← ссылка 2 (другая!)
    )
  )
)
// Config будет создан ДВАЖДЫ!

// ✅ ПРАВИЛЬНО: сохраняем ссылку
const ConfigLive2 = Layer.succeed(Config, { id: `cfg-${Date.now()}` })

const GoodMainLive = DatabaseLive.pipe(
  Layer.provide(
    Layer.merge(
      LoggerLive.pipe(Layer.provide(ConfigLive2)),   // ← одна ссылка
      CacheLive.pipe(Layer.provide(ConfigLive2))     // ← та же ссылка
    )
  )
)

Многослойная архитектура

Clean Architecture с Layer

┌──────────────────────────────────────────────────────┐
│  Presentation Layer                                   │
│  ┌───────────────────────────────────────────────┐   │
│  │  Domain Layer                                  │   │
│  │  ┌────────────────────────────────────────┐   │   │
│  │  │  Infrastructure Layer                   │   │   │
│  │  │  ┌─────────────────────────────────┐   │   │   │
│  │  │  │  Foundation Layer                │   │   │   │
│  │  │  │  Config, Logger, Telemetry       │   │   │   │
│  │  │  └─────────────────────────────────┘   │   │   │
│  │  │  Database, Cache, MQ, S3              │   │   │
│  │  └────────────────────────────────────────┘   │   │
│  │  UserService, OrderService, PaymentService    │   │
│  └───────────────────────────────────────────────┘   │
│  HttpServer, GraphQL, WebSocket                       │
└──────────────────────────────────────────────────────┘

Реализация через паттерн Layer-per-tier


// === FOUNDATION LAYER ===

class Config extends Context.Tag("Config")<
  Config,
  {
    readonly dbUrl: string
    readonly redisUrl: string
    readonly port: number
    readonly env: "development" | "production" | "test"
  }
>() {}

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

class Telemetry extends Context.Tag("Telemetry")<
  Telemetry,
  {
    readonly trackEvent: (name: string, props?: Record<string, string>) => Effect.Effect<void>
    readonly trackDuration: (name: string, ms: number) => Effect.Effect<void>
  }
>() {}

const ConfigLive = Layer.succeed(Config, {
  dbUrl: "postgres://localhost:5432/app",
  redisUrl: "redis://localhost:6379",
  port: 3000,
  env: "production"
})

const LoggerLive = Layer.effect(Logger, Effect.gen(function* () {
  const config = yield* Config
  const prefix = config.env === "production" ? "" : `[${config.env}] `
  return {
    debug: (msg) => config.env !== "production" ? Effect.log(`${prefix}DEBUG: ${msg}`) : Effect.void,
    info: (msg) => Effect.log(`${prefix}INFO: ${msg}`),
    error: (msg, err) => Effect.log(`${prefix}ERROR: ${msg} ${err ?? ""}`)
  }
}))

const TelemetryLive = Layer.effect(Telemetry, Effect.gen(function* () {
  const config = yield* Config
  return {
    trackEvent: (name, props) =>
      config.env === "production"
        ? Effect.log(`[TEL] Event: ${name} ${JSON.stringify(props ?? {})}`)
        : Effect.void,
    trackDuration: (name, ms) =>
      Effect.log(`[TEL] Duration: ${name} = ${ms}ms`)
  }
}))

// === Foundation = Config + Logger + Telemetry ===
const FoundationLive = Layer.mergeAll(
  LoggerLive,
  TelemetryLive
).pipe(Layer.provideMerge(ConfigLive))

// === INFRASTRUCTURE LAYER ===

class Database extends Context.Tag("Database")<
  Database,
  { readonly query: (sql: string) => Effect.Effect<ReadonlyArray<Record<string, unknown>>> }
>() {}

class Cache extends Context.Tag("Cache")<
  Cache,
  {
    readonly get: (key: string) => Effect.Effect<string | null>
    readonly set: (key: string, value: string, ttlMs?: number) => Effect.Effect<void>
    readonly del: (key: string) => Effect.Effect<void>
  }
>() {}

class MessageQueue extends Context.Tag("MessageQueue")<
  MessageQueue,
  {
    readonly publish: (topic: string, message: unknown) => Effect.Effect<void>
    readonly subscribe: (topic: string) => Effect.Effect<void>
  }
>() {}

const DatabaseLive = Layer.scoped(Database, Effect.gen(function* () {
  const config = yield* Config
  const logger = yield* Logger

  const connection = yield* Effect.acquireRelease(
    Effect.gen(function* () {
      yield* logger.info(`Connecting to database: ${config.dbUrl}`)
      return { url: config.dbUrl, connected: true }
    }),
    (conn) => logger.info(`Disconnecting from database: ${conn.url}`)
  )

  return {
    query: (sql) => Effect.gen(function* () {
      yield* logger.debug(`SQL: ${sql}`)
      return [{ id: 1, data: "sample" }]
    })
  }
}))

const CacheLive = Layer.scoped(Cache, Effect.gen(function* () {
  const config = yield* Config
  const logger = yield* Logger

  const client = yield* Effect.acquireRelease(
    Effect.gen(function* () {
      yield* logger.info(`Connecting to Redis: ${config.redisUrl}`)
      return { store: new Map<string, string>() }
    }),
    (client) => Effect.gen(function* () {
      yield* logger.info("Disconnecting from Redis")
      client.store.clear()
    })
  )

  return {
    get: (key) => Effect.sync(() => client.store.get(key) ?? null),
    set: (key, value, _ttl) => Effect.sync(() => { client.store.set(key, value) }),
    del: (key) => Effect.sync(() => { client.store.delete(key) })
  }
}))

const MessageQueueLive = Layer.effect(MessageQueue, Effect.gen(function* () {
  const logger = yield* Logger
  return {
    publish: (topic, message) =>
      logger.info(`[MQ] Published to ${topic}: ${JSON.stringify(message)}`),
    subscribe: (topic) =>
      logger.info(`[MQ] Subscribed to ${topic}`)
  }
}))

// Infrastructure = Database + Cache + MQ (provided by Foundation)
const InfrastructureLive = Layer.mergeAll(
  DatabaseLive,
  CacheLive,
  MessageQueueLive
).pipe(Layer.provideMerge(FoundationLive))

// === DOMAIN LAYER ===

class UserService extends Context.Tag("UserService")<
  UserService,
  {
    readonly findById: (id: string) => Effect.Effect<{ id: string; name: string } | null>
    readonly create: (name: string) => Effect.Effect<{ id: string; name: string }>
  }
>() {}

class OrderService extends Context.Tag("OrderService")<
  OrderService,
  {
    readonly createOrder: (userId: string, items: ReadonlyArray<string>) => Effect.Effect<{ orderId: string }>
  }
>() {}

const UserServiceLive = Layer.effect(UserService, Effect.gen(function* () {
  const db = yield* Database
  const cache = yield* Cache
  const logger = yield* Logger
  const telemetry = yield* Telemetry

  return {
    findById: (id) => Effect.gen(function* () {
      const start = Date.now()
      const cached = yield* cache.get(`user:${id}`)
      if (cached !== null) {
        yield* telemetry.trackDuration("user.find.cache_hit", Date.now() - start)
        return JSON.parse(cached) as { id: string; name: string }
      }
      const rows = yield* db.query(`SELECT * FROM users WHERE id = '${id}'`)
      const user = rows[0] as { id: string; name: string } | undefined ?? null
      if (user !== null) {
        yield* cache.set(`user:${id}`, JSON.stringify(user), 300000)
      }
      yield* telemetry.trackDuration("user.find.db", Date.now() - start)
      return user
    }),
    create: (name) => Effect.gen(function* () {
      const id = crypto.randomUUID()
      yield* db.query(`INSERT INTO users (id, name) VALUES ('${id}', '${name}')`)
      yield* cache.del(`user:${id}`)
      yield* logger.info(`User created: ${id}`)
      yield* telemetry.trackEvent("user.created", { id })
      return { id, name }
    })
  }
}))

const OrderServiceLive = Layer.effect(OrderService, Effect.gen(function* () {
  const db = yield* Database
  const mq = yield* MessageQueue
  const logger = yield* Logger
  const users = yield* UserService

  return {
    createOrder: (userId, items) => Effect.gen(function* () {
      const user = yield* users.findById(userId)
      if (user === null) {
        return yield* Effect.die(new Error(`User ${userId} not found`))
      }
      const orderId = crypto.randomUUID()
      yield* db.query(`INSERT INTO orders (id, user_id) VALUES ('${orderId}', '${userId}')`)
      yield* mq.publish("orders.created", { orderId, userId, items })
      yield* logger.info(`Order created: ${orderId} for user ${user.name}`)
      return { orderId }
    })
  }
}))

// Domain = UserService + OrderService (provided by Infrastructure)
const DomainLive = Layer.mergeAll(
  UserServiceLive,
  OrderServiceLive.pipe(Layer.provide(UserServiceLive)) // OrderService зависит от UserService
).pipe(Layer.provideMerge(InfrastructureLive))

// === FINAL COMPOSITION ===
const MainLive = DomainLive
// Layer<Config | Logger | Telemetry | Database | Cache | MQ | UserService | OrderService, never, never>

Тестирование через подмену Layer

Стратегия: подмена на уровне архитектурного слоя


// Предположим все определения сервисов из предыдущего раздела

// === Test Foundation ===
const ConfigTest = Layer.succeed(Config, {
  dbUrl: "memory://",
  redisUrl: "memory://",
  port: 0,
  env: "test" as const
})

const LoggerTest = Layer.succeed(Logger, {
  debug: (_msg) => Effect.void,
  info: (_msg) => Effect.void,
  error: (_msg, _err) => Effect.void
})

const TelemetryTest = Layer.succeed(Telemetry, {
  trackEvent: (_name, _props) => Effect.void,
  trackDuration: (_name, _ms) => Effect.void
})

const FoundationTest = Layer.mergeAll(ConfigTest, LoggerTest, TelemetryTest)

// === Test Infrastructure ===
const DatabaseTest = Layer.succeed(Database, {
  query: (sql) => Effect.succeed([{ id: "1", data: "test", name: "Alice" }])
})

const CacheTest = Layer.succeed(Cache, {
  get: (_key) => Effect.succeed(null),
  set: (_key, _value, _ttl) => Effect.void,
  del: (_key) => Effect.void
})

const MessageQueueTest = Layer.succeed(MessageQueue, {
  publish: (_topic, _msg) => Effect.void,
  subscribe: (_topic) => Effect.void
})

const InfrastructureTest = Layer.mergeAll(DatabaseTest, CacheTest, MessageQueueTest).pipe(
  Layer.provideMerge(FoundationTest)
)

// === Test Domain (реальные реализации на тестовой инфраструктуре) ===
const DomainTest = Layer.mergeAll(
  UserServiceLive,
  OrderServiceLive.pipe(Layer.provide(UserServiceLive))
).pipe(Layer.provideMerge(InfrastructureTest))

// === Использование в тестах ===
const testProgram = Effect.gen(function* () {
  const users = yield* UserService
  const orders = yield* OrderService

  const user = yield* users.create("Test User")
  const order = yield* orders.createOrder(user.id, ["item1", "item2"])

  return { user, order }
})

// Запуск с тестовым окружением
Effect.runPromise(Effect.provide(testProgram, DomainTest)).then(console.log)

Стратегия: точечная подмена одного сервиса


class Database extends Context.Tag("Database")<
  Database,
  { readonly query: (sql: string) => Effect.Effect<ReadonlyArray<Record<string, unknown>>> }
>() {}

// Подмена только Database, остальное — production
const DatabaseSlow = Layer.succeed(Database, {
  query: (sql) =>
    Effect.gen(function* () {
      // Симуляция медленного запроса для нагрузочного тестирования
      yield* Effect.sleep("500 millis")
      return [{ id: "1" }]
    })
})

// Заменяем только Database в production-стеке
// MainLive уже содержит Database, мы его перекрываем
const SlowDbMainLive = Layer.merge(
  MainLive, // все production-сервисы
  DatabaseSlow // перекрывает Database из MainLive
)

API Reference

Полная таблица операторов композиции

ОператорТипНазначение
Layer.provide(self, that)ВертикальнаяПодключает выходы that ко входам self
Layer.provideMerge(self, that)Вертикальная + горизонтальнаяТо же + сохраняет выходы that
Layer.merge(self, that)ГоризонтальнаяОбъединяет выходы и входы
Layer.mergeAll(...layers)Горизонтальная (N)Variadic merge
Effect.provide(effect, layer)ПредоставлениеПодключает Layer к программе

Типовые трансформации

provide(Layer<A, E1, B>, Layer<B, E2, C>)
  → Layer<A, E1|E2, C>

provideMerge(Layer<A, E1, B>, Layer<B, E2, C>)
  → Layer<A|B, E1|E2, C>

merge(Layer<A, E1, R1>, Layer<B, E2, R2>)
  → Layer<A|B, E1|E2, R1|R2>

Effect.provide(Effect<X, E1, A>, Layer<A, E2, R>)
  → Effect<X, E1|E2, R>

Примеры

Пример: Полный production-граф с визуализацией типов


// Минималистичные сервисы для демонстрации типов

class A extends Context.Tag("A")<A, "a">() {}
class B extends Context.Tag("B")<B, "b">() {}
class C extends Context.Tag("C")<C, "c">() {}
class D extends Context.Tag("D")<D, "d">() {}
class E extends Context.Tag("E")<E, "e">() {}

const ALive = Layer.succeed(A, "a" as const)
// Layer<A>

const BLive = Layer.effect(B, Effect.gen(function* () {
  yield* A
  return "b" as const
}))
// Layer<B, never, A>

const CLive = Layer.effect(C, Effect.gen(function* () {
  yield* A
  return "c" as const
}))
// Layer<C, never, A>

const DLive = Layer.effect(D, Effect.gen(function* () {
  yield* B
  yield* C
  return "d" as const
}))
// Layer<D, never, B | C>

const ELive = Layer.effect(E, Effect.gen(function* () {
  yield* A
  yield* D
  return "e" as const
}))
// Layer<E, never, A | D>

// === Граф ===
//     A
//    / \
//   B   C
//    \ /
//     D
//     |
//     E (также зависит от A)

// Сборка
const BCLive = Layer.merge(BLive, CLive).pipe(
  Layer.provideMerge(ALive)
)
// Layer<A | B | C, never, never>

const DResolved = DLive.pipe(Layer.provideMerge(BCLive))
// Layer<A | B | C | D, never, never>

const MainLive = ELive.pipe(Layer.provideMerge(DResolved))
// Layer<A | B | C | D | E, never, never>

const program = Effect.gen(function* () {
  const a = yield* A
  const b = yield* B
  const c = yield* C
  const d = yield* D
  const e = yield* E
  return { a, b, c, d, e }
})

Effect.runPromise(Effect.provide(program, MainLive)).then(console.log)
// { a: "a", b: "b", c: "c", d: "d", e: "e" }

Упражнения

Упражнение

Сборка простого пайплайна

Легко

Соберите пайплайн из 4 сервисов:

  • Config → Logger → HttpClient → ApiService
  • Каждый зависит от предыдущего
  • Результат: MainLive с типом Layer<…сервисы…, never, never>
Упражнение

Diamond dependency с проверкой мемоизации

Средне

Создайте diamond: Config → (Logger, Metrics) → App.

Config должен логировать создание через console.log.

Проверьте, что Config создаётся ровно один раз.

Упражнение

Полная Clean Architecture с тестовым и production окружением

Сложно

Реализуйте полную Clean Architecture:

  1. Foundation: Config, Logger (2 реализации: prod и test)
  2. Infrastructure: Database, Cache (2 реализации: prod и test)
  3. Domain: ProductService, InventoryService
  4. Application: OrderWorkflow (зависит от обоих доменных сервисов)

Требования:

  • Diamond dependency: ProductService и InventoryService оба зависят от Database
  • OrderWorkflow зависит от ProductService, InventoryService и Logger
  • Создайте ProductionMainLive и TestMainLive
  • Программа должна работать идентично с обоими

🔗 Далее: Мемоизация и кэширование Layer — как Effect оптимизирует конструирование сервисов