Steven's Knowledge

Boundaries

Working with third-party code, external services, and unstable interfaces

Boundaries

Every system has edges — places where code you control meets code you do not. Third-party libraries, vendor APIs, operating system calls, network protocols, configuration formats, file systems. The choices made at these boundaries determine how much of your codebase the outside world can disrupt.

The strategic goal at every boundary is the same: control what depends on what is outside your control.

The Risks of External Code

External code is not under your governance:

  • It changes on someone else's schedule. A breaking change in a dependency forces work that was never on your roadmap.
  • Its API was designed for general use, not your use. Default abstractions rarely match your domain shape.
  • Its quality varies. Even well-maintained libraries have surprising defaults, undocumented edge cases, and bugs you will discover by hitting them.
  • Its reach can spread. Once a vendor's types appear throughout your code, the vendor has a vote in every future change.

These are not arguments to avoid dependencies — modern systems are unbuildable without them. They are arguments to be deliberate about where and how dependencies appear.

Wrap External APIs

The default discipline is to interpose a thin layer of code you own between your code and any external API.

// Direct dependency — vendor types spread everywhere
import { stripe } from '@stripe/sdk';
async function processPayment(intent: Stripe.PaymentIntent) { ... }

// Wrapped — your code talks to your interface
interface PaymentGateway {
  charge(amount: Money, customer: CustomerId): Promise<ChargeResult>;
}
class StripePaymentGateway implements PaymentGateway { ... }
async function processPayment(charge: ChargeRequest) { ... }

The wrapper costs little and buys several things at once:

  • The vendor's types stay confined to one module.
  • The wrapper exposes only the operations your code needs.
  • Tests substitute a fake implementation without involving the vendor at all.
  • A future migration to a different provider is a rewrite of the wrapper, not the system.

The wrapper does not have to be a class. A module of functions, each translating one of your operations into a call into the library, works the same way.

Adapter and Anti-Corruption Layer

When the external API's shape — not just the call surface — does not match your domain, an adapter translates between the two.

┌────────────────┐         ┌────────────────┐         ┌────────────────┐
│ Your domain    │  uses   │ Adapter        │  calls  │ External API   │
│ (Order, Money, │ ──────▶ │ (translates    │ ──────▶ │ (Vendor types  │
│ Customer)      │         │ shapes)        │         │ and idioms)    │
└────────────────┘         └────────────────┘         └────────────────┘

The pattern, named the Anti-Corruption Layer in Domain-Driven Design, prevents the vendor's idioms from leaking into your domain model. The adapter does the bulk translation work; your domain stays consistent.

When to invest in an adapter:

  • The vendor's API is deeply different from your domain (different vocabulary, different lifecycle, different consistency model).
  • You expect to support more than one provider over time.
  • You are integrating with a legacy system whose model you do not want to inherit.

When not to:

  • The vendor's API is a thin protocol (HTTP, SQL, file I/O) where the abstraction is already the right shape.
  • The dependency is small, well-fitted, and unlikely to change. Adding an adapter has a cost, and over-abstracted code is its own problem.

Learning Tests

When adopting a new library, write tests against the library itself, not the application:

// What does this library do when given an empty array?
test('encodes empty arrays as ""', () => {
  expect(lib.encode([])).toBe('');
});

// What format does this date function return?
test('formatDate returns ISO 8601 with milliseconds', () => {
  expect(formatDate(d)).toMatch(/^\d{4}-\d{2}-\d{2}T.*\.\d{3}Z$/);
});

These learning tests (Clean Code, ch. 8) capture your assumptions about how the library behaves. They have a second life: when the library upgrades, the tests run against the new version. If the library has changed its behavior, the tests fail before your application code does.

Learning tests are cheap — a few minutes for the assumptions you actually rely on — and make every dependency upgrade safer.

Defending Against Future Changes

The library version your code was written against is not the version that will run a year from now. Reduce the surface area exposed to changes:

  • Pin versions, but plan to upgrade. Lock files prevent surprise breakage; upgrade cycles prevent "we're four major versions behind."
  • Limit the API surface used. A wrapper that exposes ten methods is easier to migrate than scattered calls to a hundred.
  • Stay close to documented, stable APIs. Reaching into a library's internals is a hidden coupling that the maintainers do not promise to preserve.
  • Watch for deprecation. Most breakages are announced; missed deprecations become breaking changes.

External Data

The same discipline applies to data crossing the boundary, not just code.

Schema validation at the edge

Data from outside (HTTP requests, queue messages, file uploads, database rows from a system you do not own) should be validated into a known shape before it reaches the rest of your code:

const RawInput = z.object({ ... });          // schema
const input = RawInput.parse(req.body);       // validate

After this point, the rest of the code can rely on the type. Skipping validation pushes the verification cost — and the failure handling — into every downstream caller.

Translate to internal types immediately

Even when validation passes, the external shape may not match your domain. Translate at the edge:

function fromVendorEvent(event: VendorWebhookPayload): OrderUpdate {
  return {
    orderId: event.order_id,
    status: mapVendorStatus(event.status),
    placedAt: new Date(event.created_at),
  };
}

Internal code uses your shape; vendor field names and quirks live in this translator and nowhere else.

Errors are external too

External services fail in ways internal code does not:

  • Network timeouts and disconnects.
  • Rate limits.
  • Partial failures (the request succeeded; the response was lost).
  • Provider-side outages and incidents.

Treat these as expected, not exceptional. The wrapper is the natural place for retry policies, circuit breakers, fallback logic, and idempotency keys — concentrating these concerns at the boundary keeps the rest of the code simple.

Testing at Boundaries

Tests at boundaries are different from tests of internal logic:

  • Unit tests. Use a fake or mock at the wrapper interface. They run fast and verify business logic without touching the network.
  • Contract tests. Verify that your fake matches the real provider's behavior. Run against the real provider periodically (in CI on a schedule, not on every PR).
  • Integration tests. Exercise the real provider in a staging environment, for a small set of critical flows. Slow, flaky, expensive — keep the set small and the tests strategic.

The combination provides fast feedback for everyday work and catches drift between your assumptions and the real provider's behavior.

A Common Anti-Pattern: The Vendor Object Everywhere

The most expensive boundary mistake is to allow vendor types to spread through the codebase. Five years later, replacing the vendor — for cost, for capability, for compliance — requires rewriting every module that touched it.

The discipline is small at the start (one wrapper, one set of internal types) and compounds over time (a clean migration when the time comes). The opposite — letting the vendor's API be your API — also compounds, in the wrong direction.

Pre-Commit Checklist

  • Are external API calls confined to a wrapper module?
  • Do internal callers use your interface, or the vendor's directly?
  • Is data crossing the boundary validated and translated to internal types at the edge?
  • Are retry, timeout, and failure policies centralized at the boundary?
  • Are there learning tests that capture the library's behavior on assumptions you depend on?
  • Could the dependency be replaced by editing one module — or would the change ripple through the codebase?

On this page