SOLIDGENERALCONTESTEDPARADIGM-SPECIFIC

Dependency Inversion, Critically

The idea is which module declares the interface. The cargo cult is a container, an interface per class, and the same dependency graph as before.

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.

The question

We installed a DI container and registered everything. Have we inverted any dependencies?

The requirement

An architecture review says the codebase "does not follow DIP". The team points at their container configuration, which registers two hundred and forty types. The reviewer points at the domain package, which imports the Stripe SDK, the AWS SDK and the ORM.

The obvious build

Register everything in the container, inject by interface everywhere, and DIP is satisfied. The reasoning is coherent and extremely widespread: the principle says depend on abstractions, the container resolves abstractions to implementations, therefore the container implements the principle. Every step of that is plausible and the conclusion is false.

Why it breaks

The container has no opinion about which package an interface lives in. If domain/PricingRules imports infrastructure/IPaymentGateway, the arrow points from policy to infrastructure and a container cannot change that (Dependency Direction).

How it breaks as requirements change
  • The container has no opinion about which package an interface lives in. If domain/PricingRules imports infrastructure/IPaymentGateway, the arrow points from policy to infrastructure and a container cannot change that (Dependency Direction).
  • Two hundred and forty registrations with fifteen real variations means two hundred and twenty-five interfaces that exist to satisfy a rule, each costing a file, a name and an indirection (How SOLID Gets Misused).
  • Graph errors moved from the compiler to startup, or to the first request that resolves a rarely-used branch — a strictly worse failure mode, adopted in the belief that it was a design improvement (Service Locator).
  • Because the ceremony is present, the actual problem is invisible: the team cannot tell that their domain is untestable, because by every ritual measure they are doing dependency inversion correctly.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The container is load-bearing: removing it is a multi-month change nobody will fund, so any advice that starts with "remove the container" is not advice.
  • Two hundred and forty registrations, of which the team estimates fewer than fifteen have more than one implementation.
  • The domain package cannot currently be compiled or tested without the vendor SDKs present, which is the concrete pain.
  • The team has genuinely read the principle and genuinely believes they are following it, which means the fix is a change of understanding before it is a change of code.
Invariants
  • The business rules must be executable with no network, no credentials and no vendor SDK. Whatever else changes, this is the property being bought.
  • Any claim that a dependency has been inverted must be checkable by looking at import statements, not by looking at container configuration.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The policy package owns declaring the interfaces it needs, in its own vocabulary. That single decision is what the principle is about.
  • The infrastructure package owns implementing them and owns every vendor detail.
  • The container, if there is one, owns wiring only. It is a convenience for construction and has no architectural authority (Wiring and the Composition Root).
  • Nobody owns an interface that exists because a rule said classes should have interfaces.
Boundaries
  • The boundary is checkable and takes a minute: delete the infrastructure package and try to compile the domain. If it compiles, the dependency is inverted; if it does not, nothing has been inverted whatever the container says.
  • A second, sharper check: does any file under domain/ contain an import from a vendor package or a framework? That grep is the review the architecture meeting should have run.
  • The interfaces worth having are the ones around volatile dependencies. That is a short list — the database, the payment provider, the clock, the object store, the mail transport — and it is discoverable in an afternoon (Volatile Dependencies).

The ceremony and the property are different things

Both versions below use constructor injection, both type their dependencies as interfaces, and one of them is registered in a container while the other is three lines in main. Only one has inverted anything, and the difference is a single import line.

This is why the review disagreement in the requirement happened. The team was looking at the injection, which was impeccable, and the reviewer was looking at the import graph, which was upside down. They were both right about what they were looking at.

Two codebases that both "use dependency injection"
Container-configured, not inverted
// domain/PricingRules.ts
import { IPaymentGateway } from '../infrastructure/IPaymentGateway'
import type { Stripe } from 'stripe'          // <-- in the domain

