Effect Курс Layer<ROut, E, RIn>

Layer<ROut, E, RIn>

Layer — это декларативный blueprint для конструирования сервисов.

Теория

Зачем нужен Layer

В предыдущем модуле мы узнали, как определять сервисы через Context.Tag и предоставлять их с помощью Effect.provideService. Этот подход работает для простых случаев, но в production-системах сервисы редко существуют изолированно. Типичная ситуация:

DatabaseService зависит от ConfigService и LoggerService
LoggerService зависит от ConfigService
CacheService зависит от ConfigService и DatabaseService

Если мы попробуем вручную передавать зависимости через Effect.provideService, то столкнёмся с рядом проблем:

  • Утечка деталей реализации — интерфейс сервиса начинает содержать информацию о своих зависимостях
  • Ручное управление порядком инициализации — мы должны сами следить, что Config создан раньше Logger
  • Дублирование инстансов — один и тот же Config может быть создан несколько раз
  • Отсутствие управления ресурсами — нет гарантии, что соединения будут закрыты при завершении

Layer решает все эти проблемы, предоставляя декларативный способ описания графа зависимостей с автоматическим разрешением, мемоизацией и управлением жизненным циклом.

Ментальная модель

Представьте Layer как рецепт или чертёж для создания сервиса:

┌─────────────────────────────────────────────────┐
│                    Layer                         │
│                                                  │
│   RequirementsIn ──────► [Construction] ──────► RequirementsOut  │
│   (ингредиенты)          (процесс)       (готовый продукт)       │
│                              │                   │
│                              ▼                   │
│                           Error                  │
│                      (что может                  │
│                       пойти не так)              │
└─────────────────────────────────────────────────┘

Важно понимать: Layer — это описание, а не результат. Он не выполняет конструирование до тех пор, пока не будет предоставлен программе через Effect.provide. Это ключевое свойство — Layer ленив.

Разница между Layer и Effect

Новички часто путают Layer и Effect. Вот принципиальные различия:

Effect<A, E, R>  — описание вычисления, которое:
  • Производит значение типа A
  • Может завершиться ошибкой типа E
  • Требует окружение типа R
  • Выполняется КАЖДЫЙ раз при запуске

Layer<ROut, E, RIn> — описание конструирования сервиса, которое:
  • Производит сервис типа ROut (добавляет в Context)
  • Может завершиться ошибкой типа E при конструировании
  • Требует сервисы типа RIn для конструирования
  • Выполняется ОДИН раз (мемоизация по умолчанию)

Ещё одно важное различие — направление потока данных. В Effect<A, E, R> тип R стоит на третьем месте и означает “что мне нужно”. В Layer<ROut, E, RIn> тип ROut стоит на первом месте и означает “что я произвожу”, а RIn на третьем — “что мне нужно для производства”.


Концепция ФП

Layer как морфизм в категории Context

С точки зрения теории категорий, Layer можно рассматривать как морфизм (стрелку) между контекстами:

Layer<ROut, E, RIn> : Context<RIn> ──► Context<ROut>

Это объясняет, почему Layer поддерживает композицию — морфизмы в категории естественно компонуются:

Layer<A, never, B>  :  Context<B> ──► Context<A>
Layer<B, never, C>  :  Context<C> ──► Context<B>
─────────────────────────────────────────────────
Layer<A, never, C>  :  Context<C> ──► Context<A>   (вертикальная композиция)

Layer как Reader + Resource

Если провести аналогию с классическими паттернами ФП:

  • Reader Monad предоставляет доступ к окружению — Layer делает то же через RIn
  • Resource Monad управляет жизненным циклом — Layer делает то же через интеграцию с Scope
  • Coproduct / Sum описывает объединение — Layer использует union types для RIn и ROut

Layer объединяет все три концепции в единую абстракцию, предоставляя type-safe конструирование с управлением зависимостями и ресурсами.

Принцип инверсии зависимостей (DIP)

