У традиційній 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 без надання всіх трьох реалізацій неможливо.
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 } 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 { Context, Effect, Layer } 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.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), виділяючи спільні залежності на нижній рівень.
Описати TypeScript-інтерфейс сервісу, де всі методи повертають об'єкти Effect.
Створити клас тегу через Context.Tag('ServiceName')<ServiceName, Interface>().
Імпортувати тег у бізнес-функції та викликати yield* TagName всередині Effect.gen.
Реалізувати Layer.succeed або Layer.scoped із відкриттям реальних драйверів та мережевих клієнтів.
Створити легковажний Layer.succeed з in-memory сховищем для миттєвих юніт-тестів.