Skip to content

Contract vs implementation

A service has a contract (the tag and its shape) and an implementation (make and the Layer). This page answers whether they share a file, what a configured service exports, and where the test seam is.

Keep the tag and its implementation in one file

Section titled “Keep the tag and its implementation in one file”

Impact: HIGH sets the shape of every service file

  • One file per service holds the shape type, the bare tag, make and layer, as sibling const exports. No Services/ + Layers/ split, and no static members on the tag (see service definition).
  • A service with no options exports make and layer only.
  • A method on a tenant table carries CurrentOrg in R; the org is never a parameter.
  • Accepted cost: importing the tag also imports what the implementation needs. Only the directory split avoids that, and it stays deferred.

❌ Incorrect — two directories with paired filenames for every service:

labeling/
Services/LabelingRules.ts ← tag + LabelingRulesShape, `import type` only
Layers/LabelingRules.ts ← Layer.effect(...), PubSub, SqlClient, Metric…

✅ Correct — one file, sibling exports:

labeling/labeling-rules.ts
export type LabelingRulesShape = {
readonly get: (id: RuleId, options?: GetLabelingRuleOptions) =>
Effect.Effect<LabelingRule, RuleNotFound | PersistenceError, CurrentOrg>
}
export class LabelingRules extends Context.Service<LabelingRules, LabelingRulesShape>()(
"@app/labeling/LabelingRules",
) {}
export const make = Effect.gen(function* () { … })
export const layer = Layer.effect(LabelingRules, make)

Source: notes/01-code-organization/contract-vs-implementation.md · Decision (amended)

Give a configured service a config export and a layer fed from it

Section titled “Give a configured service a config export and a layer fed from it”

Impact: MEDIUM one shape for every configured service

  • layerWithOptions and the options parameter on make arrive in the PR that adds the first option. make then becomes a function, and its direct callers change in that PR.
  • make(options) takes options as required. config is a Config.Config<LabelingRulesOptions>. layer reads it, so main.ts never reads a feature’s config. A value that is the same everywhere stays in config behind Config.withDefault.
  • See config & secrets for where config is read.

❌ Incorrect — code defaults and a zero-argument default layer, a second shape:

export const make = (options?: LabelingRulesOptions) => Effect.gen(function* () { … })
export const layerWithOptions = (options?: LabelingRulesOptions) =>
Layer.effect(LabelingRules, make(options))
export const layer = layerWithOptions()

✅ Correct — options come from config:

export const make = (options: LabelingRulesOptions) => Effect.gen(function* () { … })
export const layerWithOptions = (options: LabelingRulesOptions) =>
Layer.effect(LabelingRules, make(options))
export const config: Config.Config<LabelingRulesOptions> = Config.all({
timeout: Config.Duration("LABELING_TIMEOUT").pipe(Config.withDefault("5 seconds")),
})
export const layer = Layer.unwrap(Effect.map(config, layerWithOptions))

Source: notes/01-code-organization/contract-vs-implementation.md · Decision (amended)

Impact: MEDIUM swap an implementation without editing wiring

  • The point of separating contract from implementation is a seam. Here the seam is make: a test calls it directly with fakes, or builds its own Layer.effect(Tag, make). It costs one export, not a second file.
  • A test of a configured service calls layerWithOptions with plain values, with no ConfigProvider.
  • The module’s layer is layer, never Live or Default (both v3-era names). Consumers use a namespace import.

❌ Incorrect — no exported layer; the only wiring is buried in the root:

labeling/labeling-rules.ts
export class LabelingRules extends Context.Service<LabelingRules, LabelingRulesShape>()(
"@app/labeling/LabelingRules",
) {}
// main.ts builds Layer.effect(LabelingRules, …) inline, by hand

✅ Correct — the same file serves the app and the test:

import * as LabelingRules from "./labeling-rules.ts"
Layer.provide(LabelingRules.layer) // app wiring
LabelingRules.layerWithOptions({ timeout: Duration.seconds(5) }) // a test: plain values

Source: notes/01-code-organization/contract-vs-implementation.md · Decision

Keep service contracts out of the domain package

Section titled “Keep service contracts out of the domain package”

Impact: MEDIUM keeps domain the narrowest package

  • Putting every service contract in a shared package, with implementations elsewhere, inverts the dependency direction the graph needs. domain must depend on nothing, and it would become the widest package in the repo.
  • Features are folders inside apps/server, so there is no feature package to split into anyway. The contract lives with its feature. See workspace boundaries for the graph rules.

❌ Incorrect — every contract pushed into the domain package:

packages/domain/src/labeling-rules.ts ← LabelingRules tag + shape
apps/server/src/labeling/labeling-rules.ts ← make + layer

✅ Correct — contract and implementation together in the feature:

apps/server/src/labeling/labeling-rules.ts ← shape, tag, make, layer

Source: notes/01-code-organization/contract-vs-implementation.md · Rejected

  • The Services/ + Layers/ directory split — trigger: root tsc --noEmit takes more than 60 s locally.
  • layerWithOptions on a service — trigger: the service gets its first option.