Layer — это реализация Dependency Inversion Principle из SOLID на уровне типов:

                    БЕЗ LAYER                          С LAYER
              ┌──────────────────┐             ┌──────────────────┐
              │   Application    │             │   Application    │
              │                  │             │                  │
              │  new Database()  │             │  yield* Database │
              │  new Logger()    │             │  yield* Logger   │
              └──────────────────┘             └────────┬─────────┘
                      │                                 │
                      ▼                                 ▼
              Конкретные классы              Абстракции (Tag/Interface)
              (жёсткая связь)                          │

                                               Layer (конструктор)
                                               (связывание на границе)

Программный код зависит только от абстракций (Tag), а конкретные реализации предоставляются через Layer на границе приложения.


Анатомия типа Layer

Сигнатура типа

// Полная сигнатура
type Layer<out ROut, out E = never, in RIn = never>

Разберём каждый параметр типа:

        ┌─── Сервис(ы), которые Layer производит (ковариантный)
        │              ┌─── Ошибка при конструировании (ковариантный)
        │              │        ┌─── Зависимости для конструирования (контравариантный)
        ▼              ▼        ▼
Layer<ROut,           E,      RIn>
ПараметрВариантностьПо умолчаниюОписание
ROutout (ковариантный)Сервис(ы), добавляемые в Context
Eout (ковариантный)neverТип ошибки при конструировании
RInin (контравариантный)neverТребуемые зависимости

Примеры типов


// Layer без зависимостей и без ошибок
// "Я произвожу Config, мне ничего не нужно"
type SimpleLayer = Layer.Layer<Config>
// Эквивалент: Layer.Layer<Config, never, never>

// Layer с зависимостями
// "Я произвожу Logger, мне нужен Config"
type DependentLayer = Layer.Layer<Logger, never, Config>

// Layer с возможной ошибкой
// "Я произвожу Database, могу упасть с DbError, мне нужны Config и Logger"
type FailableLayer = Layer.Layer<Database, DbError, Config | Logger>

// Layer производящий несколько сервисов
// "Я произвожу и Config, и Logger одновременно"
type MultiOutputLayer = Layer.Layer<Config | Logger>

Вариантность на практике

Ковариантность ROut означает, что Layer производящий больше сервисов можно использовать там, где нужен Layer производящий меньше:


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

declare const fullLayer: Layer.Layer<Config | Logger>

// Можно использовать там, где нужен только Config
// потому что ROut ковариантен
const program: Effect.Effect<void, never, Config> = Effect.gen(function* () {
  const config = yield* Config
  console.log(config.port)
})

// fullLayer предоставляет Config | Logger, что покрывает Config
const runnable = Effect.provide(program, fullLayer)

Контравариантность RIn означает, что Layer требующий меньше зависимостей можно использовать там, где ожидается Layer с большими требованиями — ведь он сможет работать в более богатом окружении.


Связь с Context и Tag

От Tag к Layer

В Module 03 мы определяли сервисы и предоставляли их напрямую:


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

// Прямое предоставление — работает для простых случаев
const program = Effect.gen(function* () {
  const config = yield* Config
  return `${config.host}:${config.port}`
})

const runnable = Effect.provideService(program, Config, {
  port: 3000,
  host: "localhost"
})

Layer абстрагирует этот процесс, позволяя описать как создать сервис:


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

// Layer описывает КАК создать Config
const ConfigLive: Layer.Layer<Config> = Layer.succeed(Config, {
  port: 3000,
  host: "localhost"
})

// Программа не знает, как Config создаётся
const program = Effect.gen(function* () {
  const config = yield* Config
  return `${config.host}:${config.port}`
})

// Подключение Layer к программе
const runnable = Effect.provide(program, ConfigLive)

Naming Convention

В Effect-сообществе принято соглашение об именах:

СуффиксНазначениеПример
LiveProduction-реализацияDatabaseLive
TestТестовая реализацияDatabaseTest
MockMock с контролируемым поведениемDatabaseMock
DevВерсия для локальной разработкиDatabaseDev

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

