Skip to content

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.

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.

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.

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.

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.

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.