A dependency can look harmless when it is introduced. A business rule calls a payment SDK directly, a reporting module knows the exact storage format, or an order workflow imports a concrete notification client. Each choice may save a small amount of code today.

The cost appears later. When an external library, storage mechanism, or delivery channel changes, code that represents important business behaviour must change with it.

Dependency direction is a way to reduce that coupling. The central idea is simple: code that expresses important, relatively stable policy should not have to know the details that are likely to change around it.

This article explains how to recognise an unhealthy dependency direction, reverse it without unnecessary abstraction, and decide when the extra boundary is worth having.

Think about who should absorb the change

Suppose an application sends a receipt after an order is completed. A first implementation might make the order workflow call a particular email library directly:

completeOrder(order):
    save(order)
    emailSdk.sendReceipt(order.customerEmail, order.id)

This works, but it gives the order workflow two reasons to change:

  1. the rules for completing an order change;
  2. the email integration changes.

Those reasons do not have the same stability. The order-completion policy may remain recognisable for years, while an email provider, SDK, authentication method, or message format can change independently.

A useful design question is therefore not merely, “Does module A use module B?” Ask instead:

If this dependency changes, which side should be forced to edit its code?

Dependency direction is healthy when likely changes are absorbed near the details that caused them rather than propagating into unrelated policy.

Separate policy from mechanism

Policy describes what the software needs to accomplish. Mechanism describes one way to accomplish it.

For the receipt example:

  • “send a receipt after a completed order” is policy;
  • “call this email SDK with these parameters” is mechanism.

The distinction matters because mechanisms are often replaceable. A team might switch email providers, move notifications to a queue, add an in-memory implementation for tests, or introduce retries around delivery. None of those changes should redefine what it means to complete an order.

The policy can express the capability it needs without naming a particular mechanism:

interface ReceiptSender:
    sendReceipt(order)

completeOrder(order, receiptSender):
    save(order)
    receiptSender.sendReceipt(order)

A concrete adapter can then translate that capability into the external SDK:

class EmailReceiptSender implements ReceiptSender:
    sendReceipt(order):
        emailSdk.sendReceipt(order.customerEmail, order.id)

The important change is not the interface itself. It is who owns the expectation.

The order workflow says, “I need something that can send a receipt.” The email integration conforms to that need. The policy no longer imports the details of the email provider.

Source-code dependencies and runtime calls are different

Dependency direction can be confusing because runtime control may travel one way while source-code dependencies point another way.

At runtime, the order workflow still calls the email adapter. Nothing about dependency inversion prevents that.

At the source-code level, however, the stable policy depends only on the ReceiptSender abstraction. The concrete email adapter depends on that abstraction because it implements the contract.

Conceptually:

runtime call
OrderWorkflow  ----------------->  EmailReceiptSender

source dependency
OrderWorkflow  ---> ReceiptSender <--- EmailReceiptSender

This distinction is the heart of dependency inversion. The goal is not to eliminate calls to infrastructure. It is to prevent infrastructure details from becoming compile-time knowledge inside code that should remain independent of them.

Put the abstraction near the need it represents

A common mistake is to create an interface beside every concrete class and assume the design is now decoupled.

Imagine this structure:

email/
    EmailClient
    EmailClientInterface

orders/
    OrderWorkflow

If EmailClientInterface exposes provider-shaped operations such as sendTemplate(templateId, recipient, variables), the order module still understands the email mechanism. The concrete class has been hidden, but the volatile vocabulary has leaked through the interface.

A stronger boundary describes the consumer’s need:

orders/
    OrderWorkflow
    ReceiptSender

email/
    EmailReceiptSender

Now the contract can speak in order-domain terms. The adapter is responsible for translating those terms into provider-specific operations.

This is sometimes called a consumer-owned interface: the code that needs a capability defines the smallest useful contract for that capability. It keeps the abstraction aligned with policy rather than with the current implementation.

Stability is about reasons to change

“Stable” does not mean “never changes.” All useful software changes.

A component is relatively stable when its purpose and contract change less often than the details around it, or when changes to it have a wider cost that deserves protection.

For example, consider three pieces of an invoicing system:

  • tax calculation rules;
  • PDF rendering;
  • a vendor-specific PDF library wrapper.

Tax rules can certainly change, sometimes frequently. But a vendor SDK update should not force a tax calculation module to change. The two have different reasons to change.

The useful comparison is therefore contextual:

  • Which decision is central to the application?
  • Which detail is replaceable?
  • Which side is more likely to vary independently?
  • Which change should be prevented from spreading?

Dependency direction should follow those answers, not a fixed rule that one technical layer is always more stable than another.

Use boundaries where volatility crosses into policy

Not every dependency deserves an abstraction. A good place to consider one is where stable application behaviour meets a detail with a separate lifecycle.

Typical examples include:

  • external service clients;
  • message delivery mechanisms;
  • clocks and random-value sources when deterministic behaviour matters;
  • file or object storage mechanisms;
  • payment or shipping providers;
  • platform-specific APIs;
  • framework callbacks that would otherwise leak through application logic.

The boundary gives the application a vocabulary for what it needs. An adapter handles how the current mechanism supplies it.

For example, a subscription policy may need the current time:

interface Clock:
    now()

isTrialExpired(subscription, clock):
    return clock.now() >= subscription.trialEndsAt

Production code can provide a system clock. A test can provide a fixed clock. More importantly, the subscription rule does not need to know which operating-system or framework API returns the current time.