// Production: реальное подключение к PostgreSQL
const DatabaseLive: Layer.Layer<Database, never, Config> = Layer.effect(
  Database,
  Effect.gen(function* () {
    const config = yield* Config
    // ... реальное подключение
    return { query: (sql) => Effect.succeed({ rows: [] }) }
  })
)

// Test: in-memory реализация
const DatabaseTest: Layer.Layer<Database> = Layer.succeed(Database, {
  query: (sql) => Effect.succeed({ rows: [{ id: 1 }] })
})

Граф зависимостей

Визуализация

Рассмотрим типичное приложение с несколькими сервисами:

                    ┌──────────┐
                    │  Config  │  Layer<Config>
                    │ (no deps)│
                    └────┬─────┘

              ┌──────────┴──────────┐
              │                     │
        ┌─────▼─────┐        ┌─────▼─────┐
        │  Logger   │        │   Cache   │
        │ needs:    │        │ needs:    │
        │  Config   │        │  Config   │
        └─────┬─────┘        └─────┬─────┘
              │                     │
              └──────────┬──────────┘

                   ┌─────▼─────┐
                   │ Database  │
                   │ needs:    │
                   │  Config   │
                   │  Logger   │
                   │  Cache    │
                   └───────────┘

Каждый узел в этом графе — это Layer. Стрелки показывают направление зависимостей. Layer автоматически:

  1. Топологически сортирует зависимости — Config создаётся первым
  2. Мемоизирует — Config создаётся один раз и разделяется между Logger, Cache и Database
  3. Управляет жизненным циклом — ресурсы освобождаются в обратном порядке

Типы в графе


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

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

class Cache extends Context.Tag("Cache")<
  Cache,
  { readonly get: (key: string) => Effect.Effect<string | null> }
>() {}

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

// Типы Layer отражают граф:
const ConfigLive:   Layer.Layer<Config>                          = Layer.succeed(Config, { dbUrl: "postgres://...", cacheUrl: "redis://..." })
const LoggerLive:   Layer.Layer<Logger, never, Config>           = Layer.effect(Logger, Effect.gen(function* () { /* ... */ yield* Config; return { log: (msg) => Effect.log(msg) } }))
const CacheLive:    Layer.Layer<Cache, never, Config>            = Layer.effect(Cache, Effect.gen(function* () { /* ... */ yield* Config; return { get: (_key) => Effect.succeed(null) } }))
const DatabaseLive: Layer.Layer<Database, never, Config | Logger | Cache> = Layer.effect(Database, Effect.gen(function* () {
  const config = yield* Config
  const logger = yield* Logger
  const cache = yield* Cache
  return {
    query: (sql) => Effect.gen(function* () {
      yield* logger.log(`Executing: ${sql}`)
      return { rows: [] }
    })
  }
}))

Жизненный цикл Layer

Этапы

1. Описание (Definition Time)
   ────────────────────────────
   const MyLayer = Layer.effect(Tag, ...)
   → Создаётся blueprint, ничего не выполняется

2. Предоставление (Provision Time)
   ────────────────────────────────
   Effect.provide(program, MyLayer)
   → Layer привязывается к программе, но всё ещё не выполняется

3. Конструирование (Construction Time)
   ────────────────────────────────────
   Effect.runPromise(runnable)
   → Layer выполняется: зависимости разрешаются, сервисы создаются
   → Ресурсы приобретаются (acquire)
   → Мемоизация: каждый Layer конструируется ровно один раз

4. Использование (Usage Time)
   ──────────────────────────
   yield* MyService
   → Программа получает доступ к сконструированным сервисам

5. Финализация (Finalization Time)
   ───────────────────────────────
   → Программа завершается (успешно или с ошибкой)
   → Scope закрывается
   → Ресурсы освобождаются в обратном порядке (release)

Scope и Layer

Layer тесно интегрирован с Scope. Когда Layer использует ресурсы (например, соединение с базой данных), он может объявить acquire/release через Layer.scoped:


class DbConnection extends Context.Tag("DbConnection")<
  DbConnection,
  { readonly execute: (sql: string) => Effect.Effect<unknown> }
