When designing Effect services, there's a critical distinction between what the service needs to exist versus what the program needs to execute. This distinction determines whether your services can be managed at the runtime level or need to be recreated for each execution.
export class UserSrv extends Effect.Service<UserSrv>()("UserSrv", {
dependencies: [UserRepo.Default],
effect: Effect.gen(function* () {
const userRepo = yield* UserRepo;
const currentUserId = yield* CurrentUserId; // ❌ Service depends on currentUserId
return {
getMe: Effect.gen(function* () {
return userRepo.find(currentUserId); // Uses captured currentUserId
}),
};
}),
}) {}
const runtime = ManagedRuntime.make(Layer.mergeAll(UserRepo.Default));
const program = Effect.gen(function* () {
const repo = yield* UserSrv;
return yield* repo.getMe;
});
const aaa = program.pipe(
Effect.provide(UserSrv.Default), // ❌ UserSrv must be provided per execution
Effect.provideService(CurrentUserId, "123"),
);
console.log(runtime.runSync(aaa));
console.log(runtime.runSync(aaa));
Output:
Initialized UserRepo
Initialized UserRepo // ❌ UserRepo gets reinitialized!
{ id: "123", name: "Asaf" }
Initialized UserRepo // ❌ Again!
{ id: "123", name: "Asaf" }
- UserRepo gets reinitialized on every call (3 times total!)
- UserSrv cannot be part of the managed runtime because it depends on
CurrentUserId
- Poor performance due to repeated service initialization
- Resource waste from recreating expensive resources
export class UserSrv extends Effect.Service<UserSrv>()("UserSrv", {
dependencies: [UserRepo.Default],
effect: Effect.gen(function* () {
const userRepo = yield* UserRepo;
return {
getMe: Effect.gen(function* () {
const currentUserId = yield* CurrentUserId; // ✅ Effect depends on currentUserId
return userRepo.find(currentUserId);
}),
};
}),
}) {}
const runtime = ManagedRuntime.make(
Layer.mergeAll(UserSrv.Default, UserRepo.Default), // ✅ Both services in runtime
);
const program = Effect.gen(function* () {
const repo = yield* UserSrv;
return yield* repo.getMe;
});
const aaa = program.pipe(Effect.provideService(CurrentUserId, "123"));
console.log(runtime.runSync(aaa));
console.log(runtime.runSync(aaa));
Output:
Initialized UserRepo // ✅ Only initialized once!
{ id: "123", name: "Asaf" }
{ id: "123", name: "Asaf" }
- UserRepo initializes only once (managed at runtime level)
- UserSrv is part of the managed runtime and doesn't need per-execution provision
- Better performance - no repeated initialization
- Cleaner separation between service structure and program requirements
By moving currentUserId
from the root of UserSrv into the getMe
Effect, we made it a requirement of the program itself—not a UserSrv requirement.
This architectural change allows:
- UserSrv to be moved to the managed runtime layer
- Avoiding reinitializing expensive resources like database connections, HTTP clients, etc.
- Clear separation between what the service needs to exist vs. what the program needs to execute
Use this pattern when:
- ✅ Your service has expensive initialization (database connections, file handles, etc.)
- ✅ The dependency is program-specific rather than service-specific
- ✅ The dependency changes between program executions (user IDs, request contexts, etc.)
- ✅ You want to manage services at the runtime level for better performance
Avoid this pattern when:
- ❌ The dependency is truly required for service creation
- ❌ The dependency is static and unchanging
- ❌ The service is stateless and cheap to recreate
- Identify true service dependencies vs. program dependencies during design
- Move dynamic/contextual dependencies into the effect execution phase
- Keep expensive resources (connections, clients) at the service level
- Use ManagedRuntime for services that can be shared across program executions
- Test initialization behavior to ensure services aren't being recreated unnecessarily
This pattern is fundamental to building efficient Effect applications that properly leverage the layer system for resource management and performance optimization.
From the Effect documentation, we can see this pattern used extensively in real-world services. For example, when building HTTP clients or database connections:
// ❌ Don't make request-specific data a service dependency
class ApiService extends Effect.Service<ApiService>()("ApiService", {
dependencies: [HttpClient.Default, UserSession], // UserSession varies per request
effect: Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const session = yield* UserSession // This ties the service to specific session
return {
fetchUserData: Effect.succeed(client.get(`/users/${session.userId}`))
}
})
}) {}
// ✅ Move request-specific dependencies into the effect
class ApiService extends Effect.Service<ApiService>()("ApiService", {
dependencies: [HttpClient.Default], // Only truly service-level dependencies
effect: Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
return {
fetchUserData: Effect.gen(function* () {
const session = yield* UserSession // Get session at execution time
return yield* client.get(`/users/${session.userId}`)
})
}
})
}) {}
The documentation emphasizes that services following this pattern can be composed more effectively in layers:
- Runtime-managed services can be shared across different program executions
- Better resource management for expensive resources like database pools
- Cleaner layer composition without circular or temporal dependencies
Effect's service system is designed around this principle: services should depend on stable, infrastructure-level resources, while programs should depend on request-specific or temporal data. This allows for better:
- Testing isolation - mock services without mocking request contexts
- Performance optimization - expensive service initialization happens once
- Resource sharing - database connections, HTTP clients can be pooled at the service level
This architectural pattern aligns with Effect's design philosophy of separating infrastructure concerns (services) from business logic concerns (effects).