The dependency injection decision record: why the DI pattern you chose shapes the testability, startup cost, and cognitive overhead of every feature you add
Every codebase has a dependency injection pattern. Most teams didn't choose it deliberately — it came with the framework, or emerged from whoever wrote the first service class. Once two hundred classes are structured around constructor injection, or annotated for Spring's container, or wired through NestJS providers, that pattern is no longer a preference. It is a structural constraint that every subsequent feature extends, every test suite works around, and every new engineer must internalize before they can read the code productively.
"We use dependency injection" is not a decision. It is a category of decisions that most teams have made individually, feature by feature, without a governing policy. Which mechanism resolves dependencies — a container that infers the graph from annotations, or a composition root that wires it explicitly? What lifetime does each service have — singleton, per-request, per-use? Where is the boundary between an injectable dependency and a hard-coded implementation detail? Which classes should be substitutable in tests, and which are concrete enough that substitution is never expected? These are the decisions that determine what "we use DI" actually means in practice. None of them are in the team's ADR directory. Most of them are not even recognized as decisions.
The consequences of these decisions are visible in three places. They are visible in the test suite, where the DI mechanism determines which things are easy to isolate and which things require elaborate setup. They are visible in startup time metrics, where container initialization cost determines how fast the application starts and how expensive Lambda cold starts are. And they are visible in code review, where the gap between what a constructor signature tells you and what the container actually resolves determines how long a new engineer spends tracing resolution rules before they can make a confident change. Like most structural decisions that accumulate feature by feature, the DI architecture is visible as a fact but invisible as a decision — making it hard to evaluate, hard to change, and hard to explain to the engineer who asks why things are wired the way they are.
What "we use dependency injection" actually means across five mechanisms
The first decision concealed inside "we use DI" is the mechanism — the specific technical approach by which a class receives its dependencies rather than constructing them directly. The mechanism choice is often made implicitly when a framework is adopted, but it is a real choice with real trade-offs, and the team that adopted NestJS made a different DI decision than the team that chose Hapi with manual constructor wiring, even if both teams would describe themselves as "using dependency injection."
No DI is a legitimate choice that most teams don't acknowledge as a choice. A class that constructs its database connection directly, or reads configuration from a global singleton, has dependencies — it just doesn't inject them. This is the starting point for most early-stage projects, and many teams that believe they are using DI have modules where construction is still happening inline. The consequence is that these modules are hard to test in isolation, hard to configure differently across environments, and hard to modify without affecting every consumer of the concrete class. The absence of DI is an architectural decision with specific consequences, and it deserves a record even when it is intentional — especially when it is intentional, because "we deliberately avoid DI ceremony in utility modules" is a constraint that new engineers need to know about or they will add DI plumbing where none was wanted.
Manual constructor injection without a container means every dependency is a parameter to the class constructor, and the composition root — the top-level startup code that assembles the application — calls each constructor with the appropriate instances. Dependencies are explicit: reading a constructor signature tells you exactly what a class depends on, without understanding the container's registration rules. Testing is mechanically simple: pass mock implementations as constructor arguments. The downside is a verbose composition root that must change every time a dependency is added or removed anywhere in the graph. For large applications, this becomes a maintenance burden. For small applications and for teams that prize explicitness over convenience, it is often the right call — but only if the team has made it consciously rather than simply not adopted a container yet.
IoC containers (Spring, NestJS, ASP.NET Core DI, Guice, Dagger, InversifyJS) are configured registries that resolve the complete dependency graph automatically. A service declares its dependencies via constructor parameters (in languages where type information is runtime-available) or via annotations; the container instantiates everything and injects the resolved instances. The benefit is that adding a dependency to a class doesn't require updating the composition root — the container discovers the new dependency and wires it. The cost is that the resolution process is now invisible from the call site. A new engineer reading a class cannot determine what it actually receives by reading the class alone; they must also understand the container's registration configuration, which may span dozens of files.
Framework-integrated DI means the framework owns the container and the registration conventions. Angular's dependency injection system, NestJS's module system, ASP.NET Core's built-in service container, and Ruby on Rails' implicit service object convention all provide a DI mechanism that is part of the framework's architecture rather than a separate library. The benefit is that following the framework's conventions requires no additional decisions; the convention determines how dependencies are registered and resolved. The cost is that the framework's DI assumptions are load-bearing — deviating from the conventions to use a different DI pattern requires fighting the framework's defaults.
Service locators are a pattern distinct from injection: rather than receiving dependencies from outside, a class calls a central registry to retrieve them internally. `MyService.getService(UserRepository.class)`. Service locators are widely recognized as an anti-pattern for testability — the dependency is invisible from the class signature, so test code cannot substitute a mock without modifying the global registry or the service locator implementation. But service locators appear in codebases that started without DI and added a registry as a partial improvement, and they appear in frameworks that use them internally as an implementation detail. Documenting the presence of a service locator and the plan for migrating away from it — or the deliberate decision to keep it for a specific module — is an architectural decision record with practical consequences.
The testability consequence of the DI mechanism
The DI mechanism determines which things the test suite can substitute and which things require real infrastructure. This shapes the test strategy in ways that become load-bearing — a decision that starts as a DI mechanism choice is also, simultaneously, a test strategy decision, but the two are almost never documented together. As with the testing pyramid choice, the DI mechanism becomes a structural constraint that every test either works within or fights against.
Manual constructor injection produces the most testable structure by default. Every dependency is a constructor parameter, which means every dependency can be substituted by a test that passes a different implementation. There is no container to configure, no annotation to understand, no registration system to trick. The test constructs the class under test with mock dependencies and calls methods on it. This is the mechanism that produces clean unit tests almost automatically — the structure of the code makes substitution natural. The downside is that the composition root, where all the constructors are called, becomes long and tedious. Teams that adopt manual injection often find themselves extracting factory functions or builder patterns to manage the composition root's verbosity, which adds indirection without adding value.
IoC container injection produces testability that depends heavily on how the container is configured for tests. A container that supports swapping registrations for tests allows test code to register mock implementations alongside the production application, which works but requires understanding the container's registration API. A container that does not support per-test registration swap requires test-specific configurations or profiles that duplicate the production configuration and drift over time. Some IoC containers produce test codebases where each test file begins with fifteen lines of container setup code to register the one mock that the specific test needs — this is testability, but it is testability with high friction.
The most consequential DI testability decision is where the injectable boundary is. Some teams inject every class — every utility, every formatter, every helper function becomes a registered service. This produces codebases where the container knows about hundreds of classes, most of which are never substituted in tests or alternative environments. The container's registration becomes a maintenance burden rather than a useful substitution surface. Other teams draw the injectable boundary tightly around the things that are genuinely substitutable — external clients, database repositories, message queue producers — and leave internal utilities as concrete classes. The boundary decision is an architectural decision with test consequences: it determines which classes tests must run against real implementations (because they are not injectable) and which classes tests can substitute (because they are).
The substitution contract — the set of behaviors that must be preserved across all implementations of an injectable interface — is an interface contract decision that belongs in the same record as the DI mechanism decision. When a UserRepository interface is the injectable boundary, the substitution contract specifies what behavior tests can rely on when they pass a mock UserRepository: does the mock need to preserve insertion ordering? Does it need to enforce unique constraints? Does it need to simulate transaction rollback? The tighter the substitution contract, the more confident the tests — and the more work each mock implementation requires. Without a documented substitution contract, test mocks diverge from the real implementation in subtle ways that produce the mock drift bugs that the unit-test-dominant strategy is characteristically vulnerable to.
The startup cost consequence
IoC containers with eager initialization scan the registered class graph and instantiate all services before the application starts handling requests. For a traditional long-lived server process — a web application that runs for days or weeks between restarts — this cost is paid once at deployment and amortized across thousands of requests. The 3-second startup time that a Spring Boot application with 600 beans needs to initialize is invisible to users because the server is already running when their request arrives.
The same startup cost becomes visible in two contexts that most teams add after the initial framework decision: Lambda functions and development iteration speed. Lambda functions run in ephemeral execution environments that are recycled after inactivity or scaled out to handle load spikes. Every new execution environment pays the container initialization cost before it can process its first request — this is the cold start. A Spring Boot application that initializes in 6 seconds has a 6-second cold start; a Lambda function with a 6-second cold start fails most production SLAs for user-facing endpoints and adds perceptible latency to user actions that trigger a Lambda function mid-flow.
Teams that built a web application on a container-based DI framework and then added Lambda functions are often surprised by cold start performance. The framework choice was made in the context of long-lived servers where the amortization assumption was valid. The Lambda context makes the assumption invalid without changing anything about the framework or the application code. The cold start problem is a consequence of the DI mechanism decision — specifically, the decision to use an eager-initializing container — combined with a deployment context that the original decision did not anticipate. Without a decision record that names the startup cost trade-off and a revisitation condition tied to the Lambda use case, the team discovers the consequence after deploying the Lambda rather than before.
The development iteration consequence is less visible but equally real. A developer who runs the application locally to test a change pays the container initialization cost on every restart. A 6-second startup is barely noticeable for production deployments; it becomes perceptible annoyance in a tight edit-restart-test cycle. Teams that notice their development loops are slower than they expect are often experiencing the accumulated container initialization cost rather than a performance problem in the application code itself. Like caching decisions, the startup cost of DI initialization is invisible without a baseline measurement that captures what the cost was at the time the mechanism was chosen and what it has grown to as the application has added more registered services.
Lazy initialization — instantiating services only when they are first requested rather than at startup — is the standard mitigation for container initialization cost. Most IoC containers support it as a configuration option. The team that documents "we use eager initialization because it surfaces missing dependency registrations at startup rather than at first use" has made an explicit choice between the fail-fast-at-startup behavior of eager initialization and the reduced-cold-start behavior of lazy initialization. The team that has never written this down has made the same choice by default, but cannot explain it when a new engineer asks why the Lambda cold starts are 5 seconds.
The cognitive overhead consequence
The cognitive overhead of a DI mechanism is the amount of context a new engineer needs before they can confidently understand what a class actually receives and make a change that affects its dependencies. This cost is proportional to the gap between what the class's constructor signature tells you and what you actually need to know to understand the dependency graph.
Manual constructor injection has the lowest cognitive overhead per class. The constructor signature is the complete list of what the class depends on. Reading the class tells you everything — what its dependencies are, what types they are, and (if the types are interfaces) which contract they must satisfy. The cognitive overhead is at the composition root, where the full dependency graph is assembled, but the composition root is a small surface that changes infrequently and can be read end-to-end when it does change.
IoC containers with annotation-based injection have higher cognitive overhead because the dependency graph is implicit. A class annotated with `@Service` and `@Autowired private UserRepository userRepository` does not tell you which UserRepository implementation will be injected unless you also know the container's scanning configuration. If there is only one UserRepository implementation, the answer is obvious. If there are multiple implementations — a primary database implementation and a cache-backed implementation — understanding which one is injected requires knowing the container's disambiguation rules: which implementation has `@Primary`? Which bean is active in the current profile? Is there a `@Qualifier` annotation that selects the specific implementation? This is not a large amount of information, but it is not in the class itself — a new engineer must go looking for it.
The cognitive overhead compounds as the application grows. A class with five injected dependencies, each potentially having multiple registered implementations, requires understanding five disambiguation decisions before the engineer can confidently answer "what does this class actually do with a real database versus a test database?" In a large application with many beans and multiple environment profiles, this overhead accumulates. Teams that notice engineers taking longer than expected to make "simple" changes often find that the time is spent tracing container resolution rather than reading application logic.
The cognitive overhead decision is not about choosing the mechanism with the lowest overhead — there are legitimate reasons to accept higher overhead in exchange for less verbosity or more powerful features. It is about acknowledging the trade-off at decision time and documenting it so that future engineers understand why the pattern is what it is. A new technical leader who reads the codebase and finds a mix of manual injection in some modules and container-based injection in others, or annotations in some classes and explicit configuration in others, cannot determine whether this is inconsistency or a deliberate architectural partition. The decision record answers that question — and if the answer is inconsistency, the record triggers the conversation about which approach should be standard rather than leaving each engineer to make their own determination.
The scope and lifetime decision
Every dependency has a lifetime: how long a single instance lives and how many callers share it. Most IoC frameworks offer three lifetime options. Singleton means one instance exists for the entire application lifetime — all callers receive the same instance. Scoped (or per-request) means one instance per logical scope — in a web application, one instance per HTTP request; in a Lambda function, one instance per invocation. Transient means a new instance is created for each resolution — each caller that requests the dependency receives a fresh instance.
The lifetime decision for each service category is one of the most consequential undocumented DI decisions in most codebases. The reason is that the wrong lifetime produces correctness bugs that are hard to reproduce in development and appear in production under concurrent load. The most common version is the singleton with request-scoped state.
A service that holds per-request state — the currently authenticated user's ID, the current database transaction context, the per-request trace correlation ID — must not be a singleton. When two concurrent HTTP requests are processed by the same singleton service, they share the singleton's state. Request A sets the current user ID to 123; request B starts processing; request A's database queries now execute in request B's context, or vice versa. This is a data privacy violation and a correctness bug in the same package. It is a race condition that is invisible in single-threaded development but appears immediately under concurrent load in production. The bug is caused by a lifetime mismatch — a request-scoped service instantiated with singleton lifetime — which is a DI configuration decision, not an application logic error.
The lifetime decision must be made as a policy rather than service by service, because the service-by-service approach produces inconsistency that is hard to audit. A team that documents "stateless services (services that hold no instance state and read only from their injected dependencies) use singleton lifetime; services that hold per-request state (authentication context, transaction context, per-request trace) use request-scoped lifetime; services that are expensive to construct and not thread-safe use transient lifetime with a documented reason" has a policy that new engineers can apply and that reviewers can audit. A team without a documented policy relies on each engineer's understanding of the container's lifetime semantics, which varies with experience level and framework familiarity.
The interaction between lifetimes is a specific source of bugs that the policy should address: captive dependencies. When a singleton service holds a reference to a scoped service — the singleton is constructed once and receives the scoped instance at construction time — the scoped service's lifetime is extended to singleton scope. The scoped service designed to be request-specific is now shared across all requests. Most IoC containers provide runtime warnings or errors for this configuration, but only if captive dependency detection is enabled, which requires knowing that the feature exists and choosing to enable it. The decision record that documents the lifetime policy should also document the captive dependency policy: "we enable captive dependency detection in development and test environments; production is fail-safe because all lifetime violations are caught before deployment."
How DI decisions accumulate into irrecoverable commitments
The DI mechanism decision is structurally different from most other architectural decisions in one important respect: it is embedded in every class in the codebase. A database choice affects every query but can be abstracted behind a repository interface. A caching decision affects every cacheable operation but can be added or removed layer by layer. The DI mechanism choice is expressed in the constructor signature or annotation of every class, which means changing it requires touching every class.
A team that has 300 classes wired through Spring's `@Autowired` annotations cannot migrate to manual constructor injection class by class without maintaining two parallel systems during the migration — which produces the same cognitive overhead problem in a worse form. The migration requires either a big-bang rewrite (risky, expensive, time-consuming) or a multi-month incremental migration with all the tracking and coordination costs of a large refactoring project. This is not technically impossible, but it is expensive enough that it rarely happens — which means the initial DI mechanism decision is effectively a long-term commitment. As with any architectural decision that is expensive to reverse, the fact that it is irrecoverable without significant effort makes the initial decision more important to document, not less.
The accumulation of irrecoverable commitment happens in three phases. In phase one, the team is small and the DI pattern is informal — constructor injection in some places, framework conventions in others, a service locator in a module someone added before the team settled on a framework. In phase two, the team grows and new engineers import the patterns they know — more NestJS providers, more Spring annotations — which is the right behavior given no documented standard. In phase three, a new engineer or technical leader reads the codebase and finds a mix that looks like inconsistency but is actually three separate decisions made by three different engineers at three different points in the project's history. The record that would have made this legible was never written. The decision that would have prevented the accumulation was never made as a decision.
The recovery from undocumented DI accumulation is the same as the recovery from any undocumented architectural drift: write the retrospective ADR first, then decide whether to standardize or formalize the current mixed state. The de-standardization migration record — the document that names the old pattern charitably, explains the specific failure modes that triggered re-evaluation, names the new standard, and records the migration status — is often the first useful document that emerges when a team sits down to understand why their DI is what it is. It is the record that prevents the next engineer from importing the old pattern without knowing it is being phased out.
The composition root decision
Every application that uses dependency injection has a composition root — the place where the dependency graph is assembled. In manual constructor injection, the composition root is explicit: a `main` function or startup module that constructs all services and passes them to the server. In container-based DI, the composition root is distributed across registration calls, module decorators, and provider arrays — but it still exists, even if its surface area is larger and its location is less obvious.
The composition root decision is where the DI mechanism choice becomes architectural policy. The team that documents "the composition root for each bounded context lives in that context's module file; cross-context dependencies are registered in the application module; no service registration happens outside a designated module file" has a composition root policy that new engineers can follow when adding a new service. The team that has never written this down leaves each engineer to infer the registration convention from the existing codebase, which works until the conventions diverge.
For platform teams that provide shared infrastructure services — HTTP clients, database connections, message queue producers, observability adapters — the composition root decision determines what downstream teams receive and how they consume it. A platform that provides services through the container's registration mechanism requires downstream teams to understand the container's module system to consume the service. A platform that provides services through explicit factory functions requires downstream teams to call the factory, which is more explicit but less integrated. The platform team's DI composition root decision constrains what product teams can do without the platform's involvement — which is an architectural decision that belongs in the platform team's ADR.
Writing the dependency injection decision record
The Nygard ADR format adapts for dependency injection with four sections that most teams leave entirely undocumented.
The DI mechanism decision. Name the specific mechanism chosen and what was evaluated and rejected. "We use manual constructor injection without a container for all service classes. We evaluated NestJS's built-in IoC container and InversifyJS before adopting this approach. NestJS's container was rejected for two reasons: (1) decorator support requires reflect-metadata, which adds a runtime dependency and a TypeScript compiler configuration that affects all files in the project; (2) the container's lazy resolution makes Lambda cold start costs hard to predict and profile. InversifyJS was rejected because it adds container configuration that is separate from the class definition, producing two files to maintain for each service (the class and its binding). Manual constructor injection produces verbose composition root code, which we accept as the explicit trade-off for a dependency graph that is readable without understanding a container's resolution rules." This is the decision that most teams do not write. Without it, the next engineer who proposes NestJS is starting the evaluation from scratch.
The scope and lifetime policy. Name the lifetime for each service category. "We recognize three lifetime categories: (1) stateless services — services whose behavior is determined entirely by their injected dependencies and their method arguments, holding no instance state; these use singleton lifetime and are constructed once at application startup; (2) request-scoped services — services that hold state meaningful only for a single request (current authenticated user, database transaction, per-request trace context); these use the request context object passed as a method argument rather than held as instance state — we do not use per-request container scoping because our Lambda deployment context makes request-scoped container lifetime ambiguous; (3) expensive-to-construct services — services that must be constructed per use because they are not thread-safe; these are explicitly noted in their class documentation and receive a new instance from a factory function rather than the composition root." The scope and lifetime policy answers the question that produces concurrent-state bugs when left to individual engineer judgment.
The injectable boundary decision. Name what is injectable and what is not. "Injectable boundaries are the interfaces that cross the application/infrastructure layer boundary: database repositories, external HTTP clients, message queue producers, and the clock interface (for testable time-dependent code). All other classes are concrete and are not injectable: formatters, validators, calculation utilities, pure-function helpers. Tests that need to exercise application logic pass mock implementations of the boundary interfaces as constructor arguments. Tests that need to exercise infrastructure layer behavior hit real infrastructure — we do not mock the database layer above the repository interface." The injectable boundary decision determines the shape of the test suite more concretely than any other DI decision.
The revisitation conditions. Name the specific triggers for re-evaluation. "Re-evaluate this decision if: (1) Lambda cold start time (measured as the P99 of the first invocation after a container recycle) exceeds 2 seconds for any function — this is the performance threshold at which eager initialization cost becomes user-visible; (2) the composition root file exceeds 500 lines — this is the verbosity threshold at which manual wiring overhead starts to exceed the cognitive overhead of a container; (3) the team size grows past 20 engineers who regularly touch the service layer — at this scale, the convention enforcement benefit of a container's registration system may outweigh the explicitness benefit of manual injection; (4) a platform team provides services that the team must consume using a framework-specific DI mechanism — at this point, maintaining two DI approaches in the same codebase is likely worse than adopting the platform team's approach uniformly." Revisitation conditions are what transform the decision record from a historical document into a living governance tool — they are the signal that the decision should be reopened before the accumulation becomes irrecoverable.
Finding DI decisions in AI chat
The WhyChose extractor surfaces dependency injection decisions from four session types in AI chat.
The framework evaluation session is where the DI mechanism is first considered. "How does NestJS handle dependency injection?", "What's the difference between a service locator and constructor injection?", "Should I use a DI container for a TypeScript project?", "How do I wire up dependencies without a framework?" These sessions contain the alternatives the engineer considered and the reasoning that led to the choice. They are often the most important DI sessions to recover because the mechanism choice — the decision that all subsequent DI decisions build on — is rarely revisited once the first service class is structured around it. A team that cannot explain why they chose manual injection over NestJS's container has usually lost the evaluation session to AI chat history.
The testability problem session is where the DI mechanism's testability consequences first become visible. "How do I test this service without hitting the real database?", "My tests are running all the Spring application context — how do I speed them up?", "How do I mock a service that's injected through the container?", "Why does my test need so much setup to test one method?" These sessions identify the specific friction the DI mechanism creates for testing and often contain the engineer's first explicit evaluation of whether the DI mechanism's testability trade-off is acceptable. The fix applied in these sessions — a test profile, a mock registration convention, a change to how the injectable boundary is drawn — is often the policy decision that should have been documented at DI mechanism selection time.
The startup time session is where the container initialization cost first appears as a problem. "My Lambda cold start is 8 seconds — how do I speed it up?", "Why does my application take 12 seconds to start in development?", "How do I configure Spring to use lazy initialization for Lambda?", "Should I switch from NestJS to a lighter framework for my Lambda functions?" These sessions contain the first explicit evaluation of the startup cost consequence of the DI mechanism choice — often happening months or years after the framework was adopted, when the Lambda use case was added or when the application's service count grew past the point where eager initialization became noticeable. The analysis in these sessions — which beans are slowest to initialize, whether lazy initialization mitigates the problem, whether a framework migration is worth the cost — is the data that should inform the DI decision record's revisitation condition.
The "what does this class actually do" session is where the cognitive overhead of container-based injection becomes visible in a new engineer's onboarding. "How do I find which implementation gets injected for this interface?", "There are two UserRepository beans — how does Spring choose?", "What's the difference between @Primary and @Qualifier?", "How do I add a new service to the NestJS dependency graph?" These sessions reveal the resolution rules the container applies and the configuration the engineer must understand before they can make a change. They are the most useful sessions for auditing whether the cognitive overhead of the chosen DI mechanism is within the acceptable range the team implicitly assumed when they adopted it. Early-stage teams adopting a framework for speed often discover later that the cognitive overhead of the framework's DI system is a meaningful factor in how quickly new engineers contribute — a trade-off worth documenting before onboarding the fifth engineer rather than after.
What the DI decision record prevents
A documented dependency injection decision prevents three recurring problems that teams without a DI ADR encounter as they grow.
It prevents the framework proposal cycle. Without a DI decision record, every engineer who arrives from a Spring Boot background and sees manual constructor injection will, at some point, propose "why don't we use a container?" The proposal is reasonable on its merits — containers have real benefits — and evaluating it requires the team to reconstruct the reasoning that led to the original choice, often without the people who made it. The decision record makes the original reasoning available, either confirming that the proposal is worth a revisitation or identifying which revisitation condition the proposal satisfies. The forcing function that writing an ADR creates works in the opposite direction too: the record that documents the DI mechanism forces any future proposal to engage with the specific reasons the current approach was chosen rather than starting the evaluation from neutral ground.
It prevents the lifetime mismatch bug class. A team that documents the lifetime policy — specifically the rule about request-scoped state and the prohibition on holding per-request state in singleton services — gives every engineer a policy to apply when they write a service that touches per-request context. Without the policy, the engineer applies their own judgment about what is safe to hold in a singleton, and different engineers make different judgments, producing a codebase where most services are correctly scoped but some are not, and the incorrectly scoped ones produce intermittent bugs under concurrent load that are difficult to attribute to their cause.
It prevents the injectable boundary sprawl. Teams without a documented injectable boundary policy tend to make everything injectable by default — "more injectable means more testable" is a plausible heuristic. The consequence is a container that knows about hundreds of concrete classes that are never actually substituted, a registration system that is expensive to maintain, and a test suite that requires elaborate container setup to run tests against classes that could be tested with direct construction. The injectable boundary decision, documented, contains this sprawl by naming the criterion for injectability (does this class cross a layer boundary? will it ever need to be substituted in a test or alternative environment?) and making the criterion stable across engineers and features.
Further reading
- Decisions that never get written down — the DI mechanism choice is a canonical example of the implicit-decision problem: it is made feature by feature through accumulated convention rather than through a deliberate policy, and it becomes irrecoverable before the team recognizes it as a decision that needed documentation
- The test strategy decision record — the DI mechanism and the test strategy are deeply coupled decisions: the mock boundary in the test strategy corresponds directly to the injectable boundary in the DI decision; the unit-test-dominant strategy assumes a DI mechanism that makes dependencies easily substitutable; the integration-test-dominant strategy assumes a DI mechanism that allows real infrastructure to be wired in without restructuring the class
- Interface contracts and component ADRs — the injectable boundary is a set of interface contracts; the substitution contract for each injectable interface (what behavior must be preserved across all implementations) determines what the test suite can guarantee and what it cannot; without a documented substitution contract, test mocks diverge from real implementations in the subtle ways that produce mock drift bugs
- The rejected pattern — the DI pattern that was evaluated and rejected deserves its own record: the team that evaluated Spring Boot and chose manual injection has a rejected-pattern decision that answers every future Spring Boot proposal; without it, the proposal cycle restarts from neutral ground each time
- ADRs for platform teams — platform teams that provide shared services through a specific DI mechanism constrain what product teams can consume and how; the platform team's DI composition root decision is a blast-radius decision that affects every downstream team's DI architecture
- The new-CTO onboarding problem — a new technical leader who reads a codebase and finds a mix of manual injection, container-based injection, and service locator patterns cannot determine whether this is architectural inconsistency or a deliberate partition without a decision record; the record converts the variation from apparent inconsistency into a visible history of decisions made at different points
- The performance optimization decision record — container initialization cost is a performance characteristic of the DI mechanism that becomes visible when the deployment context changes; the baseline measurement at adoption time and the revisitation condition tied to startup time thresholds are the same pattern as any performance optimization decision
- ADR lifecycle: superseding and deprecating decisions — the DI mechanism decision is among the most expensive to supersede because it is embedded in every class in the codebase; documenting the mechanism choice with explicit revisitation conditions is what makes the supersession decision deliberate rather than forced by accumulated technical debt
- The ADR as a forcing function — writing the DI decision record before adopting a mechanism forces the team to name the injectable boundary (which forces a decision about testability strategy), the lifetime policy (which forces a decision about concurrent state safety), and the revisitation conditions (which forces a decision about what would make the current approach insufficient) — all decisions that produce better architecture when made explicitly rather than accumulated implicitly
- The startup founder's ADR starter pack — the DI mechanism choice is one of the first-year architectural decisions whose absence is most expensive later; a 3-person team that adopts NestJS's container because the tutorial uses it is making a DI mechanism decision that will constrain their 20-person team's testability and onboarding cost
- Nygard ADR template — the standard format applies to DI decisions with four underspecified sections: the mechanism decision (what was evaluated and why the chosen approach won), the lifetime policy (which lifetime each service category uses and why), the injectable boundary (where substitutable interfaces end and concrete classes begin), and the composition root policy (where registrations live and who maintains them)
- How to document architecture decisions — DI decisions are among the least visible architectural decisions precisely because they are expressed as code structure rather than as explicit configuration; the standard ADR guidance applies, but the DI record requires naming specific mechanism trade-offs (testability, startup cost, cognitive overhead) that general guidance does not cover
- WhyChose extractor — dependency injection decisions appear in AI chat in four session types: framework evaluation sessions where the mechanism is first considered; testability problem sessions where the mechanism's test consequences first become visible; startup time sessions where the container initialization cost first appears as a problem; and onboarding sessions where a new engineer's questions about container resolution reveal the cognitive overhead the mechanism creates for anyone who didn't write the original code