A dependency can be technically easy to call and still be expensive to change. A pricing library may rename operations between releases. A shipping provider may expose a model that changes as its API evolves. An internal rules engine may be rewritten while the business workflow around it stays largely the same.

When application code uses those changing details everywhere, each dependency change becomes an application-wide edit. The problem is not simply that the dependency changes. The problem is that knowledge of how it works today has spread into code that has different reasons to change.

A useful design response is to place a small, stable boundary around the volatile dependency. Application code depends on the capability it needs; one adapter translates that capability to the dependency’s current API.

This article develops that mental model, shows what belongs on each side of the boundary, and explains when the extra abstraction is worth its cost.

Think in terms of change propagation

Suppose checkout code needs a shipping quote. The external shipping library currently exposes this operation:

shippingClient.calculate(
    destinationPostalCode,
    packageWeightGrams,
    serviceCode
)

If many application modules call that operation directly, they learn several dependency-specific facts: method name, argument order, weight unit, and service-code representation.

Now imagine a later library version accepts a request object, measures weight in kilograms, and renames service codes. Even if the business requirement remains “get a shipping quote,” every direct caller may need editing.

The important quantity is change propagation: how far one reason for change travels through the codebase.

A stable boundary tries to make that distance short:

application -> ShippingQuotes -> shipping-library adapter -> library

The application knows the capability ShippingQuotes. The adapter knows the current library. A library change should therefore affect the adapter first, rather than every business caller.

This is not a guarantee that only one file will ever change. If the dependency introduces a genuinely new business concept, the application may need to change too. The boundary is useful because implementation-shaped changes can often stop at the boundary.

Define the boundary in application terms

A boundary works poorly when it merely copies the dependency’s interface.

Consider this abstraction:

ShippingGateway.calculate(postalCode, weightGrams, serviceCode)

It gives the dependency a new name, but application code still knows its units and service codes. If those details change, the abstraction leaks the same volatility it was meant to contain.

Instead, describe what the application needs:

ShippingQuotes.quote(shipment):
    -> ShippingQuote

A simplified application model might be:

Shipment:
    destination
    weight
    speed

ShippingQuote:
    amount
    estimatedDeliveryDate

The adapter performs the translation:

LibraryShippingQuotes.quote(shipment):
    request = LibraryRequest(
        postalCode = shipment.destination.postalCode,
        kilograms = shipment.weight.inKilograms(),
        service = mapSpeed(shipment.speed)
    )

    response = client.getQuote(request)

    return ShippingQuote(
        amount = mapMoney(response.price),
        estimatedDeliveryDate = response.deliveryDate
    )

This pseudocode is intentionally language-neutral. Its purpose is to show ownership: application concepts enter the adapter, dependency concepts stay inside it, and application concepts come back out.

Translate both inputs and outputs

Teams sometimes isolate dependency calls but let dependency data types escape from the adapter. That creates only half a boundary.

Suppose ShippingQuotes.quote returns LibraryQuoteResponse. Callers then need to understand fields, nullability rules, status codes, or enum values defined by the library. Those callers become coupled to the library even though they never invoke it directly.

A stronger boundary translates in both directions:

application request
      |
      v
adapter -> dependency request
adapter <- dependency response
      |
      v
application result

The same principle applies to failures. If the library throws VendorTimeoutException, application code should not need that vendor-specific type merely to decide whether quoting is temporarily unavailable.

The adapter can translate it to an application-level result or error when the meanings align:

VendorTimeoutException -> ShippingQuoteTemporarilyUnavailable

Do not erase distinctions the application needs. If the dependency reports “destination unsupported” and “temporary outage,” mapping both to a generic failure would make useful information disappear. Translation should remove irrelevant implementation detail while preserving relevant meaning.

Put the boundary where ownership changes

The boundary is easiest to reason about when one side owns business policy and the other owns dependency translation.

Checkout might decide when a quote is needed and which shipping speed the customer selected. The adapter should not make those product decisions merely because it talks to the shipping library.

Conversely, checkout should not decide which vendor service code represents EXPRESS. That mapping exists because of the dependency and belongs near the dependency.

A practical ownership test is to ask why a line would change:

  • If it changes because the business changes its shipping policy, it probably belongs in application code.
  • If it changes because the dependency changes its API, representation, units, or error model, it probably belongs in the adapter.