The abstraction is useful because time is an external input to the policy and controlling it changes how easily the policy can be reasoned about and tested.

Keep contracts smaller than implementations

When reversing a dependency, it is tempting to reproduce the entire concrete API as an interface. That usually preserves more coupling than necessary.

Suppose a storage SDK offers dozens of operations. A document workflow may need only two:

interface DocumentStore:
    save(document)
    load(documentId)

Exposing the SDK’s complete surface gives the workflow knowledge it does not need. A small contract has several advantages:

  • fewer details can leak into policy;
  • implementations have more freedom behind the boundary;
  • tests need to model less behaviour;
  • future migrations have a smaller compatibility surface.

The contract should be large enough to express the consumer’s real needs, but no larger.

This also prevents a common failure mode: creating a generic abstraction such as StorageService that gradually becomes a mirror of one vendor’s API.

Do not hide important semantics

A clean dependency direction should not erase behaviour that the policy actually needs to understand.

Suppose an external operation can legitimately produce three outcomes: accepted, rejected for a business reason, or temporarily unavailable. An abstraction that reduces all three to true or false may make the interface look simple while discarding information required for correct decisions.

A better contract exposes the meaningful semantics without exposing provider details:

result = paymentAuthorizer.authorize(payment)

if result is Approved:
    continueOrder()
else if result is Declined:
    rejectOrder(result.reason)
else if result is TemporarilyUnavailable:
    scheduleRetry()

The policy still knows the outcomes relevant to its job. It does not need to know vendor error codes, HTTP response shapes, SDK exceptions, or transport details.

A useful boundary hides irrelevant mechanism, not relevant meaning.

Watch for dependency direction that only moved on paper

Several designs appear inverted while still leaking implementation knowledge.

Provider-specific types cross the boundary

If ReceiptSender accepts an EmailSdkMessage, the policy still depends conceptually on the email SDK even if the import is indirect.

Prefer application-owned values at the boundary and translate them inside the adapter.

The interface mirrors the vendor

Methods such as createRemoteMessage, setProviderHeader, and executeProviderRequest reveal the mechanism. They make replacing the provider difficult because callers have already adopted its model.

Name operations after the capability the application needs.

One abstraction serves unrelated consumers

A large shared interface often grows because different modules need different subsets of one implementation. The result is a broad contract that couples every consumer to capabilities it does not use.

Prefer focused consumer contracts when their needs differ materially.

The abstraction exists without a real boundary

An interface around a pure, stable value object or a tiny deterministic helper can add navigation cost without isolating meaningful volatility.

Dependency inversion is a tool for controlling change, not a requirement to place an interface in front of every class.

Test the policy at its own boundary

A well-directed dependency makes policy tests more focused because tests can provide controlled implementations of external capabilities.

For the order workflow, a test can use a recording receipt sender:

class RecordingReceiptSender implements ReceiptSender:
    sentOrders = []

    sendReceipt(order):
        sentOrders.append(order.id)

The test can verify that completing an eligible order requests a receipt without invoking a real email system.

That does not prove the email adapter works. The adapter needs its own tests at the appropriate integration boundary. The benefit is separation: policy tests verify policy, while integration tests verify translation to the external mechanism.

This division makes failures easier to interpret. A broken SDK mapping should not make every order-policy test fail, and a broken order rule should not require debugging the email provider.

Dependency inversion has a cost

A boundary introduces names, files, constructor parameters or wiring, and another concept for developers to understand. Those costs are justified only when they buy useful independence.

Direct dependency can be the better choice when:

  • the dependency is a small, stable part of the language or standard library;
  • the calling code is itself an infrastructure adapter;
  • the behaviour is trivial and unlikely to need independent testing or replacement;
  • an abstraction would merely duplicate a concrete API without creating a meaningful policy boundary.

For example, wrapping every basic string operation behind an application interface would usually add indirection without protecting an important decision.

The question is not, “Can this dependency be abstracted?” Almost anything can. The better question is, “Which future change would this abstraction contain?”

If there is no convincing answer, keep the direct dependency until the design provides a real reason to introduce a boundary.

A practical way to review dependency direction

When a module is becoming difficult to change, trace its dependencies and ask four questions.

First, what decision does this module own? State its responsibility without mentioning frameworks or vendors if possible.

Second, which dependencies represent replaceable mechanisms? Look for SDKs, transport details, persistence details, system resources, and platform-specific APIs.

Third, what is the smallest capability the policy actually needs? Define that contract in the vocabulary of the consumer.

Fourth, where should translation happen? Put vendor-specific types, exceptions, configuration, and protocol details in an adapter on the mechanism side of the boundary.

Then check the result against a real change. If the email provider changed tomorrow, would order-completion policy need editing? If storage moved to another mechanism, would document rules need to learn the new API?

A useful boundary makes the answer “no” for changes that should remain local.

Conclusion

Dependency direction is fundamentally about controlling the spread of change.

Keep important policy dependent on contracts that express what it needs, and let volatile mechanisms depend on those contracts through adapters. Place the abstraction near the consumer’s need, keep it small, and preserve the business semantics that the policy must understand.

Do not invert dependencies mechanically. Add a boundary when it protects a meaningful decision from an independently changing detail.

When that boundary is well chosen, replacing an implementation becomes a local change instead of a reason to rewrite the code that defines what the application is supposed to do.