Skip to content

Layer composition

Every service is a Layer; something has to wire them into one running program. This page answers where that wiring lives and which combinator joins the pieces.

Impact: HIGH keeps the assembled layer’s type honest

  • Layer.provide satisfies a requirement and removes it from the type, so the assembled layer’s type says exactly what is still missing.
  • Reach for Layer.provideMerge only when a layer genuinely should export what it consumed. As a default it widens every downstream signature.
  • Accepted cost: a service needed at two heights of the graph is provided twice. That is safe because memoization collapses the same layer object into one instance, with one exception: a stateful layer shared between an HttpRouter.serve app and a sibling goes outside serve (see layer memoization).

❌ Incorrect — provideMerge everywhere; satisfied requirements leak into the output:

const HttpLayer = ApiHandlersLayer.pipe(
Layer.provideMerge(PolicyTesterLayer),
Layer.provideMerge(GitHubClient.layer),
)
// HttpLayer now also "provides" PolicyTester and GitHubClient

✅ Correct — provide, and re-provide where a higher layer needs the same service:

const PolicyTesterLayer = PolicyTester.layer.pipe(
Layer.provide(GitHubClient.layer),
)
const HttpLayer = ApiHandlersLayer.pipe(
Layer.provide(PolicyTesterLayer),
Layer.provide(GitHubClient.layer), // same object → one instance
)

Source: notes/05-effect-fundamentals/layer-composition.md · Decision 1, amended

Assemble the graph in one file per deployable, infrastructure last

Section titled “Assemble the graph in one file per deployable, infrastructure last”

Impact: HIGH one place answers “what does this app need”

  • Each deployable has one file, main.ts, that builds its whole graph. Intermediate layers are named <Name>Layer consts, and unrelated peers go in as an array (Layer.provide([Etag.layer, Path.layer])).
  • Infrastructure (the database, Redis, …) is provided last, at the root, so everything above can use it and there is exactly one instance of it.
  • Accepted cost: that file grows, and every new feature touches it.

❌ Incorrect — each feature assembles itself with its own database layer:

labeling/layer.ts
export const LabelingLayer = Labeling.layer.pipe(Layer.provide(Db.layerWithOptions(cfg)))
// billing/layer.ts
export const BillingLayer = Billing.layer.pipe(Layer.provide(Db.layerWithOptions(cfg))) // a second pool

✅ Correct — one root graph, infrastructure at the bottom:

apps/server/src/main.ts
export const ServicesLayer = Layer.mergeAll(Labeling.layer, Billing.layer)
const AppLayer = Layer.mergeAll(
HttpRouter.serve(RoutesLayer),
Outbox.dispatcherLayer,
).pipe(
Layer.provide(ServicesLayer), // shared with the dispatcher → outside serve
Layer.provide(SqlLayer), // infrastructure last
)

Source: notes/05-effect-fundamentals/layer-composition.md · Decision 2, Rejected