24 - Advanced DI: Composing the Dependency Graph
Layers form a directed acyclic graph. Effect resolves the graph at startup and constructs each service exactly once, sharing the instance with every dependent.
This step covers the patterns that scale: wiring chains, merging many independent services, exposing intermediate services with provideMerge, and guaranteeing shared instances.
What you’ll work through
index.test.ts contains three beats: composing a multi-layer DAG via Layer.provide / Layer.provideMerge, combining many independents with Layer.mergeAll, and asserting that a shared dependency is constructed exactly once across the graph.
By the end you should be able to lay out an application’s service graph as a single AppLayer value and reason about construction order from the shape of the wiring.
Layers as a DAG
ConfigLive ─────────────────┐ │ │ ▼ ▼ DatabaseLive ──────┐ LoggerLive │ │ │ ▼ ▼ ▼ ApiLive ReportLive AuditLive │ │ │ └─────────────┴─────────┘ ▼ AppLayer │ ▼ Effect.provide(AppLayer)
Arrow = "this layer's input" ConfigLive is constructed ONCE. Both Database and Logger share the same instance.The shared-instance guarantee is what makes Layer composition tractable. A connection pool, a config object, a logger, declare it once, wire it through, and Effect never constructs it twice.
Layer.provide: Wire a Dependency
import { Layer } from "effect"
// ┌─── Layer<Database, never, never>// ▼const DatabaseWithConfig = DatabaseLive.pipe(Layer.provide(ConfigLive))Layer.provide(dependency) plugs dependency into the receiving layer’s RIn, removing it from the result’s RIn. The dependency is no longer visible to callers of the resulting layer, it has been satisfied internally.
Layer.provideMerge: Wire and Expose
import { Layer } from "effect"
// ┌─── Layer<UserService | Database | Config, ..., never>// ▼const AppLayer = UserServiceLive.pipe( Layer.provideMerge(DatabaseLive), Layer.provideMerge(ConfigLive))Layer.provideMerge does the same wiring as Layer.provide but keeps the wired-in services available in the output. Use it when downstream code also needs direct access to those services, for example, a request handler that calls UserService but also needs raw Database queries elsewhere.
Your turn chain `Layer.provide` so `ApiLive` is satisfied by `DatabaseLive`, which itself is satisfied by `ConfigLive`.
Layer.mergeAll: Combine Independents
import { Layer } from "effect"
// ┌─── Layer<Logger | Cache | Metrics, never, ConfigService>// ▼const AppLayer = Layer.mergeAll(LoggerLive, CacheLive, MetricsLive)Layer.mergeAll combines layers that do not depend on each other into a single layer that provides every service. Their RIn channels are unioned (so any shared input appears exactly once in the result), their errors are unioned, and the output ROut exposes every service from every layer.
This is the typical top-level shape of a production AppLayer: a flat bundle of horizontal capabilities sitting on top of whatever the vertical Layer.provide chain produced.
Rule of thumb. Reach for
mergeAllonce your tree has more than two “siblings”. For one or two, a singleLayer.merge(a, b)reads fine; past that, the variadic form is much easier to keep tidy.
Your turn call `Layer.mergeAll(ConfigLive, DatabaseLive, CacheLive)` so the resulting `AppLayer` provides all three services to the program in one shot. The test reads `Config`, `Database`, and `Cache` and asserts each came from its expected `Live` implementation.
Shared Instances
When a layer value is referenced more than once in the graph, Effect constructs the service once. The contract is per-Layer-value, not per-reference: two separate Layer.sync calls with the same factory produce two different services.
import { Layer } from "effect"
// One named layer, used in two places, one Database instance.const DatabaseShared = Layer.sync(Database, makeDatabase)
const AppLayer = Layer.mergeAll( ApiLive.pipe(Layer.provide(DatabaseShared)), ReportLive.pipe(Layer.provide(DatabaseShared)), DatabaseShared // exposed directly for code that does raw queries)If you call Layer.sync(Database, makeDatabase) twice, even with the same factory, the result is two distinct layers and two distinct instances. Name the layer once and reuse the value.
Your turn bind the shared layer to a `const` and pass that same value to every consumer; the construction counter must stay at one.
Wiring three services into one AppLayer
Layer.provide chains a dependency into a layer; Layer.mergeAll bundles independent layers into one. The standard application shape combines both — provide the wiring you need, then merge the independent results.
import { Layer } from "effect"
// Config is leaf. Database needs Config. Logger is independent.const DatabaseWithConfig = DatabaseLive.pipe(Layer.provide(ConfigLive))
// ┌─── Layer<Logger | Database, never, never>// ▼const AppLayer = Layer.mergeAll(LoggerLive, DatabaseWithConfig)
await Effect.runPromise(program.pipe(Effect.provide(AppLayer)))The runner sees a single AppLayer value. Internally that value is a DAG of layers — Layer.provide writes edges, Layer.mergeAll collects roots.
Your turn wire `Database` with `Config` via `Layer.provide`, then merge it with `Logger` into a single `AppLayer` and run a program that depends on all three.
A Full Example
A four-service graph: Config is shared, Database depends on Config, both Api and Report depend on Database, and the top-level program needs all four.
import { Effect, Context, Layer } from "effect"
class Config extends Context.Service<Config, { readonly dbUrl: string }>()("app/Config") {}
class Database extends Context.Service<Database, { readonly query: (sql: string) => Effect.Effect<ReadonlyArray<string>> }>()("app/Database") {}
class Api extends Context.Service<Api, { readonly getUser: (id: string) => Effect.Effect<ReadonlyArray<string>> }>()("app/Api") {}
class Report extends Context.Service<Report, { readonly generate: (sql: string) => Effect.Effect<string> }>()("app/Report") {}
const ConfigLive = Layer.succeed(Config, { dbUrl: "postgres://prod" })
const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const { dbUrl } = yield* Config return { query: (sql) => Effect.succeed([`${dbUrl}: ${sql}`]), } }))
const ApiLive = Layer.effect( Api, Effect.gen(function* () { const db = yield* Database return { getUser: (id) => db.query(`SELECT * FROM users WHERE id='${id}'`), } }))
const ReportLive = Layer.effect( Report, Effect.gen(function* () { const db = yield* Database return { generate: (sql) => Effect.gen(function* () { const rows = yield* db.query(sql) return rows.join(", ") }), } }))
const DatabaseWithConfig = DatabaseLive.pipe(Layer.provide(ConfigLive))
// ┌─── Layer<Api | Report | Database, never, never>// ▼const AppLayer = Layer.mergeAll( ApiLive.pipe(Layer.provide(DatabaseWithConfig)), ReportLive.pipe(Layer.provide(DatabaseWithConfig)), DatabaseWithConfig)Config and Database are each constructed exactly once. Api, Report, and any code that yields Database directly all share the same Database instance, which itself shares the same Config.
Common Pitfalls
1, Confusing Layer.merge with Layer.provide. merge combines independents. provide satisfies a dependency.
If A needs B, Layer.merge(A, B) leaves B in A’s RIn, you must use A.pipe(Layer.provide(B)).
2, Duplicating a “shared” layer. Two Layer.sync(Tag, factory) calls produce two separate services. Bind the layer to a const and reuse the value.
3, Wrong order for Layer.provide. The receiver pipes into Layer.provide with its dependency: UserServiceLive.pipe(Layer.provide(DatabaseLive)). Reversing the relationship is a common source of confusing type errors.
4, Providing layers inside business logic. Wire once at the application boundary. Helpers should accept whatever R they need; the entry point provides every layer.
the assertion counts constructions.
See Also
- Previous,
23-service-driven:Context.Serviceand contract-first design. - Next,
28-testing-basics: structured testing built on Layer composition. - Related,
22-layers: the Layer primitives. - Later,
33-source-patterns: large-app patterns and module layout.
Run the tests: pnpm test 24-advanced-di
The full test suite
The index.test.ts for this lesson, verbatim from steps/24-advanced-di/.
import { describe, it, expect } from "vite-plus/test"import { Effect, Context, Layer } from "effect"
// =============================================================================// 14 - Advanced DI: Composing the Dependency Graph// =============================================================================//// Layer.provide — wire a dependency into a layer that needs it// Layer.provideMerge — wire and also expose the dependency downstream// Layer.mergeAll — combine many independent layers//// A Layer value referenced multiple times is constructed exactly once. Two// separate Layer.sync calls with the same factory produce two services.
class Config extends Context.Service<Config, { readonly port: number; readonly dbUrl: string }>()("app/Config") {}
class Database extends Context.Service<Database, { readonly query: (sql: string) => Effect.Effect<ReadonlyArray<string>> }>()("app/Database") {}
class Cache extends Context.Service<Cache, { readonly get: (key: string) => Effect.Effect<string | null> }>()("app/Cache") {}
class Api extends Context.Service<Api, { readonly getUser: (id: string) => Effect.Effect<ReadonlyArray<string>> }>()("app/Api") {}
class Report extends Context.Service<Report, { readonly generate: (sql: string) => Effect.Effect<string> }>()("app/Report") {}
describe("14 - Advanced DI", () => { // @beat // id: dag-composition // shape: multi-todo // title: Compose a multi-level dependency graph // teaches: Layer.provide chains across layers; the receiver pipes the dependency it needs. // hint: ApiLive.pipe(Layer.provide(DatabaseLive.pipe(Layer.provide(ConfigLive)))) describe("Layer.provide", () => { it("satisfies a layer's RIn by piping in the dependency", async () => { const ConfigLive = Layer.succeed(Config, { port: 3000, dbUrl: "postgres://localhost", })
const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const { dbUrl } = yield* Config return { query: (sql: string) => Effect.succeed([`${dbUrl}: ${sql}`]), } }) )
// ┌─── Layer<Database, never, never> // ▼ const AppLayer = DatabaseLive.pipe(/* @stub */ Layer.provide(ConfigLive) /* @end */)
const program = Effect.gen(function* () { const db = yield* Database return yield* db.query("SELECT 1") })
const result = await Effect.runPromise(program.pipe(Effect.provide(AppLayer))) expect(result[0]).toContain("postgres://localhost") })
it("chains across a multi-level dependency graph", async () => { const ConfigLive = Layer.succeed(Config, { port: 5432, dbUrl: "postgres://prod" })
const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const { dbUrl } = yield* Config return { query: (sql: string) => Effect.succeed([`${dbUrl}: ${sql}`]), } }) )
const ApiLive = Layer.effect( Api, Effect.gen(function* () { const db = yield* Database return { getUser: (id: string) => db.query(`SELECT * FROM users WHERE id='${id}'`), } }) )
const AppLayer = ApiLive.pipe( /* @stub */ Layer.provide(DatabaseLive.pipe(Layer.provide(ConfigLive))) /* @end */ )
const program = Effect.gen(function* () { const api = yield* Api return yield* api.getUser("99") })
const result = await Effect.runPromise(program.pipe(Effect.provide(AppLayer))) expect(result[0]).toContain("postgres://prod") }) }) // @end-beat
// @beat // id: merge-all // shape: one-todo // title: Bundle independents with Layer.mergeAll // teaches: Layer.mergeAll combines many independent layers into a single AppLayer. // hint: Layer.mergeAll(ConfigLive, DatabaseLive, CacheLive) describe("Layer.mergeAll", () => { it("combines independent layers into a single AppLayer", async () => { const ConfigLive = Layer.succeed(Config, { port: 3000, dbUrl: "test" }) const DatabaseLive = Layer.succeed(Database, { query: (sql) => Effect.succeed([`db: ${sql}`]), }) const CacheLive = Layer.succeed(Cache, { get: (key) => Effect.succeed(`cached: ${key}`), })
const AppLayer = /* @stub */ Layer.mergeAll(ConfigLive, DatabaseLive, CacheLive) /* @end */
const program = Effect.gen(function* () { const config = yield* Config const db = yield* Database const cache = yield* Cache
const cached = yield* cache.get("u:1") const rows = yield* db.query("SELECT 1")
return { port: config.port, cached, rows } })
const result = await Effect.runPromise(program.pipe(Effect.provide(AppLayer))) expect(result.port).toBe(3000) expect(result.cached).toBe("cached: u:1") expect(result.rows).toEqual(["db: SELECT 1"]) }) }) // @end-beat
// @beat // id: shared-instances // shape: multi-todo // title: Share a single construction across consumers // teaches: A Layer value referenced multiple times is constructed exactly once; bind to a const and reuse. // hint: name the layer once and pass the same value to every consumer describe("shared instances", () => { it("a Layer value referenced multiple times is constructed once", async () => { let constructions = 0
const ConfigShared = /* @stub */ Layer.sync(Config, () => { constructions += 1 return { port: 5432, dbUrl: "postgres://shared" } }) /* @end */
const DatabaseLive = Layer.effect( Database, Effect.gen(function* () { const { dbUrl } = yield* Config return { query: (sql: string) => Effect.succeed([`${dbUrl}: ${sql}`]), } }) )
const ApiLive = Layer.effect( Api, Effect.gen(function* () { const db = yield* Database return { getUser: (id: string) => db.query(`u=${id}`), } }) )
const AppLayer = ApiLive.pipe( Layer.provide(DatabaseLive.pipe(Layer.provide(ConfigShared))) )
const program = Effect.gen(function* () { const api = yield* Api return yield* api.getUser("5") })
await Effect.runPromise(program.pipe(Effect.provide(AppLayer))) expect(constructions).toBe(1) })
it("two consumers of one Database layer share the same instance", async () => { let dbConstructions = 0
const DatabaseShared = Layer.sync(Database, () => { dbConstructions += 1 return { query: (sql: string) => Effect.succeed([`row: ${sql}`]), } })
const ApiLive = Layer.effect( Api, Effect.gen(function* () { const db = yield* Database return { getUser: (id: string) => db.query(`u=${id}`) } }) )
const ReportLive = Layer.effect( Report, Effect.gen(function* () { const db = yield* Database return { generate: (sql: string) => Effect.gen(function* () { const rows = yield* db.query(sql) return rows.join(", ") }), } }) )
const AppLayer = /* @stub */ Layer.mergeAll( ApiLive.pipe(Layer.provide(DatabaseShared)), ReportLive.pipe(Layer.provide(DatabaseShared)), DatabaseShared ) /* @end */
const program = Effect.gen(function* () { const api = yield* Api const report = yield* Report const db = yield* Database
yield* api.getUser("1") yield* report.generate("SELECT 1") yield* db.query("SELECT 2") })
await Effect.runPromise(program.pipe(Effect.provide(AppLayer))) expect(dbConstructions).toBe(1) }) }) // @end-beat
// @beat // id: wire-three-services // shape: from-scratch // title: Wire a three-service application from scratch // teaches: Layer.provide chains dependencies across layers; Layer.mergeAll bundles independent layers; combining both produces an AppLayer the runner can use directly. // hint: DatabaseTest needs ConfigTest wired via Layer.provide; ApiTest needs DatabaseTest; then bundle with Layer.mergeAll or a final Layer.provide chain
describe("capstone: three-service graph", () => { it("builds an AppLayer that satisfies Api, Database, and Config from scratch", async () => { const ConfigTest = Layer.succeed(Config, { port: 3000, dbUrl: "postgres://test" })
const DatabaseTest = Layer.effect( Database, Effect.gen(function* () { const { dbUrl } = yield* Config return { query: (sql: string) => Effect.succeed([`${dbUrl}: ${sql}`]), } }), )
const ApiTest = Layer.effect( Api, Effect.gen(function* () { const db = yield* Database return { getUser: (id: string) => db.query(`SELECT * FROM users WHERE id='${id}'`), } }), )
// Your turn: build AppLayer so the program below runs without providing anything else. // DatabaseTest requires Config; ApiTest requires Database. // // ┌─── Layer<Api, never, never> // ▼ const AppLayer = /* @stub */ ApiTest.pipe( Layer.provide(DatabaseTest.pipe(Layer.provide(ConfigTest))), ) /* @end */
const program = Effect.gen(function* () { const api = yield* Api return yield* api.getUser("42") })
const result = await Effect.runPromise(program.pipe(Effect.provide(AppLayer))) expect(result[0]).toContain("postgres://test") expect(result[0]).toContain("42") }) }) // @end-beat})