Skip to content

Composing Layers

Real applications are graphs of services: a repository needs a database client, the client needs a connection pool, the pool needs configuration. Layer composition is how you wire that graph. The two essential operators are Layer.provide — feed one layer’s output into another’s requirements and keep it private — and Layer.provideMerge — do the same but also expose the dependency.

import { Array, Config, Context, Effect, type Option, Layer, Schema } from "effect"
import { SqlClient, SqlError } from "effect/unstable/sql"
import { PgClient } from "@effect/sql-pg"
// A leaf layer: the Postgres client, built from configuration. It produces a
// SqlClient and may fail if config or the connection is bad.
const SqlClientLayer: Layer.Layer<
PgClient.PgClient | SqlClient.SqlClient,
Config.ConfigError | SqlError.SqlError
> = PgClient.layerConfig({
url: Config.redacted("DATABASE_URL")
})
class UserRepoError extends Schema.TaggedErrorClass<UserRepoError>()(
"UserRepoError",
{ reason: SqlError.SqlError }
) {}
export class UserRepo extends Context.Service<UserRepo, {
findById(id: string): Effect.Effect<
Option.Option<{ readonly id: string; readonly name: string }>,
UserRepoError
>
}>()("myapp/UserRepo") {
// The "naked" layer: it produces UserRepo but still *requires* a SqlClient.
// Note `SqlClient.SqlClient` in the third type-parameter slot.
static readonly layerNoDeps: Layer.Layer<
UserRepo,
never,
SqlClient.SqlClient
> = Layer.effect(
UserRepo,
Effect.gen(function*() {
const sql = yield* SqlClient.SqlClient
const findById = Effect.fn("UserRepo.findById")(function*(id: string) {
const rows = yield* sql<{ readonly id: string; readonly name: string }>`
SELECT * FROM users WHERE id = ${id}
`
return Array.head(rows)
}, Effect.mapError((reason) => new UserRepoError({ reason })))
return UserRepo.of({ findById })
})
)
// `Layer.provide` satisfies the SqlClient requirement *and hides it*. The
// result exposes only UserRepo — callers can't reach the SqlClient.
static readonly layer: Layer.Layer<
UserRepo,
Config.ConfigError | SqlError.SqlError
> = this.layerNoDeps.pipe(Layer.provide(SqlClientLayer))
// `Layer.provideMerge` satisfies the requirement *and re-exposes* it, so the
// result provides both UserRepo and SqlClient.
static readonly layerWithSql: Layer.Layer<
UserRepo | SqlClient.SqlClient,
Config.ConfigError | SqlError.SqlError
> = this.layerNoDeps.pipe(Layer.provideMerge(SqlClientLayer))
}

Both feed SqlClientLayer into layerNoDeps, removing SqlClient from its requirements. The difference is what the result exposes:

  • Layer.provide(dep) — the dependency is an implementation detail. The output type drops the dependency (UserRepo only). This is the default: keep internals private so other parts of the app can’t accidentally couple to your database client.
  • Layer.provideMerge(dep) — the dependency is part of your public surface. The output type unions both (UserRepo | SqlClient). Use it when callers legitimately need both — for example a health-check that pings the database directly while also using the repository.

When several layers don’t depend on one another, combine them into a single application layer with Layer.mergeAll. The result provides the union of every service and requires the union of their dependencies:

import { Layer } from "effect"
import { Database } from "./Database.ts"
import { Mailer } from "./Mailer.ts"
import { Metrics } from "./Metrics.ts"
// One layer that provides Database, Mailer, and Metrics together.
export const AppLayer = Layer.mergeAll(
Database.layer,
Mailer.layer,
Metrics.layer
)

A layer included more than once in the graph is built only once by default, and the same instance is shared. If UserRepo.layer and OrderRepo.layer both provide SqlClientLayer, exactly one Postgres client is created and both repos share it. This is what makes deep dependency graphs efficient — you don’t have to hoist shared dependencies by hand.