>() {}

const DbConnectionLive: Layer.Layer<DbConnection> = Layer.scoped(
  DbConnection,
  Effect.gen(function* () {
    // Acquire: открыть соединение
    const connection = yield* Effect.acquireRelease(
      Effect.sync(() => {
        console.log("Opening DB connection")
        return { raw: "connection-handle" }
      }),
      (conn) => Effect.sync(() => {
        console.log(`Closing DB connection: ${conn.raw}`)
      })
    )
    
    return {
      execute: (sql) => Effect.sync(() => {
        console.log(`Executing on ${connection.raw}: ${sql}`)
        return { rows: [] }
      })
    }
  })
)

При использовании Layer.scoped ресурс будет автоматически освобождён при закрытии Scope программы.


API Reference

Основные типы

// Основной тип Layer
interface Layer<out ROut, out E = never, in RIn = never> {
  // Layer — это opaque тип, нет публичных полей
}

// Namespace Layer содержит все конструкторы и комбинаторы
namespace Layer {
  // Конструкторы (подробно в следующей статье)
  function succeed<T extends Context.Tag<any, any>>(
    tag: T,
    service: Context.Tag.Service<T>
  ): Layer<Context.Tag.Identifier<T>>

  function effect<T extends Context.Tag<any, any>, E, R>(
    tag: T,
    effect: Effect<Context.Tag.Service<T>, E, R>
  ): Layer<Context.Tag.Identifier<T>, E, R>

  function scoped<T extends Context.Tag<any, any>, E, R>(
    tag: T,
    effect: Effect<Context.Tag.Service<T>, E, Exclude<R, Scope>>
  ): Layer<Context.Tag.Identifier<T>, E, Exclude<R, Scope>>

  // Композиция (подробно в статьях 03-05)
  function provide<ROut2, E2, RIn2>(
    self: Layer<ROut, E, RIn>,
    that: Layer<ROut2, E2, RIn2>
  ): Layer<ROut, E | E2, RIn2 | Exclude<RIn, ROut2>>

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

  // Мемоизация (подробно в статье 06)
  function fresh<ROut, E, RIn>(
    self: Layer<ROut, E, RIn>
  ): Layer<ROut, E, RIn>

  function memoize<ROut, E, RIn>(
    self: Layer<ROut, E, RIn>
  ): Effect<Layer<ROut, E, RIn>, never, Scope>
}

Предоставление Layer программе

// Основной способ — через Effect.provide
const runnable = Effect.provide(program, layer)

// Можно предоставлять несколько Layer
const runnable2 = program.pipe(
  Effect.provide(layer1),
  Effect.provide(layer2)
)

Примеры

Пример 1: Минимальный Layer


// 1. Определяем сервис
class Greeting extends Context.Tag("Greeting")<
  Greeting,
  { readonly greet: (name: string) => string }
>() {}

// 2. Создаём Layer
const GreetingLive = Layer.succeed(Greeting, {
  greet: (name) => `Hello, ${name}!`
})

// 3. Используем сервис в программе
const program = Effect.gen(function* () {
  const greeting = yield* Greeting
  return greeting.greet("Effect")
})

// 4. Связываем и запускаем
const runnable = Effect.provide(program, GreetingLive)

Effect.runPromise(runnable).then(console.log)
// Output: Hello, Effect!

Пример 2: Layer с зависимостью


// Сервис Config
class Config extends Context.Tag("Config")<
  Config,
  {
    readonly logLevel: string
    readonly appName: string
  }
>() {}

// Сервис Logger зависит от Config
class Logger extends Context.Tag("Logger")<
  Logger,
  {
    readonly info: (msg: string) => Effect.Effect<void>
    readonly error: (msg: string) => Effect.Effect<void>
  }
>() {}

// Layer для Config — не имеет зависимостей
const ConfigLive = Layer.succeed(Config, {
  logLevel: "INFO",
  appName: "MyApp"
})

