Wiring and the Composition Root
Somebody has to construct the object graph. Doing it in one deliberate place is the design decision; doing it with a container is a separate, later, optional one.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
If nothing constructs its own dependencies, who constructs anything — and where does that code live?
Injection is in place everywhere. Now main.ts, the worker entry point and four test helpers each build their own object graph, they have drifted, and a bug reproduces in the worker but not in the API.
Construct what you need where you need it. Each entry point builds its own graph inline, which is easy to read top to bottom and requires no shared abstraction. For one entry point this is not merely acceptable, it is correct: a composition root with one caller is just main.
The second entry point copies the first, and from that moment the two graphs drift. The drift is invisible because both work — until a bug reproduces in one and not the other, which is the most expensive kind of difference to find.
- The second entry point copies the first, and from that moment the two graphs drift. The drift is invisible because both work — until a bug reproduces in one and not the other, which is the most expensive kind of difference to find.
- Test helpers become a third and fourth graph, so tests exercise a configuration that does not exist in production (Testing as Design Feedback).
- Something that should be a singleton — a connection pool, a cache — gets constructed in two graphs, and the resource limits are quietly doubled (Connection Pool Exhaustion).
- Configuration reading spreads through the construction code, so "which environment variable does this need" has no single answer and a missing one is discovered by whichever code path runs first (Validate at Startup, Fail Loudly).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Three entry points that must share behaviour: an HTTP server, a background worker, and a CLI used for backfills.
- Configuration comes from environment variables and a secrets manager, and some of it is only available asynchronously.
- The graph is around eighty objects today and growing steadily.
- Deploys are frequent, so a wiring mistake that only shows up at runtime is a production incident rather than a nuisance.
- The API and the worker must run the same business objects, configured the same way, except for a short and explicit list of intended differences.
- A missing or unconstructible dependency must be detected at build or startup, never on the first request that happens to need it.
- No object may be constructed twice when the design says there is one — a connection pool built per-request is an outage waiting for load.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The composition root owns construction: reading configuration, choosing implementations, deciding lifetimes, and assembling the graph. That is all it owns — it contains no business logic, ever.
- Each entry point owns only its differences from the shared graph, and that list should be short enough to read.
- Every other module owns *not* constructing anything volatile, which is what makes a single root possible at all.
- Something owns shutdown as well as startup, and it is the same place: whoever created the pool closes it (Graceful Shutdown: The 502 Spike Nobody Investigates).
- The composition root sits outside every boundary, which is why it is allowed to depend on everything. It is the one place in the codebase where high fan-out is the correct design (Fan-in and Fan-out).
- The boundary between wiring and logic is absolute and worth policing: the moment an
if (customer.isPremium)appears in the root, business rules have leaked into infrastructure and the root has become untestable. - The boundary between shared graph and per-entry-point differences should be a function signature:
buildGraph(config, overrides), where the overrides are enumerable.
One graph, three entry points
The concrete shape of the fix is unglamorous: one function that builds the graph, taking configuration and an explicit set of differences. Every entry point calls it. The differences between the API and the worker stop being a diff between two files and become an argument you can read.
Notice what is absent. No framework, no registration, no annotations, and no logic — the root decides *which* implementations, never *what* they do.
1// composition/graph.ts — the only place that says `new`2export function buildGraph(cfg: Config, o: Overrides = {}) {3 const pool = o.pool ?? new PgPool(cfg.databaseUrl, cfg.poolSize)4 const clock = o.clock ?? new SystemClock()5 const gateway = o.gateway ?? new StripeGateway(cfg.stripeKey, cfg.timeoutMs)6 const orders = new PgOrderRepository(pool)7 const audit = cfg.auditEnabled ? new DbAuditLog(pool) : new NoopAudit()8 9 return {10 placeOrder: new OrderService(gateway, orders, clock),11 refund: new RefundService(gateway, orders, audit),12 close: () => pool.end(),13 }14}15 16// api/main.ts const g = buildGraph(loadConfig())17// worker/main.ts const g = buildGraph(loadConfig(), { pool: new PgPool(url, 4) })18// test helper const g = buildGraph(testConfig(), { gateway: fakeGateway, clock: fixedClock })The worker line is the whole argument. Its only intended difference from the API is a smaller connection pool, and here that difference is four visible words instead of a second forty-line file that nobody diffs. The test helper uses the same builder, so tests exercise the production graph rather than an imitation of it.
Startup is a sequence, and its order is a design decision
A composition root is not just a pile of constructor calls; it is an ordered process, and getting the order wrong produces a specific and recognisable class of production failure — a process that starts successfully and fails later on a code path that needed something it never got.
The rule that prevents most of it: everything that can fail should fail before the process reports itself healthy. Configuration first, connections second, graph third, and only then start accepting work.
- 1Load configuration
Read environment, files and secrets into one typed value. Nothing else may read the environment after this point.
fails by Scattered
process.envreads mean a missing variable is discovered by whichever request first needs it, hours after deploy (Hidden Global State). - 2Validate configuration
Parse and check every value — URLs, timeouts, key formats — and exit non-zero with a readable message on failure.
fails by Validating lazily turns a deploy-time error into a runtime error, and a typo in a rarely-used setting survives to production (Validate at Startup, Fail Loudly).
- 3Open external resources
Connection pools, message-broker channels, caches. Verify one connection actually works rather than assuming.
fails by Lazy connections make a wrong database URL look like a healthy process until the first query (Connection Pools).
- 4Build the object graph
One
buildGraph(cfg, overrides)call. Under manual wiring the compiler has already proven this can succeed.fails by Under a container, a missing registration surfaces here at best and on first resolution at worst — which is why the startup resolve test exists.
- 5Run readiness checks
Ping each critical dependency once; only now report ready to the orchestrator (Health Checks).
fails by Reporting ready before dependencies are reachable sends traffic to a process that cannot serve it, and the load balancer will happily do so.
- 6Start accepting work
Bind the port, or start consuming the queue. First real request runs through a graph that is already proven.
fails by Binding first means requests arrive during construction and see half-built objects.
- 7Register shutdown
Reverse the sequence on signal: stop accepting, drain, close pools (Graceful Shutdown: The 502 Spike Nobody Investigates).
fails by Whoever built the pool must close it; if construction is scattered, so is teardown, and connections leak on every deploy.
Every step in this list exists because the failure it prevents is one that otherwise happens *after* the deployment is declared successful. That is the whole design goal of a startup sequence: convert late, confusing failures into early, obvious ones.
Manual or container, decided on the actual costs
The choice is usually made on aesthetics or on what the team used last. It is better made on four questions, none of which is about elegance: how big is the graph, how many entry points are there, does the team already know the container, and which class of failure hurts more here — a merge conflict or a runtime resolution error.
A useful default: manual until the wiring file is genuinely hard to change, then a container with registration kept in one place. Note that this ordering is cheap to follow, because moving from manual to container is easy and moving back is not.
- If the answer is a container, keep registration in one module. The container's cost is invisibility, and centralised registration buys most of it back (Service Locator).
- Whichever you choose, keep configuration loading out of modules. That decision matters more than manual-versus-container and gets argued about far less.
- A container does not give you dependency direction or inversion. If policy imports the vendor SDK, it still does after you install one (Dependency Inversion, Critically).
| Manual wiring | Container | |
|---|---|---|
| Adding a dependency | Constructor parameter plus one line in the root | Usually nothing — the real convenience, and it compounds |
| Missing dependency | Compile error, free, before anything runs | Runtime resolution error at startup or, worse, on first use |
| Reading the graph | One file, top to bottom, greppable | Depends on registration style; annotation scanning means no file shows it |
| Lifetimes | Explicit in the code — you can see the one pool being shared | A keyword. Easy to change, and the blast radius is invisible |
| Merge conflicts | Everyone edits the same file; a real cost at organisational scale | Registration is distributed, so conflicts are rarer |
| Onboarding | Nothing to learn beyond the language | One more framework, with its own scoping rules and failure modes |
| Where it stops working | Somewhere past a few hundred wired objects | When registration is spread widely enough that nobody can see the graph |
How to build it
Most important first.
- Have exactly one function that builds the graph, and let each entry point call it. The differences become parameters, which makes them visible and reviewable.
- Read all configuration first, validate it, and fail before constructing anything. A process that starts and then fails on the third request is much worse than one that refuses to start (Validate at Startup, Fail Loudly).
- Make lifetimes explicit and few. In practice almost everything is either "one per process" or "one per request", and a third category usually means something is confused (State Ownership).
- Start with manual wiring — plain constructor calls in one file. It is checked by the compiler, it is greppable, and it works to a size that surprises people who have only used containers.
- Adopt a container when the threading cost is genuinely dominating, not before, and treat it as the framework dependency it is (What a Framework Charges). Keep registration in one file so the graph is still readable in one place.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding a dependency to an object under manual wiring: one constructor parameter, one line in the root. Under a container: usually zero lines, which is the container's actual selling point and it is a real one.
- Adding a new entry point: with one shared graph builder, it is a call plus its differences — an hour. With copied graphs, it is a fourth copy that will drift, and the cost is paid later by whoever debugs the divergence.
- Changing a lifetime — making something per-request that was per-process: under manual wiring the compiler shows you every construction site; under a container it is a one-word change whose blast radius is invisible, which is cheaper to make and much more dangerous to get wrong.
- Diagnosing "what actually got constructed in production": manual wiring, read one file. Container, read registration spread across the codebase and reason about scanning order. This cost is paid during incidents, which is the worst time to pay anything.
- A single composition root is a coordination point: every team adding a dependency edits the same file, and in a large codebase that is a real source of merge conflicts.
- Manual wiring is verbose, and the verbosity is not virtuous — it is a cost paid for a compile-time proof, and reasonable people conclude the proof is not worth it.
- Insisting on no container in a large codebase means hand-maintaining a very large file, and that is a defensible choice only if it is a choice.
What can go wrong
- Two composition roots that drift. The most common failure, and it produces bugs that reproduce in one deployment and not another.
- Business logic in the root. It starts as one conditional for a customer-specific behaviour and ends as a second, untested implementation of a domain rule.
- Container registration spread across twenty modules by annotation, so no file shows the graph and understanding it requires running it.
- The mitigation fails: a startup test that resolves the graph passes in CI with test configuration and misses a production-only branch of the wiring. Wiring differences between environments need their own test, and mostly do not get one.
- The root depends on every concrete implementation and on configuration. Nothing depends on the root, which is what keeps that fan-out harmless.
- Under manual wiring, the compiler depends on the whole graph type-checking, which is a free proof that every dependency is satisfiable.
- Under a container, that proof is gone: the graph is resolved by type or name at runtime, and you have to buy the proof back with a startup test.
- "A composition root means using a DI container." It means one place constructs the graph. That place can be forty lines of
newinmain(), and often should be (Dependency Injection). - "The root violates every principle in this domain — it depends on everything." Yes, deliberately. Concentrating the knowledge of what-connects-to-what in one logic-free file is the point; spreading it out is the failure mode being avoided (Kinds of Coupling).
- "One root means one process." A root per deployable is normal. What is not normal is two roots inside one deployable, or a root duplicated rather than shared between an app and its worker.
- "Containers make wiring disappear." They make it implicit. The graph still exists, still has lifetimes and still fails; what changed is that the compiler no longer checks it and no single file shows it (Service Locator).
- shotgun-surgery
- hidden-global-state
Testing it, and how it ages
- A test that builds the full production graph with production-shaped configuration and asserts it succeeds. Under manual wiring the compiler does most of this; under a container it is essential and often missing (Validate at Startup, Fail Loudly).
- A test that the API graph and the worker graph differ only in the intended ways — easiest to write if the differences are an explicit parameter rather than a code difference.
- Test the configuration parsing separately from the wiring, so "the environment variable was missing" and "the object could not be constructed" are distinguishable failures (An Error Taxonomy That Survives Contact).
- Do not unit test the composition root itself. It has no logic, so there is nothing to assert beyond "it constructed", and a test that mirrors its structure will break on every change (Mocking).
- The root grows linearly with the system, which is fine — it is a long, boring file, and long and boring is exactly what you want from the code that has no logic.
- Somewhere past a few hundred objects, manual wiring stops being merely verbose and starts being genuinely hard to change, and that is the signal for a container rather than a fashion cycle.
- The differences parameter tends to grow, and when it stops being a short list the entry points have diverged in ways that deserve a design conversation rather than another flag.
- It ages badly if configuration loading creeps back into modules. That is the drift to watch for, because it happens one convenient
process.envat a time (Hidden Global State).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat construction has to happen somewhere, and that having it happen in two places lets them drift, is true of any system with more than one entry point regardless of language or framework.
- SCALE-SPECIFICAt eighty objects a manually-wired root is one readable file and the compiler checks it for free. At eight hundred it is a genuine maintenance burden and the threading cost dominates, which is where a container starts winning on its merits. Advice on this question is almost always written from one end of that range without saying which.
- CONTESTEDThe strongest case for containers, stated properly: manual wiring makes every dependency addition an edit to a shared file, which at organisational scale is a merge-conflict generator and a cross-team coordination cost, and the compile-time proof it buys is largely redundant with a startup smoke test that you should have anyway. Against that, the strongest case for manual wiring is that a container converts a class of error the compiler catches for free into a class of error you must remember to test for — and teams reliably forget. Both arguments are about which failures your organisation actually experiences, not about which is theoretically cleaner.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — the same "one place decides what talks to what" argument reappears as service discovery and configuration distribution once the graph spans processes rather than objects.
- — Testing & Reliability Engineering — a startup smoke test that builds the real graph is one of the highest-value tests a system has, and where it belongs in a pipeline is theirs.