The unit of sharing is the same Layer reference. Two distinct Layer values that happen to build the same service are not deduplicated — only re-uses of one value are. Sharing is coordinated by a MemoMap: each build gets one, and every node in the graph consults it before constructing a layer. See Managing Layers for how a build starts, and the Managed Runtime page for sharing a MemoMap across many runs.

If you genuinely need a fresh copy of a layer (a second, isolated connection pool, say), wrap it in Layer.fresh to opt out of sharing.

import { Layer } from "effect"
import { SqlClientLayer } from "./sql.ts"
// A second, independent SqlClient instance, not shared with the memoized one.
const IsolatedSql = Layer.fresh(SqlClientLayer)

When writing generic wrappers over layers, extract the three type parameters from a Layer type with these helpers — no need to restate them:

  • Layer.Success<L> — the services the layer provides (ROut).
  • Layer.Error<L> — the construction failure type (E).
  • Layer.Services<L> — the requirements the layer still needs (RIn).
import { Layer } from "effect"
declare const SomeLayer: Layer.Layer<{ a: 1 }, Error, { b: 2 }>
type Out = Layer.Success<typeof SomeLayer> // => { a: 1 }
type Err = Layer.Error<typeof SomeLayer> // => Error
type Deps = Layer.Services<typeof SomeLayer> // => { b: 2 }

The provide, provideMerge, and updateService signatures use NoInfer on the dependency/transform position. This keeps inference flowing from the layer being composed into the dependency, so TypeScript reports a mismatch at the layer you wrote rather than silently widening the dependency’s type to fit.

Next: build a layer whose implementation is chosen at runtime with Layers from config.

Every combinator below is data-last and pipeable: self.pipe(Layer.provide(dep)) or Layer.provide(self, dep). Each produces a new layer; nothing is mutated.

Feeds the output of that into the requirements of self and keeps it private — the result drops the satisfied services from its requirements (Exclude<RIn, ROut>) and does not re-expose them. Accepts a single layer or an array of layers.

import { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, { readonly prefix: string }>()("Config") {}
class Greeter extends Context.Service<Greeter, {
readonly greet: (name: string) => Effect.Effect<string>
}>()("Greeter") {}
const ConfigLayer = Layer.succeed(Config, { prefix: "Hello" })
const GreeterLayer = Layer.effect(Greeter, Effect.gen(function*() {
const config = yield* Config
return { greet: (name) => Effect.succeed(`${config.prefix}, ${name}`) }
})) // Layer<Greeter, never, Config>
// Array form: provide several deps at once. Result: Layer<Greeter, never, never>
const Wired = GreeterLayer.pipe(Layer.provide([ConfigLayer]))
// => Config is satisfied and hidden; only Greeter is exposed

Like provide, but the dependency services are also re-exposed in the result (ROut | ROut2). Reach for it when a caller legitimately needs both the built service and the dependency used to build it.

import { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, { readonly prefix: string }>()("Config") {}
class Greeter extends Context.Service<Greeter, {
readonly greet: (name: string) => Effect.Effect<string>
}>()("Greeter") {}
const ConfigLayer = Layer.succeed(Config, { prefix: "Hi" })
const GreeterLayer = Layer.effect(Greeter, Effect.gen(function*() {
const config = yield* Config
return { greet: (name) => Effect.succeed(`${config.prefix} ${name}`) }
}))
const Both = GreeterLayer.pipe(Layer.provideMerge(ConfigLayer))
// => Layer<Greeter | Config, never, never> — both are reachable downstream

Combines two independent layers (or one layer with an array of layers) so the result provides the union of their services. Unlike provide, neither layer feeds the other — use it for siblings, not for a dependency relationship. Both layers build concurrently.

import { Context, Effect, Layer } from "effect"
class Database extends Context.Service<Database, {
readonly query: (sql: string) => Effect.Effect<string>
}>()("Database") {}
class Logger extends Context.Service<Logger, {
readonly log: (msg: string) => Effect.Effect<void>
}>()("Logger") {}
const dbLayer = Layer.succeed(Database, { query: (sql) => Effect.succeed("ok") })
const loggerLayer = Layer.succeed(Logger, { log: (msg) => Effect.void })
const merged = Layer.merge(dbLayer, loggerLayer)
// => Layer<Database | Logger, never, never>

The variadic form of merge: pass any number of independent layers and get one layer providing the union of all their services and requiring the union of all their dependencies.

import { Context, Layer } from "effect"
class A extends Context.Service<A, { readonly a: number }>()("A") {}
class B extends Context.Service<B, { readonly b: number }>()("B") {}
class C extends Context.Service<C, { readonly c: number }>()("C") {}
const AppLayer = Layer.mergeAll(
Layer.succeed(A, { a: 1 }),
Layer.succeed(B, { b: 2 }),
Layer.succeed(C, { c: 3 })
)
// => Layer<A | B | C, never, never>

Builds a follow-on layer from the output of this one. The callback receives the built Context.Context of the source layer, so you can read a service (e.g. a Config) and pick or construct the next layer based on its value.

import { Context, Layer } from "effect"
class Config extends Context.Service<Config, { readonly url: string }>()("Config") {}
class Client extends Context.Service<Client, { readonly url: string }>()("Client") {}
const ConfigLayer = Layer.succeed(Config, { url: "postgres://localhost" })
const ClientLayer = ConfigLayer.pipe(
Layer.flatMap((context) => {
const config = Context.get(context, Config) // read the built Config
return Layer.succeed(Client, { url: config.url })
})
)
// => Layer<Client, never, never> — Config was consumed to build Client

Runs an effect when the layer builds successfully, receiving the produced context. The result is discarded and the original output is preserved — use it for logging or metrics on startup.

import { Console, Context, Layer } from "effect"
class Server extends Context.Service<Server, { readonly port: number }>()("Server") {}
const ServerLayer = Layer.succeed(Server, { port: 3000 }).pipe(
Layer.tap((context) =>
Console.log(`listening on ${Context.get(context, Server).port}`)
)
)
// => still Layer<Server>; logs "listening on 3000" once built

Runs an effect when layer construction fails with a typed error, receiving that error. The layer still fails afterward with the original error (unless your callback itself fails, which adds to the error type).

import { Console, Context, Effect, Layer } from "effect"
class DbError extends Error { readonly _tag = "DbError" }
class Database extends Context.Service<Database, { readonly q: string }>()("Database") {}
const DatabaseLayer = Layer.effect(Database, Effect.fail(new DbError())).pipe(
Layer.tapError((error) => Console.error(`db build failed: ${error._tag}`))
)
// => logs "db build failed: DbError", then re-fails with the same DbError

Like tapError, but the callback receives the full Cause — so it can inspect typed failures, unexpected defects, and interruption. Useful for reporting that must distinguish a real error from a crash.

import { Cause, Console, Context, Effect, Layer } from "effect"
class Database extends Context.Service<Database, { readonly q: string }>()("Database") {}
const DatabaseLayer = Layer.effect(Database, Effect.fail("boom")).pipe(
Layer.tapCause((cause) => Console.error(Cause.pretty(cause)))
)
// => prints the rendered cause, then re-fails with the original cause

Turns layer construction failures into defects, removing the error from the type (E becomes never). Use only when a build failure is genuinely unrecoverable and should crash the fiber rather than be handled.

import { Context, Effect, Layer } from "effect"
class ConfigError extends Error { readonly _tag = "ConfigError" }
class Config extends Context.Service<Config, { readonly url: string }>()("Config") {}
const ConfigLayer = Layer.effect(Config, Effect.fail(new ConfigError()))
const Reliable = ConfigLayer.pipe(Layer.orDie)
// => Layer<Config, never, never> — the ConfigError is now a defect, not typed

Recovers from all typed errors by switching to a fallback layer. The handler receives the typed error and returns the replacement layer to build instead.

import { Context, Effect, Layer } from "effect"
class Config extends Context.Service<Config, { readonly url: string }>()("Config") {}
const primary = Layer.effect(Config, Effect.fail(new Error("no config")))
const fallback = Layer.succeed(Config, { url: "http://localhost" })
const recovered = primary.pipe(Layer.catch(() => fallback))
// => Layer<Config, never, never> — falls back when primary fails

Recovers from one or more specific tagged errors, leaving other error tags in the result’s error type. Pass a single tag string or an array of tags; the handler receives the matched, narrowed error.

import { Context, Data, Effect, Layer } from "effect"
class ConfigError extends Data.TaggedError("ConfigError")<{}> {}
class Config extends Context.Service<Config, { readonly apiUrl: string }>()("Config") {}
const configLayer = Layer.effect(Config, Effect.fail(new ConfigError()))
const fallbackLayer = Layer.succeed(Config, { apiUrl: "http://localhost" })
const recovered = configLayer.pipe(
Layer.catchTag("ConfigError", () => fallbackLayer)
)
// => recovers from ConfigError; any other tagged error stays in the type

Recovers from any failure cause by switching to a fallback layer, with access to the full Cause (typed errors, defects, interruption). Finalizers for resources acquired by the failed layer run before the fallback is acquired.

import { Context, Effect, Layer } from "effect"
class Database extends Context.Service<Database, {
readonly query: (sql: string) => Effect.Effect<string>
}>()("Database") {}
const primary = Layer.effect(Database, Effect.fail("primary unreachable"))
const withFallback = primary.pipe(
Layer.catchCause(() =>
Layer.succeed(Database, { query: (sql) => Effect.succeed(`memory: ${sql}`) })
)
)
// => Layer<Database, never, never> — uses the in-memory fallback on any failure

Transforms a service produced by a downstream layer: it reads the existing service via its Tag, applies f, and replaces it with the result. The updated service tag is added to the layer’s requirements, so something upstream must still provide the original.

import { Context, Layer } from "effect"
class Logger extends Context.Service<Logger, {
readonly prefix: string
}>()("Logger") {}
const withLoudPrefix = <A, E, R>(layer: Layer.Layer<A, E, R>) =>
layer.pipe(
Layer.updateService(Logger, (logger) => ({
...logger,
prefix: logger.prefix.toUpperCase()
}))
)
// => wraps a layer so the Logger it sees has an upper-cased prefix

Opts a single layer out of memoization, so each use builds a separate instance. Reach for it only when two parts of the app genuinely need isolated resources — by default sharing is what you want.

import { Context, Effect, Layer, Ref } from "effect"
class Counter extends Context.Service<Counter, { readonly id: number }>()("Counter") {}
const program = Effect.gen(function*() {
const nextId = yield* Ref.make(0)
const counterLayer = Layer.effect(Counter, Effect.gen(function*() {
return { id: yield* Ref.updateAndGet(nextId, (n) => n + 1) }
}))
// Each `Layer.fresh(counterLayer)` builds its own Counter with a new id.
const a = Layer.fresh(counterLayer)
const b = Layer.fresh(counterLayer)
// => a and b produce different Counter ids; the bare counterLayer would share one
})

Builds a layer from a partial service implementation, for tests. You supply only the members you exercise; any omitted Effect/Stream/Channel-returning member becomes a value that dies with an UnimplementedError if it is ever used.

The partial shape is typed by Layer.PartialEffectful<S>: effectful members (and functions returning them) are made optional, while plain data properties stay required — so you can’t forget non-effect fields. mock is curried: Layer.mock(Tag)(partial) or Layer.mock(Tag, partial).

import { Context, Effect, Layer } from "effect"
class UserService extends Context.Service<UserService, {
readonly config: { readonly apiUrl: string } // required: plain data
readonly getUser: (id: string) => Effect.Effect<{ id: string; name: string }>
readonly deleteUser: (id: string) => Effect.Effect<void>
}>()("UserService") {}
const testLayer = Layer.mock(UserService, {
config: { apiUrl: "https://test" }, // required by PartialEffectful
getUser: (id) => Effect.succeed({ id, name: "Test User" })
// deleteUser omitted -> calling it dies with "UnimplementedError"
})
// => Layer<UserService>; getUser works, deleteUser fails loudly if called