// Layer для Logger — зависит от Config
const LoggerLive = Layer.effect(
  Logger,
  Effect.gen(function* () {
    const config = yield* Config
    return {
      info: (msg) => Effect.log(`[${config.appName}] [${config.logLevel}] ${msg}`),
      error: (msg) => Effect.log(`[${config.appName}] [ERROR] ${msg}`)
    }
  })
)

// Программа использует Logger
const program = Effect.gen(function* () {
  const logger = yield* Logger
  yield* logger.info("Application started")
  yield* logger.info("Processing request")
})

// Собираем граф: LoggerLive нуждается в Config, предоставляем его
const AppLayer = LoggerLive.pipe(
  Layer.provide(ConfigLive)
)

// AppLayer: Layer<Logger, never, never> — все зависимости разрешены
const runnable = Effect.provide(program, AppLayer)

Effect.runPromise(runnable)

Пример 3: Layer с обработкой ошибок


// Типизированная ошибка конструирования
class ConfigError extends Data.TaggedError("ConfigError")<{
  readonly message: string
}>() {}

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

// Layer может завершиться ошибкой
const ConfigFromEnv: Layer.Layer<Config, ConfigError> = Layer.effect(
  Config,
  Effect.gen(function* () {
    const dbUrl = process.env["DATABASE_URL"]
    if (dbUrl === undefined) {
      return yield* Effect.fail(
        new ConfigError({ message: "DATABASE_URL is not set" })
      )
    }
    return { dbUrl }
  })
)

// Fallback Layer
const ConfigDefault: Layer.Layer<Config> = Layer.succeed(Config, {
  dbUrl: "postgres://localhost:5432/dev"
})

// Используем catchAll для graceful degradation
const ConfigLive = ConfigFromEnv.pipe(
  Layer.catchAll((_error) => ConfigDefault)
)
// ConfigLive: Layer<Config, never, never>

Упражнения

Упражнение

Определение простых Layer

Легко

Создайте три сервиса и соответствующие Layer:

// 1. TimeService — предоставляет текущее время
//    Метод: now: () => Effect.Effect<Date>
//    Layer: TimeServiceLive (без зависимостей)

// 2. RandomService — генерирует случайные числа
//    Метод: nextInt: (max: number) => Effect.Effect<number>
//    Layer: RandomServiceLive (без зависимостей)

// 3. IdGenerator — генерирует уникальные ID
//    Метод: generate: () => Effect.Effect<string>
//    Layer: IdGeneratorLive (зависит от TimeService и RandomService)

// Напишите программу, которая генерирует 3 ID и выводит их
Упражнение

Layer с ошибками и fallback

Средне

Реализуйте систему конфигурации с несколькими источниками:

// 1. ConfigFromFile — читает конфиг из файла (может упасть с FileError)
// 2. ConfigFromEnv — читает конфиг из env переменных (может упасть с EnvError)  
// 3. ConfigDefault — дефолтная конфигурация (всегда успешна)
//
// Создайте ConfigLive, который:
//   - Сначала пытается прочитать из файла
//   - Если не удалось — из env переменных
//   - Если и это не удалось — использует дефолтные значения
Упражнение

Полный граф зависимостей

Сложно

Спроектируйте и реализуйте граф зависимостей для микросервиса:

// Сервисы:
// 1. Config — конфигурация (без зависимостей)
// 2. Logger — логирование (зависит от Config)
// 3. Metrics — метрики (зависит от Config)
// 4. DbPool — пул соединений (зависит от Config, Logger)
// 5. Cache — кэш (зависит от Config, Logger)
// 6. UserRepository — репозиторий (зависит от DbPool, Cache, Logger)
// 7. AuthService — аутентификация (зависит от UserRepository, Logger, Config)
//
// Требования:
// - Все Layer должны иметь корректные типы
// - Создайте MainLive, который разрешает все зависимости
// - Напишите программу, которая использует AuthService
// - Config должен быть создан ОДИН раз (мемоизация)

🔗 Далее: Создание Layer — подробное руководство по всем конструкторам Layer