Раздел 25 · Effect-TS

Services и Layer: Context.Service, Layer, make, ManagedRuntime, MemoMap

middle-senior~150 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Services и Layer: Context.Service, Layer, make, ManagedRuntime, MemoMap

Сцена · сборка машины

Машина едет потому, что в неё собрали мотор, колёса, аккумулятор. У каждого узла есть контракт (форма крепления, разъём, диапазон напряжений) и есть конкретное исполнение (какой именно мотор, какой именно аккумулятор). На стенде у тебя стоят несколько вариантов мотора, на проде один. На сборке узлы подаются по дереву зависимостей: аккумулятор питает мотор, мотор крутит колёса.

Эта аналогия лежит в основе того, что Effect называет dependency injection. Сервис, это контракт. Layer, это рецепт сборки. Канал R в типе Effect<A, E, R>, это место, где живут все требования программы. И до этого урока он у нас стоял never. Сегодня мы его обживаем.

Сцена · зачем вообще DI

Возьми обычный TS-код. Функция getUser(id) под капотом дёргает db.query. Чтобы дёргать db.query, ей нужен клиент базы. Клиент создавать в самой функции нельзя (тогда у нас будет десять подключений вместо одного), значит передаём аргументом. Дальше выясняется, что getUser вызывается из getOrder, а getOrder из placeOrder, а placeOrder из HTTP-обработчика. Клиент базы протекает через все четыре функции, хотя пользуется им только листовая.

HTTP handler ─┐
              ▼
        placeOrder(db, ...)
              │
              ▼
         getOrder(db, ...)
              │
              ▼
         getUser(db, id)
              │
              ▼
         db.query(...)

Это называется prop drilling. Каждый промежуточный слой носит чужой груз, сигнатуры разбухают, добавил новую зависимость, прошёлся по десяти файлам.

В TypeScript есть много DI-библиотек: tsyringe, InversifyJS, awilix. Все они работают в рантайме: ты регистрируешь токен, при запросе контейнер ищет реализацию, иногда ругается, что не нашёл. Effect делает это иначе. У него dependency injection встроен в систему типов через третий параметр Effect<A, E, R>. Если зависимость не подана, программа не скомпилируется. Никаких рантайм-сюрпризов.

   Effect<  A  ,   E   ,   R   >
            │       │       │
            │       │       └── что нужно подложить
            │       │           (Database, HttpService, ...)
            │       └────────── чем может упасть
            │                   (NetworkError, ...)
            └────────────────── что вернёт при успехе
                                (Response, User, ...)

В этом уроке мы построим эту систему пошагово. Начнём с Context.Service (контракт), пройдём через Effect.provideService (одно подложить руками), Layer (граф сборки с мемоизацией), опцию make (конструктор рядом с тегом), ManagedRuntime (долгоживущий рантайм для нескольких точек входа), и закончим MemoMap (как Effect делит экземпляры). По пути пройдём: dependency leak, optional services, scoped layers, mocking.

Карта урока · что заберёшь домой

Третий параметр R в Effect<A, E, R> оживает. Context.Service, ключ к сервису. Layer.effect, Layer.succeed, Layer.merge, Layer.provide, Layer.provideMerge, Layer.fresh, Layer.mock, способы собрать значение под ключ. Опция make и конвенция static layer, чтобы держать конструктор рядом с тегом. Default services (Clock, Random, Console). Context.Reference, optional services с дефолтом. Scoped layers с фоновыми процессами через Effect.forkScoped. ManagedRuntime для долгоживущей сборки и нескольких точек входа. Layer memoization и Layer.makeMemoMap.

Что будет в уроке

  1. Раздел 1, сервис через Context.Service и Effect.provideService. Effectful constructor.
  2. Раздел 2, Layer 101. Зачем нужен и почему Effect.provideServiceEffect не делится экземпляром.
  3. Раздел 3, dependency leak. Yield в конструкторе, не в методах. provideMerge.
  4. Раздел 4, опция make. Конструктор рядом с тегом, зависимости через Layer.provide, конвенция static layer.
  5. Раздел 5, default services и accessors. Когда Clock.currentTimeMillis работает без provide.
  6. Раздел 6, optional services. Context.Reference против Effect.serviceOption.
  7. Раздел 7, scoped layers. Effect.forkScoped и фоновые процессы.
  8. Раздел 8, ManagedRuntime. Несколько точек входа без пересборки графа.
  9. Раздел 9, MemoMap. Почему мемоизация по ссылке, а не по тегу. Layer.makeMemoMap.
  10. Раздел 10, mocking. Layer.succeed, Layer.mock, размер сервиса.

Сквозной проект · Pulse · что доедет в этот раз

В уроке 03 · Tagged-ошибки у тебя в pulse-<nick> уже лежат src/errors.ts, src/probe.ts, src/matching.ts. Все они до сих пор брали зависимости неявно: fetch из глобала, Date.now() из браузерного API, никакой записи в журнал.

К концу этого урока в src/services/ появятся три файла:

HttpService    // get, post; HttpLive через fetch, HttpTest через Layer.mock
Storage        // appendEvent, readAll; StorageLive (JSONL, scoped с finalizer-ом)
Logger         // info, warn, error; LoggerLive (Console)

Тип сквозной программы Pulse становится:

Effect<void, PulseError, HttpService | Storage | Logger>

MainLive = Layer.mergeAll(HttpService.layer, Storage.layer, Logger.layer), это прод-сборка. MainTest, это та же сборка, но с HttpTest и StorageInMemoryTest. ManagedRuntime, который мы поднимаем в src/runtime.ts, переиспользуется и CLI-командой pulse watch, и HTTP-сервером pulse serve (http-модуль ядра effect, урок 12).

Каталог сервисов Pulse

К концу серии в MainLive стоит девять сервисов. Чтобы не путать имена при чтении соседних уроков, держи рядом эту таблицу: имя класса, файл, тег, и в каком уроке сервис появляется. Все они импортируются как import { Logger } from '~/services/logger.ts' и так далее.

СервисФайлТегГде появляется
Loggerservices/logger.tsPulse/Logger04
HttpServiceservices/http.tsPulse/HttpService04
Storageservices/storage.tsPulse/Storage04, 05
Configservices/config.tsPulse/Config07, 12
UrlQueueservices/url-queue.tsPulse/UrlQueue07
MonitorEventsservices/monitor-events.tsPulse/MonitorEvents07, 09
DomainLimiterservices/domain-limiter.tsPulse/DomainLimiter07
Slaservices/sla.tsPulse/Sla08
DnsCacheservices/dns.tsPulse/DnsCache10

Когда в коде урока ты видишь yield* MonitorEvents или MonitorEvents.layer, имя класса и тег всегда совпадают с этой таблицей. Если уточняешь импорт в своём pulse-<nick>, опирайся на неё.

Раздел 1 · Сервис через Context.Service

Сцена

У Pulse внутри probe руками вызывается fetch. На проде это работает, в тесте мы тестируем с реальной сетью. Хочется уметь подменить fetch фиксированными ответами без vi.mock. Значит, нужен контракт: “у программы есть способ сходить по URL, на выходе будет тело и статус”. Этот контракт называется сервисом.

Идея словами

Context.Service, это тег (имя сервиса) плюс форма (что у этого сервиса за методы). Тег нужен, чтобы Effect внутри контейнера различал сервисы. Форма нужна, чтобы код, который сервис использует, знал, какие у него методы.

import { Context, Effect } from 'effect';

import type { Response } from './http-types.ts';
import type { NetworkError } from './errors.ts';

class HttpService extends Context.Service<
  HttpService,
  {
    readonly get: (url: string) => Effect.Effect<Response, NetworkError>;
    readonly post: (url: string, body: unknown) => Effect.Effect<Response, NetworkError>;
  }
>()('Pulse/HttpService') {}

Что тут читается:

  • 'Pulse/HttpService', это уникальный идентификатор. Effect использует его внутри Context (своей карты сервисов). Если ты случайно объявишь два разных сервиса с одной и той же строкой, в рантайме они конфликтнут. Поэтому конвенция: префикс пакета или модуля.
  • Первый параметр generic, HttpService, ссылается на сам класс. Это нужно TypeScript, чтобы корректно сужать тип в Effect.Effect<A, E, HttpService>.
  • Второй параметр generic, форма сервиса. Все поля с readonly, потому что сервис, это значение, мутации ему не нужны.

