Construction workers at site with upside-down Burj Khalifa structure

One pattern to fix it all: dependency inversion at every scale

Doomed from the start

Our industry is rightly suspicious of silver bullets — we’ve buried enough of them. But after two decades of building distributed systems, there’s one thing I keep coming back to: almost everything we work so hard to get right — clean modules, independent deployments, integration that scales, teams that can actually move — sits downstream of a single decision. Which way do the dependencies point? Get that wrong, and none of the rest is worth getting right, because it’s doomed from the start.

Anyone selling a cure-all is a quack, but there’s one concept that fixes countless problems, from very small code issues to making sure the tallest buildings in the world actually get built.

Judge for yourself; is dependency inversion the most important pattern ever?

The pattern, precisely

Before making that case, let’s be precise, because dependency inversion is the most misquoted letter in SOLID. It is not “use interfaces”. It is emphatically not “use a DI container” — dependency injection is one way to deliver the principle, not the principle itself, and you can satisfy it without a framework in sight.

The principle is about which way the arrow points. High-level policy should not depend on low-level detail; both should depend on an abstraction; and — the part everyone forgets — the abstraction belongs with the consumer, and should change more slowly than either side. I’ve written before about keeping dependency arrows pointed at the least volatile packages; this is the same idea, about to have a much bigger day out.

// The problem: high-level policy welded to low-level detail.
// Reporting references Persistence — the arrow points the wrong way,
// and every schema tweak in SqlOrderStore ripples into ReportGenerator.
using Persistence;
namespace Reporting;
public class ReportGenerator
{
private readonly SqlOrderStore _orders = new();
public Report Monthly() => Report.Build(_orders.FetchOrders());
}
// The fix: the abstraction belongs to the consumer.
// IOrderSource lives in the Reporting package, alongside its consumer.
// (Order and Report live here too — the consumer owns its model.)
namespace Reporting;
public interface IOrderSource
{
IReadOnlyList<Order> FetchOrders();
}
public class ReportGenerator(IOrderSource orders)
{
public Report Monthly() => Report.Build(orders.FetchOrders());
}
// A different package entirely. Persistence now references
// Reporting — the arrow flipped.
using Reporting;
namespace Persistence;
public class SqlOrderStore : IOrderSource
{
public IReadOnlyList<Order> FetchOrders() => /* SQL elided */ [];
}

Notice where IOrderSource lives: in the Reporting package, next to the thing that consumes it. That’s what makes this inversion rather than mere indirection — the detail now depends on the policy. Hold onto that sentence. It’s the mechanism for every scale that follows.

Dependency inversion isn’t “use interfaces”. It’s choosing which way the arrow points — and pointing it at whatever changes slowest.

Modules that couldn’t move

Start at the smallest scale, with a situation everyone has met. What we want from modularity is replaceability, parallel work, and honest boundaries. What we usually get is a codebase where changing one module lights up the whole build like a switchboard — so, human nature being what it is, nothing gets changed. That’s not a discipline problem or a tooling problem. While the modules depend on each other’s internals, modularity was doomed from the start.

Flip the arrows — modules depend on contracts owned by their consumers — and a module can be rewritten wholesale behind a stable interface without anyone else noticing. The same move works at platform scale: put the abstraction at the platform boundary and you can swap the database, upgrade the runtime, or change vendor without the applications above ever finding out.

A module you can’t change in isolation isn’t a module. It’s a region of a bigger module, with delusions.

Services in lockstep

Now scale up. What everyone wants is dozens of applications integrating simultaneously while each deploys on any Tuesday it likes. What most estates have instead: point-to-point integrations, deploy trains, and version-negotiation meetings where service A can’t release until B, C, and D are ready to catch it. Independence was the goal, and with the arrows pointing at each other, it was doomed from the start.

I’ve lived this one. A client of mine used an external third party to build the user interface for their microservice-based platform. By the time I joined the team, the damage was already done: too much legacy work in place to have the third party start building against imposters — stand-in services that honour the contract — so the UI and the backend were in lock-step. Every piece of front-end work queued up behind the backend work it depended on. No feature toggling, just hard dependency. And it bit us repeatedly over the years, because the business thrashed on requirements — deliverables changing days before go-live — turning each release into a massive, error-prone effort just to make sure what was deployed in the UI matched what was in the backend. That’s the other lesson of the arrows: they’re cheapest to point correctly on day one, and every sprint after that raises the price of the flip.

