Вертикальные пайплайны
Полная картина композиции 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. Это означает, что:
- Layer без зависимостей конструируются первыми
- Layer конструируются только когда все их зависимости уже готовы
- Независимые 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>
class Config extends Context.Tag("Config")<Config, { readonly baseUrl: string }>() {}
class Logger extends Context.Tag("Logger")<Logger, { readonly log: (msg: string) => Effect.Effect<void> }>() {}
class HttpClient extends Context.Tag("HttpClient")<HttpClient, { readonly get: (path: string) => Effect.Effect<string> }>() {}
class ApiService extends Context.Tag("ApiService")<ApiService, { readonly fetchUsers: () => Effect.Effect<string> }>() {}
const ConfigLive = Layer.succeed(Config, { baseUrl: "https://api.example.com" })
const LoggerLive = Layer.effect(Logger, Effect.gen(function* () {
yield* Config
return { log: (msg) => Effect.log(msg) }
}))
const HttpClientLive = Layer.effect(HttpClient, Effect.gen(function* () {
const config = yield* Config
const logger = yield* Logger
return {
get: (path) => Effect.gen(function* () {
yield* logger.log(`GET ${config.baseUrl}${path}`)
return `Response from ${path}`
})
}
}))
const ApiServiceLive = Layer.effect(ApiService, Effect.gen(function* () {
const http = yield* HttpClient
return { fetchUsers: () => http.get("/users") }
}))
const MainLive = ApiServiceLive.pipe(
Layer.provide(HttpClientLive),
Layer.provide(LoggerLive),
Layer.provide(ConfigLive)
)
const program = Effect.gen(function* () {
const api = yield* ApiService
return yield* api.fetchUsers()
})
Effect.runPromise(Effect.provide(program, MainLive)).then(console.log)Diamond dependency с проверкой мемоизации
Создайте diamond: Config → (Logger, Metrics) → App.
Config должен логировать создание через console.log.
Проверьте, что Config создаётся ровно один раз.
class Config extends Context.Tag("Config")<Config, { readonly instanceId: string }>() {}
class Logger extends Context.Tag("Logger")<Logger, { readonly cfgId: string }>() {}
class Metrics extends Context.Tag("Metrics")<Metrics, { readonly cfgId: string }>() {}
class App extends Context.Tag("App")<App, { readonly info: string }>() {}
const ConfigLive = Layer.effect(Config, Effect.gen(function* () {
const id = `cfg-${Math.random().toString(36).slice(2, 8)}`
yield* Effect.log(`Config CREATED with id: ${id}`)
return { instanceId: id }
}))
const LoggerLive = Layer.effect(Logger, Effect.gen(function* () {
const config = yield* Config
return { cfgId: config.instanceId }
}))
const MetricsLive = Layer.effect(Metrics, Effect.gen(function* () {
const config = yield* Config
return { cfgId: config.instanceId }
}))
const AppLive = Layer.effect(App, Effect.gen(function* () {
const logger = yield* Logger
const metrics = yield* Metrics
return {
info: `logger.cfg=${logger.cfgId}, metrics.cfg=${metrics.cfgId}, same=${logger.cfgId === metrics.cfgId}`
}
}))
// Diamond composition
const MainLive = AppLive.pipe(
Layer.provide(
Layer.merge(
LoggerLive.pipe(Layer.provide(ConfigLive)),
MetricsLive.pipe(Layer.provide(ConfigLive))
)
)
)
const program = Effect.gen(function* () {
const app = yield* App
yield* Effect.log(app.info)
})
Effect.runPromise(Effect.provide(program, MainLive))
// Config CREATED with id: cfg-abc123 ← ОДИН раз
// logger.cfg=cfg-abc123, metrics.cfg=cfg-abc123, same=trueПолная Clean Architecture с тестовым и production окружением
Реализуйте полную Clean Architecture:
- Foundation: Config, Logger (2 реализации: prod и test)
- Infrastructure: Database, Cache (2 реализации: prod и test)
- Domain: ProductService, InventoryService
- Application: OrderWorkflow (зависит от обоих доменных сервисов)
Требования:
- Diamond dependency: ProductService и InventoryService оба зависят от Database
- OrderWorkflow зависит от ProductService, InventoryService и Logger
- Создайте ProductionMainLive и TestMainLive
- Программа должна работать идентично с обоими
// === Tags ===
class Config extends Context.Tag("Config")<Config, { readonly env: string; 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 exec: (sql: string) => Effect.Effect<string> }>() {}
class Cache extends Context.Tag("Cache")<Cache, { readonly get: (k: string) => Effect.Effect<string | null> }>() {}
class ProductService extends Context.Tag("ProductService")<ProductService, { readonly getPrice: (id: string) => Effect.Effect<number> }>() {}
class InventoryService extends Context.Tag("InventoryService")<InventoryService, { readonly checkStock: (id: string) => Effect.Effect<number> }>() {}
class OrderWorkflow extends Context.Tag("OrderWorkflow")<OrderWorkflow, { readonly placeOrder: (productId: string, qty: number) => Effect.Effect<string> }>() {}
// === Domain (implementation — shared between prod and test) ===
const ProductServiceLive = Layer.effect(ProductService, Effect.gen(function* () {
const db = yield* Database
return { getPrice: (id) => db.exec(`SELECT price FROM products WHERE id='${id}'`).pipe(Effect.map(Number)) }
}))
const InventoryServiceLive = Layer.effect(InventoryService, Effect.gen(function* () {
const db = yield* Database
return { checkStock: (id) => db.exec(`SELECT stock FROM inventory WHERE product_id='${id}'`).pipe(Effect.map(Number)) }
}))
const OrderWorkflowLive = Layer.effect(OrderWorkflow, Effect.gen(function* () {
const products = yield* ProductService
const inventory = yield* InventoryService
const logger = yield* Logger
return {
placeOrder: (productId, qty) => Effect.gen(function* () {
const stock = yield* inventory.checkStock(productId)
if (stock < qty) {
return yield* Effect.fail(new Error(`Insufficient stock: ${stock} < ${qty}`))
}
const price = yield* products.getPrice(productId)
const total = price * qty
yield* logger.log(`Order placed: ${qty}x ${productId} = $${total}`)
return `order-${Date.now()}`
}).pipe(Effect.catchAll((e) => Effect.succeed(`FAILED: ${(e as Error).message}`)))
}
}))
// === PRODUCTION ===
const ConfigProd = Layer.succeed(Config, { env: "production", dbUrl: "postgres://prod:5432" })
const LoggerProd = Layer.effect(Logger, Effect.gen(function* () {
const cfg = yield* Config
return { log: (msg) => Effect.log(`[${cfg.env}] ${msg}`) }
}))
const DatabaseProd = Layer.effect(Database, Effect.gen(function* () {
const cfg = yield* Config
return { exec: (sql) => Effect.succeed(`42`) } // simplified
}))
const CacheProd = Layer.succeed(Cache, { get: (_k) => Effect.succeed(null) })
const FoundationProd = LoggerProd.pipe(Layer.provideMerge(ConfigProd))
const InfraProd = Layer.merge(DatabaseProd, CacheProd).pipe(Layer.provideMerge(FoundationProd))
const DomainProd = Layer.merge(ProductServiceLive, InventoryServiceLive).pipe(Layer.provideMerge(InfraProd))
const ProductionMainLive = OrderWorkflowLive.pipe(Layer.provideMerge(DomainProd))
// === TEST ===
const ConfigTest = Layer.succeed(Config, { env: "test", dbUrl: "memory://" })
const LoggerTest = Layer.succeed(Logger, { log: (_msg) => Effect.void })
const DatabaseTest = Layer.succeed(Database, { exec: (_sql) => Effect.succeed("100") })
const CacheTest = Layer.succeed(Cache, { get: (_k) => Effect.succeed(null) })
const FoundationTest = Layer.mergeAll(ConfigTest, LoggerTest)
const InfraTest = Layer.merge(DatabaseTest, CacheTest).pipe(Layer.provideMerge(FoundationTest))
const DomainTest = Layer.merge(ProductServiceLive, InventoryServiceLive).pipe(Layer.provideMerge(InfraTest))
const TestMainLive = OrderWorkflowLive.pipe(Layer.provideMerge(DomainTest))
// === Program (one program, two environments) ===
const program = Effect.gen(function* () {
const workflow = yield* OrderWorkflow
const config = yield* Config
const orderId = yield* workflow.placeOrder("WIDGET-1", 5)
return { env: config.env, orderId }
})
Effect.runPromise(Effect.provide(program, ProductionMainLive)).then(console.log)
Effect.runPromise(Effect.provide(program, TestMainLive)).then(console.log)🔗 Далее: Мемоизация и кэширование Layer — как Effect оптимизирует конструирование сервисов