@injectable()
export class PricingRules {
  constructor(@inject('IPaymentGateway') private gw: IPaymentGateway) {}
}

// container.ts — 240 registrations, ~15 with a real second impl
container.bind<IPaymentGateway>('IPaymentGateway').to(StripeGateway)

// Delete infrastructure/ and the domain does not compile.
// Uninstall the Stripe package and the domain does not compile.
// Nothing has been inverted.
Inverted, container optional
// domain/PaymentGateway.ts   — declared by policy, in policy's words
export interface PaymentGateway {
  capture(amount: Money, ref: OrderRef): Promise<Settlement>
}

// domain/PricingRules.ts     — imports nothing outside domain/
import type { PaymentGateway } from './PaymentGateway'
export class PricingRules {
  constructor(private gw: PaymentGateway) {}
}

// infrastructure/StripeGateway.ts
import type { PaymentGateway } from '../domain/PaymentGateway'  // <-- the arrow

// Delete infrastructure/ and the domain still compiles and tests.
// Whether a container wires it is now irrelevant.

The load-bearing difference is which package declares the interface, and it is visible as one import line. In the right-hand version the domain has no knowledge that Stripe exists, so it compiles, tests and runs with the vendor package uninstalled — which is the property being bought, and it is worth having whether or not a second gateway ever exists. The decorators, the container and the registration count are orthogonal: the left-hand version has more of all three and none of the property.

What a container does and does not give you

It is worth being fair to containers, because the argument here is not that they are bad. They solve a real problem — threading a large object graph by hand — and at sufficient scale they solve it well. The claim is only that the problem they solve is not the one DIP is about.

Read the right-hand column and the review disagreement dissolves. Everything the team pointed at is in the left column; everything the reviewer cared about is in the right one, and no amount of the former produces the latter.

  • The right-hand column is checkable in about a minute with a grep and a build, and it is almost never checked (Dependency Direction).
  • A codebase with no container at all can satisfy every cell in the right-hand column (Wiring and the Composition Root).
  • If you have a container, keep it and fix the direction separately. They are independent decisions and treating them as one is how this went wrong originally.
A DI container gives youOnly your import graph gives you
ConstructionAutomatic resolution of constructor arguments across a deep graph
LifetimesSingleton, per-request and transient scoping as configuration
DirectionNothing. It never sees which package an interface is declared inWhether policy can compile without infrastructure
IsolationNothing. Registering a type does not keep its SDK off the domain classpathWhether the domain's tests run with vendor packages uninstalled
VocabularyNothing. It will happily resolve an interface named after a vendorWhether the port speaks the policy's language or the provider's
Failure timingMoves graph errors from compile time to startup or first resolutionCompile-time proof, for free, under manual wiring
CostA framework dependency, a scoping model to learn, a new class of runtime errorA file move and an architecture test

The five-part reading

Stated in the same shape as the others. DIP has the highest ratio in this module of teams who believe they follow it to teams who do, and the gap is almost entirely explained by mistaking injection ceremony for direction.

The scored comparison below is deliberately uncomfortable: the option most codebases have adopted scores worst on almost everything, and the caveat matters more than the numbers.

  • Problem it addresses — stable business policy that imports volatile detail cannot be compiled, tested or changed without that detail, so the most frequently changed code in the system is the hardest to exercise.
  • Useful example — moving PaymentGateway into the domain package so the rules compile and test with the Stripe SDK uninstalled, and a vendor upgrade cannot alter a price.
  • Misuse — installing a container, registering everything, generating an interface per class, and believing the graph has changed when it has not (How SOLID Gets Misused).
  • Trade-off — a domain package that now carries port declarations, an architecture test that occasionally blocks an expedient change, and a frozen vocabulary that costs an N-implementation change when it turns out wrong.
  • Counterexample — a small service whose "domain" is three hundred lines of transaction script over one database it will never leave, with a fast local Postgres in every developer's environment. Direct calls to the ORM are simpler, the tests are fast enough, and every port would be pure ceremony (Transaction Script, When Design Does Not Pay).