Обрати внимание на порядок вызовов: сначала параметры типа Context.Service<Self, Shape>(), и только потом строка-идентификатор. Читается это как “объявляем сервис такой-то формы, зовут его так-то”.

Шаг 1 · Достать сервис из контекста

В генераторе Effect.gen сервис достаётся через yield*:

const probe = (url: string) =>
  Effect.gen(function* () {
    const http = yield* HttpService;
    const response = yield* http.get(url);
    return response;
  });
// probe: (url: string) => Effect<Response, NetworkError, HttpService>

Заметь третий параметр в типе. HttpService появился в R. Программа требует этот сервис, без него её нельзя запустить.

Effect.runPromise(probe('https://x'));
// Type error:
// Argument of type 'Effect<Response, NetworkError, HttpService>'
// is not assignable to parameter of type 'Effect<Response, NetworkError, never>'.

Компилятор останавливает запуск. Это и есть та статическая безопасность, ради которой Effect трогает третий параметр.

Шаг 2 · Подложить реализацию через provideService

Самый простой способ снять требование, Effect.provideService:

const httpLive = {
  get: (url: string) =>
    Effect.tryPromise({
      try: () => fetch(url).then((r) => ({ status: r.status, body: '' })),
      catch: (cause) => new NetworkError({ url, cause }),
    }),
  post: (url: string, body: unknown) =>
    Effect.tryPromise({
      try: () =>
        fetch(url, { method: 'POST', body: JSON.stringify(body) }).then((r) => ({
          status: r.status,
          body: '',
        })),
      catch: (cause) => new NetworkError({ url, cause }),
    }),
};

const program = probe('https://github.com').pipe(Effect.provideService(HttpService, httpLive));
// program: Effect<Response, NetworkError, never>

После provideService HttpService исчезает из R, и runPromise пропускает программу.

Шаг 3 · Effectful constructor

Реализация выше построена синхронно. Но настоящий клиент часто требует асинхронной инициализации: открыть соединение, прочитать конфиг, дёрнуть crypto.subtle.generateKey. Тогда конструктор сам становится Effect:

const HttpServiceLive = Effect.gen(function* () {
  yield* Effect.logInfo('HttpService готов');
  // тут можно дёрнуть конфиг, подключить таймауты, прокачать заголовки

  return {
    get: (url: string) =>
      Effect.tryPromise({
        try: () => fetch(url).then((r) => ({ status: r.status, body: '' })),
        catch: (cause) => new NetworkError({ url, cause }),
      }),
    post: (url: string, body: unknown) => Effect.die('not implemented'),
  };
});
// HttpServiceLive: Effect<{ get, post }, never, never>

Эту форму подкладывают через Effect.provideServiceEffect (с суффиксом Effect):

const program = probe('https://github.com').pipe(
  Effect.provideServiceEffect(HttpService, HttpServiceLive),
);

Effect сначала вычисляет конструктор, а потом запускает программу. Если в конструкторе стоит Effect.sleep('1 second'), программа ждёт секунду перед первым запросом. Это удобно для ресурсов: соединение должно быть открыто до того, как прилетит первый запрос.

Тип сервиса в Context.Service теперь хочется вывести из конструктора, а не дублировать руками. Для этого есть утилита:

class HttpService extends Context.Service<
  HttpService,
  Effect.Effect.Success<typeof HttpServiceLive>
>()('Pulse/HttpService') {}

Effect.Effect.Success<T> достаёт тип успешного значения из Effect. Без этой утилиты ты бы писал тип формы дважды: один раз в Context.Service, второй раз руками в HttpServiceLive. С ней живая реализация диктует форму.

Шаг 4 · Приоритет “ближайшей” реализации

Если ты внутри программы дополнительно подложишь сервис в каком-то поддереве, Effect возьмёт внутреннюю реализацию. Это удобно для override на конкретном куске:

const inner = probe('https://example.com').pipe(
  Effect.provideService(HttpService, httpFakeReturning200),
);

const program = Effect.gen(function* () {
  const a = yield* probe('https://github.com'); // HttpServiceLive
  const b = yield* inner; // httpFakeReturning200, не HttpServiceLive
  return [a, b];
}).pipe(Effect.provideServiceEffect(HttpService, HttpServiceLive));

Внутренний provideService побеждает внешний. Это пригодится в тестах: общий live, но в одном конкретном gen-блоке хочется заглушку.

Что взять с собой

  • Context.Service<Self, Shape>()(name), это контракт сервиса. Имя уникально по проекту, форма описывает методы.
  • yield* Service достаёт реализацию. Третий параметр Effect показывает, что сервис требуется.
  • Effect.provideService подкладывает синхронную реализацию, Effect.provideServiceEffect, эффектную (включая ресурсы и инициализацию).
  • Внутренний provide перекрывает внешний. Это override, не “конфликт”.

Раздел 2 · Layer 101 · граф зависимостей

Сцена

У HttpService будет своя зависимость, Logger. У Storage своя зависимость, Database. На каждой границе хочется писать provideServiceEffect, и быстро выясняется, что “положить всё в корень” не работает: HttpService и Storage оба требуют Logger, а каждый provideServiceEffect создаёт новый экземпляр.

Идея словами

Effect вводит отдельный тип Layer. Внешне это похоже на Effect: его можно pipe-ить, у него три параметра.

Layer<ROut, E, RIn>
       │    │    │
       │    │    └── что нужно подать на вход (зависимости конструктора)
       │    └────── ошибка, которая может быть при сборке
       └─────────── сервис, который Layer отдаёт

Принципиальное отличие от Effect: Layer мемоизирует сборку. Если один и тот же Layer используется в графе несколько раз, сервис строится один раз и переиспользуется. Effect.provideServiceEffect так не умеет, его конструктор вычисляется столько раз, сколько раз ты его подложишь.

Шаг 1 · Layer.effect из эффектного конструктора

Мы уже видели HttpServiceLive как Effect. Превращение в Layer одно правило:

import { Layer } from 'effect';

const HttpLive = Layer.effect(
  HttpService,
  Effect.gen(function* () {
    yield* Effect.logInfo('HttpService готов');
    return {
      get: (url) => Effect.succeed({ status: 200, body: '' }),
      post: (url, body) => Effect.succeed({ status: 200, body: '' }),
    };
  }),
);
// HttpLive: Layer<HttpService, never, never>

Layer.effect принимает тег и эффект, который этот тег строит. Тип Layer<HttpService, never, never> читается так: “строит HttpService, не падает, ничего на вход не требует”.

Подкладывается Layer через Effect.provide:

const program = probe('https://github.com').pipe(Effect.provide(HttpLive));
// program: Effect<Response, NetworkError, never>

Шаг 2 · Layer с зависимостью

Теперь Storage, который требует Logger:

class Logger extends Context.Service<
  Logger,
  { readonly info: (msg: string) => Effect.Effect<void> }
>()('Pulse/Logger') {}

class Storage extends Context.Service<
  Storage,
  { readonly append: (event: MonitorEvent) => Effect.Effect<void, StorageError> }
>()('Pulse/Storage') {}

const StorageLive = Layer.effect(
  Storage,
  Effect.gen(function* () {
    const logger = yield* Logger; // <-- зависимость

    return {
      append: (event) =>
        Effect.gen(function* () {
          yield* logger.info(`appending ${event._tag}`);
          // запись в файл здесь
        }),
    };
  }),
);
// StorageLive: Layer<Storage, never, Logger>

Третий параметр Layer<Storage, never, Logger> подсветил: “этот Layer строит Storage, но требует Logger”. Без Logger сборка не запустится.

Шаг 3 · Подложить зависимость через Layer.provide

const LoggerLive = Layer.succeed(Logger, {
  info: (msg) => Effect.sync(() => console.log(msg)),
});

const StorageWithLogger = StorageLive.pipe(Layer.provide(LoggerLive));
// StorageWithLogger: Layer<Storage, never, never>

Layer.succeed собирает Layer из уже готового объекта, без всяких эффектов на старте: вот реализация Logger, заверни её в Layer. Дальше Layer.provide(LoggerLive) подаёт этот Logger на вход StorageLive, тому самому, который раньше требовал Logger. Требование снято, и третий параметр в типе из Logger превратился в never. Теперь StorageWithLogger собирается сам по себе, ничего извне ему не нужно.

