Skip to content

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.Service does not exist in v4, and Context.Tag / GenericTag are 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. make and layer are module-level consts in the same file. Consumers use a namespace import, so LabelingRules.layer reads the same as a static member would.
  • The reason is uniformity, not capability: inline make in the tag options can take arguments, but the first option would then turn static layer into a function or add a second static, and the config chain would become statics reaching each other through this.
  • Accepted cost: nothing but discipline keeps layer beside 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):

labeling-rules.ts
export class LabelingRules extends Context.Service<
LabelingRules,
LabelingRulesShape
>()("@app/labeling/LabelingRules") {}
export const make = Effect.gen(function* () { … })
export const layer = Layer.effect(LabelingRules, make)
// elsewhere
import * 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 make and layer only. The PR that adds the first option turns make into a function and updates its direct callers (mostly tests).
  • A configured service has four exports: make(options) takes them required, layerWithOptions(options) wraps make, config is a Config.Config<<Service>Options>, and layer = Layer.unwrap(Effect.map(config, layerWithOptions)). A value that is the same everywhere stays in config behind Config.withDefault (see config and secrets).
  • Tests call layerWithOptions with plain values and no ConfigProvider. Hoist each layerWithOptions(…) 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 test
LabelingRules.layerWithOptions({ timeout: Duration.seconds(5) })

Source: notes/05-effect-fundamentals/service-definition.md · Decision 1, amended

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>Shape and 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 as labeling_rules, the org is CurrentOrg in R, 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

  • layerWithOptions and make(options) on a service — trigger: the service gets its first option.
  • The Services/ + Layers/ directory split — trigger: root tsc --noEmit takes more than 60 s locally (see contract vs implementation).