En el desarrollo enterprise tradicional con TypeScript, la inyección de dependencias (Dependency Injection, DI) suele pivotar en torno a frameworks orientados a objetos (Nest.js, Inversify, TSyringe) que dependen intensamente de decoradores experimentales (experimentalDecorators) y metadatos de reflexión en runtime (reflect-metadata).
Este patrón impone severos inconvenientes arquitectónicos:
@Module({ providers: [...] }) solo se descubre cuando la aplicación colapsa al arrancar (Nest can't resolve dependencies of the Service).El ecosistema Effect-TS propone un paradigma diametralmente opuesto: inyección de dependencias con seguridad de tipos y sin decoradores, donde los requisitos del entorno forman parte integral de la firma de tipos estática a través del parámetro Requirements (R).
Los pilares de esta arquitectura son:
La arquitectura de gestión de dependencias en Effect separa limpiamente la declaración de requisitos de la entrega de implementaciones:
+-------------------------------------------------------------------------+
| DECLARACIÓN DE DEPENDENCIAS (TAGS DE CONTEXTO) |
+-------------------------------------------------------------------------+
| class Database extends Context.Tag("Database")<Database, DBService>() |
| class Mailer extends Context.Tag("Mailer")<Mailer, MailerService>() |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| LÓGICA DE NEGOCIO (REQUIERE SERVICIOS EN REQUIREMENTS) |
| const registerUser = Effect.gen(function* () { |
| const db = yield* Database; <-- Agrega Database a Requirements |
| const mail = yield* Mailer; <-- Agrega Mailer a Requirements |
| }); |
| // Tipo: Effect<User, RegistrationError, Database | Mailer> |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| PROVISIÓN DE IMPLEMENTACIONES VÍA CAPAS (LAYERS) |
| |
| Capa de producción: Capa de pruebas (In-Memory): |
| DatabaseLive (Pool Postgres) DatabaseTest (Map en memoria) |
| MailerLive (AWS SES) MailerTest (Array Spy mock) |
| \ / |
| +-- Effect.provide(AppLive) ---+ |
| | | |
| v v |
| Effect<User, RegistrationError, never> (Listo para Effect.runPromise) |
+-------------------------------------------------------------------------+El compilador de TypeScript rastrea en todo momento la unión de dependencias: si una función invoca tres servicios, su tipo inferido es Effect<A, E, ServiceA | ServiceB | ServiceC>. Compilar Effect.runPromise sin haber suministrado los tres servicios es un error estático irrecuperable.
SIGTERM.TenantContext) a partir de cabeceras de autorización HTTP entrantes.Context.Tag#En Effect 3.x y superior, los servicios se declaran extendiendo la clase 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>;
}
// Declaración del tag tipado del servicio
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>() {}El flujo de dominio consume los servicios directamente mediante yield* dentro de Effect.gen. Las dependencias inferidas convergen automáticamente en el parámetro 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* () {
// Extraer instancias de servicios del contexto de ejecución
const db = yield* Database;
const notifier = yield* Notifier;
// Invocar métodos del servicio
yield* db.saveUser({ id, email });
yield* notifier.sendNotification(email, '¡Bienvenido a Fullstack Agentic Lab!');
return { id, email } satisfies UserRecord;
});
// Tipo inferido:
// Effect.Effect<UserRecord, Error, Database | Notifier>Una Layer<OutputService, Error, InputRequirements> especifica cómo ensamblar OutputService a partir de las dependencias InputRequirements:
import { Layer, Effect } from 'effect';
import { Database, type UserRecord } from '../services/Database';
import { Notifier } from '../services/Notifier';
// 1. Capa de base de datos para producción (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. Capa de notificaciones para producción (AWS SES)
export const NotifierLive = Layer.succeed(
Notifier,
Notifier.of({
sendNotification: (email, message) =>
Effect.sync(() => {
console.log(`[SMTP] To: ${email} | Body: ${message}`);
}),
})
);
// 3. Fusión horizontal de capas de aplicación
export const AppLive = Layer.merge(DatabaseLive, NotifierLive);En pruebas automatizadas creamos capas ligeras en memoria que registran eventos en espías locales:
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: Flujo registerUser', () => {
it('guarda el usuario y envía notificación utilizando capas de prueba en memoria', async () => {
// Almacenes de prueba en memoria
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);
// Suministrar capas de prueba al programa
const programWithMocks = registerUser('u_101', '[email protected]').pipe(
Effect.provide(TestAppLayer)
);
// Ejecutar programa: ¡el parámetro Requirements ahora es estrictamente 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]');
});
});Definir interfaces abstractas de servicio (Database, Cache, Logger).
Construcción del grafo: Cache depende de Config; App fusiona Database y Cache.
Inyección de capas en el programa. El compilador valida que no queden dependencias abiertas.
Inicialización de recursos antes de ejecutar y cierre ordenado garantizado al finalizar.
A continuación se muestra una capa para pool de base de datos que garantiza adquisición segura y liberación automática mediante Layer.scoped:
import { Context, Effect, Layer } from 'effect';
import { Database, type DatabaseService } from '../../services/Database';
// Tag de configuración
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. Apertura asíncrona del pool de conexiones
yield* Effect.logInfo(`[DB Pool] Inicializando pool hacia ${config.connectionString}...`);
const poolInstance = {
connected: true,
activeClients: 0,
};
// 2. Registro de finalizador en el Scope actual:
// Se ejecutará indefectiblemente al apagar la aplicación o ante caídas críticas
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* Effect.logInfo('[DB Pool] Clausurando conexiones activas del pool...');
poolInstance.connected = false;
})
);
// 3. Devolución de la implementación del servicio
const service: DatabaseService = {
findUserById: (id) =>
Effect.gen(function* () {
if (!poolInstance.connected) {
return yield* Effect.fail(new Error('El pool de conexiones está cerrado'));
}
return { id, email: `user_${id}@agenticlab.dev` };
}),
saveUser: (user) =>
Effect.sync(() => {
console.log(`[DB Pool Execute] Guardado ${user.email}`);
}),
};
return service;
})
);Llamar a Effect.runPromise(program) cuando Requirements no es never genera un error descriptivo de compilación en TypeScript.
// ❌ Error de compilación: 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); // ERROR: ¡La dependencia Database no ha sido provista!
// ✅ Solución correcta: resolver el requisito con provide
await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)));Invocar Effect.provide(DatabaseLive) dentro de cada controlador HTTP abre un pool de conexiones nuevo por solicitud, agotando velozmente los límites de sockets de la base de datos.
Solución: instanciar la capa global de la aplicación AppLayer una sola vez durante el bootstrap y proveerla al dispatcher raíz.
Si LayerA depende de LayerB y simultáneamente LayerB requiere LayerA, el compilador detectará un ciclo de tipos y la compilación fallará.
Solución: aplicar principios de Clean Architecture abstrayendo contratos comunes en capas de menor nivel jerárquico.
Declarar una interfaz de TypeScript donde los métodos que puedan fallar retornen valores Effect.
Crear una clase de tag extendiendo Context.Tag('ServiceName')<ServiceName, Interface>().
Importar el tag en funciones de negocio y desenvolverlo mediante yield* TagName dentro de Effect.gen.
Implementar Layer.succeed o Layer.scoped conectando drivers reales, clientes de red y SDKs.
Crear un Layer.succeed respaldado por estructuras en memoria para tests unitarios ultrarrápidos.