Стоп. В Шаге 1 мы подкладывали Layer через Effect.provide, а тут вдруг Layer.provide. Это не опечатка, это две разные операции. Обе снимают требование, но с разных вещей:

  • Effect.provide снимает требование с программы. Программа говорила “мне нужен Storage”, мы подали ей Layer, и Effect<A, E, Storage> превратился в Effect<A, E, never>. Это финал сборки, момент, когда граф сервисов встречается с кодом, который их использует.
  • Layer.provide снимает требование с другого Layer. Конструктор StorageLive говорил “чтобы собрать Storage, мне нужен Logger”, мы подключили LoggerLive, и Layer<Storage, never, Logger> превратился в Layer<Storage, never, never>. Программы здесь ещё нет, мы пока собираем граф из деталей.

Запомнить просто: смотри, что стоит слева от .pipe. Программа, значит Effect.provide. Конструктор сервиса, значит Layer.provide.

И ещё одно отличие, оно выстрелит в Разделе 3: Layer.provide прячет зависимость. StorageWithLogger отдаёт наружу только Storage, достать Logger через него программа не сможет. Если зависимость нужна и сервису, и внешнему коду, понадобится Layer.provideMerge, до него дойдём.

Шаг 4 · Почему мемоизация важна

Покажу проблему, которая возникает без Layer. Допустим, и HttpService, и Storage хотят Logger, и мы пишем по-наивному:

const HttpLive = Layer.effect(
  HttpService,
  Effect.gen(function* () {
    const logger = yield* Logger;
    yield* logger.info('http готов');
    return { get: (url) => Effect.succeed({ status: 200, body: '' }) };
  }),
).pipe(Layer.provide(LoggerLive));

const StorageLive = Layer.effect(
  Storage,
  Effect.gen(function* () {
    const logger = yield* Logger;
    yield* logger.info('storage готов');
    return { append: (event) => Effect.succeed(undefined) };
  }),
).pipe(Layer.provide(LoggerLive));

const program = Effect.gen(function* () {
  const http = yield* HttpService;
  const storage = yield* Storage;
}).pipe(Effect.provide(HttpLive), Effect.provide(StorageLive));

Раньше это была настоящая ловушка: каждый Effect.provide заводил свою карту мемоизации, и LoggerLive, реально открывающий файл (например, ротатор лога), собирался дважды. В v4 карта мемоизации общая на весь файбер, поэтому цепочка из двух provide даст один Logger, а не два.

Но полагаться на эту страховку не стоит. Она нужна, чтобы случайная цепочка не пробила ногу, а не чтобы заменить нормальную сборку графа. Когда Layer-ы склеены в один, ты видишь всю структуру зависимостей в одном месте, а не восстанавливаешь её по цепочке pipe. И если тебе всё же нужны два разных экземпляра, общая мемоизация начнёт мешать: отключается она через Layer.fresh(L) или через Effect.provide(L, { local: true }), который строит поддерево на своей отдельной карте.

Шаг 5 · Склеиваем Layer-ы

Решение, собрать все Layer-ы в один и подать их одним Effect.provide:

const MainLive = Layer.mergeAll(HttpLive, StorageLive);

const program = Effect.gen(function* () {
  const http = yield* HttpService;
  const storage = yield* Storage;
}).pipe(Effect.provide(MainLive));

Layer.mergeAll(...) склеивает несколько Layer-ов в один. Если внутри них есть пересекающиеся зависимости (например, оба требуют Logger), Effect строит общий граф и зовёт конструктор LoggerLive один раз.

Альтернативная запись, эквивалентная mergeAll, передать массив прямо в Effect.provide:

const program = body.pipe(Effect.provide([HttpLive, StorageLive]));

Под капотом массив склеивается через Layer.mergeAll.

Шаг 6 · Локальное предоставление масштабируется

Возникает соблазн положить всё в корень программы:

const program = body.pipe(
  Effect.provide(
    Layer.mergeAll(HttpLive, StorageLive).pipe(
      Layer.provide(LoggerLive),
      Layer.provide(DatabaseLive),
      Layer.provide(ConfigLive),
    ),
  ),
);

На пяти сервисах это ещё читается, на двадцати становится свалкой. Эффект-каноничный путь, локально вкладывать зависимости в место, где они нужны:

const HttpLive = Layer.effect(HttpService, /* ... */).pipe(Layer.provide(LoggerLive));
const StorageLive = Layer.effect(Storage, /* ... */).pipe(Layer.provide(LoggerLive));

const MainLive = Layer.mergeAll(HttpLive, StorageLive);

Теперь читая HttpLive, ты сразу видишь его требования. Корень программы видит только верхнеуровневые сервисы. Несмотря на то что LoggerLive упомянут в двух местах, мемоизация сделает экземпляр один.

Шаг 7 · Layer.fresh, отказ от мемоизации

Бывает обратная задача: ты хочешь два разных экземпляра одного и того же сервиса. Например, два Database-клиента под разные пулы:

const ReadStorageLive = Layer.effect(Storage, /* ... */).pipe(
  Layer.provide(Layer.fresh(DatabaseLive)),
);
const WriteStorageLive = Layer.effect(Storage, /* ... */).pipe(
  Layer.provide(Layer.fresh(DatabaseLive)),
);

Layer.fresh(L) возвращает Layer, который не делится с остальным графом. Это исключение из правила, не норма. Если ты ставишь fresh, это сознательное решение, а не “на всякий случай”.

Что взять с собой

  • Layer, это Effect с мемоизацией. Один Layer строит сервис один раз на сборку графа.
  • Layer.effect(Tag, ctor) для эффектного конструктора, Layer.succeed(Tag, value) для синхронного.
  • Effect.provide снимает требование с программы (убирает сервис из R). Layer.provide(dep) снимает требование с другого Layer (убирает сервис из RIn), зависимость при этом прячется внутри. Layer.mergeAll(...) склеивает несколько Layer-ов в один.
  • Цепочка Effect.provide(...).pipe(Effect.provide(...)) в v4 уже не ломает мемоизацию, карта общая на файбер. Но слить в один Layer и подать один раз всё равно правильнее: граф зависимостей виден целиком.
  • Layer.fresh(L) и Effect.provide(L, { local: true }) для редкого случая, когда нужны два разных экземпляра одного и того же сервиса.

Раздел 3 · Не утекай зависимостями

Сцена

Ты пишешь UserRepository, у которого внутри getUser есть запрос в базу. Вместо того чтобы вытащить Database в конструкторе, ты случайно делаешь это в методе:

class UserRepository extends Context.Service<
  UserRepository,
  {
    readonly getUser: (id: string) => Effect.Effect<User, DatabaseError>;
  }
>()('UserRepository') {}

const UserRepositoryLive = Layer.succeed(UserRepository, {
  getUser: (id) =>
    Effect.gen(function* () {
      const database = yield* Database; // <-- утечка
      return yield* database.query(`select * from users where id = ${id}`);
    }),
}).pipe(Layer.provide(DatabaseLive));

Что не так? Тип getUser объявлен как Effect<User, DatabaseError> (без зависимостей), а реально функция требует Database. TypeScript поначалу не ругается, потому что внутри лямбды у нас широкий вывод. Но когда ты используешь getUser снаружи, обнаруживаешь:

const repo = yield* UserRepository
const user = yield* repo.getUser('1')
// Effect<User, DatabaseError, Database>, Database всплыл

Database, который мы вроде бы подложили в Layer, всплыл наружу. Layer подкладывает его только для конструктора, не для каждого вызова метода.

Идея словами

Правило: зависимости сервиса должны быть взяты в конструкторе, а не в методах. Конструктор это место, где работает Layer и где работает мемоизация. Методы это рантайм-вызовы, и каждый из них требует ту же зависимость заново.

Шаг 1 · Правильная форма

const UserRepositoryLive = Layer.effect(
  UserRepository,
  Effect.gen(function* () {
    const database = yield* Database; // конструктор, тут можно

    return {
      getUser: (id) => database.query(`select * from users where id = ${id}`),
    };
  }),
).pipe(Layer.provide(DatabaseLive));

Что изменилось:

  • Layer.succeed стал Layer.effect, потому что нам нужен yield* Database в конструкторе.
  • Database забран в замыкании через переменную database. Дальше getUser пользуется именно ей.
  • Тип getUser теперь честный: (id) => Effect<User, DatabaseError>. Никаких висящих требований.

Шаг 2 · Layer.provideMerge для случая, когда Database нужен и снаружи

