LayersGENERALLANGUAGE-SPECIFICSCALE-SPECIFIC

Dependency Management Without the Container

Passing a dependency in instead of importing it is the whole idea; a container is one way to do the wiring and is not the idea.

What actually happensHow to build it

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 has a problem.

The question

How should code get hold of the things it depends on, and who decides which implementation it gets?

The requirement

A service needs a database handle, a payment client, a mailer and the current time. It has to run in production, in tests, and in a one-off script that reads from a replica.

The obvious build

Import them. import { db } from '../db' at the top of the file, import { stripe } from '../stripe' next to it, and call new Date() where the time is needed. It is the shortest thing that works and every editor can jump to the definition.

Why it breaks

The module-level db is constructed at import time, which happens before configuration is read and validated. A bad DATABASE_URL now surfaces as a connection error on the first request instead of a failed boot (Validate at Startup, Fail Loudly).

How it breaks in production
  • The module-level db is constructed at import time, which happens before configuration is read and validated. A bad DATABASE_URL now surfaces as a connection error on the first request instead of a failed boot (Validate at Startup, Fail Loudly).
  • A test that touches the service opens a real connection to whatever DATABASE_URL says, because importing the module is enough to create it. Running the suite with production credentials in the environment is a live incident, not a hypothetical.
  • The script that should read from a replica cannot: there is one module-level handle and it points at the primary. Making it configurable means a global mutable that every other caller now shares (Read Replicas From the Application).
  • new Date() inside a rule makes the test that covers "expires at end of month" pass for 28 days and fail on the 31st.
  • The dependency graph is invisible. "What does this module need in order to run?" can only be answered by reading every import transitively, and the answer includes a live network connection.
  • Two tenants needing different API keys in one process is impossible without rewriting every module that imports the client.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • Dependency injection means exactly one thing: the caller supplies the dependency rather than the callee fetching it. A constructor parameter is dependency injection. A function argument is dependency injection. No framework is involved in either.
  • A container is a tool that builds the object graph for you from registrations or type annotations. It is one implementation of the wiring step, useful when the graph is large, and irrelevant to whether your code is injected.
  • The composition root is the one place — usually main, server.ts or an app factory — that reads configuration, constructs every real implementation, wires them together and starts the server. Everything below it receives what it needs.
  • What injection buys is substitutability at a seam you chose, plus explicit construction order, plus a dependency graph you can read. Testing is one consequence, not the purpose.
  • The interface you depend on should be as narrow as what you use. A function that needs to charge a card should take { charge }, not the whole SDK client — that is what makes the test double two lines instead of a mock framework.
  • Ambient access — module singletons, service locators, thread-local or async-local context — is the opposite trade: less plumbing, and a dependency that does not appear in any signature (Request Context Propagation).

The whole idea, in two signatures

Strip away the vocabulary and dependency injection is a difference of one line: whether the thing you need comes from an import at the top of the file or from a parameter. Everything else — containers, scopes, lifetimes, annotations — is machinery for doing that at volume.

The second version is not better because it is more abstract. It is better because the same code can run against a replica, against a test database, and with a frozen clock, and because a wrong DATABASE_URL stops the process at boot rather than at 3am.

Where the dependency comes from
Fetched by the callee
import { db } from '../infra/db'        // connects at import time
import { stripe } from '../infra/stripe'

export async function expireTrials() {
  const cutoff = new Date()                // untestable boundary condition
  const orgs = await db.org.findMany({ where: { trialEndsAt: { lt: cutoff } } })
  for (const o of orgs) await stripe.subscriptions.cancel(o.subId)
}
Supplied by the caller
type Deps = {
  orgs: { findExpiredTrials(at: Date): Promise<Org[]> }
  billing: { cancel(subId: string): Promise<void> }   // narrow: one method, not the SDK
  now: () => Date
}

export async function expireTrials(deps: Deps) {
  const orgs = await deps.orgs.findExpiredTrials(deps.now())
  for (const o of orgs) await deps.billing.cancel(o.subId)
}

The first version connects to a database as a side effect of being imported, which means a test that imports it connects too, and a malformed connection string is discovered on the first request rather than at boot. The second can be run against a replica by passing a different orgs, tested with a two-line fake because billing has one method rather than an SDK's surface, and tested at a month boundary because now is a value. None of that required a framework — only a parameter.

One place that knows how everything is built

The composition root is the piece people skip, and it is where most of the benefit is. It reads configuration once, validates it, constructs the real implementations in order, and refuses to start if anything is wrong. Every wrong value becomes a failed deploy instead of a failed request.

It is also the file to read when you want to know what a service actually talks to. That question has no other cheap answer.

