В традиционной enterprise-разработке на TypeScript инъекция зависимостей (Dependency Injection, DI) строится вокруг объектно-ориентированных фреймворков (Nest.js, Inversify, TSyringe), опирающихся на экспериментальные декораторы (experimentalDecorators) и метаданные отражения (reflect-metadata).
Этот подход имеет фундаментальные недостатки:
@Module({ providers: [...] }) обнаруживается только при падении приложения в рантайме (Nest can't resolve dependencies of the Service).reflect-metadata плохо поддаются Tree-shaking и не могут компилироваться чистыми транспайлерами вроде esbuild или SWC без специфичных плагинов.Экосистема Effect-TS предлагает принципиально иную парадигму: типобезопасное внедрение зависимостей без декораторов, где зависимости являются неотъемлемой частью сигнатуры типов функций через параметр Requirements (R).
Связующими звеньями этой архитектуры выступают:
Архитектура управления зависимостями в Effect разделяет систему на декларацию требований и их удовлетворение:
+-------------------------------------------------------------------------+
| ДЕКЛАРАЦИЯ ЗАВИСИМОСТЕЙ (КОНТЕКСТНЫЕ ТЕГИ) |
+-------------------------------------------------------------------------+
| class Database extends Context.Tag("Database")<Database, DBService>() |
| class Mailer extends Context.Tag("Mailer")<Mailer, MailerService>() |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| БИЗНЕС-ЛОГИКА (ТРЕБУЕТ СЕРВИСЫ В REQUIREMENTS) |
| const registerUser = Effect.gen(function* () { |
| const db = yield* Database; <-- Добавляет Database в Requirements |
| const mail = yield* Mailer; <-- Добавляет Mailer в Requirements |
| }); |
| // Тип: Effect<User, RegistrationError, Database | Mailer> |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| ПРЕДОСТАВЛЕНИЕ РЕАЛИЗАЦИЙ ЧЕРЕЗ СЛОИ (LAYERS) |
| |
| Production Layer: Test / In-Memory Layer: |
| DatabaseLive (Postgres Pool) DatabaseTest (In-Memory Map) |
| MailerLive (AWS SES) MailerTest (Mock Spy Array) |
| \ / |
| +-- Effect.provide(AppLive) ---+ |
| | | |
| v v |
| Effect<User, RegistrationError, never> (Готов к Effect.runPromise) |
+-------------------------------------------------------------------------+Компилятор TypeScript отслеживает объединение зависимостей: если функция вызывает три сервиса, ее тип становится Effect<A, E, ServiceA | ServiceB | ServiceC>. Скомпилировать вызов Effect.runPromise без предоставления всех трех реализаций невозможно.
Описать TypeScript-интерфейс сервиса, где все методы возвращают объекты Effect.
Создать класс тега через Context.Tag('ServiceName')<ServiceName, Interface>().
Импортировать тег в бизнес-функции и вызывать yield* TagName внутри Effect.gen.
Реализовать Layer.succeed или Layer.scoped с открытием реальных драйверов и сетевых клиентов.
Создать легковесный Layer.succeed с in-memory хранилищем для мгновенных юнит-тестов.
В чем фундаментальное преимущество Context.Tag в Effect перед токенами классических IoC-контейнеров (Nest.js, Inversify)?
SIGTERM.TenantContext) на основе HTTP-заголовков входящего запроса.Context.Tag#В версии Effect 3.x и выше сервисы объявляются с помощью наследования от класса Context.Tag:
import { Context, Effect } from 'effect';
export interface UserRecord {
readonly id: string;
readonly email: string;
}
export interface DatabaseService {
readonly findUserById: (id: string) => Effect.Effect<UserRecord, Error>;
readonly saveUser: (user: UserRecord) => Effect.Effect<void, Error>;
}
// Создание типизированного тега сервиса
export class Database extends Context.Tag('Database')<Database, DatabaseService>() {}import { Context, Effect } from 'effect';
export interface NotifierService {
readonly sendNotification: (email: string, message: string) => Effect.Effect<void, Error>;
}
export class Notifier extends Context.Tag('Notifier')<Notifier, NotifierService>() {}Бизнес-логика запрашивает сервисы напрямую через оператор yield* внутри Effect.gen. Типы объединяются в параметре Requirements автоматически:
import { Effect } from 'effect';
import { Database, type UserRecord } from './Database';
import { Notifier } from './Notifier';
export class UserAlreadyExistsError {
readonly _tag = 'UserAlreadyExistsError';
constructor(readonly email: string) {}
}
export const registerUser = (id: string, email: string) =>
Effect.gen(function* () {
// Извлекаем инстансы сервисов из контекста выполнения
const db = yield* Database;
const notifier = yield* Notifier;
// Вызываем методы сервисов
yield* db.saveUser({ id, email });
yield* notifier.sendNotification(email, 'Добро пожаловать в Fullstack Agentic Lab!');
return { id, email } satisfies UserRecord;
});
// Результирующий тип:
// Effect.Effect<UserRecord, Error, Database | Notifier>Слой Layer<OutputService, Error, InputRequirements> описывает то, как создать сервис OutputService, используя зависимости InputRequirements:
import { Layer, Effect } from 'effect';
import { Database, type UserRecord } from '../services/Database';
import { Notifier } from '../services/Notifier';
// 1. Продакшен-реализация базы данных (PostgreSQL / Redis)
export const DatabaseLive = Layer.succeed(
Database,
Database.of({
findUserById: (id) =>
Effect.succeed({ id, email: `user_${id}@agenticlab.dev` }),
saveUser: (user) =>
Effect.sync(() => {
console.log(`[SQL] INSERT INTO users VALUES ('${user.id}', '${user.email}')`);
}),
})
);
// 2. Продакшен-реализация отправщика почты
export const NotifierLive = Layer.succeed(
Notifier,
Notifier.of({
sendNotification: (email, message) =>
Effect.sync(() => {
console.log(`[SMTP] To: ${email} | Body: ${message}`);
}),
})
);
// 3. Композиция слоев приложения: горизонтальное объединение
export const AppLive = Layer.merge(DatabaseLive, NotifierLive);Для тестирования мы создаем тестовый слой, собирающий отправленные сообщения в локальный массив (Spy):
import { describe, it, expect } from 'vitest';
import { Effect, Layer, Ref } from 'effect';
import { Database, type UserRecord } from '../src/services/Database';
import { Notifier } from '../src/services/Notifier';
import { registerUser } from '../src/domain/registerUser';
describe('Unit Test: registerUser Flow', () => {
it('успешно сохраняет пользователя и отправляет уведомление без реальной БД', async () => {
// Создаем тестовые in-memory хранилища
const usersStore = new Map<string, UserRecord>();
const sentEmails: string[] = [];
const DatabaseTest = Layer.succeed(
Database,
Database.of({
findUserById: (id) =>
usersStore.has(id)
? Effect.succeed(usersStore.get(id)!)
: Effect.fail(new Error('User not found')),
saveUser: (user) =>
Effect.sync(() => {
usersStore.set(user.id, user);
}),
})
);
const NotifierTest = Layer.succeed(
Notifier,
Notifier.of({
sendNotification: (email) =>
Effect.sync(() => {
sentEmails.push(email);
}),
})
);
const TestAppLayer = Layer.merge(DatabaseTest, NotifierTest);
// Предоставляем тестовый слой в программу
const programWithMocks = registerUser('u_101', '[email protected]').pipe(
Effect.provide(TestAppLayer)
);
// Запускаем программу: тип Requirements теперь равен never!
const result = await Effect.runPromise(programWithMocks);
expect(result.email).toBe('[email protected]');
expect(usersStore.get('u_101')?.email).toBe('[email protected]');
expect(sentEmails).toContain('[email protected]');
});
});Определение абстрактных интерфейсов сервисов (Database, Cache, Logger).
Построение графа зависимостей: слой Cache зависит от Config, слой App объединяет Database и Cache.
Внедрение слоя в бизнес-программу. Компилятор сверяет, что все теги из Requirements закрыты.
Инициализация ресурсов слоев перед стартом и гарантированное закрытие при завершении работы.
Ниже представлен production-слой для пула соединений с базой данных, требующий безопасного открытия и закрытия ресурсов через Layer.scoped:
import { Effect, Layer, Scope } from 'effect';
import { Database, type DatabaseService } from '../../services/Database';
// Конфигурационный тег
export class DbConfig extends Context.Tag('DbConfig')<
DbConfig,
{ readonly connectionString: string; readonly maxConnections: number }
>() {}
export const DatabaseLive = Layer.scoped(
Database,
Effect.gen(function* () {
const config = yield* DbConfig;
// 1. Асинхронное открытие пула соединений
yield* Effect.logInfo(`[DB Pool] Инициализация пула к ${config.connectionString}...`);
const poolInstance = {
connected: true,
activeClients: 0,
};
// 2. Регистрация финализатора в текущей области видимости Scope:
// гарантированно выполнится при остановке приложения или фатальном сбое
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* Effect.logInfo('[DB Pool] Закрытие активных соединений пула...');
poolInstance.connected = false;
})
);
// 3. Возврат реализации сервиса
const service: DatabaseService = {
findUserById: (id) =>
Effect.gen(function* () {
if (!poolInstance.connected) {
return yield* Effect.fail(new Error('Пул соединений закрыт'));
}
return { id, email: `user_${id}@agenticlab.dev` };
}),
saveUser: (user) =>
Effect.sync(() => {
console.log(`[DB Pool Execute] Сохранен ${user.email}`);
}),
};
return service;
})
);Какую опасность несет вызов Effect.provide(DatabaseLive) локально внутри обработчика каждого HTTP-запроса?
Если разработчик пытается вызвать Effect.runPromise(program) для программы, у которой параметр Requirements не равен never, TypeScript выдаст длинную ошибку компиляции.
// ❌ Ошибка компиляции: Type 'Database' is not assignable to type 'never'
const program = Effect.gen(function* () {
const db = yield* Database;
return yield* db.findUserById('1');
});
await Effect.runPromise(program); // ОШИБКА: Зависимость Database не предоставлена!
// ✅ Правильное решение: закрыть зависимость через provide
await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)));Вызов Effect.provide(DatabaseLive) внутри обработчика каждого HTTP-запроса приводит к повторному открытию пула соединений на каждый клик, что быстро исчерпывает лимиты сокетов базы данных.
Решение: собирать глобальный слой приложения AppLayer один раз при старте сервера и предоставлять его в корневой обработчик.
Если слой LayerA требует LayerB, а LayerB требует LayerA, компилятор зафиксирует циклическую зависимость в типах, и код упадет при сборке графа.
Решение: применять архитектуру луковичных слоев (Clean Architecture), выделяя общие зависимости на нижний уровень.