Requirements Management in Effect
Overview
The third type parameter in Effect<A, E, R> represents requirements - services and dependencies the effect needs to run:
Effect<Success, Error, Requirements>;
// ^^^^^^^^^^^^ Services needed
Effect uses a powerful dependency injection system based on Context and Layer.
The primary reason to define services is testability. Every external dependency (API calls, databases, file systems, third-party SDKs) MUST be wrapped in a Context.Tag service so that tests can provide a test implementation instead of hitting real systems. This is how Effect achieves 100% test coverage — business logic depends only on service interfaces, and tests swap in test layers that control all I/O.
Defining Services
Using Effect.Tag (Recommended)
import { Effect, Context } from "effect";
// Define service interface and tag together
class UserRepository extends Context.Tag("UserRepository")<
UserRepository,
{
readonly findById: (id: string) => Effect.Effect<User, UserNotFound>;
readonly save: (user: User) => Effect.Effect<void>;
}
>() {}
// Using the service
const program = Effect.gen(function* () {
const repo = yield* UserRepository;
const user = yield* repo.findById("123");
return user;
});
// Type: Effect<User, UserNotFound, UserRepository>
Alternative: Context.Tag Directly
interface UserRepository {
readonly findById: (id: string) => Effect.Effect<User, UserNotFound>;
}
const UserRepository = Context.Tag<UserRepository>("UserRepository");
Using Services
const program = Effect.gen(function* () {
const userRepo = yield* UserRepository;
const emailService = yield* EmailService;
const user = yield* userRepo.findById(userId);
yield* emailService.send(user.email, "Welcome!");
});
Creating Layers
Layers are recipes for building services:
Layer.succeed - Simple Value
const LoggerLive = Layer.succeed(Logger, {
log: (msg) => Effect.sync(() => console.log(msg)),
});
Layer.effect - Effect-Based Construction
const ConfigLive = Layer.effect(
Config,
Effect.gen(function* () {
const env = yield* Effect.sync(() => process.env);
return {
apiUrl: env.API_URL ?? "http://localhost:3000",
debug: env.DEBUG === "true",
};
}),
);
Layer.scoped - Resource with Lifecycle
const DatabaseLive = Layer.scoped(
Database,
Effect.gen(function* () {
const pool = yield* Effect.acquireRelease(createPool(), (pool) => Effect.promise(() => pool.end()));
return {
query: (sql) => Effect.promise(() => pool.query(sql)),
};
}),
);
Layer.function - From Function
const HttpClientLive = Layer.function(HttpClient, (baseUrl: string) => ({
get: (path) => Effect.tryPromise(() => fetch(baseUrl + path)),
}));
Providing Dependencies
Effect.provide - Provide Layer
const program = getUserById("123");
const runnable = program.pipe(Effect.provide(AppLive));
await Effect.runPromise(runnable);
Effect.provideService - Provide Single Service
const runnable = program.pipe(
Effect.provideService(UserRepository, {
findById: (id) => Effect.succeed(mockUser),
save: (user) => Effect.void,
}),
);
Composing Layers
Layer.merge - Combine Independent Layers
const InfraLive = Layer.merge(DatabaseLive, LoggerLive);
Layer.provide - Layer Dependencies
const UserRepositoryLive = Layer.effect(
UserRepository,
Effect.gen(function* () {
const db = yield* Database;
return {
findById: (id) => db.query(`SELECT * FROM users WHERE id = ${id}`),
};
}),
);
const FullUserRepo = UserRepositoryLive.pipe(Layer.provide(DatabaseLive));
Layer.provideMerge - Provide and Keep
const Combined = UserRepositoryLive.pipe(Layer.provideMerge(DatabaseLive));
Building Application Layers
Typical Pattern
const InfraLive = Layer.mergeAll(DatabaseLive, LoggerLive, HttpClientLive);
const RepositoryLive = Layer.mergeAll(UserRepositoryLive, OrderRepositoryLive).pipe(Layer.provide(InfraLive));
const ServiceLive = Layer.mergeAll(UserServiceLive, OrderServiceLive).pipe(Layer.provide(RepositoryLive));
const AppLive = ServiceLive.pipe(Layer.provide(InfraLive));
Layer Memoization
Layers are memoized by default - each service is created once:
const AppLive = Layer.mergeAll(UserServiceLive, OrderServiceLive).pipe(Layer.provide(DatabaseLive));
Fresh Layers (No Memoization)
const FreshDatabase = Layer.fresh(DatabaseLive);
Default Services
Effect provides default implementations for common services:
const program = Effect.gen(function* () {
const now = yield* Clock.currentTimeMillis;
const random = yield* Random.next;
});
Overriding Defaults
import { TestClock } from "effect";
const testProgram = program.pipe(Effect.provide(TestClock.layer));
Testing with Services (CRITICAL for 100% Coverage)
Every service MUST have a test layer. This is how you achieve complete test coverage without hitting real external systems.
Simple Test Layer (Stateless)
Use Layer.succeed for services that don't need to track state:
const EmailServiceTest = Layer.succeed(EmailService, {
send: (to, subject, body) => Effect.void, // No-op in tests
sendBulk: (recipients, subject, body) => Effect.void,
});
Stateful Test Layer (Repositories)
Use Layer.effect with Ref for services that need to maintain state:
const UserRepositoryTest = Layer.effect(
UserRepository,
Effect.gen(function* () {
const store = yield* Ref.make<Map<string, User>>(new Map());
return {
findById: (id: string) =>
Effect.gen(function* () {
const users = yield* Ref.get(store);
return yield* Option.match(Option.fromNullable(users.get(id)), {
onNone: () => Effect.fail(new UserNotFound({ userId: id })),
onSome: Effect.succeed,
}).pipe(Effect.flatten);
}),
save: (user: User) => Ref.update(store, (m) => new Map(m).set(user.id, user)),
};
}),
);
Composing Test Layers
const TestEnv = Layer.mergeAll(UserRepositoryTest, EmailServiceTest, PaymentGatewayTest);
Using Test Layers with @effect/vitest
import { it, expect, layer } from "@effect/vitest";
import { Effect } from "effect";
layer(TestEnv)("UserService", (it) => {
it.effect("should create user and send welcome email", () =>
Effect.gen(function* () {
const repo = yield* UserRepository;
const email = yield* EmailService;
const user = new User({ id: "1", name: "Alice", email: "alice@test.com" });
yield* repo.save(user);
yield* email.send(user.email, "Welcome!", "Hello Alice");
const found = yield* repo.findById("1");
expect(found.name).toBe("Alice");
}),
);
});
Combining Test Layers with Property Testing
The ultimate testing pattern — service test layers control all I/O, Arbitrary generates all data:
layer(TestEnv)("UserService Properties", (it) => {
it.effect.prop("should save and retrieve any valid user", [Arbitrary.make(User)], ([user]) =>
Effect.gen(function* () {
const repo = yield* UserRepository;
yield* repo.save(user);
const found = yield* repo.findById(user.id);
expect(found).toEqual(user);
}),
);
});
Best Practices
- Define service interface with Tag - Keeps interface and tag together
- ALWAYS create a test layer for every service - This is required, not optional. Without test layers, your code is untestable.
- Wrap ALL external dependencies in services - API calls, database queries, file I/O, third-party SDKs, email, caches, queues — everything external MUST go through a service
- Use Layer.scoped for resources - Ensures proper cleanup
- Compose layers bottom-up - Infrastructure → Repositories → Services
- Keep layers focused - One service per layer typically
- Name convention -
*Livefor production layers,*Testfor test layers (e.g.,UserRepositoryLive,UserRepositoryTest) - Use
Layer.effectwithReffor stateful test layers - Repositories and caches need state tracking - Combine test layers with Arbitrary - Services control I/O, Arbitrary generates data — together they enable 100% coverage
Additional Resources
For comprehensive requirements management documentation, consult ${CLAUDE_PLUGIN_ROOT}/references/llms-full.txt.
Search for these sections:
- "Managing Services" for service patterns
- "Managing Layers" for layer composition
- "Layer Memoization" for sharing services
- "Default Services" for built-in services