100%

Lesson 3 of 3

Dependency Inversion: The Architectural One

Four of the five principles improve a class. This one changes the shape of a system, decides what can be tested without infrastructure, and determines what can later be extracted.

12 min read

Of the five principles, dependency inversion is the one that reaches architecture in the Chapter 1 sense: reversing it later is expensive, because everything downstream inherits it. The other four make a module pleasant to change. This one decides whether your business rules can be tested without infrastructure, and whether a module can ever be extracted into its own service.

That last sentence is the whole idea and is worth reading twice. At runtime the domain calls into the database adapter. At compile time the adapter depends on the domain's interface, and the domain depends on nothing. This is what lets you compile, test and reason about business rules with no database in existence — and it is what this platform enforces mechanically: the domain layer of every feature slice is forbidden by a build check from importing the database client, the UI framework, or another slice.

Call direction versus dependency direction

Inversion means the arrow of compilation points against the arrow of execution. Only the policy column owns the interface.

At runtime a payout service calls a Postgres repository, which calls the database. At compile time, the Postgres repository imports the PayoutLedger interface that the domain declares, and the domain imports nothing from infrastructure. The interface sits in the domain.

Policy (owns the interface)

PayoutService

business rules

PayoutLedger

interface declared here

Detail (implements it)

PostgresPayoutLedger

imports the interface

InMemoryPayoutLedger

used by domain tests

Infrastructure

PostgreSQL

Nothing (tests)

The interface lives with the policy that needs it, and names a business need rather than a storage mechanism.typescript
// features/payouts/domain/ports.ts — no imports from infrastructure.
export interface PayoutLedger {
  /** Business language: "record that we owe this seller". */
  recordObligation(sellerId: SellerId, amount: Money): Promise<void>;
  outstandingFor(sellerId: SellerId): Promise<Money>;
}

// features/payouts/domain/payout-service.ts — pure, testable with no database.
export class PayoutService {
  constructor(
    private readonly ledger: PayoutLedger,
    private readonly provider: PayoutSender,
  ) {}

  async settle(sellerId: SellerId, minimum: Money): Promise<SettlementOutcome> {
    const owed = await this.ledger.outstandingFor(sellerId);
    if (isLessThan(owed, minimum)) return { settled: false, reason: 'below_minimum' };
    const receipt = await this.provider.send({ sellerId, amount: owed });
    return { settled: true, receipt };
  }
}

// features/payouts/infrastructure/prisma-payout-ledger.ts — depends inward.
export class PrismaPayoutLedger implements PayoutLedger {
  /* the ORM is imported only here */
}

What inversion buys, and what it charges

EffectWhy it happensBuys or costs
Domain tests run in milliseconds with no containersThe policy has no compile-time path to a database or networkBuys
A module can be extracted into a service laterIts dependencies are already explicit and fewBuys
Vendor changes stay in one directoryOnly the adapter imports the SDKBuys
Business language is forced into the openYou must name the operation the policy needsBuys
Extra indirection when reading codeReaching the implementation takes one more hopCosts
Wiring must happen somewhere explicitSomething has to choose implementations at start-upCosts
Tempting to invert dependencies that never varyThe pattern is easy to over-applyCosts
Inversion is not free. Four things it reliably purchases, three it reliably costs.

Applying inversion to one existing dependency

  1. 1

    Find the calls the policy actually makes

    List every method of the infrastructure object that the business code calls, and only those. If the policy calls four of the SDK's sixty methods, your interface has at most four methods.

  2. 2

    Rename them into the business vocabulary

    `db.payout.findMany({ where: { status: "PENDING" } })` becomes `ledger.pendingPayouts()`. Most of the value is in this step: it forces you to say what the operation means rather than how it is fetched.

  3. 3

    Move the interface next to the policy

    The file lives in the domain directory. The domain imports nothing from infrastructure; the adapter imports the interface. If your build cannot express this, an import-boundary check in CI can.

  4. 4

    Write the fake before the real adapter

    An in-memory implementation is the fastest proof that the interface is expressed in policy terms. If the fake is awkward to write, the interface is still leaking storage concepts.

  5. 5

    Wire both up in one composition place

    A single module constructs the real graph, and a test helper constructs the fake one. Keeping construction in one place is what stops the abstraction being quietly bypassed.

In practice

The inversions that paid for themselves, and the ones that did not

A logistics team applied dependency inversion consistently across a monolith: every external system sat behind a domain-owned port. Two years later they reviewed which ports had earned their keep.

Constraints

  • Eleven ports in total
  • Two years of history to review
  • One module extraction to a separate service had happened
  • A carrier integration had been replaced once

Decision

Keep the ports around carriers, payments, notifications and the ledger. Delete four — the logging client, the feature-flag SDK, a clock wrapper used in two places, and the templating library — and call those libraries directly.

Why

The kept ports had each been exercised: the carrier port absorbed a provider replacement in three weeks instead of three months, and the ledger port made the shipment module extractable without touching business logic. The deleted ports had never had a second implementation, were never faked in a meaningful test, and each cost a hop of indirection every time somebody read the code.

What it cost

Deleting the clock port made two tests harder — they needed an injected time source anyway, so one of the four returned a year later in a different shape. The lesson the team recorded was not "invert less" but "invert where you can name the second implementation or the test you need, and review the rest annually".

Being asked "should this go behind an interface?"

As a developer

Answers from habit: yes, because interfaces are good practice and make code testable — or no, because YAGNI and it adds a file. Both answers are about the pattern rather than about this dependency.

As an architect

Answers with three questions. Can you name a plausible second implementation, including a test double you actually need? Does the business logic become untestable without it? Would a future extraction of this module be blocked by the direct dependency? Two yeses justify the port; zero means call the library directly and revisit if that changes.

Your team puts every repository behind an interface, but each interface mirrors the ORM model and its methods are named `findMany`, `create` and `update`. Have you achieved dependency inversion?Reveal

No — you have achieved indirection. The domain still depends on a relational, ORM-shaped view of the world; you have simply spelled it through an extra file. The test is what happens if the data moves to an event stream or an external API: with a genuinely inverted interface such as `outstandingFor(sellerId)` you write one new adapter, whereas with a mirrored one every caller must change, because callers were composing query filters. The value of DIP comes from the vocabulary of the interface, not from the existence of an interface.

Key takeaways

  • DIP inverts compile-time dependency against runtime call direction: the policy owns the interface, the detail implements it.
  • It is the architectural member of SOLID because it decides testability and future extractability, which are expensive to retrofit.
  • An interface phrased in ORM or SDK vocabulary is indirection, not inversion.
  • Justify each port by naming its second implementation or the test it enables, and delete the ones that never acquire either.
  • Boundaries that matter should fail the build, not merely appear on a review checklist.