Иногда нам в одной программе нужны и UserRepository, и сам Database (например, чтобы делать прямой запрос в каком-то редком кейсе). Если UserRepositoryLive держит Database внутри, снаружи Database уже не виден.

Решение, Layer.provideMerge. Он одновременно подкладывает зависимость и выставляет её наружу:

const AppLive = UserRepositoryLive.pipe(Layer.provideMerge(DatabaseLive));
// AppLive: Layer<UserRepository | Database, never, never>

Теперь и UserRepository, и Database видны программе. Один экземпляр Database, никакой дублирующей сборки. А вот стандартный Layer.provide спрятал бы Database внутри слоя: снаружи остался бы только UserRepository, и прямой запрос в базу сделать бы не вышло.

Шаг 3 · Когда метод обязан требовать сервис

Бывает легитимный случай, когда метод честно зависит от сервиса. Пример, request-scoped зависимость: текущий пользователь, текущий тенант, request-id. Это значения, которые приходят на каждый запрос, а не на старте программы.

В Pulse такой зависимостью будет CurrentMonitor, идентификатор того монитора, чей probe сейчас выполняется:

class CurrentMonitor extends Context.Service<
  CurrentMonitor,
  { readonly id: MonitorId; readonly url: string }
>()('Pulse/CurrentMonitor') {
  static readonly provide = (monitor: { id: MonitorId; url: string }) =>
    Effect.provideService(this, monitor);
}

Конструктору Storage CurrentMonitor не нужен, и вот почему. Конструктор Layer-а выполняется один раз, на старте программы, и Storage у нас один на всю программу. Если взять CurrentMonitor в конструкторе, значение зафиксируется в момент сборки: один конкретный монитор навсегда впаяется в общий экземпляр Storage, и все probe всех мониторов будут писать события с одним и тем же monitorId:

// Так нельзя: CurrentMonitor читается один раз, при сборке Layer
const StorageBroken = Layer.effect(
  Storage,
  Effect.gen(function* () {
    const current = yield* CurrentMonitor; // выполнится однажды, на старте

    return {
      append: (event: ProbeResult) =>
        Effect.sync(() => {
          // current.id здесь навсегда один и тот же, для всех мониторов
        }),
    };
  }),
);
// StorageBroken: Layer<Storage, never, CurrentMonitor>
// Layer требует CurrentMonitor уже на сборке, хотя probe ещё не запускались

А вот методу append CurrentMonitor нужен: мы хотим записать monitorId в каждое событие. yield* CurrentMonitor внутри метода читает контекст в момент вызова, на каждый вызов свежий:

const StorageLive = Layer.effect(
  Storage,
  Effect.gen(function* () {
    const logger = yield* Logger; // глобальная зависимость, в конструктор

    return {
      append: (event: ProbeResult) =>
        Effect.gen(function* () {
          const current = yield* CurrentMonitor; // request-scoped, в метод
          yield* logger.info(`event for ${current.id}`);
          // запись с monitorId = current.id
        }),
    };
  }),
);

Тип append теперь честный: Effect<void, StorageError, CurrentMonitor>. Посмотри, как это выглядит со стороны вызывающего. Вот минимальный probe, который дёргает append:

const probe = (url: string) =>
  Effect.gen(function* () {
    const http = yield* HttpService;
    const storage = yield* Storage;

    const startedAt = yield* Effect.sync(() => Date.now());
    const response = yield* http.get(url);
    const elapsedMs = yield* Effect.sync(() => Date.now() - startedAt);

    yield* storage.append({ url, status: response.status, elapsedMs });
  });
// probe: (url: string) =>
//   Effect<void, NetworkError | StorageError, HttpService | Storage | CurrentMonitor>

Заметь: сам probe нигде не упоминает CurrentMonitor, но тот всё равно всплыл в R. Требование append протекло наружу через yield*, и компилятор не даст запустить probe, пока кто-то его не закроет. Обычно это делается через CurrentMonitor.provide(...) в обёртке probe-петли:

const probeWithContext = (monitor: { id: MonitorId; url: string }) =>
  probe(monitor.url).pipe(CurrentMonitor.provide(monitor));
// probeWithContext: (monitor) =>
//   Effect<void, NetworkError | StorageError, HttpService | Storage>

CurrentMonitor закрыт в точке, где значение реально известно, на каждый монитор своё. А HttpService и Storage остались: они глобальные, их закроет общий Layer на старте программы.

Это и есть правильная DI-дисциплина: глобальные зависимости в конструктор, request-scoped в методы.

Что взять с собой

  • yield* Service в конструкторе Layer-а закрывает зависимость на сборке: значение читается один раз и замораживается в экземпляре. yield* Service в методе оставляет требование на каждом вызове и читает контекст свежим.
  • Глобальные сервисы (Database, Logger, HttpService) бери в конструкторе. Request-scoped (CurrentUser, RequestId) бери в методах.
  • Layer.provideMerge(dep) подкладывает зависимость и выставляет её наружу. Полезно, если зависимость нужна и сервису, и внешнему коду.
  • На сервисе с request-scoped зависимостью часто заводят static readonly provide = ..., чтобы у вызова был короткий синтаксис.

Раздел 4 · Конструктор рядом с тегом: опция make

Сцена

Объявить тег отдельно, конструктор отдельно, Layer отдельно и связать их руками получается многословно, и три куска легко расползаются по файлу. Для глобальных сервисов (тех, что строятся один раз и живут до конца программы) Context.Service умеет держать конструктор прямо на классе, через опцию make.

Идея словами

Вторым аргументом Context.Service принимает объект с полем make, эффектным конструктором сервиса. Что это даёт:

  • форма сервиса выводится из того, что возвращает конструктор, руками её писать не надо;
  • конструктор лежит рядом с тегом и доступен как Service.make;
  • Layer ты собираешь сам, одной строкой Layer.effect(this, this.make).

Последний пункт важен, и он сознательный. Автоматически сгенерированного слоя тут нет: сборка графа остаётся явной, поэтому ты в любой момент видишь, откуда взялись зависимости и какого они вида. Для request-scoped штук (CurrentMonitor, CurrentUser) опция make не подходит: они не строятся один раз, у них нет канонической реализации. Им остаётся голый Context.Service без make.

Шаг 1 · Минимальный пример

import { Context, Effect, Layer } from 'effect';

class Logger extends Context.Service<Logger>()('Pulse/Logger', {
  make: Effect.gen(function* () {
    yield* Effect.logInfo('Logger готов');
    return {
      info: (msg: string) => Effect.sync(() => console.log(msg)),
      warn: (msg: string) => Effect.sync(() => console.warn(msg)),
      error: (msg: string) => Effect.sync(() => console.error(msg)),
    };
  }),
}) {
  static readonly layer = Layer.effect(this, this.make);
}

Что тут происходит:

  • класс Logger работает как тег, его можно yield*-ить;
  • форма выведена из того, что вернул Effect.gen;
  • Logger.layer, это Layer<Logger, never, never>, собранный из Logger.make.

Имя layer не случайное, это принятая в v4 конвенция вместо старых Default и Live. Основной слой зовут layer, варианты получают уточняющий суффикс: layerTest, layerSilent, layerConfig. Когда во всех сервисах проекта одно и то же имя, читать сборку графа заметно легче.

Подкладывается одной строкой:

const program = body.pipe(Effect.provide(Logger.layer));

Шаг 2 · Зависимости конструктора

Если конструктор сам что-то требует, ты просто берёшь это yield*-ом, а закрываешь требование при сборке слоя, обычным Layer.provide:

class Storage extends Context.Service<Storage>()('Pulse/Storage', {
  make: Effect.gen(function* () {
    const logger = yield* Logger; // требование конструктора

    return {
      append: (event: MonitorEvent) =>
        Effect.gen(function* () {
          yield* logger.info(`append ${event._tag}`);
          // запись в файл
        }),
    };
  }),
}) {
  static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(Logger.layer));
}

Отдельного поля dependencies в v4 нет, и это упрощение: способ подключить зависимость ровно один, тот же Layer.provide из Раздела 2. Storage.layer готов к подаче в программу, Layer<Storage, never, never>, требования закрыты.

Шаг 3 · Разные виды конструктора

make это всегда Effect, и этого достаточно на все случаи:

Что за конструкторКак записать
асинхронный или с зависимостямиmake: Effect.gen(function* () { ... })
требует Scope (ресурс с finalizer-ом)тот же Effect.gen с Effect.acquireRelease внутри
синхронный без ошибокmake: Effect.sync(() => shape)
значение уже на рукахбез make, слой через Layer.succeed(Tag, value)