The boundary becomes valuable when these two reasons for change can move independently.

Volatility is contextual, not an intrinsic label

A dependency is not simply “stable” or “unstable” forever. Volatility depends on what your system relies on and how likely those relied-upon details are to change.

A mature library with a compatibility policy may be stable for your use case. An internal module under active redesign may be volatile even though it lives in the same repository. A vendor API may rarely change its transport format while your own mapping rules change every month.

Look for evidence in the system you maintain:

  • repeated upgrade edits across unrelated modules;
  • dependency-specific types imported through many layers;
  • frequent migrations caused by one integration;
  • tests that require widespread rewrites when a dependency changes;
  • business code containing conversions or mappings that exist only for one dependency.

These are signals, not automatic proof that an adapter is required. They indicate that one source of change may be reaching farther than necessary.

Do not predict every future change

It is easy to overreact to possible volatility by building a large abstraction before the application has a real need.

Suppose a program calls a small, stable parsing library in one module. Creating interfaces, factories, wrappers, custom result objects, and translation layers “in case the library changes” adds code that must itself be understood and maintained.

A boundary has a cost:

  • another concept for developers to learn;
  • translation code to test;
  • possible loss of useful dependency features;
  • additional navigation when debugging;
  • pressure to design an application-level model before its needs are clear.

For a dependency used in one place, a local direct call may already contain its volatility well enough. The simplest adequate boundary can be a function or module rather than a formal interface and hierarchy.

Introduce more structure when there is evidence that dependency knowledge is spreading or when the cost of a likely change is high enough to justify isolation.

Keep the boundary narrow

A boundary should expose the capability the application needs, not attempt to create a complete replacement API for the dependency.

If checkout needs only shipping quotes, an interface containing label purchase, tracking, address validation, pickup scheduling, and every vendor feature creates unnecessary surface area. It also makes the abstraction more likely to mirror the vendor.

Start from a concrete application use case:

ShippingQuotes.quote(shipment)

Add another operation only when application behavior requires it. A narrow boundary is easier to keep independent because fewer dependency concepts need translation.

This also makes replacement less misleading. The goal is not necessarily to make every vendor interchangeable. Different providers often have genuinely different capabilities. The goal is to keep the application from depending on details that are irrelevant to its current needs.

Test the translation where mistakes can occur

The adapter is small, but it is not trivial. Unit conversions, enum mappings, missing fields, and error translations are places where subtle defects can appear.

Useful focused tests might establish that:

2.5 kg shipment
    -> dependency request uses the expected weight representation

application speed EXPRESS
    -> dependency service code used by the current integration

dependency timeout
    -> ShippingQuoteTemporarilyUnavailable

successful dependency response
    -> ShippingQuote with the expected amount and delivery date

These tests protect the assumptions concentrated at the boundary. When the dependency changes, they help identify which translations need updating.

Application tests can then work with ShippingQuotes without reproducing dependency-specific setup everywhere. Integration tests should still exercise the real adapter where practical, because a unit test of translation logic cannot prove that an external API or library behaves as assumed.

Know what the boundary cannot protect

A stable boundary reduces coupling to dependency details; it does not make dependency changes irrelevant.

If a shipping provider stops supporting a country, changes a business guarantee, or removes a capability your product depends on, that is not merely an interface-shape change. The application may need a product or policy decision.

Likewise, an adapter cannot hide operational characteristics that matter. Rate limits, latency, partial availability, or eventual completion may affect application design even if vendor-specific names do not. A useful abstraction hides irrelevant details, not consequential behavior.

This distinction prevents a common mistake: treating abstraction as concealment. The boundary should preserve every property that callers need to make correct decisions.

Use change direction as the design test

The purpose of a stable boundary is not to add an interface between every pair of modules. It is to control where knowledge of a volatile dependency lives.

When a dependency changes, ask which code should reasonably know about that change. If the answer is “the integration layer,” but many business modules must also be edited, dependency details have probably crossed the intended boundary.

Define the application-facing side in terms of the capability your software needs. Translate dependency-specific inputs, outputs, and failures at one owned location. Keep the surface narrow, and preserve operational or business distinctions that callers genuinely need.

When direct use is already local and stable, keep it simple. When one dependency repeatedly sends changes through otherwise stable code, a small boundary can turn a widespread migration into a contained engineering task.