server.ts — the only file that constructs real things
1async function main() {
2 const cfg = loadConfig(process.env) // throws on a missing or malformed value
3
4 // construct eagerly: a bad credential fails the boot, not the first request
5 const pool = new Pool({ connectionString: cfg.databaseUrl, max: cfg.poolSize })
6 await pool.query('select 1') // prove it before accepting traffic
7 const payments = makeStripeBilling(cfg.stripeKey, { timeoutMs: 3000 })
8 const clock = () => new Date()
9
10 const deps = {
11 db: pool,
12 orgs: makeOrgRepository(pool),
13 billing: payments, // narrowed to { charge, cancel } by the factory
14 now: clock,
15 }
16
17 const app = buildApp(deps) // handlers close over deps; nothing imports them
18 const server = app.listen(cfg.port)
19
20 onShutdown(async () => { // the root also owns teardown
21 server.close()
22 await pool.end()
23 })
24}
25
26main().catch((err) => { logger.fatal({ err }, 'startup failed'); process.exit(1) })

Three properties come from this shape and not from any framework: configuration is validated before anything connects, the process exits rather than serving traffic in a broken state, and shutdown has an owner that knows what was opened (Graceful Shutdown).

Ways to wire, and what each costs

FRAMEWORK-SPECIFICThe fourth option is not really a choice inside Spring or NestJS, where the container is the framework's execution model; it is a live choice in Express, FastAPI, Flask and Go's net/http, which supply nothing. Note that FastAPI's Depends is per-request resolution and does not manage singletons the way a Spring context does — the same word covers different mechanics.

The wiring mechanism is a genuine choice with genuine costs, and it is not the same choice as whether to inject. All five options below appear in serious production codebases; the top and bottom rows are the ones most often adopted for reasons that do not survive scrutiny.

The question to ask is not "which is most professional" but "how large is the graph, and what does a missing dependency look like — a compile error, a boot error, or a 3am runtime error?".

How do components get their dependencies?

How big is the object graph, and when do you want a wiring mistake to be discovered?

Module imports / singletons

when Scripts, prototypes, and genuinely process-global things with no configuration — a logger, a metrics registry.

cost Construction at import time, before config is validated; no substitution; the dependency appears in no signature. Import-time side effects are the specific hazard.

Manual wiring at a composition root

when Up to a few dozen components. The default answer for most services in Go, TypeScript and Python.

cost Threading parameters by hand; the root file grows; adding a dependency touches every level between root and use.

A DI container

when Large graphs, many scopes (per request, per tenant), lifecycle management, or a team that already uses one fluently.

cost A framework between you and construction; missing registrations surface at runtime; stack traces pass through generated code.

Framework-provided injection

when You are already in Spring, NestJS, or using FastAPI's Depends — fighting it costs more than using it.

cost Your components become framework-coupled, which is exactly the coupling injection was meant to avoid; testing outside the framework gets harder.

Ambient context (async-local, thread-local)

when Genuinely cross-cutting per-request values that every layer needs — correlation id, tenant, trace span (Request Context Propagation).

cost Invisible dependencies. Code compiles and runs, then fails wherever the context was not propagated — a background task, a callback, a stream handler.

How to build it

Most important first.

  • Build one composition root. Read config, validate it, construct clients, wire, start. If construction fails, the process exits before it takes traffic (Configuration: Separating Code From Environment).
  • Pass dependencies explicitly as a parameter — a constructor argument, or a deps object as the first function argument. Both are fine; consistency matters more than which.
  • Depend on the narrowest interface you use, and declare it where it is used rather than where it is implemented (Transport, Application, Domain, Infrastructure).
  • Treat the clock, randomness and id generation as injected dependencies. They are the ones whose absence makes tests flaky rather than failing.
  • Construct eagerly at startup. Lazy construction moves configuration errors from boot into the first request that happens to need them, which is a much worse place to find them.
  • Reach for a container when manual wiring is genuinely painful — dozens of components, deep graphs, or a framework that supplies one anyway. Adopting it earlier buys magic in exchange for a stack trace you cannot read.
  • Keep the graph acyclic. If two components need each other, the shared piece belongs in a third.

What can go wrong

Failure modes
  • The deps object becomes a grab bag of thirty things because adding to it is easier than threading one parameter. Nothing declares what it actually uses any more.
  • A container configured by string keys or decorators, where a missing registration is a runtime error on first use rather than a compile error at build.
  • Constructor injection combined with lazy singletons, so the order of first use decides construction order — a startup ordering bug that reproduces only under a particular request sequence.
  • Test doubles that drift from the real implementation, so the suite verifies a behaviour production does not have (When the Repository Is Just Indirection).
  • A getDb() service locator introduced "just for this one place", which becomes the pattern because it needs no plumbing.
  • Per-request construction of an expensive client, so the connection pool inside the SDK is recreated per request and no connection is ever reused (Keep-Alive and Connection Reuse).