Пример последней строки:

class Config extends Context.Service<
  Config,
  { readonly apiUrl: string; readonly timeoutMs: number }
>()('Pulse/Config') {
  static readonly layer = Layer.succeed(this, {
    apiUrl: 'https://example.com',
    timeoutMs: 5000,
  });
}

Ресурсный вариант мы разберём в Разделе 7. Забегая вперёд: отдельного Layer.scoped в v4 нет, Layer.effect сам съедает Scope из требований, поэтому ресурсный конструктор пишется тем же вызовом, что и обычный.

Шаг 4 · Тестовые слои

Форма сервиса это обычный объект, никаких скрытых служебных полей в него Context.Service не добавляет. Поэтому тестовая реализация пишется прямо через Layer.succeed, без вспомогательных конструкторов:

const LoggerSilent = Layer.succeed(Logger, {
  info: () => Effect.void,
  warn: () => Effect.void,
  error: () => Effect.void,
});

Если хочется подчеркнуть, что объект удовлетворяет форме сервиса, есть Logger.of({...}): на рантайме это тождество, зато TypeScript проверит форму прямо в месте объявления, а не на строчке Layer.succeed.

Шаг 5 · Pulse · полная сборка

Logger, HttpService и Storage собраны по одному шаблону: Context.Service с make, у двух последних в static layer стоит Layer.provide(Logger.layer). Покажу самый плотный из трёх, остальные пишутся по аналогии.

// src/services/storage.ts
import { Context, Effect, Layer } from 'effect';
import { Logger } from './logger.ts';

export class Storage extends Context.Service<Storage>()('Pulse/Storage', {
  make: Effect.gen(function* () {
    const logger = yield* Logger;
    const events: MonitorEvent[] = []; // позже заменим на JSONL-файл

    return {
      append: (event: MonitorEvent) =>
        Effect.gen(function* () {
          events.push(event);
          yield* logger.info(`append ${event._tag}`);
        }),
      readAll: () => Effect.succeed(events.slice()),
    };
  }),
}) {
  static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(Logger.layer));
}

MainLive склеивает три сервиса в один Layer:

// src/main.ts
import { Layer } from 'effect';
import { HttpService } from './services/http.ts';
import { Logger } from './services/logger.ts';
import { Storage } from './services/storage.ts';

export const MainLive = Layer.mergeAll(HttpService.layer, Storage.layer, Logger.layer);
// MainLive: Layer<HttpService | Storage | Logger, never, never>

Logger.layer упомянут трижды (внутри HttpService.layer, внутри Storage.layer, и в MainLive). Благодаря мемоизации это один экземпляр.

Что взять с собой

  • Context.Service<Self>()(name, { make }), это конструктор рядом с тегом. Форма выводится из того, что вернул make.
  • Layer собираешь сам: static readonly layer = Layer.effect(this, this.make). Автоматического слоя нет, зависимости подключаются явным Layer.provide.
  • Конвенция имени слоя, layer. Варианты получают суффикс: layerTest, layerSilent.
  • Service.of({...}) помогает строить тестовые реализации: форму проверяет TypeScript, на рантайме это тождество.
  • Для request-scoped штук (CurrentMonitor) остаётся Context.Service без make.

Раздел 5 · Default services и короткие обращения

Сцена

Ты в recordResult из урока 03 дёргал Date.now(). Это нечестно: на тестах нельзя замораживать время, на проде у разных машин разный clock skew. Хочется сервис Clock, и хочется, чтобы он был уже подан (потому что почти всем программам нужно знать текущее время).

Идея словами

Effect отдаёт несколько сервисов, которые добавляются в любой runtime автоматически:

СервисЧто отдаётКогда подменять
ClockcurrentTimeMillis, currentTimeNanos, sleepв тестах через TestClock
RandomnextInt, nextBoolean, choiceв тестах через Random.withSeed
Consolelog, warn, error, tableесли хочешь капчурить вывод
Tracerspans, attributesдля OpenTelemetry-интеграции

Они называются default services. Они не появляются в R, ты пользуешься ими так, как будто они всегда есть.

Технически это Context.Reference, разновидность сервиса со встроенным дефолтом. Именно дефолт и объясняет, почему они не всплывают в R: реализация есть всегда, подменять её нужно только когда ты сам этого хочешь.

Шаг 1 · Clock без provide

import { Clock, Effect } from 'effect';

const program = Effect.gen(function* () {
  const millis = yield* Clock.currentTimeMillis;
  return millis;
});
// program: Effect<number, never, never>

Effect.runPromise(program); // работает, никаких provide не надо

Clock.currentTimeMillis, это компактная запись для Effect.gen(function* () { const c = yield* Clock.Clock; return yield* c.currentTimeMillis }). Третьего параметра у program нет, потому что Clock входит в default services.

Аналогично Random.next, Random.nextInt, Console.log, Console.warn. Это готовые функции модуля, а не магия: сам сервис лежит рядом, Clock.Clock и Random.Random, и именно его ты подменяешь, когда нужна детерминированность.

Шаг 2 · Effect.Date.now() для Pulse

В recordResult была строчка at: Date.now(). Заменяем:

import { Clock, Effect } from 'effect';

const recordResult = (probeEffect, monitorId) =>
  probeEffect.pipe(
    Effect.matchEffect({
      onSuccess: (result) =>
        Effect.gen(function* () {
          const at = yield* Clock.currentTimeMillis;
          return {
            _tag: 'ProbeSuccess',
            monitorId,
            url: result.url,
            status: result.status,
            elapsedMs: result.elapsedMs,
            at,
          };
        }),
      onFailure: (error) =>
        Effect.gen(function* () {
          const at = yield* Clock.currentTimeMillis;
          return {
            _tag: 'ProbeFailure',
            monitorId,
            error,
            at,
          };
        }),
    }),
  );

Тип всё ещё Effect<MonitorEvent, never, R>, потому что Clock не добавляется в R. На проде Clock, это системные часы. В тестах подменишь, и время станет детерминированным.

Шаг 3 · Подмена Random в тестах

import { Effect, Random } from 'effect';

const generateEventId = Effect.gen(function* () {
  const millis = yield* Clock.currentTimeMillis;
  const suffix = yield* Random.nextIntBetween(1000, 9999);
  return `${millis}-${suffix}`;
});

// в тесте:
const test = generateEventId.pipe(Random.withSeed('pulse-test'));

Random.withSeed(seed) прогоняет кусок программы на генераторе с фиксированным зерном: последовательность одна и та же от запуска к запуску. Если нужна не воспроизводимая, а именно заданная реализация, подменяй сервис напрямую, Effect.provideService(Random.Random, myImpl). То же работает и для часов: Effect.provideService(Clock.Clock, myClock), хотя в тестах чаще берут TestClock из effect/testing.

Отдельных комбинаторов вроде withRandom и withClock в v4 нет, и это к лучшему: подмена любого сервиса делается одним и тем же provideService, помнить надо одно имя вместо десятка.

Шаг 4 · Короткое обращение к собственному сервису

Clock.currentTimeMillis короче, чем доставать сервис руками. Хочется того же для своих сервисов, и такая возможность есть, Service.use:

const logged = Logger.use((logger) => logger.info('hello'));
// Effect<void, never, Logger>

use принимает функцию, которой отдают экземпляр сервиса, и возвращает Effect с этим сервисом в R. Есть парный useSync для чистой функции: Config.useSync((c) => c.port) вернёт Effect<number, never, Config>.

Удобно, но у этого есть тёмная сторона. Когда у тебя в Storage внутри метода захочется залогировать, ты напишешь:

const StorageLive = Layer.effect(
  Storage,
  Effect.succeed({
    append: (event) => Logger.use((l) => l.info(`append ${event._tag}`)),
  }),
);

Тип append стал Effect<void, never, Logger>, и Logger вытек из методов, хотя по правилу из Раздела 3 он должен был быть в конструкторе. Короткое обращение прячет зависимость от глаз на месте вызова, и поверх “удобства” даёт тебе шанс утечь незаметно.

Рекомендация: в теле сервиса и в прикладном коде пиши const logger = yield* Logger в конструкторе. Это явный контракт, зависимость видно там же, где её берут. use оставь для одноразовых мест, где вся работа с сервисом это ровно один вызов.

