Effect Курс Кэширование слоёв

Кэширование слоёв

Как 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 по ссылочному равенству. Это означает:

  1. Каждый уникальный Layer (по ссылке ===) конструируется ровно один раз
  2. Результат (сконструированный сервис) разделяется между всеми потребителями
  3. Финализация происходит один раз при закрытии 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 — управление жизненным циклом ресурсов