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,
makeandlayer, as sibling const exports. NoServices/+Layers/split, and no static members on the tag (see service definition). - A service with no options exports
makeandlayeronly. - A method on a tenant table carries
CurrentOrginR; 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:
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
layerWithOptionsand theoptionsparameter onmakearrive in the PR that adds the first option.makethen becomes a function, and its direct callers change in that PR.make(options)takes options as required.configis aConfig.Config<LabelingRulesOptions>.layerreads it, somain.tsnever reads a feature’s config. A value that is the same everywhere stays inconfigbehindConfig.withDefault.- See config & secrets for where
configis 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)
Export make so tests have a seam
Section titled “Export make so tests have a seam”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 ownLayer.effect(Tag, make). It costs one export, not a second file. - A test of a configured service calls
layerWithOptionswith plain values, with noConfigProvider. - The module’s layer is
layer, neverLiveorDefault(both v3-era names). Consumers use a namespace import.
❌ Incorrect — no exported layer; the only wiring is buried in the root:
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 wiringLabelingRules.layerWithOptions({ timeout: Duration.seconds(5) }) // a test: plain valuesSource: 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.
domainmust 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 + shapeapps/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, layerSource: notes/01-code-organization/contract-vs-implementation.md · Rejected
Deferred
Section titled “Deferred”- The
Services/+Layers/directory split — trigger: roottsc --noEmittakes more than 60 s locally. layerWithOptionson a service — trigger: the service gets its first option.