Кстати, старой формы “статический метод-прокси”, когда Logger.info('hello') писался прямо на классе, в v4 больше нет. Причина не в стиле, а в типах: прокси строился отображением по форме сервиса и терял параметры у обобщённых методов. Метод get<T>(key: string): Effect<T> через такой прокси схлопывался в Effect<unknown>, перегрузки тоже не переживали дорогу.

Что взять с собой

  • Clock, Random, Console, Tracer, это default services на Context.Reference. Они не попадают в R, потому что у них есть встроенный дефолт.
  • Clock.currentTimeMillis, Random.nextInt, Console.log пиши прямо, без provide.
  • В тестах подменяй через Effect.provideService(Clock.Clock, ...) и Effect.provideService(Random.Random, ...), для воспроизводимой случайности есть Random.withSeed. Полную замену часов на детерминированные делает TestClock из effect/testing.
  • Service.use(...) и Service.useSync(...) дают короткое обращение к своему сервису. Не злоупотребляй: легко утечь зависимостью в подпись метода.

Раздел 6 · Optional services

Сцена

В Pulse мы хотим, чтобы probe можно было запустить с CurrentMonitor (тогда событие получает monitorId) и без него (тогда событие получает дефолтный monitorId = 'unknown'). Хочется, чтобы методы Storage не требовали CurrentMonitor, но если тот есть, использовали бы его.

Идея 1 · Effect.serviceOption

Первый подход, попросить сервис как Option:

import { Effect, Option } from 'effect';

const append = (event) =>
  Effect.gen(function* () {
    const currentOpt = yield* Effect.serviceOption(CurrentMonitor);
    const monitorId = Option.getOrElse(currentOpt, () => 'unknown' as MonitorId);
    // ...
  });

Effect.serviceOption(Tag), это отдельная функция: вместо того чтобы тащить сервис как требование, она достаёт его из контекста как Option. Если сервис подан, получаешь Option.some(impl). Если нет, получаешь Option.none(). CurrentMonitor в R не появляется, метод можно вызвать без provide.

Минус, дефолт писать на каждом вызове. Если у тебя двадцать мест с Option.getOrElse(currentOpt, () => ...), дефолт продублирован двадцать раз.

Идея 2 · Context.Reference с дефолтом

Второй подход, описать сервис так, чтобы у него уже был дефолт:

import { Context, Option } from 'effect';

const CurrentMonitor = Context.Reference<Option.Option<{ id: MonitorId; url: string }>>(
  'Pulse/CurrentMonitor',
  { defaultValue: () => Option.none() },
);

Context.Reference, это разновидность сервиса, у которой есть встроенный дефолт. Обрати внимание, что тут не класс, а обычное значение: тип задаётся одним параметром, идентификатор и дефолт идут аргументами. Что меняется на стороне потребителя:

const append = (event) =>
  Effect.gen(function* () {
    const currentOpt = yield* CurrentMonitor; // уже Option, без serviceOption
    const monitorId = Option.getOrElse(currentOpt, () => 'unknown' as MonitorId);
  });

Дефолт спрятан в само описание сервиса. Подавать CurrentMonitor теперь не обязательно. Если кто-то захочет, делается это так же, как раньше:

program.pipe(
  Effect.provideService(
    CurrentMonitor,
    Option.some({ id: 'github' as MonitorId, url: 'https://github.com' }),
  ),
);

Когда что брать

СлучайБери
Библиотечный код, default разумныйContext.Reference
Прикладной код, явный контракт лучшеContext.Service плюс Effect.serviceOption, или просто Context.Service без optional-логики
Optional ради удобстваContext.Reference
Optional ради явного “иногда нет данных”Context.Service плюс Option-форма

Простое правило: если в подпись метода ты не хочешь добавлять CurrentMonitor, бери Context.Reference. Если хочешь, чтобы вызывающий видел факт зависимости, оставляй Context.Service.

В Pulse мы возьмём Context.Service без optional: monitorId присутствует всегда, потому что probe запускается из контекста “вот этот монитор”. Optional-вариант появится в HTTP-сервере, где входящий запрос может содержать или не содержать X-Tenant-Id.

Тот же Context.Reference стоит и за default services из Раздела 5, и заодно он заменил собой старый FiberRef. Если ты видел в чужом коде Effect.locally(ref, value), в v4 это тот же Effect.provideService(Reference, value): одна дверь вместо двух.

Что взять с собой

  • Effect.serviceOption(Tag), достаёт сервис как Option и не добавляет его в R. Дефолт пишет вызывающий.
  • Context.Reference<T>(name, { defaultValue }), описывает сервис со встроенным дефолтом. Вызывающий не обязан подавать, а подменяет через обычный Effect.provideService.
  • Бери Context.Reference для библиотечных сервисов с разумным дефолтом. Бери Context.Service плюс Option для прикладных контрактов, где optional, это часть бизнес-логики.

Раздел 7 · Scoped layers · ресурсы и фоновые процессы

Сцена

StorageLive хочет писать события в JSONL-файл. Файл нужно открыть на старте программы и закрыть на её завершении. Без этого механизма мы либо забудем закрыть (и потеряем буфер), либо откроем и закроем на каждый append (ломаем производительность).

Идея словами

Effect использует механику Scope для управления ресурсами. Layer, у которого конструктор требует Scope (открывает ресурс с finalizer-ом), называется scoped layer. Эту тему мы плотно разберём в уроке 05 · Resources, здесь только тот минимум, который нужен для сервисов.

Шаг 1 · Ресурс в конструкторе сервиса

import { Context, Effect, Layer } from 'effect';
import * as fs from 'node:fs/promises';

class Storage extends Context.Service<Storage>()('Pulse/Storage', {
  make: Effect.gen(function* () {
    const handle = yield* Effect.acquireRelease(
      Effect.tryPromise(() => fs.open('events.jsonl', 'a')),
      (h) => Effect.promise(() => h.close()),
    );

    return {
      append: (event: MonitorEvent) =>
        Effect.tryPromise(() => handle.write(JSON.stringify(event) + '\n')),
    };
  }),
}) {
  static readonly layer = Layer.effect(this, this.make);
}

Effect.acquireRelease(acquire, release), это базовый кирпич Scope. Он гарантирует, что release выполнится после того, как программа использовала ресурс, независимо от того, успехом или провалом она закончилась. release исполняется на закрытии Scope.

Ничего специального для ресурсного случая писать не пришлось: тот же make, тот же Layer.effect. Отдельного Layer.scoped в v4 нет, потому что Layer.effect сам вычитает Scope из требований конструктора. Конструктор внутри имеет тип Effect<Shape, E, R | Scope>, а собранный слой уже Layer<Storage, E, R>, Scope наружу не торчит.

Шаг 2 · Когда finalizer срабатывает

Программа:

const program = Effect.gen(function* () {
  const storage = yield* Storage;
  yield* storage.append({ _tag: 'ProbeSuccess', /* ... */ });
  yield* Effect.sleep('100 millis');
  yield* Effect.logInfo('программа закончилась');
});

Effect.runPromise(program.pipe(Effect.provide(Storage.layer)));

В лог:

append ...
программа закончилась
storage closed (handle.close)

Finalizer Storage срабатывает после того, как тело программы закончилось. Если бы тело упало с ошибкой, finalizer всё равно выполнился бы, такая гарантия.

Шаг 3 · Фоновый процесс через Effect.forkScoped

Иногда сервис хочет запустить фоновый процесс на всё время своей жизни. Например, Storage мог бы раз в десять секунд флашить буфер на диск:

class Storage extends Context.Service<Storage>()('Pulse/Storage', {
  make: Effect.gen(function* () {
    const buffer: MonitorEvent[] = [];

    yield* Effect.forkScoped(
      Effect.gen(function* () {
        yield* Effect.logInfo('flusher started');
        // запись buffer на диск, очищение
      }).pipe(Effect.repeat({ schedule: Schedule.spaced('10 seconds') })),
    );

    return {
      append: (event: MonitorEvent) => Effect.sync(() => void buffer.push(event)),
    };
  }),
}) {
  static readonly layer = Layer.effect(this, this.make);
}

Что важно различать, Effect.forkChild против Effect.forkScoped:

ИмяК чему привязан файбер
Effect.forkChild(eff)к родительскому файберу: когда родитель завершается, файбер прерывается
Effect.forkScoped(eff)к Scope из Context: когда Scope закрывается, файбер прерывается

