Кэширование слоёв
Как Effect оптимизирует конструирование сервисов.
Теория
Зачем нужна мемоизация Layer
В реальном приложении один и тот же сервис часто требуется нескольким другим сервисам. Без мемоизации каждое обращение к Layer создавало бы новый экземпляр сервиса. Это порождает критические проблемы:
БЕЗ МЕМОИЗАЦИИ С МЕМОИЗАЦИЕЙ
───────────── ──────────────
Database#1 ← Logger Database ← Logger
Database#2 ← Cache Database ← Cache (тот же!)
Database#3 ← UserRepo Database ← UserRepo (тот же!)
Проблемы: Результат:
• 3 пула соединений вместо 1 • 1 пул соединений
• 3x потребление памяти • Оптимальное потребление
• Разная конфигурация? • Единая конфигурация
• Несогласованное состояние • Согласованность гарантирована
Effect решает это через автоматическую мемоизацию при глобальном предоставлении Layer. Но есть нюансы, которые важно понимать.
Два режима предоставления
Effect различает два режима предоставления Layer, и мемоизация работает по-разному в каждом:
Глобальное предоставление (Effect.provide на уровне программы):
┌────────────────────────────────────────┐
│ Effect.provide(program, MainLive) │
│ │
│ → Layer конструируется ОДИН раз │
│ → Результат разделяется между всеми │
│ → Мемоизация АВТОМАТИЧЕСКАЯ │
└────────────────────────────────────────┘
Локальное предоставление (Effect.provide внутри программы):
┌────────────────────────────────────────┐
│ const program = Effect.gen(function*() │
│ yield* Effect.provide(A, ALive) │
│ yield* Effect.provide(A, ALive) │
│ }) │
│ │
│ → Layer конструируется КАЖДЫЙ раз │
│ → Мемоизации НЕТ по умолчанию │
└────────────────────────────────────────┘
Концепция ФП
Мемоизация как оптимизация чистых вычислений
В функциональном программировании мемоизация — это стандартная оптимизация для чистых функций. Если функция f(x) всегда возвращает один и тот же результат для одного и того же x, нет смысла вычислять её повторно.
Layer — это, по сути, ленивое вычисление, результат которого кэшируется:
Без мемоизации: Layer = () => Effect<Service> (фабрика)
С мемоизацией: Layer = lazy(() => Effect<Service>) (кэшированный thunk)
В Haskell аналогом является unsafePerformIO + NOINLINE прагма для синглтонов. В Scala ZIO использует тот же подход с Layer memoization.
Ссылочная прозрачность vs ссылочное равенство
Важное различие:
- Ссылочная прозрачность (referential transparency) — значит, что выражение можно заменить его результатом. Layer НЕ референциально прозрачен (он содержит эффекты).
- Ссылочное равенство (reference equality) — значит, что два значения ссылаются на один объект в памяти. Effect использует ссылочное равенство для определения, нужна ли мемоизация.
// Ссылочное равенство: один объект
const layer = Layer.succeed(Tag, value)
merge(provide(A, layer), provide(B, layer))
// → layer мемоизирован (одна ссылка)
// Нет ссылочного равенства: разные объекты
const makeLayer = () => Layer.succeed(Tag, value)
merge(provide(A, makeLayer()), provide(B, makeLayer()))
// → layer НЕ мемоизирован (разные ссылки!)
Глобальная мемоизация
Механизм
При глобальном предоставлении Layer через Effect.provide, Effect автоматически мемоизирует каждый Layer по ссылочному равенству. Это означает:
- Каждый уникальный Layer (по ссылке
===) конструируется ровно один раз - Результат (сконструированный сервис) разделяется между всеми потребителями
- Финализация происходит один раз при закрытии Scope программы
Демонстрация
class A extends Context.Tag("A")<A, { readonly a: number }>() {}
class B extends Context.Tag("B")<B, { readonly b: string }>() {}
class C extends Context.Tag("C")<C, { readonly c: boolean }>() {}
// Layer A логирует своё создание
const ALive = Layer.effect(
A,
Effect.succeed({ a: 5 }).pipe(
Effect.tap(() => Effect.log(">>> A initialized"))
)
)
// B и C оба зависят от A
const BLive = Layer.effect(
B,
Effect.gen(function* () {
const { a } = yield* A
return { b: String(a) }
})
)
const CLive = Layer.effect(
C,
Effect.gen(function* () {
const { a } = yield* A
return { c: a > 0 }
})
)
const program = Effect.gen(function* () {
const b = yield* B
const c = yield* C
return { b: b.b, c: c.c }
})
// === Глобальное предоставление ===
const MainLive = Layer.merge(
Layer.provide(BLive, ALive),
Layer.provide(CLive, ALive)
// ↑ один и тот же ALive (по ссылке)
)
const runnable = Effect.provide(program, MainLive)
Effect.runPromise(runnable).then(console.log)
// Output:
// timestamp=... message=">>> A initialized" ← ОДИН раз!
// { b: "5", c: true }
Как это работает внутри
Effect.provide(program, MainLive) запускает:
1. Анализ графа зависимостей MainLive
2. Топологическая сортировка Layer
3. Для каждого Layer (от корней к листьям):
a. Проверка: был ли этот Layer (по ===) уже сконструирован?
b. Если да → использовать кэшированный результат
c. Если нет → конструировать, сохранить в кэш
4. Кэш живёт в Scope программы
5. При завершении программы:
a. Закрытие Scope
b. Вызов финализаторов в обратном порядке
Локальное предоставление без мемоизации
Проблема
Когда Layer предоставляется внутри программы через Effect.provide, мемоизация не работает:
class A extends Context.Tag("A")<A, { readonly a: number }>() {}
const ALive = Layer.effect(
A,
Effect.succeed({ a: 5 }).pipe(
Effect.tap(() => Effect.log(">>> A initialized"))
)
)
// Локальное предоставление — каждый provide создаёт новый экземпляр
const program = Effect.gen(function* () {
yield* Effect.provide(A, ALive) // первая инициализация
yield* Effect.provide(A, ALive) // вторая инициализация!
})
Effect.runPromise(program)
// Output:
// timestamp=... message=">>> A initialized"
// timestamp=... message=">>> A initialized"
// ↑ A инициализирован ДВАЖДЫ!
Почему так
Локальное предоставление создаёт новый Scope для каждого вызова Effect.provide. Каждый Scope — это отдельный жизненный цикл:
Глобальный provide:
┌─── Один Scope ──────────────────────┐
│ ALive → кэш │
│ BLive → использует ALive из кэша │
│ CLive → использует ALive из кэша │
└──────────────────────────────────────┘
Локальный provide:
┌─── Scope 1 ──┐ ┌─── Scope 2 ──┐
│ ALive → new │ │ ALive → new │
└──────────────┘ └──────────────┘
Когда это приемлемо
Локальное предоставление без мемоизации полезно, когда:
- Каждый вызов должен получить свежий экземпляр (например, каждый запрос — свой контекст)
- Layer легковесный и не содержит дорогих ресурсов
- Нужна изоляция между вызовами
class RequestContext extends Context.Tag("RequestContext")<
RequestContext,
{ readonly requestId: string; readonly startedAt: number }
>() {}
// Каждый запрос получает свой контекст — мемоизация НЕ нужна
const handleRequest = (requestId: string) => {
const RequestContextLive = Layer.sync(RequestContext, () => ({
requestId,
startedAt: Date.now()
}))
return Effect.gen(function* () {
const ctx = yield* RequestContext
return `Handled ${ctx.requestId} at ${ctx.startedAt}`
}).pipe(Effect.provide(RequestContextLive))
}
Layer.fresh — отказ от мемоизации
Сигнатура
function fresh<ROut, E, RIn>(
self: Layer<ROut, E, RIn>
): Layer<ROut, E, RIn>
Layer.fresh создаёт обёртку, которая всегда конструирует новый экземпляр Layer, даже при глобальном предоставлении. Ссылочное равенство оригинала игнорируется.
Когда использовать
- Нужно несколько изолированных экземпляров одного сервиса
- Тестирование: каждый тест-кейс должен получить свежее состояние
- Сервис содержит мутабельное состояние, которое не должно разделяться
Пример
class A extends Context.Tag("A")<A, { readonly a: number }>() {}
class B extends Context.Tag("B")<B, { readonly b: string }>() {}
class C extends Context.Tag("C")<C, { readonly c: boolean }>() {}
const ALive = Layer.effect(
A,
Effect.succeed({ a: 5 }).pipe(
Effect.tap(() => Effect.log(">>> A initialized"))
)
)
const BLive = Layer.effect(
B,
Effect.gen(function* () {
const { a } = yield* A
return { b: String(a) }
})
)
const CLive = Layer.effect(
C,
Effect.gen(function* () {
const { a } = yield* A
return { c: a > 0 }
})
)
const program = Effect.gen(function* () {
yield* B
yield* C
})
// === С Layer.fresh: A конструируется дважды ===
const runnable = Effect.provide(
program,
Layer.merge(
Layer.provide(BLive, Layer.fresh(ALive)), // свежий A для B
Layer.provide(CLive, Layer.fresh(ALive)) // свежий A для C
)
)
Effect.runPromise(runnable)
// Output:
// timestamp=... message=">>> A initialized"
// timestamp=... message=">>> A initialized"
// ↑ A инициализирован ДВАЖДЫ (каждый fresh — уникальный экземпляр)
Сравнение: с и без fresh
Без fresh (мемоизация):
merge(provide(B, ALive), provide(C, ALive))
→ A создан 1 раз, B и C получают один экземпляр
С fresh:
merge(provide(B, fresh(ALive)), provide(C, fresh(ALive)))
→ A создан 2 раза, B и C получают разные экземпляры
Практический сценарий: изолированные пулы
class ConnectionPool extends Context.Tag("ConnectionPool")<
ConnectionPool,
{
readonly poolId: string
readonly getConnection: () => Effect.Effect<string>
}
>() {}
const ConnectionPoolLive = Layer.scoped(
ConnectionPool,
Effect.gen(function* () {
const poolId = `pool-${Math.random().toString(36).slice(2, 8)}`
yield* Effect.log(`Creating pool: ${poolId}`)
yield* Effect.acquireRelease(
Effect.succeed(poolId),
(id) => Effect.log(`Destroying pool: ${id}`)
)
return {
poolId,
getConnection: () => Effect.succeed(`conn@${poolId}`)
}
})
)
// Два сервиса, каждый со своим изолированным пулом
class ReadService extends Context.Tag("ReadService")<
ReadService,
{ readonly read: (id: string) => Effect.Effect<string> }
>() {}
class WriteService extends Context.Tag("WriteService")<
WriteService,
{ readonly write: (data: string) => Effect.Effect<void> }
>() {}
const ReadServiceLive = Layer.effect(ReadService, Effect.gen(function* () {
const pool = yield* ConnectionPool
return { read: (id) => Effect.succeed(`read ${id} from ${pool.poolId}`) }
}))
const WriteServiceLive = Layer.effect(WriteService, Effect.gen(function* () {
const pool = yield* ConnectionPool
return { write: (data) => Effect.log(`write "${data}" to ${pool.poolId}`) }
}))
// Каждый сервис получает СВОЙ пул (изоляция read/write)
const MainLive = Layer.merge(
ReadServiceLive.pipe(Layer.provide(Layer.fresh(ConnectionPoolLive))),
WriteServiceLive.pipe(Layer.provide(Layer.fresh(ConnectionPoolLive)))
)
const program = Effect.gen(function* () {
const reader = yield* ReadService
const writer = yield* WriteService
const data = yield* reader.read("user:1")
yield* writer.write(data)
yield* Effect.log(`Read pool != Write pool: isolated!`)
})
Effect.runPromise(Effect.provide(program, MainLive))
// Output:
// Creating pool: pool-abc123 ← Пул для ReadService
// Creating pool: pool-def456 ← Пул для WriteService (отдельный!)
// write "read user:1 from pool-abc123" to pool-def456
// Read pool != Write pool: isolated!
// Destroying pool: pool-def456
// Destroying pool: pool-abc123
Layer.memoize — ручная мемоизация
Сигнатура
function memoize<ROut, E, RIn>(
self: Layer<ROut, E, RIn>
): Effect<Layer<ROut, E, RIn>, never, Scope>
Layer.memoize возвращает scoped Effect, который при вычислении возвращает мемоизированную версию Layer. Это позволяет управлять мемоизацией вручную, что особенно полезно при локальном предоставлении.
Когда использовать
- Нужна мемоизация при локальном предоставлении
- Хочется явно контролировать, когда Layer конструируется и когда финализируется
- Тестовые сценарии, где мемоизация должна быть scoped
Пример: решение проблемы локального предоставления
class A extends Context.Tag("A")<A, { readonly a: number }>() {}
const ALive = Layer.effect(
A,
Effect.succeed({ a: 5 }).pipe(
Effect.tap(() => Effect.log(">>> A initialized"))
)
)
// === Без мемоизации: A создаётся дважды ===
const programBad = Effect.gen(function* () {
yield* Effect.provide(A, ALive)
yield* Effect.provide(A, ALive)
})
// Output: ">>> A initialized" × 2
// === С ручной мемоизацией: A создаётся один раз ===
const programGood = Effect.scoped(
Layer.memoize(ALive).pipe(
Effect.andThen((memoized) =>
Effect.gen(function* () {
yield* Effect.provide(A, memoized) // первое использование → конструирование
yield* Effect.provide(A, memoized) // второе использование → из кэша
})
)
)
)
Effect.runPromise(programGood)
// Output:
// timestamp=... message=">>> A initialized" ← ОДИН раз!
Жизненный цикл мемоизированного Layer
Effect.scoped(
Layer.memoize(ALive).pipe( ← 1. Создаётся "holder" для мемоизации
Effect.andThen((memoized) =>
Effect.gen(function* () {
yield* use(memoized) ← 2. Первое использование: ALive конструируется
yield* use(memoized) ← 3. Второе использование: из кэша
})
)
)
) ← 4. Scope закрывается: финализаторы ALive вызываются
Пример: мемоизация с ресурсами
class DbConnection extends Context.Tag("DbConnection")<
DbConnection,
{ readonly connectionId: string }
>() {}
const DbConnectionLive = Layer.scoped(
DbConnection,
Effect.gen(function* () {
const id = `db-${Math.random().toString(36).slice(2, 8)}`
yield* Effect.acquireRelease(
Effect.log(`[DB] Opening connection: ${id}`).pipe(Effect.as(id)),
(connId) => Effect.log(`[DB] Closing connection: ${connId}`)
)
return { connectionId: id }
})
)
// Мемоизируем: одно соединение для нескольких операций
const program = Effect.scoped(
Layer.memoize(DbConnectionLive).pipe(
Effect.andThen((memoizedDb) =>
Effect.gen(function* () {
// Операция 1: использует соединение
const conn1 = yield* Effect.provide(DbConnection, memoizedDb)
yield* Effect.log(`Op1 uses: ${conn1.connectionId}`)
// Операция 2: повторно использует ТО ЖЕ соединение
const conn2 = yield* Effect.provide(DbConnection, memoizedDb)
yield* Effect.log(`Op2 uses: ${conn2.connectionId}`)
yield* Effect.log(`Same connection: ${conn1.connectionId === conn2.connectionId}`)
})
)
)
)
Effect.runPromise(program)
// Output:
// [DB] Opening connection: db-abc123
// Op1 uses: db-abc123
// Op2 uses: db-abc123
// Same connection: true
// [DB] Closing connection: db-abc123 ← закрывается при выходе из scoped
Ссылочное равенство
Правило
Мемоизация работает через === сравнение Layer:
// ✅ Одна ссылка — мемоизируется
const layer = Layer.succeed(Tag, value)
Layer.merge(
Layer.provide(A, layer),
Layer.provide(B, layer)
)
// layer сконструирован 1 раз
// ❌ Две ссылки — НЕ мемоизируется
Layer.merge(
Layer.provide(A, Layer.succeed(Tag, value)), // анонимный Layer 1
Layer.provide(B, Layer.succeed(Tag, value)) // анонимный Layer 2
)
// Tag сконструирован 2 раза
Типичные ошибки
class Config extends Context.Tag("Config")<Config, { readonly id: string }>() {}
// ❌ Ошибка 1: Layer создаётся внутри функции
const makeConfig = () => Layer.succeed(Config, { id: crypto.randomUUID() })
// Каждый вызов — новая ссылка!
const badMain = Layer.merge(
someLayer.pipe(Layer.provide(makeConfig())), // ← ссылка A
otherLayer.pipe(Layer.provide(makeConfig())) // ← ссылка B (другая!)
)
// ✅ Правильно: сохранить в переменную
const ConfigLive = Layer.succeed(Config, { id: crypto.randomUUID() })
const goodMain = Layer.merge(
someLayer.pipe(Layer.provide(ConfigLive)), // ← ссылка A
otherLayer.pipe(Layer.provide(ConfigLive)) // ← ссылка A (та же!)
)
// ❌ Ошибка 2: пересоздание Layer в цикле
const layers = [1, 2, 3].map((n) =>
Layer.succeed(Config, { id: String(n) }) // каждый раз новая ссылка!
)
// ✅ Правильно: определить один раз снаружи
const ConfigLive2 = Layer.succeed(Config, { id: "shared" })
const layers2 = [1, 2, 3].map(() => ConfigLive2) // одна ссылка
// ❌ Ошибка 3: .pipe создаёт новую ссылку на обёртку, но базовый Layer тот же
// На самом деле это работает корректно — внутренний Layer сохраняет ссылку
const ConfigLive3 = Layer.succeed(Config, { id: "ok" })
const withTap = ConfigLive3.pipe(Layer.tap(() => Effect.log("tapped")))
// withTap — это НОВЫЙ Layer, но ConfigLive3 внутри остаётся тем же
Стратегии sharing в production
Стратегия 1: Singleton через глобальный provide (по умолчанию)
Самая распространённая стратегия — все сервисы создаются один раз:
class Database extends Context.Tag("Database")<Database, { readonly id: string }>() {}
const DatabaseLive = Layer.scoped(Database, Effect.gen(function* () {
const id = `db-${Date.now()}`
yield* Effect.acquireRelease(
Effect.log(`DB opened: ${id}`).pipe(Effect.as(id)),
(connId) => Effect.log(`DB closed: ${connId}`)
)
return { id }
}))
// Все сервисы, зависящие от Database, получат один экземпляр
const MainLive = Layer.mergeAll(
ServiceA.pipe(Layer.provide(DatabaseLive)),
ServiceB.pipe(Layer.provide(DatabaseLive)),
ServiceC.pipe(Layer.provide(DatabaseLive))
)
// Database сконструирован 1 раз, разделяется между A, B, C
Стратегия 2: Per-request isolation через fresh
Для сервисов, которые должны быть изолированы на каждый запрос:
class RequestScope extends Context.Tag("RequestScope")<
RequestScope,
{ readonly requestId: string; readonly startedAt: number }
>() {}
const RequestScopeLive = Layer.sync(RequestScope, () => ({
requestId: crypto.randomUUID(),
startedAt: Date.now()
}))
// Каждый обработчик запроса получает свой RequestScope
const handleRequest = (path: string) =>
Effect.gen(function* () {
const scope = yield* RequestScope
yield* Effect.log(`[${scope.requestId}] Handling ${path}`)
return { requestId: scope.requestId, path }
}).pipe(
Effect.provide(Layer.fresh(RequestScopeLive))
)
const program = Effect.gen(function* () {
// Три запроса — три разных RequestScope
const r1 = yield* handleRequest("/api/users")
const r2 = yield* handleRequest("/api/orders")
const r3 = yield* handleRequest("/api/products")
return [r1, r2, r3] as const
})
Effect.runPromise(program).then(console.log)
// Каждый запрос имеет уникальный requestId
Стратегия 3: Tiered sharing
Разные уровни мемоизации для разных архитектурных слоёв:
class Config extends Context.Tag("Config")<Config, { readonly env: string }>() {}
class DbPool extends Context.Tag("DbPool")<DbPool, { readonly poolId: string }>() {}
class RequestLogger extends Context.Tag("RequestLogger")<RequestLogger, { readonly reqId: string }>() {}
class UserService extends Context.Tag("UserService")<UserService, { readonly find: (id: string) => Effect.Effect<string> }>() {}
// Singleton: Config и DbPool — создаются один раз
const ConfigLive = Layer.succeed(Config, { env: "production" })
const DbPoolLive = Layer.scoped(DbPool, Effect.gen(function* () {
const id = `pool-${Date.now()}`
yield* Effect.log(`Pool created: ${id}`)
yield* Effect.addFinalizer(() => Effect.log(`Pool destroyed: ${id}`))
return { poolId: id }
}))
// Per-request: RequestLogger — свежий для каждого запроса
const RequestLoggerLive = Layer.sync(RequestLogger, () => ({
reqId: crypto.randomUUID().slice(0, 8)
}))
const UserServiceLive = Layer.effect(UserService, Effect.gen(function* () {
const pool = yield* DbPool
const reqLog = yield* RequestLogger
return {
find: (id) => Effect.succeed(`user:${id} from ${pool.poolId} [req:${reqLog.reqId}]`)
}
}))
// Singleton слой — конструируется один раз
const SingletonLive = DbPoolLive.pipe(Layer.provideMerge(ConfigLive))
// Per-request Layer
const buildRequestLayer = () =>
UserServiceLive.pipe(
Layer.provide(Layer.fresh(RequestLoggerLive)), // fresh для каждого запроса
Layer.provide(SingletonLive) // singleton для пула
)
// Обработка запросов
const handleReq = (n: number) =>
Effect.gen(function* () {
const users = yield* UserService
return yield* users.find(`user-${n}`)
}).pipe(Effect.provide(buildRequestLayer()))
const program = Effect.gen(function* () {
const r1 = yield* handleReq(1)
const r2 = yield* handleReq(2)
yield* Effect.log(`R1: ${r1}`)
yield* Effect.log(`R2: ${r2}`)
})
Effect.runPromise(program)
// Pool created: pool-1234567890 ← один раз
// R1: user:user-1 from pool-1234567890 [req:abc12345]
// R2: user:user-2 from pool-1234567890 [req:def67890] ← другой reqId!
// Pool destroyed: pool-1234567890 ← один раз
Стратегия 4: Тестирование с изоляцией
class Database extends Context.Tag("Database")<
Database,
{
readonly data: Map<string, string>
readonly get: (key: string) => Effect.Effect<string | undefined>
readonly set: (key: string, value: string) => Effect.Effect<void>
}
>() {}
// Каждый тест получает свежую базу
const DatabaseTest = Layer.sync(Database, () => {
const data = new Map<string, string>()
return {
data,
get: (key) => Effect.sync(() => data.get(key)),
set: (key, value) => Effect.sync(() => { data.set(key, value) })
}
})
// Утилита для тестов: каждый тест изолирован
const withFreshDb = <A, E>(test: Effect.Effect<A, E, Database>) =>
Effect.provide(test, Layer.fresh(DatabaseTest))
// Тесты
const test1 = withFreshDb(Effect.gen(function* () {
const db = yield* Database
yield* db.set("key", "value1")
const result = yield* db.get("key")
console.assert(result === "value1", "test1 passed")
}))
const test2 = withFreshDb(Effect.gen(function* () {
const db = yield* Database
// Эта база ПУСТАЯ — изолирована от test1
const result = yield* db.get("key")
console.assert(result === undefined, "test2 passed — isolation works!")
}))
const testSuite = Effect.all([test1, test2])
Effect.runPromise(testSuite)
API Reference
Layer.fresh [STABLE]
function fresh<ROut, E, RIn>(
self: Layer<ROut, E, RIn>
): Layer<ROut, E, RIn>
Создаёт Layer, который не будет мемоизирован при глобальном предоставлении. Каждое использование создаёт новый экземпляр.
Layer.memoize [STABLE]
function memoize<ROut, E, RIn>(
self: Layer<ROut, E, RIn>
): Effect<Layer<ROut, E, RIn>, never, Scope>
Возвращает scoped Effect, который при вычислении даёт мемоизированную версию Layer. Мемоизация привязана к Scope.
Таблица поведения мемоизации
| Сценарий | Мемоизация | Как |
|---|---|---|
| Глобальный provide, одна ссылка | ✅ Автоматическая | По умолчанию |
Глобальный provide, Layer.fresh | ❌ Отключена | Layer.fresh(layer) |
| Локальный provide | ❌ Нет | По дизайну |
Локальный provide, Layer.memoize | ✅ Ручная | Effect.scoped(Layer.memoize(layer).pipe(...)) |
| Разные ссылки | ❌ Нет | Ссылочное равенство не выполняется |
Примеры
Пример: Мемоизация в микросервисной архитектуре
// === Shared infrastructure ===
class Config extends Context.Tag("Config")<
Config,
{ readonly region: string; readonly instanceId: string }
>() {}
class TelemetryClient extends Context.Tag("TelemetryClient")<
TelemetryClient,
{
readonly clientId: string
readonly send: (event: string) => Effect.Effect<void>
}
>() {}
class DatabasePool extends Context.Tag("DatabasePool")<
DatabasePool,
{
readonly poolId: string
readonly query: (sql: string) => Effect.Effect<ReadonlyArray<unknown>>
}
>() {}
// Каждый из этих Layer должен быть singleton
const ConfigLive = Layer.effect(Config, Effect.gen(function* () {
const instanceId = `inst-${Math.random().toString(36).slice(2, 6)}`
yield* Effect.log(`Config created: region=us-east-1, instance=${instanceId}`)
return { region: "us-east-1", instanceId }
}))
const TelemetryClientLive = Layer.scoped(TelemetryClient, Effect.gen(function* () {
const config = yield* Config
const clientId = `tel-${config.instanceId}`
yield* Effect.acquireRelease(
Effect.log(`Telemetry client created: ${clientId}`).pipe(
Effect.as(clientId)
),
(id) => Effect.log(`Telemetry client destroyed: ${id}`)
)
return {
clientId,
send: (event) => Effect.log(`[${clientId}] Event: ${event}`)
}
}))
const DatabasePoolLive = Layer.scoped(DatabasePool, Effect.gen(function* () {
const config = yield* Config
const poolId = `pool-${config.instanceId}`
yield* Effect.acquireRelease(
Effect.log(`DB pool created: ${poolId} (region: ${config.region})`).pipe(
Effect.as(poolId)
),
(id) => Effect.log(`DB pool destroyed: ${id}`)
)
return {
poolId,
query: (sql) => Effect.succeed([{ sql, pool: poolId }])
}
}))
// === Services using shared infrastructure ===
class UserService extends Context.Tag("UserService")<
UserService,
{ readonly getUser: (id: string) => Effect.Effect<string> }
>() {}
class OrderService extends Context.Tag("OrderService")<
OrderService,
{ readonly getOrder: (id: string) => Effect.Effect<string> }
>() {}
class NotificationService extends Context.Tag("NotificationService")<
NotificationService,
{ readonly notify: (userId: string, msg: string) => Effect.Effect<void> }
>() {}
const UserServiceLive = Layer.effect(UserService, Effect.gen(function* () {
const db = yield* DatabasePool
const tel = yield* TelemetryClient
return {
getUser: (id) => Effect.gen(function* () {
yield* tel.send(`user.get:${id}`)
const rows = yield* db.query(`SELECT * FROM users WHERE id='${id}'`)
return `User(${id}) from ${db.poolId}`
})
}
}))
const OrderServiceLive = Layer.effect(OrderService, Effect.gen(function* () {
const db = yield* DatabasePool
const tel = yield* TelemetryClient
return {
getOrder: (id) => Effect.gen(function* () {
yield* tel.send(`order.get:${id}`)
return `Order(${id}) from ${db.poolId}`
})
}
}))
const NotificationServiceLive = Layer.effect(NotificationService, Effect.gen(function* () {
const tel = yield* TelemetryClient
return {
notify: (userId, msg) => Effect.gen(function* () {
yield* tel.send(`notification.sent:${userId}`)
yield* Effect.log(`[NOTIFY] ${userId}: ${msg}`)
})
}
}))
// === Composition: all share Config, TelemetryClient, and DatabasePool ===
// Shared base (будет мемоизирована автоматически)
const SharedBase = Layer.mergeAll(
TelemetryClientLive,
DatabasePoolLive
).pipe(Layer.provideMerge(ConfigLive))
// All services
const AllServices = Layer.mergeAll(
UserServiceLive,
OrderServiceLive,
NotificationServiceLive
).pipe(Layer.provide(SharedBase))
// === Program ===
const program = Effect.gen(function* () {
const users = yield* UserService
const orders = yield* OrderService
const notif = yield* NotificationService
const user = yield* users.getUser("u1")
const order = yield* orders.getOrder("o1")
yield* notif.notify("u1", "Your order is ready!")
yield* Effect.log(`User: ${user}`)
yield* Effect.log(`Order: ${order}`)
})
Effect.runPromise(Effect.provide(program, AllServices))
// Output:
// Config created: region=us-east-1, instance=ab12 ← 1 раз
// Telemetry client created: tel-ab12 ← 1 раз
// DB pool created: pool-ab12 (region: us-east-1) ← 1 раз
// [tel-ab12] Event: user.get:u1
// [tel-ab12] Event: order.get:o1
// [tel-ab12] Event: notification.sent:u1
// [NOTIFY] u1: Your order is ready!
// User: User(u1) from pool-ab12
// Order: Order(o1) from pool-ab12
// DB pool destroyed: pool-ab12 ← 1 раз
// Telemetry client destroyed: tel-ab12 ← 1 раз
Упражнения
Basic
Упражнение 1: Проверка мемоизации
Создайте Layer, который логирует своё создание. Используйте его в двух разных сервисах через merge. Проверьте, что Layer создаётся один раз при глобальном предоставлении и два раза при использовании Layer.fresh.
// 1. Создайте CounterService с Layer, который инкрементирует глобальный счётчик
// 2. Создайте ServiceA и ServiceB, оба зависят от CounterService
// 3. Проверьте: при merge + глобальном provide — счётчик = 1
// 4. Проверьте: при merge + fresh — счётчик = 2
Решение:
let initCount = 0
class Counter extends Context.Tag("Counter")<
Counter,
{ readonly instanceNumber: number }
>() {}
class ServiceA extends Context.Tag("ServiceA")<
ServiceA,
{ readonly counterInstance: number }
>() {}
class ServiceB extends Context.Tag("ServiceB")<
ServiceB,
{ readonly counterInstance: number }
>() {}
const CounterLive = Layer.effect(
Counter,
Effect.sync(() => {
initCount++
console.log(`Counter initialized: instance #${initCount}`)
return { instanceNumber: initCount }
})
)
const ServiceALive = Layer.effect(ServiceA, Effect.gen(function* () {
const counter = yield* Counter
return { counterInstance: counter.instanceNumber }
}))
const ServiceBLive = Layer.effect(ServiceB, Effect.gen(function* () {
const counter = yield* Counter
return { counterInstance: counter.instanceNumber }
}))
// === Test 1: мемоизация (глобальный provide) ===
initCount = 0
const MemoizedMain = Layer.merge(
ServiceALive.pipe(Layer.provide(CounterLive)),
ServiceBLive.pipe(Layer.provide(CounterLive))
)
const test1 = Effect.gen(function* () {
const a = yield* ServiceA
const b = yield* ServiceB
console.log(`Memoized: A=${a.counterInstance}, B=${b.counterInstance}, total inits=${initCount}`)
console.assert(a.counterInstance === b.counterInstance, "Same instance!")
console.assert(initCount === 1, "Only 1 init!")
})
Effect.runPromise(Effect.provide(test1, MemoizedMain)).then(() => {
// === Test 2: fresh (без мемоизации) ===
initCount = 0
const FreshMain = Layer.merge(
ServiceALive.pipe(Layer.provide(Layer.fresh(CounterLive))),
ServiceBLive.pipe(Layer.provide(Layer.fresh(CounterLive)))
)
const test2 = Effect.gen(function* () {
const a = yield* ServiceA
const b = yield* ServiceB
console.log(`Fresh: A=${a.counterInstance}, B=${b.counterInstance}, total inits=${initCount}`)
console.assert(a.counterInstance !== b.counterInstance, "Different instances!")
console.assert(initCount === 2, "Two inits!")
})
Effect.runPromise(Effect.provide(test2, FreshMain))
})
Intermediate
Упражнение 2: Layer.memoize для локального предоставления
// Проблема: вы используете Layer.provide внутри Effect.gen (локально)
// и хотите, чтобы дорогой Layer (DatabasePool) был создан один раз.
//
// Используйте Layer.memoize для решения.
// Программа: выполнить 5 запросов к базе, каждый через локальный provide,
// но с одним пулом.
Решение:
class DbPool extends Context.Tag("DbPool")<
DbPool,
{ readonly poolId: string; readonly query: (sql: string) => Effect.Effect<string> }
>() {}
const DbPoolLive = Layer.scoped(
DbPool,
Effect.gen(function* () {
const poolId = `pool-${Math.random().toString(36).slice(2, 8)}`
yield* Effect.acquireRelease(
Effect.log(`Pool CREATED: ${poolId}`).pipe(Effect.as(poolId)),
(id) => Effect.log(`Pool DESTROYED: ${id}`)
)
return {
poolId,
query: (sql) => Effect.succeed(`[${poolId}] Result of: ${sql}`)
}
})
)
const executeQuery = (sql: string, layer: Layer.Layer<DbPool>) =>
Effect.gen(function* () {
const pool = yield* DbPool
return yield* pool.query(sql)
}).pipe(Effect.provide(layer))
const program = Effect.scoped(
Layer.memoize(DbPoolLive).pipe(
Effect.andThen((memoizedPool) =>
Effect.gen(function* () {
// 5 локальных provide, но один пул
const results: string[] = []
for (let i = 1; i <= 5; i++) {
const result = yield* executeQuery(`SELECT ${i}`, memoizedPool)
results.push(result)
}
yield* Effect.log(`All queries used same pool: ${results.every((r) => r.includes(results[0]!.split("]")[0]!))}`)
return results
})
)
)
)
Effect.runPromise(program).then(console.log)
// Pool CREATED: pool-abc123 ← один раз
// All queries used same pool: true
// ["[pool-abc123] Result of: SELECT 1", ..., "[pool-abc123] Result of: SELECT 5"]
// Pool DESTROYED: pool-abc123 ← один раз
Advanced
Упражнение 3: Tiered memoization — singleton + per-request + per-operation
// Реализуйте три уровня мемоизации:
//
// 1. SINGLETON: Config, DbPool — создаются один раз на всё приложение
// 2. PER-REQUEST: RequestContext (requestId, userId) — свежий на каждый запрос
// 3. PER-OPERATION: TransactionContext — свежий на каждую операцию внутри запроса
//
// Программа: обработать 2 запроса, каждый с 3 операциями
// Проверить:
// - Config и DbPool одинаковы между запросами
// - RequestContext разный между запросами
// - TransactionContext разный между операциями
Решение:
// === Singleton ===
class Config extends Context.Tag("Config")<Config, { readonly instanceId: string }>() {}
class DbPool extends Context.Tag("DbPool")<DbPool, { readonly poolId: string }>() {}
const ConfigLive = Layer.effect(Config, Effect.gen(function* () {
const id = `cfg-${Math.random().toString(36).slice(2, 6)}`
yield* Effect.log(`[SINGLETON] Config: ${id}`)
return { instanceId: id }
}))
const DbPoolLive = Layer.scoped(DbPool, Effect.gen(function* () {
const config = yield* Config
const poolId = `pool-${config.instanceId}`
yield* Effect.acquireRelease(
Effect.log(`[SINGLETON] DbPool: ${poolId}`).pipe(Effect.as(poolId)),
(id) => Effect.log(`[SINGLETON] DbPool destroyed: ${id}`)
)
return { poolId }
}))
const SingletonLive = DbPoolLive.pipe(Layer.provideMerge(ConfigLive))
// === Per-request ===
class RequestCtx extends Context.Tag("RequestCtx")<
RequestCtx,
{ readonly requestId: string }
>() {}
const RequestCtxLive = Layer.sync(RequestCtx, () => {
const id = `req-${Math.random().toString(36).slice(2, 6)}`
console.log(` [PER-REQUEST] RequestCtx: ${id}`)
return { requestId: id }
})
// === Per-operation ===
class TxCtx extends Context.Tag("TxCtx")<
TxCtx,
{ readonly txId: string }
>() {}
const TxCtxLive = Layer.sync(TxCtx, () => {
const id = `tx-${Math.random().toString(36).slice(2, 6)}`
console.log(` [PER-OP] TxCtx: ${id}`)
return { txId: id }
})
// === Operations ===
const executeOperation = (opName: string) =>
Effect.gen(function* () {
const config = yield* Config
const pool = yield* DbPool
const req = yield* RequestCtx
const tx = yield* TxCtx
return `${opName}: cfg=${config.instanceId} pool=${pool.poolId} req=${req.requestId} tx=${tx.txId}`
}).pipe(
Effect.provide(Layer.fresh(TxCtxLive)) // fresh per operation
)
const handleRequest = (requestName: string) =>
Effect.gen(function* () {
yield* Effect.log(`\nHandling ${requestName}:`)
const results: string[] = []
for (const op of ["read", "validate", "write"]) {
const result = yield* executeOperation(op)
results.push(result)
}
return results
}).pipe(
Effect.provide(Layer.fresh(RequestCtxLive)), // fresh per request
Effect.provide(SingletonLive) // singleton shared
)
const program = Effect.gen(function* () {
yield* Effect.log("=== Application Start ===")
const req1Results = yield* handleRequest("Request-1")
const req2Results = yield* handleRequest("Request-2")
yield* Effect.log("\n=== Verification ===")
// All operations share same config and pool
const allResults = [...req1Results, ...req2Results]
const configs = allResults.map((r) => r.split("cfg=")[1]!.split(" ")[0]!)
const pools = allResults.map((r) => r.split("pool=")[1]!.split(" ")[0]!)
const reqs = allResults.map((r) => r.split("req=")[1]!.split(" ")[0]!)
const txs = allResults.map((r) => r.split("tx=")[1]!)
yield* Effect.log(`Configs unique: ${new Set(configs).size} (expected: 1)`)
yield* Effect.log(`Pools unique: ${new Set(pools).size} (expected: 1)`)
yield* Effect.log(`Requests unique: ${new Set(reqs).size} (expected: 2)`)
yield* Effect.log(`Transactions unique: ${new Set(txs).size} (expected: 6)`)
})
Effect.runPromise(program)
🔗 Предыдущая: Вертикальные пайплайны
🔗 Следующий модуль: Модуль 05: Scope & Resources — управление жизненным циклом ресурсов