Service definition
Every dependency in the app is a service. This page answers which v4 construct declares one, how its constructor and Layer sit beside it, and where a service’s options come from.
Declare services with Context.Service and a scoped id
Section titled “Declare services with Context.Service and a scoped id”Impact: HIGH the wrong construct is v3 code that won’t compile
- Use the class form of
Context.Service.Effect.Servicedoes not exist in v4, andContext.Tag/GenericTagare effectively gone. Treat tutorials that use them as outdated. - The tag id is
"@app/<folder>/<Name>", where the middle segment is the folder or package that owns the file (see naming and vocabulary). A bare id is a global key that collides silently; a full path is noise in every trace.
❌ Incorrect — a bare id, or a path nobody wants to read in a trace:
export class LabelingRules extends Context.Service< LabelingRules, LabelingRulesShape>()("LabelingRules") {}// or: >()("app/labeling/Services/LabelingRules/LabelingRulesService")✅ Correct — scoped by the owning folder:
export class LabelingRules extends Context.Service< LabelingRules, LabelingRulesShape>()("@app/labeling/LabelingRules") {}Source: notes/05-effect-fundamentals/service-definition.md · Rejected; naming-and-vocabulary Decision 9
Export make and layer as consts beside a bare tag
Section titled “Export make and layer as consts beside a bare tag”Impact: HIGH one shape for every service, plain module exports only
- The tag stays bare.
makeandlayerare module-level consts in the same file. Consumers use a namespace import, soLabelingRules.layerreads the same as a static member would. - The reason is uniformity, not capability: inline
makein the tag options can take arguments, but the first option would then turnstatic layerinto a function or add a second static, and the config chain would become statics reaching each other throughthis. - Accepted cost: nothing but discipline keeps
layerbeside its tag.
❌ Incorrect — make inline in the tag options, layers as static members:
export class LabelingRules extends Context.Service<LabelingRules, LabelingRulesShape>()( "@app/labeling/LabelingRules", { make: Effect.gen(function* () { … }) },) { static readonly layerNoDeps = Layer.effect(this, this.make) static readonly layer = this.layerNoDeps.pipe(Layer.provide([ … ]))}✅ Correct — sibling const exports (a service with no options):
export class LabelingRules extends Context.Service< LabelingRules, LabelingRulesShape>()("@app/labeling/LabelingRules") {}
export const make = Effect.gen(function* () { … })export const layer = Layer.effect(LabelingRules, make)
// elsewhereimport * as LabelingRules from "./labeling-rules.ts"Layer.provide(LabelingRules.layer)Source: notes/05-effect-fundamentals/service-definition.md · Decision 1, amended
Feed options from a config export, added with the first option
Section titled “Feed options from a config export, added with the first option”Impact: MEDIUM one shape for a configured service, none built on a forecast
- A service with no options exports
makeandlayeronly. The PR that adds the first option turnsmakeinto a function and updates its direct callers (mostly tests). - A configured service has four exports:
make(options)takes them required,layerWithOptions(options)wrapsmake,configis aConfig.Config<<Service>Options>, andlayer = Layer.unwrap(Effect.map(config, layerWithOptions)). A value that is the same everywhere stays inconfigbehindConfig.withDefault(see config and secrets). - Tests call
layerWithOptionswith plain values and noConfigProvider. Hoist eachlayerWithOptions(…)call to a const (see layer memoization).
❌ Incorrect — optional options with code defaults; a second shape for configured services:
export const make = (options?: LabelingRulesOptions) => Effect.gen(function* () { … })export const layerWithOptions = (options?: LabelingRulesOptions) => Layer.effect(LabelingRules, make(options))export const layer = layerWithOptions()✅ Correct — required options, read from config for the default layer:
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))
// a testLabelingRules.layerWithOptions({ timeout: Duration.seconds(5) })Source: notes/05-effect-fundamentals/service-definition.md · Decision 1, amended
Name the shape as an exported type
Section titled “Name the shape as an exported type”Impact: LOW one shape referenced from the tag and from make
make’s return type and the tag’s second type argument both reference the shape, so name it<Name>Shapeand export it. Inline it only for a service with one or two methods.- Methods follow interface design:
(primary, options?), and a per-method error union. On a tenant table such aslabeling_rules, the org isCurrentOrginR, never a parameter.
❌ Incorrect — a multi-method shape buried in the type argument, org as a parameter:
export class LabelingRules extends Context.Service< LabelingRules, { readonly get: (orgId: OrgId, id: RuleId) => …; readonly create: (orgId: OrgId, …) => … }>()("@app/labeling/LabelingRules") {}✅ Correct — a named, exported shape:
export type LabelingRulesShape = { readonly get: ( id: RuleId, options?: GetLabelingRuleOptions, ) => Effect.Effect<LabelingRule, RuleNotFound | PersistenceError, CurrentOrg> readonly create: ( input: CreateLabelingRuleInput, ) => Effect.Effect<LabelingRule, RuleLabelTaken | PersistenceError, CurrentPrincipal | CurrentOrg>}Source: notes/05-effect-fundamentals/service-definition.md · Decision 2; Decision 1, amended
Deferred
Section titled “Deferred”layerWithOptionsandmake(options)on a service — trigger: the service gets its first option.- The
Services/+Layers/directory split — trigger: roottsc --noEmittakes more than 60 s locally (see contract vs implementation).