Имя forkChild в v4 говорит прямо то, что происходит: рождается ребёнок текущего файбера. Раньше оно называлось просто fork, и по имени было не видно, к кому именно привязана жизнь нового файбера.

В конструкторе сервиса нам нужен второй вариант. Если ты напишешь Effect.forkChild, файбер будет жить, пока строится конструктор. Конструктор закончился сразу после return {...}, и файбер немедленно умрёт. У тебя будет ровно один цикл вместо непрерывного потока.

Что взять с собой

  • Ресурсный конструктор пишется тем же make и тем же Layer.effect: отдельного Layer.scoped в v4 нет, Layer.effect сам вычитает Scope.
  • Effect.acquireRelease(open, close), открывает ресурс и регистрирует finalizer. Finalizer стрельнёт на закрытии Scope.
  • Для фоновых процессов внутри ресурсного слоя бери Effect.forkScoped, не Effect.forkChild. Иначе файбер умрёт, как только построится конструктор.
  • Подробнее про Scope, finalizer-ы и acquireUseRelease, урок 05 · Resources и Scope.

Раздел 8 · ManagedRuntime · долгоживущая сборка

Сцена

Pulse живёт двумя точками входа: CLI-команда pulse watch и HTTP-сервер pulse serve (http-модуль ядра effect, урок 12). Обе хотят один и тот же Layer-граф: один Storage, один Logger, один file handle на всех. Самый частый промах, собирать граф на каждый вызов. Скажем, ты встраиваешь probe в обработчик чужого, не-Effect-фреймворка и пишешь так:

// наивно: Effect.provide(MainLive) на каждый вызов
const handleProbe = (url: string) =>
  Effect.runPromise(probe(url).pipe(Effect.provide(MainLive)));

то на каждый вызов Effect строит весь Layer-граф заново. Это значит, новый HttpService, новый Storage (а с ним новый file handle!), новый Logger. Под нагрузкой это убьёт сервер.

Идея словами

ManagedRuntime, это долгоживущий объект, который:

  • построил MainLive один раз на старте программы;
  • предоставляет методы запуска (runPromise, runFork, runSync), которые используют уже готовый граф;
  • умеет аккуратно закрыться (закрыть scoped ресурсы) на остановке программы.
import { ManagedRuntime } from 'effect';

const runtime = ManagedRuntime.make(MainLive);

// CLI-команда pulse watch:
await runtime.runPromise(probe('https://github.com'));

// встраивание probe в чужой обработчик: граф уже построен
const handleProbe = (url: string) => runtime.runPromise(probe(url));

Один runtime обслуживает все вызовы. Один HttpService, один Storage, один file handle. В самом pulse serve MainLive подаётся серверному Layer-у один раз через Layer.provide, поэтому там та же мемоизация работает из коробки (детали в уроке 12).

Шаг 1 · Создание и использование

// src/runtime.ts
import { ManagedRuntime } from 'effect';
import { MainLive } from './main.ts';

export const runtime = ManagedRuntime.make(MainLive);

ManagedRuntime.make(layer) не запускает конструкторы немедленно. Layer лениво строится при первом runtime.runPromise(...). Если хочешь форсировать сборку (например, чтобы первый HTTP-запрос не платил за инициализацию), запусти пустой эффект на старте:

await runtime.runPromise(Effect.void);

Шаг 2 · Методы запуска

ManagedRuntime отдаёт те же запускалки, что и сам Effect, только без аргумента-Layer-а:

МетодНазначение
runtime.runPromise(eff)Promise<A>, отдаёт результат, режектит на ошибке
runtime.runPromiseExit(eff)Promise<Exit<A, E>>, типизированно
runtime.runFork(eff)Fiber, для долгоживущих штук
runtime.runSync(eff)A, бросает на async-границе

Шаг 3 · dispose на остановке

Долгоживущему рантайму нужно уметь корректно закрываться. Иначе scoped-ресурсы (file handle, HTTP keep-alive, фоновые процессы) останутся висеть.

import process from 'node:process';

process.on('SIGINT', async () => {
  await runtime.dispose();
  process.exit(0);
});

runtime.dispose() закрывает Scope, на котором держится Layer. Все finalizer-ы (включая handle.close() у Storage и Effect.forkScoped-файберы) выполняются и завершаются.

Шаг 4 · Effect.provide(layer) против Effect.provide(runtime)

Тонкое место. Если внутри программы ты делаешь Effect.provide(MainLive) поверх runtime.runPromise(...), мемоизация сломается:

// ❌ плохо
await runtime.runPromise(program.pipe(Effect.provide(MainLive)));

runtime держит свой MemoMap, и вложенный Effect.provide(MainLive) про него не знает: Layer-ы соберутся заново внутри вложенного scope, а экземпляры рантайма останутся неиспользованными. Общая мемоизация v4 тут не спасает, она работает на уровне файбера и не сшивает две независимые сборки.

Правильно, передавать runtime напрямую:

// ✅ хорошо
await runtime.runPromise(program); // runtime сам подкладывает MainLive

// или, если хочется явного локального override:
await runtime.runPromise(program.pipe(Effect.provide(runtime)));

Effect.provide(runtime) (вместо Layer-а) переиспользует MemoMap рантайма, мемоизация работает.

Шаг 5 · Ошибка с Effect.forkChild в ресурсном слое

В Разделе 7 мы упоминали, что Effect.forkChild внутри ресурсного конструктора умирает сразу после построения. С ManagedRuntime это становится особенно заметно:

// внутри Storage.make:
yield* Effect.forkChild(heartbeat); // ❌ молча умирает

await runtime.runPromise(Effect.void); // конструктор отработал, fiber прерван

С Effect.forkScoped всё работает: файбер привязан к Scope, который держит ManagedRuntime, и живёт до runtime.dispose(). Это правило живёт практически: Effect.forkScoped для всего, что внутри ресурсного конструктора сервиса.

Что взять с собой

  • ManagedRuntime, это долгоживущая сборка Layer-а с лениво построенными сервисами и собственным MemoMap.
  • runtime.runPromise(eff), основная замена Effect.runPromise(eff.pipe(Effect.provide(layer))). Один граф на все вызовы.
  • Не пиши Effect.provide(layer) внутри runtime.runPromise(...), ты сломаешь мемоизацию. Если нужен локальный override, используй Effect.provide(runtime).
  • runtime.dispose() обязательно на остановке программы. Без этого scoped-ресурсы не закроются.
  • В ресурсных слоях под ManagedRuntime бери Effect.forkScoped, не Effect.forkChild.

Раздел 9 · MemoMap · как Effect делит экземпляры

Сцена

Ты подключаешь Pulse к двум сервисам одной базой данных и пишешь в лоб:

const a = Database.layerFor('localhost:5432');
const b = Database.layerFor('localhost:5432'); // тот же URL, другой объект Layer!

// MainLive = Layer.mergeAll(ServiceA(a), ServiceB(b))

URL у Layer-ов одинаковый, но конструктор Database сработает дважды, на старте откроются два пула вместо одного. Хотя теги совпадают и адрес тот же, мемоизация не сработала. Чтобы понять почему, нужно разобраться, по какому ключу MemoMap различает Layer-ы.

Идея словами

Effect мемоизирует Layer-ы по ссылке, не по тегу. Ключ MemoMap, это сам объект Layer. Каждый вызов фабрики Database.layerFor(url) возвращает новый объект, поэтому два таких вызова MemoMap считает разными Layer-ами, даже если они строят сервис под тем же тегом.

Лекарство, сохранить Layer в переменной и передавать ту же ссылку:

const databaseLive = Database.layerFor('localhost:5432');
// ServiceA.layer: ... .pipe(Layer.provide(databaseLive))
// ServiceB.layer: ... .pipe(Layer.provide(databaseLive))
// конструктор Database сработает один раз

Когда слой объявлен как static readonly layer = ... (Logger.layer, Storage.layer), ссылка одна и та же на каждое обращение, за тебя это делает поле класса. С параметризованными фабриками (Database.layerFor(url)) ссылку держишь сам.

Шаг 1 · Один MemoMap на ManagedRuntime

ManagedRuntime.make(layer) создаёт внутренний MemoMap. Все Layer-ы, упомянутые в layer, кешируются в нём. Внутри одного рантайма каждый Layer строится один раз.

Между двумя рантаймами MemoMap не делится. Если ты сделаешь:

const r1 = ManagedRuntime.make(MainLive);
const r2 = ManagedRuntime.make(MainLive);

