e2 · services and dependency injection

A service is a contract and a key. Using it is declaring it.

Requirements are inferred, not declared. A body that does yield* Db requires 'Db', and that requirement propagates to every caller without touching a signature in between. Wiring is one flat list; each entry is built on first demand, exactly once.

Defining

Two forms, one rule: the interface and the key share a name

Contract-first is the default. Write the interface, then the key. They live in different declaration spaces, so type Db is the contract, value Db is the key, yield* Db gives a Db, and helpers take (db: Db). It is two calls because Api is explicit while Name must be inferred as a literal, and TypeScript infers all of a call's type arguments or none.

export interface Db {
  query(id: string): Fx<User, DbError>
}
export const Db = service<Db>()('Db')   // Service<'Db', Db>

Implementation-first fits leaves whose shape is the value: configuration, a clock, an in-memory index. Both the contract and the dependencies come from the body, and the result carries its default, so it goes into a list as itself.

export const Config = service('Config', function* () {
  return { deviceId: 'device-main', userId: 'user-me', pageSize: 50 }
})
export type Config = ApiOf<typeof Config>
// Defaulted<'Config', { deviceId: string; userId: string; pageSize: number }, never, never>

Prefer contract-first for anything with more than a couple of methods, so hovers read Db rather than a structural type. ApiOf<typeof Db> and NameOf<typeof Db> recover the two halves of any key.

Implementing

Db.make for a body, Db.of for a value

Both hang off the key and both return a Provider, which is the type a list is made of. Nobody constructs one by hand.

Db.make(function* () { … }) runs the body when the service is first demanded. Whatever the body yields is the provider's Needs, minus Scope, which every provider has. The body may return an object literal, a class instance, or the result of a factory; the contract bounds it, so a method that reaches for a service the contract does not mention is rejected where it is written.

object
const DbLive = Db.make(function* () {
  const config = yield* Config
  const log = yield* Logger
  const pool = yield* connect(config.url)
  yield* log.info('connected')
  return { query: (id) => sync(() => pool.q(id)) }
})
// Provider<'Db', 'Config' | 'Logger', ConnError>
class, or factory
class PgDb implements Db {
  constructor(private readonly pool: Pool) {}
  query(id: string) { return sync(() => this.pool.q(id)) }
}

const DbLive = Db.make(function* () {
  const config = yield* Config
  return new PgDb(yield* connect(config.url))
})
// Provider<'Db', 'Config', ConnError>

Db.of(value) is for a value you already hold: configuration, a test double. It has no needs and cannot fail. One thing to know: it shares the instance across runtimes, so a stateful double (Store.of(new Map())) is one map in every test that uses it. Use make for state.

A dependency can also be per method. A contract may say loadRemote(id): Fx<User, DbError, 'ServerFetch'>, and then only programs that take that path require ServerFetch; the implementation resolves it with yield* ServerFetch inside the method, and it is not a construction dependency.

Wiring

One flat, unordered list, built on demand

There is no composition operator and no dependency list on a provider. A list is a set of providers, each named once. Building it means demanding each one in list order; a body that yields another service demands it, recursively, so construction order is dependency order without a sort, and a service is built exactly once however many bodies reach for it.

runtime([DbLive, LoggerLive, ConfigLive]) DbLive LoggerLive ConfigLive list order is irrelevant; each is named once what running the first body discovers Db Logger Config yield* yield* yield* (already built: the cell hands back the value) built: Config, then Logger, then Db — dependency order, nobody sorted torn down in reverse at close
Cycles and missing providers still surface at startup, because startup demands every provider. Both are defects that name the services involved.

A list that does not close over its own needs is a compile error at the list (try it). runtime's return type collapses to a literal naming the gap, so the next line reads:

TS2339an open list
Property 'run' does not exist on type '"e2: missing service Config"'.
Runtime

What runtime does, in four steps

  1. Register.

    Each provider becomes a cell under its service name, behind an empty root map that already holds the scope. Nothing is built. Two providers for one name is DuplicateProvider.

  2. Warm up.

    Every provider not wrapped in lazy() is demanded, in list order. A demand runs the body on a parentless fiber and memoises the value; a body that yields another service demands it in turn. A construction cycle is CyclicDependency, naming the loop; a body that fails tears down what was built and rejects.

  3. Serve.

    rt.run(effect) interprets an effect against the built map, with the effect's dependencies checked statically against what the runtime provides. rt.fork(effect) does the same on a detached fiber. rt.graph() reports each service's status, whether it was lazy, and what its construction demanded.

  4. Close.

    rt.close(), or await using, interrupts any construction in flight and runs every finalizer in reverse acquisition order.

