| name | effect-layer-design |
| description | Design and compose Effect layers for clean dependency management |
Layer Design Skill
Create layers that construct services while managing their dependencies cleanly.
Effect Source Reference
The Effect v4 source is available at ~/.cache/effect-v4/.
Browse and read files there directly to look up APIs, types, and implementations.
Reference this for:
- Layer source:
packages/effect/src/Layer.ts
- Context source:
packages/effect/src/Context.ts
- Migration guide:
MIGRATION.md
- Effect source:
packages/effect/src/
Layer Structure
import { Layer } from 'effect';
Pattern: Simple Layer (No Dependencies)
import { Context, Effect, Layer } from 'effect';
interface ConfigData {
readonly logLevel: string;
readonly connection: string;
}
export class Config extends Context.Service<
Config,
{
readonly getConfig: Effect.Effect<ConfigData>;
}
>()('Config') {}
export const ConfigLive = Layer.succeed(
Config,
Config.of({
getConfig: Effect.succeed({
logLevel: 'INFO',
connection: 'mysql://localhost/db'
})
})
);
Pattern: Layer with Dependencies
import { Context, Effect, Layer, Console } from 'effect';
interface ConfigData {
readonly logLevel: string;
readonly connection: string;
}
export class Config extends Context.Service<
Config,
{
readonly getConfig: Effect.Effect<ConfigData>;
}
>()('Config') {}
export class Logger extends Context.Service<
Logger,
{
readonly log: (message: string) => Effect.Effect<void>;
}
>()('Logger') {}
export const LoggerLive = Layer.effect(
Logger,
Effect.gen(function* () {
const config = yield* Config;
return Logger.of({
log: (message) =>
Effect.gen(function* () {
const { logLevel } = yield* config.getConfig;
yield* Console.log(`[${logLevel}] ${message}`);
})
});
})
);
Pattern: Layer with Resource Management
Use Layer.effect for all layers, including those with resources that need cleanup. In Effect v4, Layer.effect automatically handles Scope lifecycle — Layer.scoped is no longer needed.
Resources are acquired and released using Effect.acquireRelease or Effect.addFinalizer inside the Layer.effect constructor:
import { Context, Effect, Layer } from 'effect';
interface ConfigData {
readonly logLevel: string;
readonly connection: string;
}
interface Connection {
readonly close: () => void;
}
interface DatabaseError {
readonly _tag: 'DatabaseError';
}
export class Config extends Context.Service<
Config,
{
readonly getConfig: Effect.Effect<ConfigData>;
}
>()('Config') {}
export class Database extends Context.Service<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown, DatabaseError>;
}
>()('Database') {}
declare const connectToDatabase: (
config: ConfigData
) => Effect.Effect<Connection, DatabaseError>;
declare const executeQuery: (
connection: Connection,
sql: string
) => Effect.Effect<unknown, DatabaseError>;
export const DatabaseLive = Layer.effect(
Database,
Effect.gen(function* () {
const config = yield* Config;
const configData = yield* config.getConfig;
const connection = yield* Effect.acquireRelease(
connectToDatabase(configData),
(conn) => Effect.sync(() => conn.close())
);
return Database.of({
query: (sql) => executeQuery(connection, sql)
});
})
);
Composing Layers: Merge vs Provide
Merge (Parallel Composition)
Combine independent layers:
import { Context, Layer } from 'effect';
declare class Config extends Context.Service<Config, {}>()('Config') {}
declare class Logger extends Context.Service<Logger, {}>()('Logger') {}
declare const ConfigLive: Layer.Layer<Config, never, never>;
declare const LoggerLive: Layer.Layer<Logger, never, Config>;
const AppConfigLive = Layer.merge(ConfigLive, LoggerLive);
Result combines:
- Requirements: Union (
never | Config = Config)
- Outputs: Union (
Config | Logger)
Provide (Sequential Composition)
Chain dependent layers:
import { Context, Layer } from 'effect';
declare class Config extends Context.Service<Config, {}>()('Config') {}
declare class Logger extends Context.Service<Logger, {}>()('Logger') {}
declare const ConfigLive: Layer.Layer<Config, never, never>;
declare const LoggerLive: Layer.Layer<Logger, never, Config>;
const FullLoggerLive = Layer.provide(LoggerLive, ConfigLive);
Result:
- Requirements: Outer layer's requirements (
never)
- Output: Inner layer's output (
Logger)
Pattern: Direct Default Composition
Compose defaultLayer directly unless you have a real module-evaluation or circular-import problem. Most services do not need deferred composition.
import { Layer } from 'effect';
export const layer: Layer.Layer<MyService, never, DepA | DepB> = Layer.effect(
MyService,
Effect.gen(function* () {
const depA = yield* DepA;
const depB = yield* DepB;
return MyService.of({
});
})
);
export const defaultLayer = layer.pipe(
Layer.provide(DepA.defaultLayer),
Layer.provide(DepB.defaultLayer)
);
Deferred Composition with Layer.suspend
Use Layer.suspend(() => ...) when import evaluation order genuinely requires deferral:
import { Layer } from 'effect';
export const defaultLayer = Layer.suspend(() =>
layer.pipe(Layer.provide(Dep.defaultLayer))
);
Layer.unwrap(Effect.sync(...)) still works, but it is not the universal default. Reach for deferred composition only when the dependency graph actually needs it.
Naming convention:
layer — exposes the service's true dependency graph in its type signature. Tests compose against layer directly, providing mock layers.
defaultLayer — the fully-wired production composition with all dependencies satisfied. Only define defaultLayer when layer has unsatisfied requirements. Self-contained layers (no external dependencies) export just layer.
When to defer
Use deferred composition only for:
- real circular-import or module-evaluation hazards
- runtime-selected layer variants that should not be built eagerly
- recursive layer graphs that must be tied lazily
If none of those apply, compose directly.
Pattern: Layered Architecture
Build applications in layers:
import { Context, Layer } from 'effect';
declare class Config extends Context.Service<Config, {}>()('Config') {}
declare class Database extends Context.Service<Database, {}>()('Database') {}
declare class Cache extends Context.Service<Cache, {}>()('Cache') {}
declare class PaymentDomain extends Context.Service<PaymentDomain, {}>()(
'PaymentDomain'
) {}
declare class OrderDomain extends Context.Service<OrderDomain, {}>()(
'OrderDomain'
) {}
declare class PaymentGateway extends Context.Service<PaymentGateway, {}>()(
'PaymentGateway'
) {}
declare class NotificationService extends Context.Service<
NotificationService,
{}
>()('NotificationService') {}
declare const ConfigLive: Layer.Layer<Config, never, never>;
declare const DatabaseLive: Layer.Layer<Database, never, Config>;
declare const CacheLive: Layer.Layer<Cache, never, Config>;
declare const PaymentDomainLive: Layer.Layer<PaymentDomain, never, Database>;
declare const OrderDomainLive: Layer.Layer<OrderDomain, never, Database>;
declare const PaymentGatewayLive: Layer.Layer<
PaymentGateway,
never,
PaymentDomain
>;
declare const NotificationServiceLive: Layer.Layer<
NotificationService,
never,
OrderDomain
>;
const InfrastructureLive = Layer.mergeAll(
ConfigLive,
DatabaseLive,
CacheLive
).pipe(
Layer.provide(ConfigLive)
);
const DomainLive = Layer.mergeAll(
PaymentDomainLive,
OrderDomainLive
).pipe(Layer.provide(InfrastructureLive));
const ApplicationLive = Layer.mergeAll(
PaymentGatewayLive,
NotificationServiceLive
).pipe(Layer.provide(DomainLive));
Pattern: Multiple Implementations
Switch implementations for different environments:
import { Context, Effect, Layer } from 'effect';
interface Connection {
readonly close: () => void;
}
export class Database extends Context.Service<
Database,
{
readonly query: (sql: string) => Effect.Effect<{ rows: unknown[] }>;
}
>()('Database') {}
declare const connectToProduction: () => Effect.Effect<Connection>;
declare const createDatabaseService: (connection: Connection) => {
readonly query: (sql: string) => Effect.Effect<{ rows: unknown[] }>;
};
declare const myProgram: Effect.Effect<void, never, Database>;
export const DatabaseLive = Layer.effect(
Database,
Effect.gen(function* () {
const connection = yield* connectToProduction();
return createDatabaseService(connection);
})
);
export const DatabaseTest = Layer.succeed(
Database,
Database.of({
query: () => Effect.succeed({ rows: [] })
})
);
const program = Effect.gen(function* () {
const nodeEnv = yield* Config.string('NODE_ENV').pipe(
Config.withDefault('production')
);
yield* myProgram.pipe(
Effect.provide(nodeEnv === 'test' ? DatabaseTest : DatabaseLive)
);
});
Pattern: Layer Sharing
Layers are memoized - same instance shared across program:
import { Context, Effect, Layer } from 'effect';
declare class Config extends Context.Service<
Config,
{ readonly value: string }
>()('Config') {}
declare const ConfigLive: Layer.Layer<Config, never, never>;
const program = Effect.all([
Effect.gen(function* () {
const config = yield* Config;
}),
Effect.gen(function* () {
const config = yield* Config;
})
]).pipe(Effect.provide(ConfigLive));
Memo-map fork nuance: Sharing is mediated by a MemoMap. A root memo map (Layer.makeMemoMapUnsafe(), used implicitly by Effect.provide) shares every layer allocation it builds. A forked memo map (Layer.forkMemoMap / Layer.forkMemoMapUnsafe) can still see allocations its parent already built, but new allocations it builds stay isolated and are not written back to the parent. This is the mechanism @effect/vitest uses to reuse parent layers while isolating nested it.layer suites.
Error Handling in Layers
Handle construction errors:
import { Context, Effect, Layer, Schema } from 'effect';
interface Connection {
readonly close: () => void;
}
class ConnectionError extends Schema.TaggedErrorClass<ConnectionError>()(
'ConnectionError',
{
message: Schema.String
}
) {}
class DatabaseConstructionError extends Schema.TaggedErrorClass<DatabaseConstructionError>()(
'DatabaseConstructionError',
{ cause: ConnectionError }
) {}
export class Database extends Context.Service<
Database,
{
readonly query: (sql: string) => Effect.Effect<unknown>;
}
>()('Database') {}
declare const connectToDatabase: () => Effect.Effect<
Connection,
ConnectionError
>;
declare const createDatabaseService: (connection: Connection) => {
readonly query: (sql: string) => Effect.Effect<unknown>;
};
export const DatabaseLive = Layer.effect(
Database,
Effect.gen(function* () {
const connection = yield* connectToDatabase().pipe(
Effect.catchTag('ConnectionError', (error) =>
Effect.fail(new DatabaseConstructionError({ cause: error }))
)
);
return createDatabaseService(connection);
})
);
Composing Layers: ProvideMerge (for test stacks)
Layer.provideMerge satisfies dependencies AND passes them through to the output. This is critical for test layer stacks where multiple downstream layers need access to the same upstream services.
Provide vs ProvideMerge
import { Layer } from 'effect';
declare const ConfigLayer: Layer.Layer<Config>;
declare const DatabaseLayer: Layer.Layer<Database, never, Config>;
declare const UserServiceLayer: Layer.Layer<UserService, never, Database>;
const db = DatabaseLayer.pipe(Layer.provide(ConfigLayer));
const dbWithConfig = DatabaseLayer.pipe(Layer.provideMerge(ConfigLayer));
When to use ProvideMerge
Use Layer.provideMerge when building test layer stacks where multiple layers share the same dependencies:
import { Layer } from 'effect';
const infra = Layer.mergeAll(ConfigLayer, DatabaseLayer).pipe(
Layer.provideMerge(ConfigLayer)
);
const services = Layer.mergeAll(UserServiceLayer, OrderServiceLayer).pipe(
Layer.provideMerge(infra)
);
Without Layer.provideMerge, you would need to manually merge every intermediate layer to keep services visible to downstream consumers.
Pattern: SynchronizedRef + Deferred State Machine
For services that need atomic state transitions with concurrent callers, use SynchronizedRef.modifyEffect combined with Deferred for result sharing:
import { Deferred, Effect, Fiber, Scope, SynchronizedRef } from 'effect';
type State<A, E> =
| { readonly _tag: 'Idle' }
| {
readonly _tag: 'Running';
readonly done: Deferred.Deferred<A, E>;
readonly fiber: Fiber.Fiber<A, E>;
}
| { readonly _tag: 'Pending'; readonly done: Deferred.Deferred<A, E> };
const make = <A, E>(scope: Scope.Scope) => {
const ref = SynchronizedRef.makeUnsafe<State<A, E>>({ _tag: 'Idle' });
const run = (work: Effect.Effect<A, E>) =>
SynchronizedRef.modifyEffect(
ref,
Effect.fnUntraced(function* (state) {
switch (state._tag) {
case 'Running':
return [Deferred.await(state.done), state];
case 'Idle': {
const done = yield* Deferred.make<A, E>();
const fiber = yield* Effect.forkIn(
work.pipe(Effect.intoDeferred(done)),
scope
);
return [
Deferred.await(done),
{ _tag: 'Running' as const, done, fiber }
];
}
case 'Pending': {
return [Deferred.await(state.done), state];
}
}
})
).pipe(Effect.flatten);
return { run };
};
Key properties:
SynchronizedRef.modifyEffect — atomically reads state, runs an effect, and updates state in one operation. No other caller can interleave.
Deferred — shares the result of in-flight work with concurrent callers who arrive while it's running.
Effect.forkIn(work, scope) — ties the worker fiber to the service scope, not the calling fiber.
- The state machine pattern ensures at most one concurrent execution of
work, with all callers sharing the same result.
Use this pattern when:
- Multiple concurrent callers may trigger the same expensive operation
- Only one execution should run at a time
- All callers should receive the same result
Naming Convention
*Live - Production implementation
*Test - Test implementation
*Mock - Mock for testing
- Descriptive names for specialized implementations
Quality Checklist
Layers should make dependency management explicit while keeping service interfaces clean and focused.