What can race
  • Lazy singleton construction under concurrency can construct twice: two requests find the cached instance empty and both build one, producing two pools where one was intended (Backend Races).
  • A mutable module-level dependency swapped at runtime — the classic "point it at the replica for this script" hack — is shared mutable state read by every in-flight request in the process (Shared Mutable State).
Security
  • Secrets are read once, at the composition root, and handed to the clients that need them. Business code that reads process.env scatters the surface from which a secret can be logged or serialized (Secrets Are Not Configuration, Secrets in Logs).
  • Injection makes least privilege expressible: give the reporting component a read-only connection and the migration runner an elevated one, as two different injected dependencies rather than one shared handle (Least Privilege).
  • Containers that construct types from configuration strings or deserialized input are an injection surface of their own — the class name should never come from a request (Deserialization: Bytes to Objects).
Misreads
  • "Dependency injection means a DI container." The container is optional and secondary. Constructor parameters are the whole concept, and most services never need more.
  • "DI is for testability." It is for substitutability and explicit construction; testability follows. Framing it as a test technique produces interfaces that exist only for mocks (When the Repository Is Just Indirection).
  • "Interfaces everywhere." Depend on an interface where a second implementation exists or is genuinely near. Elsewhere, depend on the concrete type and inject it.
  • "Injecting is slower." One extra pointer dereference. If wiring shows up in a profile, something is constructing per request, which is a different bug (Measure Before You Optimize).
  • "Singletons are fine because there is only one." The problem is not the count, it is that nothing declares the dependency and nothing can supply a different one (Stateless Services).
  • "The framework does it, so it is handled." Spring, NestJS and FastAPI's Depends do the wiring. Deciding what each component should depend on, and how narrow that interface is, is still yours.

Operating it

How you see it in production
  • Log the composition root's decisions at boot: which database host, which region, which feature flags, which provider mode. One structured line at startup answers "what is this process actually configured to do" (Structured Logging).
  • Fail the health check until construction completes, so a partially wired process is never routed traffic (Health Checks: Startup, Readiness, Liveness).
  • Count client instances at runtime. More than one payment client or one pool per process usually means something is constructing per request.
What changes at 10x and 100x
  • At 10x traffic wiring is irrelevant — it happens once. What matters is that expensive clients are constructed once and shared, which is a construction-site property.
  • At 100x instances, boot time becomes a deploy-speed property: eager construction that opens twenty connections per process multiplies across the fleet and can itself be the load spike during a rolling deploy (Rolling Deployments).
  • At 10x team size an explicit graph is what makes it possible to reason about blast radius when a shared client is changed.
What this costs
  • Plumbing. Threading a new dependency through three levels is genuinely tedious, and the tedium is the pressure that produces service locators.
  • Explicit parameters make signatures longer and diffs noisier. That verbosity is the same property that makes the graph readable — you cannot have one without the other.
  • A container removes the plumbing and adds a layer whose failures are configuration errors and unreadable stack traces. That trade is worth it in a large graph and rarely in a small one.
  • Eager construction slows boot and can fail a deploy on a dependency that would only have been needed later. That is usually the behaviour you want, and it is still a cost.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALPass dependencies in rather than importing them; build the graph in one place at startup. True in every language and framework.
  • LANGUAGE-SPECIFICThe plumbing cost differs enough to change the answer. Java and C# have mature containers, and their ecosystems assume one — manual wiring is the unusual choice. Go deliberately has no runtime container and idiomatic code wires structs by hand in main, with code generation (wire) if the graph gets large. TypeScript can do either, but decorator-based containers need reflect-metadata and lose type safety at the registration boundary. Python's dynamic imports make monkeypatching so easy that many codebases skip injection entirely and patch in tests — which works, and hides the dependency graph completely.
  • SCALE-SPECIFICFlips on graph size, not traffic. Under roughly ten constructed components, manual wiring in one file is shorter and clearer than any container, and the whole graph is visible on one screen. Past several dozen, with cross-cutting scopes (per request, per tenant) and lifecycle management, hand-wiring becomes its own maintenance burden and a container starts paying for the magic it introduces.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — module initialisation order and import-time side effects, which decide when a module-level dependency is actually constructed.
  • Testing & Reliability Engineering — choosing a seam to substitute at, and why the most convenient seam is usually the least faithful one.