await using rt = await runtime([Config, NetworkLive, LocalStoreLive, lazy(MpsLive), lazy(SendLive)])

await rt.run(program)      // Promise<Result>; defects reject
await rt.runExit(program)  // Promise<Exit>
rt.fork(program)           // a Fiber; how a server handles each request
rt.graph()                 // { Mps: { status: 'empty', lazy: true, demanded: [] }, … }
// closed at the end of the block, in reverse acquisition order
Startup policy

lazy(provider) keeps a service out of the warm-up

Construction is eager by default, which is what makes a bad graph fail at startup. A provider wrapped in lazy() at the composition root is skipped by the warm-up and built by whoever demands it first: a consumer's body, a memo(Service) handle, a request, a remote call. Later demanders join that one build. It is the same cell, only skipped.

The trade is explicit and local to the root: that service's construction failure no longer fails startup. It reaches the first demander as a ProviderFailed defect, and the cell resets so the next demand retries. Use it for services genuinely optional at boot, not as a default. Whatever is expensive inside a service belongs behind memo, at the granularity of the resource, and that needs no policy at the root at all.

Testing

Substitution is another provider for the same key

There is no mocking layer. A test double is Db.of({ … }) or a Db.make with a fake inside, in the list where DbLive would be. Because the list is checked for closure and duplicates, a double cannot be silently ignored or silently doubled. A defaulted service is overridden the same way: Config.of({ … }) in the list instead of Config.

const result = await run(getUser('1'), [
  Db.of({ query: () => ok({ id: 'stub', name: 'Stub' }) }),
  ConfigLive,
  LoggerLive,
])
Extension points

A service nobody gives a default to

A module that wants to be extended defines the key and depends on it, and defines no provider. Whoever composes the application supplies one. There is no separate concept for this: it is service<Api>() like everything else, and the provider is one more entry in the list.

For example, an export feature can support any number of formats without knowing them. It asks for Formats; the application decides which exist. If the root forgets to supply them, the list does not compile.

// In the export module: depend on the formats, define none.
interface Formats {
  readonly [name: string]: (notes: readonly Note[]) => string
}
const Formats = service<Formats>()('Formats')

const ExportLive = Export.make(function* () {
  const formats = yield* Formats
  return { as: (format, notes) => sync(() => formats[format]?.(notes) ?? '') }
})

// At the composition root: whoever builds the app decides.
const FormatsLive = Formats.of({ markdown: toMarkdown, csv: toCsv })
await runtime([ExportLive, FormatsLive, /* … */])
Diagnostics

What the compiler says, measured with tsc 5.9.3 and pinned by a test

TS2345a program needs a service the list lacks
Type 'Fx<User, DbError, "Db" | "Logger">' is not assignable to type '"e2: missing service Logger"'.
TS2339a runtime list that is not closed
Property 'run' does not exist on type '"e2: missing service Config"'.
TS2739an abstract service where a provider is needed
Type 'Service<"Db", Db>' is missing the following properties from type 'Provider<any, any, any>': provides, build, _Provides, _Needs, _Error
TS2345an implementation whose method reaches for a service the contract does not declare
… seven structural lines through Generator and IteratorResult …
Type 'Fx<{ id: string; }, never, "Logger">' is not assignable to type 'Fx<User, DbError, never>'.
  Type '"Logger"' is not assignable to type 'never'.

The last one is the successor of what used to be an "undeclared dependency" error. It fires only when a contract is genuinely violated, and the fix is stated by the contract: declare the dependency on that method, or resolve the service at construction.

Honest limitations

Things the design chose, with eyes open

  • Two keys may not share a name. Names are what buy the readable diagnostics. The runtime rejects duplicates when the list is assembled. Use distinct names for distinct instances.
  • An unused runtime(...) compiles. The closure check lives in the return type, so it fires where the runtime is used. Every real call site uses it.
  • Conditional dependencies over-approximate. if (cfg.cache) yield* Cache puts Cache in Needs whether or not the branch runs, so the list must contain it.
  • Defaults are never pulled in transitively. runtime([Send]) does not drag in Config. The check works on names, and a name cannot know whether it has a default. Every service is named once, in the list.
  • The contract-violation diagnostic is deep. The useful line is there, under nine levels of structural walk.