Same fix, bigger blast radius. Services stop depending on each other and start depending on message contracts, with a bus underneath doing nothing but transport. Publisher and consumers all reference the contract; none of them reference each other.

Two details decide whether this works or quietly rebuilds the problem. First, ownership: a contract is owned by the bounded context that declares it — and the messaging style decides which context that is. Publish/subscribe means the publisher declares the event and every consumer depends on it. A command means the receiver declares it and senders depend on it. A plain HTTP call means the API owner declares the request shape and callers depend on it — and on more than the shape: an HTTP caller also depends on the service already being deployed, running, and reachable at the moment of the call. That’s a temporal coupling the message-based styles don’t carry. Choosing how a message travels is choosing which way the arrow points — an architectural decision wearing a plumbing costume. If instead your contracts live in one big shared package owned by “the domain”, you’ve built a single hard dependency and given it a friendly name.

Second, evolution: contracts change by versioning — V2 published alongside V1, consumers migrating in their own time — never by breaking change. That discipline is what makes the contract the slowest-changing thing in the system, rather than merely a promise.

// A contract is owned by the bounded context that declares it. This
// event belongs to Sales, in its published contracts package — and it
// evolves by versioning: a breaking change ships as V2 alongside V1,
// it never edits V1.
namespace Sales.Contracts.V1;
public record OrderPlaced(
Guid OrderId,
string CustomerRef,
decimal Total,
DateTimeOffset PlacedAt);

Twelve applications integrate with each other, and none of them has ever heard of the other eleven.

Teams, and the processes that span them

Out of the codebase entirely now, because the arrows don’t stop at code. What every organisation wants is teams that advance their own applications without impacting each other, and cross-application business processes that don’t require everyone to move at once. What most get: team A’s roadmap hostage to team B’s, and every cross-application process a synchronised release across five teams — everyone shipping at once, nobody shipping well. Team autonomy was the goal, and while the teams depend on each other rather than on contracts, it was doomed from the start.

The flip looks like this: the teams agree the contracts together, then depend on the contracts instead of each other. The business defines the end-to-end process once, as a chain of events, and each team implements its step on its own roadmap.

Order-to-cash, as agreed with the business:
OrderPlaced (owned by Sales)
→ CreditApproved (owned by Finance)
→ StockReserved (owned by Warehouse)
→ OrderShipped (owned by Logistics)
Each team implements its step against the contracts, on its own roadmap.
No team's release plan appears in this diagram. That's the point.

What’s agreed up front is a starting point — a v1 — not a treaty. If every change had to go back to the same all-hands meeting, the agreement itself would become the hard dependency, just wearing a lanyard. From v1 onwards, each bounded context re-versions its own contracts in its own time, and consumers migrate when they’re ready.

A word of warning for the enterprise architects: “shared capabilities” and “reusing existing platforms” are sound principles — right up until they get confused with shared data-models and god services. A shared capability is a bounded context that owns its function and exposes it through its own versioned contracts. The moment “reuse” comes to mean one data model spanning contexts, or one service everyone calls for everything, the arrow points at something that changes as fast as everyone’s combined requirements — the single hard dependency again, this time with an architecture review board defending it.

And none of this is an argument for teams retreating behind fences. The contract emerges from people talking to people; the inversion decouples ship dates, not colleagues.

Contracts don’t replace conversations. They’re what a good conversation leaves behind.

Even the ESB

And then there’s the enterprise service bus — the industry joke being that one stays useful for about eighteen months. The decline always follows the same path: orchestration logic, routing rules, transformations, and a “canonical data model” accreting in the bus until every application in the estate depends on the bus’s internals. At that point the bus is the hardest-to-change component the business owns. Nothing should ever depend on the ESB — and everything does. Eighteen months isn’t bad luck. It’s roughly how long it takes an unchangeable component to become everyone’s blocker, and the investment was doomed from the start — not because buses are bad, but because nobody inverted the dependency on the bus.

