The Direction of a Dependency Matters More Than Its Existence
In a code review, it's easy to fixate on how many dependencies a module has: how many imports show up, how many classes it takes through the constructor, how many layers you have to cross to follow an operation. But counting dependencies doesn't tell you much about how hard that code will actually be to change.
Two modules can be connected by exactly one dependency and still be tightly coupled. Others can have several and still be perfectly safe to change independently. The difference isn't really about how many dependencies exist, it's about which way they point.
Because a dependency doesn't just connect two blocks of code. It also decides which of the two ends up exposed when the other one changes.
The Question Isn't How Many Dependencies There Are, It's Which Way They Point
Picture one module depending on another. The first one calls its functions or assumes it exposes a certain interface. If the second one changes, the first one can break. The second module, on the other hand, has a lot more freedom: it can change how it's implemented internally as long as it keeps offering what the first one needs.
That's why a dependency always has a direction. One side ends up exposed to change, and the other ends up protected.
The problem is that, when nobody pays attention to the design, dependencies tend to show up following control flow. One function calls another, and naturally, we think: "okay, this function depends on that one."
But control flow and dependency direction don't have to line up:
- Control flow tells us what calls what.
- Dependency direction should help us decide what we want to be free to change without breaking everything else.
And that difference matters. Designing dependencies well isn't about having few of them — it's about orienting them so the changes that matter stay isolated, and the parts worth protecting never depend directly on the details that are free to change.
The Cost of Pointing Dependencies the Wrong Way
Picture an order service that, once a checkout finishes, emails the customer a receipt. The obvious way to write it: the same service that completes the order uses the SDK of whatever email provider is already installed, directly:
class OrderService {
constructor(
private db: Database,
private sendgrid: SendGridClient
) {}
async completeOrder(orderId: string) {
const order = await this.db.getOrder(orderId);
order.status = "completed";
await this.db.save(order);
await this.sendgrid.send({
to: order.customerEmail,
template: "order-confirmation",
data: { orderId: order.id, total: order.total },
});
}
}
There's nothing especially wrong with this code. It's simple, it works, and it'll probably keep working for a long time. The problem shows up when something outside the order's own responsibility changes. Marketing decides to switch email providers. Or engineering decides transactional emails should go through a queue instead of a synchronous call. Now OrderService has to change.
And here's the interesting part: how an order gets completed hasn't changed at all. The order still has to be saved as completed, and the customer still has to get a confirmation. What changed is how that confirmation gets sent. But OrderService knows SendGridClient directly, so an infrastructure change ends up landing inside our business code.
The dependency is simple:
OrderService ──────> SendGrid
OrderService depends on SendGrid because it knows its API and calls it directly. So any change to that integration can force a change to OrderService.
That's the cost of pointing dependencies the wrong way: external details end up with the power to force changes on the code that holds the business rules. And the more code depends on those details, the more expensive it gets to switch providers, introduce a queue, or change the infrastructure.
So the point isn't to avoid every dependency. It's to make dependencies point in a way where the details that are free to change don't get to dictate how the code you want to protect is written.
There's another cost, less technical but just as important: the cost to the people who have to work with that code.
A file that mixes business rules with the details of one specific provider becomes harder to understand and harder to change. If someone wants to change when an order counts as complete, they also have to understand how SendGrid's API works — even though that knowledge has nothing to do with the change they're trying to make.
Over time, files like that start to feel dangerous to touch. People avoid them because they know any change could have unexpected consequences. And that's exactly how you end up with the files everyone knows about but nobody wants to modify.
Flip the Arrow, Not the Behavior
Fixing this doesn't require anything to change at runtime. What needs to change is who calls whom. OrderService should know it needs to notify someone when an order completes, but it shouldn't know or care that the notification currently goes out through SendGrid.
interface OrderNotifier {
notifyCompletion(order: Order): Promise<void>;
}
class OrderService {
constructor(private db: Database, private notifier: OrderNotifier) {}
async completeOrder(orderId: string) {
const order = await this.db.getOrder(orderId);
order.status = "completed";
await this.db.save(order);
await this.notifier.notifyCompletion(order);
}
}
class SendGridOrderNotifier implements OrderNotifier {
constructor(private sendgrid: SendGridClient) {}
async notifyCompletion(order: Order): Promise<void> {
await this.sendgrid.send({
to: order.customerEmail,
template: "order-confirmation",
data: { orderId: order.id, total: order.total },
});
}
}
OrderService still ends up calling SendGrid indirectly every time an order completes — the runtime behavior hasn't moved an inch. What moved is the seam1: OrderService now depends on an interface whose shape it controls, and SendGridOrderNotifier depends on that same interface to know what's expected of it. Neither depends directly on the other. Switch providers, and the only file that changes is the new implementation — the business rule that decides an order is complete never finds out a provider changed at all.
This also pays off immediately in tests. Before, checking that completeOrder saves the order as completed meant dealing with SendGrid somehow: mocking the entire SDK, pointing at the provider's sandbox environment, or accepting that the test sends a real email every time it runs. With the interface in place, a test can hand OrderService an OrderNotifier that just records that it was called, without touching the network or depending on SendGrid being up that day. The business rule gets tested on its own, separate from whether the current provider happens to be working.
Not Everything Needs to Be Inverted
It's tempting, once you see this work, to start putting an interface in front of every dependency in the code. Don't. If OrderService calls an internal domain function, written by the same team, that changes for the same reasons OrderService does, inverting that dependency protects nothing — it just adds an interface to maintain and one more file to read to understand what's going on.
The question worth asking before inverting a dependency isn't "is this a dependency?" — it's "can this detail change for a reason that has nothing to do with the business rule?" SendGrid can change because marketing decides something else, and OrderService shouldn't care. An internal domain collaborator usually changes together with OrderService itself, because it's part of the same business decision.
The key is applying this technique where it actually helps separate reasons to change. Apply it everywhere, and you end up adding abstractions and complexity without getting anything back for it. That's the point where a good practice turns into a burden.
The Diagram Worth Keeping
The whole argument fits in four boxes:
A dependency you can't avoid is rarely the problem. A dependency that can only point at one thing is.
Why This Is Easier to Maintain
This way of writing code doesn't have to produce bugs, least of all on day one. The direct direction and the inverted one behave the same at the business level: you place an order, and the email goes out. All the cost depends on how often things need to change, which is exactly why that cost builds up quietly instead of getting caught early. A codebase where every business rule points straight at its concrete details is a codebase that decouples2 nothing — it's actually a lot more convenient and direct, right up until every change to a database driver, an email provider, or a payment gateway ripples into logic we already considered stable.
That's the same rigidity the reservation service from the last Case File had before its conflict rule stopped depending on SQLite directly. The fix there wasn't a rewrite — it was this exact move, applied one function at a time: keep the behavior fixed, and change who ends up depending on whom.
Conclusion
Dependencies are inevitable. We can't make them disappear, but we can decide which way they point.
The question that matters isn't how many dependencies our code has, but which part ends up exposed when something changes. If we make dependencies point toward the rules we want to protect, the details — a provider, a driver, a database, a library — can change without forcing us to touch the business code.
In the end, designing dependencies well comes down to protecting what's stable from what's free to change. Once we get that right, switching providers stops being a change to our business and becomes just a change to the infrastructure around it.
Footnotes
-
Seam: a point in the code where you can change behavior without editing the code at that point, usually by passing in something different. ↩
-
Decoupling: reducing how much one part of a system needs to know about another, so each can change without dragging the other along with it. ↩
Get new articles in your inbox
Writing about pragmatic architecture, maintainable systems, and real-world engineering trade-offs. Published when ready.