In traditional enterprise TypeScript development, Dependency Injection (DI) typically revolves around object-oriented frameworks (Nest.js, Inversify, TSyringe) relying heavily on experimental decorators (experimentalDecorators) and runtime reflection metadata (reflect-metadata).
This pattern carries severe architectural liabilities:
@Module({ providers: [...] }) is only detected when the application crashes at startup (Nest can't resolve dependencies of the Service).The Effect-TS ecosystem proposes a fundamentally superior paradigm: decorator-free, type-safe dependency injection, where environmental requirements are baked directly into static function signatures via the Requirements (R) parameter.
The foundational pillars of this architecture are:
Effect dependency management cleanly bifurcates declaring requirements from providing concrete implementations:
+-------------------------------------------------------------------------+
| DEPENDENCY DECLARATION (CONTEXT TAGS) |
+-------------------------------------------------------------------------+
| class Database extends Context.Tag("Database")<Database, DBService>() |
| class Mailer extends Context.Tag("Mailer")<Mailer, MailerService>() |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| BUSINESS LOGIC (REQUIRES SERVICES IN REQUIREMENTS) |
| const registerUser = Effect.gen(function* () { |
| const db = yield* Database; <-- Adds Database to Requirements |
| const mail = yield* Mailer; <-- Adds Mailer to Requirements |
| }); |
| // Type: Effect<User, RegistrationError, Database | Mailer> |
+-------------------------------------------------------------------------+
|
v
+-------------------------------------------------------------------------+
| PROVIDING IMPLEMENTATIONS VIA 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> (Ready for Effect.runPromise) |
+-------------------------------------------------------------------------+The TypeScript compiler continuously tracks union dependencies: if a function invokes three services, its inferred type becomes Effect<A, E, ServiceA | ServiceB | ServiceC>. Compiling Effect.runPromise without satisfying all three dependencies is strictly rejected by the type checker.
SIGTERM.TenantContext) parsed from incoming HTTP authorization headers per request.Context.Tag#In Effect 3.x and above, services are declared by extending the Context.Tag class:
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>;
}
// Declaring the typed service tag
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>() {}Business workflows consume services directly using yield* inside Effect.gen. Inferred dependencies coalesce into the Requirements parameter automatically:
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* () {
// Resolve service instances from runtime context
const db = yield* Database;
const notifier = yield* Notifier;
// Invoke service operations
yield* db.saveUser({ id, email });
yield* notifier.sendNotification(email, 'Welcome to Fullstack Agentic Lab!');
return { id, email } satisfies UserRecord;
});
// Inferred return type:
// Effect.Effect<UserRecord, Error, Database | Notifier>A Layer<OutputService, Error, InputRequirements> describes how to construct OutputService by consuming upstream InputRequirements:
import { Layer, Effect } from 'effect';
import { Database, type UserRecord } from '../services/Database';
import { Notifier } from '../services/Notifier';
// 1. Production database layer (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. Production notifier layer (AWS SES / SendGrid)
export const NotifierLive = Layer.succeed(
Notifier,
Notifier.of({
sendNotification: (email, message) =>
Effect.sync(() => {
console.log(`[SMTP] To: ${email} | Body: ${message}`);
}),
})
);
// 3. Merging application layers horizontally
export const AppLive = Layer.merge(DatabaseLive, NotifierLive);In automated tests, we construct lightweight in-memory layers that capture interactions in memory spies:
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('saves user and sends notification using in-memory test layers', async () => {
// In-memory test stores
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);
// Provide test layers to the domain program
const programWithMocks = registerUser('u_101', '[email protected]').pipe(
Effect.provide(TestAppLayer)
);
// Run program: Requirements parameter is now strictly 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]');
});
});Define abstract service interfaces (Database, Cache, Logger).
Compose dependency tree: Cache layer requires Config; App layer merges Database and Cache.
Inject layers into business program. Compiler ensures all required tags are satisfied.
Initialize resources before execution and ensure graceful cleanup on shutdown.
Here is an enterprise-grade database connection pool layer requiring safe resource acquisition and teardown via Layer.scoped:
import { Context, Effect, Layer } from 'effect';
import { Database, type DatabaseService } from '../../services/Database';
// Configuration tag
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. Asynchronous connection pool startup
yield* Effect.logInfo(`[DB Pool] Initializing pool connected to ${config.connectionString}...`);
const poolInstance = {
connected: true,
activeClients: 0,
};
// 2. Register finalizer in the current Scope:
// Guaranteed to execute on application shutdown or fatal failure
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
yield* Effect.logInfo('[DB Pool] Gracefully terminating connection pool...');
poolInstance.connected = false;
})
);
// 3. Return concrete service implementation
const service: DatabaseService = {
findUserById: (id) =>
Effect.gen(function* () {
if (!poolInstance.connected) {
return yield* Effect.fail(new Error('Connection pool is closed'));
}
return { id, email: `user_${id}@agenticlab.dev` };
}),
saveUser: (user) =>
Effect.sync(() => {
console.log(`[DB Pool Execute] Stored ${user.email}`);
}),
};
return service;
})
);Invoking Effect.runPromise(program) when the Requirements parameter is not never triggers a descriptive TypeScript compiler error.
// ❌ Compilation Error: 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: Database dependency not satisfied!
// ✅ Correct approach: Eliminate requirements via provide
await Effect.runPromise(program.pipe(Effect.provide(DatabaseLive)));Calling Effect.provide(DatabaseLive) inside each request handler creates a brand new connection pool per request, rapidly exhausting database socket limits.
Solution: Instantiate and resolve your root AppLayer once during application bootstrap and provide it to the root server dispatcher.
If LayerA depends on LayerB while LayerB simultaneously requires LayerA, the compiler detects a recursive type constraint and compilation fails.
Solution: Apply Clean Architecture principles by factoring shared contracts into lower-level, leaf service tags.
Draft a TypeScript interface where all fallible methods return Effect values.
Instantiate a service tag class extending Context.Tag('ServiceName')<ServiceName, Interface>().
Import the tag into business handlers and unwrap via yield* TagName within Effect.gen.
Implement Layer.succeed or Layer.scoped wiring up live drivers, SDKs, and network clients.
Provide a mock Layer.succeed backed by in-memory data structures for fast unit tests.