Four positions on this question, scored honestly
OptionSimplicityFlexibilityTestabilityOperationalMigration costNote
Domain calls vendor SDKs directlySimplest to read and write; nothing to wire. Domain tests need credentials, and a vendor upgrade can change a business outcome. Correct for a short-lived service with a fast local environment (When Design Does Not Pay).
Ports declared by the domain, wired by handThe property this lesson argues for, with no framework. Domain compiles and tests alone; the wiring is one boring file. Costs a port declaration per volatile dependency (Wiring and the Composition Root).
Ports declared by the domain, wired by a containerSame architectural property; the container is a scale convenience for the graph. Trades the compiler's proof for a startup test you must remember to write.
Container plus an interface per class, ports in infrastructureThe common state of the codebases this lesson is about. Maximum ceremony, no inversion, hundreds of files whose only justification is a rule, and errors that surface at runtime.

caveat These are relative positions on a specific codebase's concerns, not measurements, and the second and third rows swap places entirely on graph size — hand-wiring eight hundred objects is not simpler than a container by any reading. Nothing in the numbers captures the two variables that actually decide it: whether a fast local integration environment exists, which can make the top row genuinely correct, and whether the team can tell the difference between the last two rows at all, which is the real subject of this lesson.

How to build it

Most important first.

  • Move the interfaces, not the implementations. Relocating PaymentGateway from infrastructure/ to domain/ is often a one-line change per file and flips the arrow immediately.
  • Shape each interface in the policy's vocabulary. An interface that survives the move but still speaks the vendor's language has moved the coupling into a different folder (Leaky Abstractions).
  • Delete the interfaces that never had a second implementation and never kept anything off the classpath. Most of the two hundred and forty are in this category, and deleting them is the highest-value change available (Speculative Generality).
  • Keep the container if it is load-bearing; it is orthogonal. Add a startup test that resolves the whole graph, since the container removed the compiler's proof (Wiring and the Composition Root).
  • Add an architecture test that fails the build if domain/ imports a vendor package, so the boundary you just created cannot decay silently.

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.

Cost of the next change
  • Before: any change to the business rules requires the vendor SDKs installed and credentials available, so rule tests are integration tests. That cost is paid on every rule change, and rule changes are the most frequent kind in this system.
  • After: rule changes are one file plus fast offline tests. Vendor upgrades are contained in infrastructure/ and cannot alter a rule.
  • Deleting the two hundred and twenty-five pointless interfaces makes every additive change cheaper by removing one edit per change — the interface and the implementation no longer have to be updated in lockstep.
  • What stays expensive: a change that genuinely spans the boundary, such as adding a field that the domain computes and the vendor needs, still touches the interface, the implementation and the mapping. Inversion never made that cheap and does not claim to.
What the recommended approach costs
  • Moving interfaces into the policy package makes that package larger and gives it a surface it did not have. Some teams find a domain package full of port declarations harder to read than one with none.
  • Deleting speculative interfaces is a large, low-visibility change with no feature attached, and it will lose to almost any roadmap item.
  • An architecture test with no exclusions occasionally blocks a legitimate expedient change at an inconvenient moment, and teams sometimes weaken it exactly then.

What can go wrong

Failure modes
  • The interfaces move but the vocabulary does not, so domain/ now declares IStripeGateway with methods named after Stripe's API. The import graph is clean and the coupling is intact.
  • The team deletes the container instead of fixing the direction, spends a quarter on it, and ends with the same domain package importing the same SDKs.
  • The architecture test is added with an exclusion list "for now", and the list grows. This is the mitigation failing, and it fails quietly because every individual exclusion is reasonable.
  • Interfaces are deleted too enthusiastically, including two that were keeping a vendor SDK off the domain classpath, and the boundary regresses in exchange for tidiness.
