Dependency Injection
What It Is
Dependency injection in Dagonizer is plain TypeScript constructor injection. The host application creates services, passes them into node constructors, and registers those node instances with the dispatcher.
The dispatcher carries DAG state and lifecycle; it does not carry an ambient service bag. That keeps node dependencies visible in the class signature and easy to replace in tests.
How It Works
The host constructs service objects, passes them into node constructors, then registers those node instances. The dispatcher remains generic over state only. Tests instantiate the same nodes with fakes or stubs, and DAG JSON-LD remains independent of concrete service wiring.
Nodes receive external dependencies through their constructors and hold them as private fields. The dispatcher is generic over state only — Dagonizer<TState>. There is no ambient services record and no context.services.
Diagrams, Examples, and Outputs
Dependency injection is node construction, not DAG topology, so this page uses runnable source snippets instead of a graph.
Use these examples:
- The Cartographer injects geo resolvers, delivery transports, and stores into nodes before registering DAG bundles.
- The Archivist injects model adapters, embedders, and graph-backed memory services into the nodes used by the parent and embedded DAGs.
- Shared State explains when a shared store dependency is safe and how it interacts with checkpointing.
- Observability shows dispatcher subclass hooks for cross-cutting logging and tracing that do not belong inside node constructors.
What It Lets You Do
Use when
Use constructor dependency injection when nodes need external services: LLM adapters, embedders, stores, geocoders, HTTP clients, loggers, clocks, or domain repositories. Dependencies should be explicit on the node class, not hidden behind dispatcher globals.
Code Samples
Why constructor injection
Constructor injection is explicit, statically typed, and testable. Each node declares its dependencies in its constructor signature — there is no implicit runtime contract to read through. Tests instantiate nodes with stubs or fakes passed directly, with no dispatcher scaffolding required.
The dispatcher carries no ambient state. Every object a node needs is held by that node as a field, visible in the class declaration and verifiable at construction time.
Details for Nerds
Pattern
Declare the dependency as a private field on the node class. Accept it via the constructor. Register the node with an injected instance. The Cartographer resolver nodes are the runnable example: ResolveIpNode receives an IpGeolocator, holds it as a field, and calls it from execute().
export class ResolveIpNode extends MonadicNode<CartographerState, 'resolved'> {
readonly '@id' = 'urn:noocodec:node:resolve-ip';
private readonly ipGeolocator: IpGeolocator;
readonly 'name' = 'resolve-ip';
readonly 'outputs' = ['resolved'] as const;
constructor(ipGeolocator: IpGeolocator) {
super();
this.ipGeolocator = ipGeolocator;
}
override get outputSchema(): Record<'resolved', SchemaObjectType> {
return { 'resolved': { 'type': 'object' } };
}
override async execute(
batch: Batch<CartographerState>,
context: NodeContextType,
): Promise<RoutedBatchType<'resolved', CartographerState>> {
for (const item of batch) {
const raw = item.state.getMetadata('geo-signal');
if (!GeoSignalDescriptorGuard.is(raw)) {
item.state.candidate = GeoResolutionBuilder.from({ 'source': 'ip', 'weight': 0 });
continue;
}
const outcome = await this.ipGeolocator.lookup(raw.ipAddress, context.signal);
if (outcome.error !== null) {
item.state.capturedErrors = [...item.state.capturedErrors, outcome.error];
}
item.state.candidate = OutcomeResolution.of(outcome, 'ip', raw.weight);
}
return RoutedBatch.create('resolved', batch);
}
}GeoSourceResolveDAG.build(...) constructs the resolver nodes with the selected transports and returns a DispatcherBundleType containing the nodes plus their sub-DAGs:
import { resolveCoords } from '../nodes/geo/resolveCoords.ts';
import { resolveLocale } from '../nodes/geo/resolveLocale.ts';
import { resolveCode } from '../nodes/geo/resolveCode.ts';
import { resolvePhone } from '../nodes/geo/resolvePhone.ts';
import { ResolveIpNode } from '../nodes/geo/resolveIp.ts';
import { ResolveAddressNode } from '../nodes/geo/resolveAddress.ts';
import {
prepareGeoAddress,
prepareGeoCode,
prepareGeoCoords,
prepareGeoIp,
prepareGeoLocale,
prepareGeoPhone,
} from '../nodes/geo/prepareGeoSignal.ts';
import { CARTOGRAPHER_IRIS } from '../cartographerIds.ts';
import type { IpGeolocator } from '../contracts/IpGeolocator.ts';
import type { AddressGeocoder } from '../contracts/AddressGeocoder.ts';
import type { CartographerState } from '../CartographerState.ts';
// Side-effect import: registers 'geo-weighted-fusion' at module load so the
// scatter placement can resolve the strategy name at dispatcher construction.
import '../core/GeoWeightedFusionGather.ts';
import type { DispatcherBundleType } from '@studnicky/dagonizer';
import { DAGBuilder } from '@studnicky/dagonizer';
export class GeoSourceResolveDAG {
private constructor() { /* static-only */ }
static build(
ipGeolocator: IpGeolocator,
addressGeocoder: AddressGeocoder,
): DispatcherBundleType<CartographerState> {
const resolveIp = new ResolveIpNode(ipGeolocator);
const resolveAddress = new ResolveAddressNode(addressGeocoder);
const resolveCoordsDag = new DAGBuilder(CARTOGRAPHER_IRIS.dag.geoResolveCoords, '1.0')
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCoords, 'prepare-geo-coords'), prepareGeoCoords, {
'present': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCoords, 'resolve-coords'),
'missing': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCoords, 'done'),
})
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCoords, 'resolve-coords'), resolveCoords, { 'resolved': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCoords, 'done') })
.terminal(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCoords, 'done'), { 'outcome': 'completed' })
.build();
const resolveAddressDag = new DAGBuilder(CARTOGRAPHER_IRIS.dag.geoResolveAddress, '1.0')
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveAddress, 'prepare-geo-address'), prepareGeoAddress, {
'present': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveAddress, 'resolve-address'),
'missing': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveAddress, 'done'),
})
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveAddress, 'resolve-address'), resolveAddress, { 'resolved': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveAddress, 'done') })
.terminal(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveAddress, 'done'), { 'outcome': 'completed' })
.build();
const resolveIpDag = new DAGBuilder(CARTOGRAPHER_IRIS.dag.geoResolveIp, '1.0')
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveIp, 'prepare-geo-ip'), prepareGeoIp, {
'present': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveIp, 'resolve-ip'),
'missing': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveIp, 'done'),
})
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveIp, 'resolve-ip'), resolveIp, { 'resolved': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveIp, 'done') })
.terminal(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveIp, 'done'), { 'outcome': 'completed' })
.build();
const resolveCodeDag = new DAGBuilder(CARTOGRAPHER_IRIS.dag.geoResolveCode, '1.0')
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCode, 'prepare-geo-code'), prepareGeoCode, {
'present': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCode, 'resolve-code'),
'missing': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCode, 'done'),
})
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCode, 'resolve-code'), resolveCode, { 'resolved': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCode, 'done') })
.terminal(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveCode, 'done'), { 'outcome': 'completed' })
.build();
const resolvePhoneDag = new DAGBuilder(CARTOGRAPHER_IRIS.dag.geoResolvePhone, '1.0')
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolvePhone, 'prepare-geo-phone'), prepareGeoPhone, {
'present': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolvePhone, 'resolve-phone'),
'missing': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolvePhone, 'done'),
})
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolvePhone, 'resolve-phone'), resolvePhone, { 'resolved': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolvePhone, 'done') })
.terminal(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolvePhone, 'done'), { 'outcome': 'completed' })
.build();
const resolveLocaleDag = new DAGBuilder(CARTOGRAPHER_IRIS.dag.geoResolveLocale, '1.0')
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveLocale, 'prepare-geo-locale'), prepareGeoLocale, {
'present': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveLocale, 'resolve-locale'),
'missing': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveLocale, 'done'),
})
.node(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveLocale, 'resolve-locale'), resolveLocale, { 'resolved': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveLocale, 'done') })
.terminal(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoResolveLocale, 'done'), { 'outcome': 'completed' })
.build();
const dag = new DAGBuilder(CARTOGRAPHER_IRIS.dag.geoSourceResolve, '1.0')
.embed<CartographerState, CartographerState>(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-coords-source'), CARTOGRAPHER_IRIS.dag.geoResolveCoords, {
'success': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
'error': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
}, {
'gatherResult': { 'resultField': 'candidate' },
})
.embed<CartographerState, CartographerState>(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-address-source'), CARTOGRAPHER_IRIS.dag.geoResolveAddress, {
'success': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
'error': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
}, {
'gatherResult': { 'resultField': 'candidate' },
})
.embed<CartographerState, CartographerState>(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-ip-source'), CARTOGRAPHER_IRIS.dag.geoResolveIp, {
'success': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
'error': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
}, {
'gatherResult': { 'resultField': 'candidate' },
})
.embed<CartographerState, CartographerState>(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-code-source'), CARTOGRAPHER_IRIS.dag.geoResolveCode, {
'success': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
'error': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
}, {
'gatherResult': { 'resultField': 'candidate' },
})
.embed<CartographerState, CartographerState>(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-phone-source'), CARTOGRAPHER_IRIS.dag.geoResolvePhone, {
'success': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
'error': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
}, {
'gatherResult': { 'resultField': 'candidate' },
})
.embed<CartographerState, CartographerState>(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-locale-source'), CARTOGRAPHER_IRIS.dag.geoResolveLocale, {
'success': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
'error': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
}, {
'gatherResult': { 'resultField': 'candidate' },
})
.entrypoints({
'coords': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-coords-source'),
'address': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-address-source'),
'ip': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-ip-source'),
'code': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-code-source'),
'phone': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-phone-source'),
'locale': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-locale-source'),
})
// First-class gather barrier over all embedded resolver producers.
.gather(
CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'geo-weighted-fusion'),
{
[CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-coords-source')]: {},
[CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-address-source')]: {},
[CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-ip-source')]: {},
[CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-code-source')]: {},
[CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-phone-source')]: {},
[CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolve-locale-source')]: {},
},
{ 'strategy': 'geo-weighted-fusion' },
{
'success': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolved'),
'error': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolved'),
'empty': CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolved'),
},
)
.terminal(CARTOGRAPHER_IRIS.placementIri(CARTOGRAPHER_IRIS.dag.geoSourceResolve, 'resolved'), { 'outcome': 'completed' })
.build();
return {
'nodes': [
prepareGeoCoords,
prepareGeoAddress,
prepareGeoIp,
prepareGeoCode,
prepareGeoPhone,
prepareGeoLocale,
resolveCoords,
resolveAddress,
resolveIp,
resolveCode,
resolvePhone,
resolveLocale,
],
'dags': [
resolveCoordsDag,
resolveAddressDag,
resolveIpDag,
resolveCodeDag,
resolvePhoneDag,
resolveLocaleDag,
dag,
],
};
}
}Multiple dependencies
Accept multiple dependencies as individual constructor parameters or as a single typed record. Either form is fine; the key constraint is that the dependencies are positional or named at the call site, not injected through a runtime registry.
The Cartographer groups its external transports in CartographerServices, then selects live or recorded implementations before building bundles:
export interface CartographerServices {
/** IP-modality transport (geolocate gateway IP → place). */
readonly ipGeolocator: IpGeolocator;
/** Address-modality transport (forward-geocode postal address → place). */
readonly addressGeocoder: AddressGeocoder;
}export class GeoResolvers {
/** Live services: live freeipapi IP geolocation and Nominatim address geocoding. */
static live(): CartographerServices {
return {
'ipGeolocator': new LiveIpGeolocator(),
'addressGeocoder': new LiveAddressGeocoder(),
};
}
/** Recorded services: fixture-replay IP geolocation and deterministic address geocoding.
* Deterministic and offline — used by the smoke and `--recorded` CLI flag. */
static recorded(): CartographerServices {
return {
'ipGeolocator': new RecordedIpGeolocator(),
'addressGeocoder': new RecordedAddressGeocoder(),
};
}
}Shared dependencies across nodes
When several nodes share the same dependency — a store, a logger, an LLM adapter — construct the dependency once and pass the same reference to each node's constructor.
The Archivist uses this shape for model adapters, embedder adapters, and graph-backed memory services across parent and embedded-DAG placements. The focused examples/10-shared-state.ts runner isolates the store variant: StepANode, ChildStepNode, and StepBNode all receive the same MemoryStore instance, so writes by one are visible to the others within the same execution. See Shared state for the concurrency contract and checkpoint integration.
Lifetime
A node's dependencies live on the node instance. The node instance lives on the dispatcher. Construct dependencies before constructing nodes; construct nodes before registering them. There is no per-execution scope — the same instance is used across every execution, including scatter clones and sub-DAG bodies.
If a dependency needs per-execution state (such as a request identifier), put that data on state instead. Constructor-injected dependencies are for things that outlive any single execution.
Testing nodes in isolation
Because dependencies are constructor arguments, a node can be tested directly without wiring a dispatcher. Stub the dependencies at construction time, call execute(Batch.of(state), context) directly, and assert on state mutations and routing output.
Related Concepts
- Subclassing State - per-execution data lives on state; constructor deps are dispatcher-scoped
- Observability - subclass Dagonizer to wire loggers and tracers at the dispatcher level
- State Accessors - how dotted-path reads and writes resolve on state
- Reference: Dagonizer
- Reference: Contracts
- Guide: Shared state
- Demo: The Archivist — nodes receive an LLM adapter through their constructors
- Demo: The Cartographer — nodes receive geo resolvers through their constructors