Services and dependency injection
Providers need shared capabilities: a database to persist state, the provider registry to
reach other providers, and — over time — logging, secrets, and more. They must get at those
capabilities without importing whatever host is running them, or the whole point of a
decoupled provider collapses. Services is the container that makes that possible.
Tokens are classes
Section titled “Tokens are classes”Services is a dependency-injection container keyed by class. A service is simply a class
whose constructor takes the container:
class MessagingClient { constructor(services: Services) { this.db = services.get(DatabaseService); }}The class itself is the token. There are no string keys to collide, no separate registration step, and no interface-to-implementation binding to maintain — you ask for a capability by naming its class, and TypeScript infers the type you get back from the class you passed in.
Lazy instantiation and caching
Section titled “Lazy instantiation and caching”services.get(Token) resolves a service on first use and caches it:
const client = services.get(MessagingClient);The first call constructs new MessagingClient(services) and stores it against the class;
every later call returns the same instance. Two consequences follow. Services are
effectively singletons within a container — everyone sharing a Services shares one
DatabaseService. And nothing is built until something asks for it, so a host that never
touches the database never constructs one. Because the container passes itself into each
constructor, a service can pull in its own dependencies the same way, and the graph
assembles itself lazily from whatever root you first request.
Overriding with set
Section titled “Overriding with set”services.set(Token, instance) binds a value to a token directly, bypassing construction:
services.set(MessagingClient, fakeClient);This is the seam tests use. Point a token at a fake before the code under test runs and
every get for that token returns the fake — no module mocking, no patching. It is also how
a host injects a pre-built or specially configured capability that the default constructor
would not produce. See Test a provider for the
pattern in practice.
clone and destroy
Section titled “clone and destroy”services.clone() makes a shallow copy of the instance map — a new container that starts
with the same resolved services but can diverge without affecting the original. It is useful
for scoping: give a single run its own container seeded from a shared one, then let it
register or override services locally.
await services.destroy() runs cleanup. A service may define an optional [destroy]()
method — keyed by the exported destroy symbol — and destroy awaits that method on every
service in the container that has one:
import { destroy } from '@grundlag/core';
class MessagingClient { constructor(services: Services) {} async [destroy]() { await this.connection.close(); }}This gives the container a single, ordered place to release connections and handles when a host shuts down, without every caller having to know which services hold resources.
Why this matters here
Section titled “Why this matters here”The container is what keeps a provider decoupled from its host. A provider imports
DatabaseService or ProviderRegistry from core and calls services.get(...) for them; it
never learns which server, agent, or sandbox actually built the container. DatabaseService
and ProviderRegistry are both obtained this way — they are ordinary services living in the
container, not globals. Swap the host, and the provider is unchanged because the only thing
it ever depended on was a core contract and a container that honours it. See
Providers and registries for how the registry hands
that same container to every provider, and Architecture for where
the container sits in the layering.