то получишь два независимых Storage с двумя file handle на одну и ту же events.jsonl. На бэкенде такая ситуация редка, на фронте (где компоненты с собственным runtime могут жить и умирать) очень возможна.

Шаг 2 · Layer.makeMemoMap, общий MemoMap

Решение, общий MemoMap для нескольких рантаймов:

import { Effect, Layer, ManagedRuntime } from 'effect';

const memoMap = Effect.runSync(Layer.makeMemoMap);

const r1 = ManagedRuntime.make(MainLive, { memoMap });
const r2 = ManagedRuntime.make(MainLive, { memoMap });

await r1.runPromise(Effect.void); // строит Layer, кладёт в memoMap
await r2.runPromise(Effect.void); // достаёт из memoMap

Теперь оба рантайма пользуются одним экземпляром Storage. На фронте это нужно, когда у тебя useEffectRuntime()-хук разворачивает рантайм в каждом компоненте, а ты не хочешь, чтобы перерендер пересоздавал базу.

В прикладном бэкенде Pulse этого не понадобится: один runtime на процесс, один MemoMap, один Storage. Знание про Layer.makeMemoMap пригодится, когда ты будешь интегрировать Effect в React/Solid (этого в Pulse нет, но в твоих рабочих проектах будет).

Шаг 3 · Layer.launch, бесконечно живущий Layer

Маленькое побочное знание: Layer.launch(layer) превращает Layer в Effect<never, E, R>, который держит сервисы запущенными до прерывания. Полезно для serve-команд:

const httpServer = Layer.launch(HttpServer.Live);
Effect.runPromise(httpServer); // не вернётся, пока не пришёл SIGINT

В Pulse это пригодится в уроке 12, где мы поднимем HTTP-сервер на @effect/platform-node.

Что взять с собой

  • Layer мемоизируется по ссылке, не по тегу. Сохрани Layer в переменной и переиспользуй ссылку.
  • ManagedRuntime имеет внутренний MemoMap. Внутри одного рантайма Layer строится один раз.
  • Между рантаймами MemoMap не делится. Если нужно делить, используй Layer.makeMemoMap.
  • Layer.launch(layer), превращает Layer в долгоживущий Effect<never>. Удобно для серверов.

Раздел 10 · Mocking · подмена в тестах

Сцена

Тесты Pulse не должны ходить в реальный github.com. Им нужен HttpService, который отдаёт фиксированные ответы. Вариант “написать vi.mock('node:fetch', ...)” не работает: fetch живёт глубоко внутри HttpService, мокать через ESM-плагины это лотерея. Эффект-каноничный способ, подменить весь Layer в тестовой сборке.

Идея 1 · Layer.succeed для маленьких сервисов

Если у сервиса два-три метода, проще всего вручную собрать тестовую реализацию:

import { Layer } from 'effect';
import { HttpService } from '~/services/http.ts';

const HttpTest = Layer.succeed(
  HttpService,
  HttpService.of({
    get: (url) => Effect.succeed({ status: 200, body: '{"ok":true}' }),
    post: (url, body) => Effect.succeed({ status: 201, body: '{"id":1}' }),
  }),
);

const TestLayer = Layer.mergeAll(HttpTest, Storage.layer, Logger.layer);

// в тесте:
await ManagedRuntime.make(TestLayer).runPromise(probe('https://x'));

HttpService.of({...}) тут не обязателен, но полезен: он ничего не делает на рантайме и служит только затем, чтобы TypeScript проверил форму прямо в объявлении заглушки.

Идея 2 · Layer.mock для частичной подмены

Если у сервиса десять методов, а тебе важно подменить только два, остальные превратить в “если позовут, упади”, Layer.mock идеален:

import { Layer } from 'effect';

const HttpMostlyBroken = Layer.mock(HttpService, {
  get: (url) => Effect.succeed({ status: 200, body: '{}' }),
  // post не подан, при вызове бросит UnimplementedError
});

Что делает Layer.mock:

  • принимает частичный объект (только те методы, что ты указал);
  • остальные методы стрельнут UnimplementedError-defect-ом, если их кто-то позовёт.

Это удобно: тест проверяет конкретный путь, и любой случайный вызов “не того” метода падает громко, а не молча проходит.

Идея 3 · Маленькие сервисы лучше mock-аппарата

Если ты часто пишешь Layer.mock, это намёк, что сервис слишком большой. Хорошая граница для сервиса, 3-6 методов. Если их пятнадцать, разбей по принципу “одна ответственность”:

// плохо
class UserRepository {
  // findById, findAll, findByEmail, create, update, delete, archive, restore, ...
}

// лучше
class UserQueryRepository {
  // findById, findAll, findByEmail
}
class UserCommandRepository {
  // create, update, delete, archive, restore
}

Тогда тест мокает только тот сервис, который ему нужен, без Layer.mock-обходов.

Шаг 4 · Pulse · MainTest

В src/test-runtime.ts появится:

import { Effect, Layer, ManagedRuntime } from 'effect';

import { HttpService } from './services/http.ts';
import { Storage } from './services/storage.ts';
import { Logger } from './services/logger.ts';

export const buildTestRuntime = (
  fakeResponses: Record<string, { status: number; body: string }>,
) => {
  const HttpTest = Layer.succeed(
    HttpService,
    HttpService.of({
      get: (url) => {
        const fake = fakeResponses[url];
        if (!fake) {
          return Effect.fail(new NetworkError({ url, cause: 'not configured' }));
        }
        return Effect.succeed(fake);
      },
      post: () => Effect.fail(new NetworkError({ url: 'unused', cause: 'not configured' })),
    }),
  );

  const StorageInMemory = Layer.effect(
    Storage,
    Effect.gen(function* () {
      const events: MonitorEvent[] = [];
      return Storage.of({
        append: (event) => Effect.sync(() => void events.push(event)),
        readAll: () => Effect.succeed(events.slice()),
      });
    }),
  );

  const TestLive = Layer.mergeAll(HttpTest, StorageInMemory, Logger.layer);
  return ManagedRuntime.make(TestLive);
};

Тест прямо подаёт map URL-ов в фиксированные ответы и работает с runtime:

import { describe, expect, it } from 'vitest';

describe('probe', () => {
  it('возвращает 200 для зафейкованного github', async () => {
    const runtime = buildTestRuntime({
      'https://github.com': { status: 200, body: '' },
    });

    const result = await runtime.runPromise(probe('https://github.com'));
    expect(result.status).toBe(200);

    await runtime.dispose();
  });
});

Никакого vi.mock. Подмена идёт через Layer, тип-проверена компилятором, лишних вызовов не пройдёт.

Что взять с собой

  • Layer.succeed(Tag, Tag.make({...})), базовая форма тестовой реализации. Подходит для сервисов с 3-6 методами.
  • Layer.mock(Tag, partial), частичный мок: только нужные методы, остальные кидают UnimplementedError.
  • Если Layer.mock нужен часто, разбей сервис на меньшие. Граница, 3-6 методов на сервис.
  • Тестовый рантайм собирай через ManagedRuntime.make(TestLive). Не забывай runtime.dispose() в afterEach/afterAll.

Pulse · вклад этого урока

К концу урока в pulse-<nick> появилось:

src/
  services/
    logger.ts        // Context.Service Logger
    http.ts          // Context.Service HttpService, depends on Logger
    storage.ts       // Context.Service Storage, depends on Logger; file handle в acquireRelease
  main.ts            // MainLive = Layer.mergeAll(...)
  runtime.ts         // export const runtime = ManagedRuntime.make(MainLive)
  test-runtime.ts    // buildTestRuntime для тестов

Тип сквозной программы:

type PulseProgram = Effect<void, PulseError, HttpService | Storage | Logger>;

Всё, что ниже probe (запись в журнал, логирование), выражено через сервисы. На границе (CLI-команда или встраивание в чужой обработчик) подкладывается через runtime.runPromise(...). В тестах подкладывается через buildTestRuntime.

Параметр R ожил.

Финал · чек-лист

Чек-листготово

ДЗ

Дальше

Следующий урок · 05. Resources и Scope. Мы коротко увидели acquireRelease и forkScoped, в следующем уроке разбираем Scope как первоклассное понятие, acquireUseRelease для tightly-scoped ресурсов, Effect.scoped против ресурса, спрятанного в Layer, и закрываем тему ресурсов до конца. После этого все механизмы для написания одной общей программы Pulse у тебя на руках.