Dependencies, and their direction
  • After the move, domain/ depends on nothing outside itself and its own language runtime. That is the property, and it is verifiable.
  • infrastructure/ depends on domain/ for the interfaces and vocabulary, plus every vendor SDK. Both of those arrows point away from the stable core.
  • The container depends on everything and nothing depends on it, which is the same position the composition root occupies and is fine.
Misreads
  • "We use a DI container, so we follow DIP." The single most common misunderstanding in this area. A container resolves constructor arguments; it has no view on which package declares an interface, which is the entire content of the principle (Dependency Injection).
  • "Depend on abstractions, not concretions — so nothing may depend on a concrete class." Depending on a stable, pure, non-volatile concrete type is correct and universal. The principle is about volatile detail (Volatile Dependencies).
  • "So every class needs an interface." This is the forbidden version and it is where two hundred and twenty-five of the two hundred and forty registrations came from. The candidates are the volatile dependencies, and there are usually fewer than twenty (How SOLID Gets Misused).
  • "Inversion means the domain is at the centre of a diagram." Diagrams are not checkable. Import statements are. Every architecture picture in this space has been drawn over a codebase that did the opposite (Clean Architecture, and Where It Is Overused).
Smells this explains
  • speculative-generality

Testing it, and how it ages

What to test, and at which boundary
  • The diagnostic is whether the domain's tests run with the vendor packages uninstalled. Nothing else settles the question (Testing as Design Feedback).
  • An architecture test on the import graph, run in CI, with no exclusion list (Dependency Direction).
  • A contract suite for each real port, so the second implementation cannot weaken a promise the domain depends on (Contract Tests, Liskov Substitution, Critically).
  • A startup test that resolves the full container graph, because the container traded a compile-time proof for a runtime failure and the proof has to be bought back somewhere (Wiring and the Composition Root).
How this design ages
  • Once the domain compiles alone, it tends to stay that way, because the architecture test makes regression a build failure rather than a review oversight.
  • The port list stays short and changes slowly. If it is growing steadily, interfaces are being added for reasons other than volatility and it is worth asking why.
  • The container question resurfaces every few years as fashions change. It is worth remembering that it was never the architectural decision, in either direction (What a Framework Charges).

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.

  • GENERALThe claim that the declaring side of an interface determines the direction of the source dependency is a fact about how compilation units reference each other, and holds in any language with modules or separate compilation.
  • CONTESTEDThe strongest opposing case, and it is stronger than the cargo-cult framing suggests: inverting a dependency is only valuable if the isolation is exercised, and in most business systems the database is never swapped, the payment provider is never swapped, and the domain package could have imported them directly for a decade with no cost. On this view the honest justification for DIP shrinks to testability, and testability is often obtainable more cheaply — by module-level substitution, link-time seams, or a local integration environment that is fast enough not to matter. Critics also observe that a domain package full of port declarations is itself a form of framework, invented in-house and undocumented. The rebuttal is narrow and specific: the testability benefit is collected on every rule change rather than on a hypothetical swap, and a domain that cannot be compiled without credentials is a compounding tax. Where a fast local integration environment genuinely exists, the critique largely holds.
  • PARADIGM-SPECIFICIn a functional codebase the same inversion is a function parameter — price(order, fetchRates) — with no interface, no adapter class and no package relocation, and the whole cargo-cult failure mode is structurally unavailable because there is nothing to register. In Haskell or Scala it appears as a type-class constraint or an effect type. The concern transfers completely; the ceremony, the container and the folder layout do not, and teams moving between paradigms tend to carry the ceremony rather than the concern.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — module systems, link-time substitution and conditional compilation give some languages the isolation benefit without any port at all, which changes the cost-benefit of this principle substantially.
  • Testing & Reliability Engineering — "can the domain be tested with the vendor SDK uninstalled" is the only check that distinguishes real inversion from ceremony, and turning it into a CI gate is theirs.