I’ve felt that ball and chain personally. At a financial institution I worked for, anything touching integration went through the integration team — and when we needed an authentication service, the quote came back at three months and over $400,000 in cross-charges. I ended up building it myself in six weeks, for considerably less — complete with CI/CD delivery pipelines, infrastructure as code, and automated tests. (I wrote up the automation side of that work at the time: Automation with ForgeRock AM 6.5.) That’s the real price of a wrong-way arrow at organisational scale: a monopoly in the middle of everything, charging monopoly prices.

The fix doesn’t change even at this scale. The contracts are the abstraction; the bus is the postman. The microservices crowd have a phrase for it — smart endpoints, dumb pipes — and it’s dependency inversion wearing a different badge: nothing depends on the bus for anything that might change, which is precisely what lets every service change in isolation. Keep the bus dumb, keep each contract owned by the bounded context that declares it, evolve contracts by versioning rather than breaking change, and an ESB can stay useful well past its eighteenth month.

## Rules for the bus
1. The bus moves messages. It does not transform, enrich,
route-by-content, or "orchestrate".
2. Contracts are owned by the bounded context that declares
them — never by the bus, never by a central "canonical
model". They evolve by versioning, not by breaking change.
Nothing depends on the bus; everything depends on the
contracts.
3. If a business rule lives in the bus, a team just lost
the ability to test its own behaviour.

An ESB is what happens when you buy the abstraction and skip the inversion.

Why one pattern reaches everything

A fair question by now: how can one pattern touch everything from a C# namespace to an enterprise integration strategy? Because one situation is everywhere. Everything you build is made of parts that change at different rates, and welding two of them together means every change in one becomes work in the other. A module welded to a schema. A service welded to a service. A team welded to another team’s roadmap. An enterprise welded to its own integration middleware. One problem, every scale — and dependency inversion is simply the act of un-welding: find the two things changing at different rates, introduce an abstraction that changes more slowly than both, and point both arrows at it.

The wider world worked this out before we did. The screw thread — the original interchangeable-parts inversion. The mains socket: appliance makers and power stations have never met. The shipping container: ships, cranes, ports, hauliers, and cargo owners all depend on the dimensions of a steel box, and none of them depend on each other — global trade deploys independently. And the tallest buildings in the world: thousands of firms integrating simultaneously — steel, curtain wall, lifts, electrics, fire systems — and not one trade builds against another trade’s work-in-progress. Every one of them builds against the drawings, the structural grid, and the building codes: abstractions that change more slowly than any contractor. The lift manufacturer is designing against shaft dimensions while the foundations are still being poured. That’s dependency inversion you can stand on — all 828 metres of it.

If you take one sentence from this post, take the rule underneath all of it: make your dependencies point at things that change more slowly than you do.

One pattern reaches everything because one problem is everywhere: things that change at different rates, welded together.

When not to bother

None of this is free. The costs are indirection, interface sprawl, and speculative abstraction — so, two tests before you invert. First: is the boundary a true bounded context — do the two sides change at different rates, for different reasons, on different schedules? An interface with one implementation, wedged between two things that always change together, isn’t inversion; it’s clutter — and plenty of “we use interfaces everywhere” codebases are exactly that: the ceremony of DIP with none of the mechanism. Second: has enough complexity accumulated to justify the extra effort? Even a real boundary can be inverted too early — below a certain complexity, the welding is cheaper than the abstraction. Where both tests pass, invert with confidence. Everywhere else, skip it with a clear conscience.

An interface with one implementation, between two things that always change together, isn’t inversion. It’s clutter.

Finally

I think that if we look deep enough, many of the design patterns we use have dependency inversion at their core. Perhaps deeper still: cause and effect gives every moment a dependency arrow pointing at the one before it — entropy is why the arrow points that way — and the past, once written, never changes at all. It’s the most stable abstraction there is. The universe, in other words, is event sourced: an append-only log, with the present as its current projection. Perhaps a concept that universal deserves being addressed first and foremost, before anything else. What do you think?

·

Comments

Leave a comment

Check also

View Archive [ -> ]