Chapter 8: Boundaries

clean-code boundaries third-party adapter-pattern learning-tests

Status: Notes complete
Difficulty: Medium
Time to complete: ~35 min read


Overview

We seldom control all the software in our systems. We use open-source packages, we buy commercial libraries, we call team-internal APIs built by other groups, and we depend on services that don’t exist yet. At every one of these seams, we have a boundary — a place where our code meets foreign code.

Boundaries managed well are invisible to the rest of the system. Boundaries managed poorly become the source of most integration bugs, the hardest code to test, and the most painful refactors when a third-party dependency changes.

The core principle of this chapter: keep the knowledge of third-party code confined to as few places as possible. Wrap, adapt, and test at the boundary. Let the rest of your application live in terms it controls.

Related chapters: ch07-error-handling (wrap third-party exceptions at boundaries), ch09-unit-tests (learning tests are a form of unit testing), ch11-systems (dependency injection and interface design at system scale).


The Problem: What Bad Code Looks Like

Code with poorly managed boundaries:

  1. Uses third-party types (java.util.Map<String, Sensor>, stripe.Charge) throughout the application — if the library changes its API, every usage site must change
  2. Has no tests verifying what the third-party library actually does
  3. Passes Map objects between unrelated components, allowing any consumer to mutate or clear them
  4. Calls a payment gateway’s SDK directly from a service class with no indirection — switching providers requires rewriting multiple files
  5. Couples unimplemented modules tightly: team A’s code reaches into team B’s unfinished API, so neither can compile independently
// BAD: third-party type (Map) leaks across module boundaries
// Any caller can call .clear(), .remove(), or cast the values
public Map<String, Sensor> getSensors() {
    return sensors; // exposes the full Map interface to every caller
}
 
// Elsewhere in the codebase:
Map<String, Sensor> sensors = sensorManager.getSensors();
sensors.clear(); // whoops — unintended mutation
Sensor s = (Sensor) sensors.get(sensorId); // unchecked cast

Core Principles


Principle 1: Using Third-Party Code

Why this rule exists: Third-party libraries are designed for maximum generality — they serve many users with many needs. Your application needs a focused slice of that surface area. When you expose the full third-party interface across your codebase, you are coupling every usage point to every API decision the library author ever made. A Map<String, Sensor> passed around everywhere is especially problematic: it has methods like clear(), entrySet(), and putAll() that no consumer of Sensors should ever need.

The fix: wrap the third-party type in a class that exposes only the operations your application actually needs. The wrapper also hides the implementation — you can later change from HashMap to TreeMap or an entirely different data structure without touching callers.

Bad — Java:

// BAD: raw Map<Sensor> passed everywhere; any caller can mutate or misuse it
public class SensorManager {
    private Map<String, Sensor> sensors = new HashMap<>();
 
    public Map<String, Sensor> getSensors() {
        return sensors;  // BAD: exposes full Map API
    }
}
 
// Caller can do anything:
Map<String, Sensor> s = manager.getSensors();
Sensor found = (Sensor) s.get("TMP-01"); // unchecked cast
s.clear(); // accidental or malicious mutation

Good — Java (wrapper class):

// GOOD: Sensors wraps the Map; exposes only what callers need
public class Sensors {
    private final Map<String, Sensor> sensors = new HashMap<>();
 
    public Sensor getById(String id) {
        return sensors.get(id);
    }
 
    public void add(Sensor sensor) {
        sensors.put(sensor.getId(), sensor);
    }
 
    public boolean containsId(String id) {
        return sensors.containsKey(id);
    }
 
    // No public access to the underlying Map — clients can't clear or mutate it
}

C++ equivalent:

// GOOD: SensorRegistry wraps std::unordered_map; exposes only needed operations
#include <unordered_map>
#include <string>
#include <optional>
 
class SensorRegistry {
    std::unordered_map<std::string, Sensor> sensors_;
public:
    std::optional<Sensor> getById(const std::string& id) const {
        auto it = sensors_.find(id);
        if (it == sensors_.end()) return std::nullopt;
        return it->second;
    }
 
    void add(const Sensor& sensor) {
        sensors_[sensor.getId()] = sensor;
    }
 
    bool containsId(const std::string& id) const {
        return sensors_.count(id) > 0;
    }
    // std::unordered_map is hidden — callers cannot clear or iterate directly
};

Python equivalent:

# GOOD: SensorRegistry wraps dict; exposes only needed operations
from typing import Optional
 
class SensorRegistry:
    def __init__(self) -> None:
        self._sensors: dict[str, Sensor] = {}
 
    def get_by_id(self, sensor_id: str) -> Optional[Sensor]:
        return self._sensors.get(sensor_id)
 
    def add(self, sensor: Sensor) -> None:
        self._sensors[sensor.id] = sensor
 
    def contains_id(self, sensor_id: str) -> bool:
        return sensor_id in self._sensors
 
    # The underlying dict is private — callers cannot clear, pop, or iterate

Principle 2: Exploring and Learning Boundaries (Learning Tests)

Why this rule exists: When you adopt a third-party library, you must understand it. The natural approach is to read the docs and write some exploratory code. Learning tests formalize this exploration as actual unit tests: instead of throwaway scratch code, you write tests that call the library and assert its behavior. These tests have two lifetimes: they document how the library behaves today, and they run against every new version of the library to warn you of behavior changes.

Learning tests are a form of executable documentation — they prove the library behaves as you understand it, and they prove it again after every upgrade.

Java — Learning tests for a logging library (SLF4J / Logback):

// GOOD: learning tests for Logback configuration behavior
// These were written to understand how Logback behaves before integrating it
@Test
public void logbackAppendsToConsoleByDefault() {
    Logger logger = LoggerFactory.getLogger("test.logger");
    // If this doesn't throw and we can create a logger, Logback is on the classpath
    assertNotNull(logger);
}
 
@Test
public void logbackRespectsLogLevel() {
    Logger logger = LoggerFactory.getLogger("level.test");
    // Verify that DEBUG-level messages are not visible at INFO level
    // (This tests our understanding of the level filtering contract)
    assertFalse(logger.isDebugEnabled()); // Default level in test env is INFO
    assertTrue(logger.isInfoEnabled());
}
 
@Test
public void logbackFormatsMessageWithArguments() {
    // Test that {} placeholder substitution works as documented
    Logger logger = LoggerFactory.getLogger("format.test");
    // No exception thrown means parameterized logging is supported
    assertDoesNotThrow(() ->
        logger.info("Processing order {} for customer {}", "ORD-42", "CUST-7")
    );
}

C++ equivalent — learning tests for a JSON library (nlohmann/json):

// GOOD: learning tests for nlohmann/json parsing behavior
#include <gtest/gtest.h>
#include <nlohmann/json.hpp>
using json = nlohmann::json;
 
TEST(NlohmannJsonLearning, ParsesValidJson) {
    auto j = json::parse(R"({"name": "Alice", "age": 30})");
    EXPECT_EQ(j["name"].get<std::string>(), "Alice");
    EXPECT_EQ(j["age"].get<int>(), 30);
}
 
TEST(NlohmannJsonLearning, ThrowsOnInvalidJson) {
    // Verify our understanding: invalid JSON throws parse_error
    EXPECT_THROW(json::parse("{invalid}"), json::parse_error);
}
 
TEST(NlohmannJsonLearning, MissingKeyReturnsNull) {
    auto j = json::parse(R"({"name": "Bob"})");
    // Verify: accessing a missing key returns null, not an exception
    EXPECT_TRUE(j.value("age", json(nullptr)).is_null());
}

Python equivalent — learning tests for the requests library:

# GOOD: learning tests for the requests library HTTP behavior
import pytest
import requests
from unittest.mock import patch, MagicMock
 
def test_requests_raises_on_bad_status_with_raise_for_status():
    """Verify our understanding: raise_for_status() raises HTTPError on 4xx/5xx."""
    with patch("requests.get") as mock_get:
        mock_response = MagicMock()
        mock_response.status_code = 404
        mock_response.raise_for_status.side_effect = requests.HTTPError("404")
        mock_get.return_value = mock_response
 
        response = requests.get("https://api.example.com/nonexistent")
        with pytest.raises(requests.HTTPError):
            response.raise_for_status()
 
def test_requests_timeout_raises_timeout_error():
    """Verify our understanding: Timeout raises requests.exceptions.Timeout."""
    with patch("requests.get", side_effect=requests.exceptions.Timeout):
        with pytest.raises(requests.exceptions.Timeout):
            requests.get("https://api.example.com/slow", timeout=1)

Principle 3: Learning Tests Are Better Than Free

Why this rule exists: You must learn the API anyway — the question is how. If you learn by reading docs and writing throwaway scripts, you get the knowledge once, in your head, where it decays. If you learn by writing learning tests, you get: (1) your own understanding validated by running code, (2) a suite of tests that document the API’s behavior, and (3) a regression suite that detects breaking changes when you upgrade the dependency.

Learning tests have zero additional cost: the work of exploring the API is the same either way. Tests give you that work twice — once at authoring time, and once at every future library upgrade.

The pattern: when integrating a new library version, run the learning tests first. If a test fails, the library changed its behavior in that area. You now know exactly what to fix before integrating.


Principle 4: Using Code That Doesn’t Exist Yet

Why this rule exists: In large organizations, teams work in parallel. Team A needs to transmit data to a radio but Team B’s transmitter API isn’t designed yet. Without discipline, Team A either blocks on Team B or writes code coupled to a speculative interface. The clean solution: Team A defines the interface it wishes it had, writes its code against that interface, and uses a stub or mock for testing. When Team B delivers their API, Team A writes an Adapter that bridges the gap.

This is an application of the Dependency Inversion Principle: high-level modules define the interface; low-level modules implement it.

Bad — Java (tight coupling to unknown/future API):

// BAD: Team A's code directly calls Team B's unfinished Transmitter class
// This means Team A can't compile until Team B is done
public class CommunicationsController {
    public void sendData(String payload) {
        // Calling Team B's API directly — tight coupling, can't mock
        TeamBTransmitter transmitter = new TeamBTransmitter();
        transmitter.sendData(payload, FREQUENCY_100MHZ, POWER_LEVEL_5);
    }
}

Good — Java (own interface + Adapter):

// GOOD: Team A defines the interface it needs; it can work and test independently
 
// Step 1: Team A defines the interface (its own terms)
public interface Transmitter {
    void transmit(String payload);
}
 
// Step 2: Team A uses the interface everywhere
public class CommunicationsController {
    private final Transmitter transmitter;
 
    public CommunicationsController(Transmitter transmitter) {
        this.transmitter = transmitter;
    }
 
    public void sendData(String payload) {
        transmitter.transmit(payload);
    }
}
 
// Step 3: When Team B delivers their API, Team A writes one Adapter
public class TransmitterAdapter implements Transmitter {
    private final TeamBTransmitter realTransmitter;
 
    public TransmitterAdapter(TeamBTransmitter realTransmitter) {
        this.realTransmitter = realTransmitter;
    }
 
    @Override
    public void transmit(String payload) {
        // Translates our interface call to Team B's API
        realTransmitter.sendData(payload, FREQUENCY_100MHZ, POWER_LEVEL_5);
    }
}
 
// Step 4: Tests use a mock or stub — no dependency on Team B
public class CommunicationsControllerTest {
    @Test
    public void sendsPayloadToTransmitter() {
        Transmitter mockTransmitter = mock(Transmitter.class);
        CommunicationsController controller =
            new CommunicationsController(mockTransmitter);
        controller.sendData("HELLO");
        verify(mockTransmitter).transmit("HELLO");
    }
}

C++ equivalent:

// GOOD
// Step 1: Define our own interface
class ITransmitter {
public:
    virtual ~ITransmitter() = default;
    virtual void transmit(const std::string& payload) = 0;
};
 
// Step 2: Our code uses the interface
class CommunicationsController {
    std::unique_ptr<ITransmitter> transmitter_;
public:
    explicit CommunicationsController(std::unique_ptr<ITransmitter> t)
        : transmitter_(std::move(t)) {}
 
    void sendData(const std::string& payload) {
        transmitter_->transmit(payload);
    }
};
 
// Step 3: Adapter wraps Team B's API
class TransmitterAdapter : public ITransmitter {
    TeamBTransmitter& realTransmitter_;
public:
    explicit TransmitterAdapter(TeamBTransmitter& t) : realTransmitter_(t) {}
 
    void transmit(const std::string& payload) override {
        realTransmitter_.sendData(payload, FREQUENCY_100MHZ, POWER_LEVEL_5);
    }
};

Python equivalent:

# GOOD
# Step 1: Define our own Protocol (structural typing, Python 3.8+)
from typing import Protocol
 
class Transmitter(Protocol):
    def transmit(self, payload: str) -> None: ...
 
# Step 2: Our code depends on the Protocol
class CommunicationsController:
    def __init__(self, transmitter: Transmitter) -> None:
        self._transmitter = transmitter
 
    def send_data(self, payload: str) -> None:
        self._transmitter.transmit(payload)
 
# Step 3: Adapter wraps Team B's API
class TransmitterAdapter:
    def __init__(self, real_transmitter: TeamBTransmitter) -> None:
        self._real = real_transmitter
 
    def transmit(self, payload: str) -> None:
        self._real.send_data(payload, frequency=FREQUENCY_100MHZ,
                             power=POWER_LEVEL_5)
 
# Step 4: Tests use a simple stub — no Team B dependency
class FakeTransmitter:
    def __init__(self) -> None:
        self.sent: list[str] = []
 
    def transmit(self, payload: str) -> None:
        self.sent.append(payload)
 
def test_sends_payload():
    fake = FakeTransmitter()
    controller = CommunicationsController(fake)
    controller.send_data("HELLO")
    assert fake.sent == ["HELLO"]

Principle 5: Clean Boundaries

Why this rule exists: Boundaries are places where knowledge of a foreign API must exist. The goal is to minimize the places where that knowledge lives. If Stripe SDK calls are scattered across 20 service files, a Stripe API version upgrade touches 20 files. If all Stripe calls are in one StripeClient wrapper, the upgrade touches one file.

Clean boundaries also improve testability: when third-party calls are isolated behind an interface, every consumer can be tested with a mock or fake without setting up the real dependency.

Practical rules for clean boundaries:

  1. Third-party types should not appear in method signatures of your domain classes
  2. Third-party exceptions should be caught and rethrown as local exceptions at the boundary (see ch07-error-handling, Principle 5)
  3. Wrapper classes should be in a dedicated module or package (e.g., infrastructure/ or adapters/)
  4. Keep the number of “entry points” into a third-party library small — ideally one

Principle 6: The Adapter Pattern at Boundaries

Why this rule exists: The Adapter pattern solves the impedance mismatch between an interface your code needs and the interface a third-party (or different team’s) code provides. It acts as a translator: your code calls a clean interface; the adapter translates those calls into the third-party’s API. If the third-party API changes or you switch providers, only the adapter changes.

At a boundary, the Adapter gives you:

  • Isolation: your domain code never imports the third-party package
  • Replaceability: switching providers = writing one new adapter class
  • Testability: domain code can be tested with a mock/fake; adapter can be tested with an integration test

Complete Adapter example — Payment Gateway (Java):

// GOOD: our domain defines the interface it needs
// This file has no import of Stripe, PayPal, or any payment SDK
 
public interface PaymentProcessor {
    PaymentResult charge(String customerId, int amountCents, String currency);
    void refund(String chargeId, int refundAmountCents);
}
 
// Domain model — no third-party types
public record PaymentResult(String chargeId, boolean success, String errorMessage) {}
 
// Adapter for Stripe — this is the ONLY file that imports Stripe
import com.stripe.model.Charge;
import com.stripe.net.RequestOptions;
 
public class StripePaymentAdapter implements PaymentProcessor {
    private final String apiKey;
 
    public StripePaymentAdapter(String apiKey) {
        this.apiKey = apiKey;
    }
 
    @Override
    public PaymentResult charge(String customerId, int amountCents, String currency) {
        try {
            Map<String, Object> params = new HashMap<>();
            params.put("amount", amountCents);
            params.put("currency", currency);
            params.put("customer", customerId);
            Charge charge = Charge.create(params,
                RequestOptions.builder().setApiKey(apiKey).build());
            return new PaymentResult(charge.getId(), true, null);
        } catch (com.stripe.exception.StripeException e) {
            // Translate Stripe exception into our domain exception
            throw new PaymentException(
                "Stripe charge failed for customer " + customerId + ": " + e.getMessage(),
                e
            );
        }
    }
 
    @Override
    public void refund(String chargeId, int refundAmountCents) {
        try {
            Map<String, Object> params = new HashMap<>();
            params.put("charge", chargeId);
            params.put("amount", refundAmountCents);
            com.stripe.model.Refund.create(params,
                RequestOptions.builder().setApiKey(apiKey).build());
        } catch (com.stripe.exception.StripeException e) {
            throw new PaymentException(
                "Stripe refund failed for charge " + chargeId + ": " + e.getMessage(),
                e
            );
        }
    }
}
 
// Switch to PayPal: write PayPalPaymentAdapter implements PaymentProcessor
// Nothing else changes.

C++ equivalent:

// GOOD
// Domain interface — no third-party includes
struct PaymentResult {
    std::string chargeId;
    bool success;
    std::string errorMessage;
};
 
class IPaymentProcessor {
public:
    virtual ~IPaymentProcessor() = default;
    virtual PaymentResult charge(
        const std::string& customerId,
        int amountCents,
        const std::string& currency) = 0;
    virtual void refund(const std::string& chargeId, int refundAmountCents) = 0;
};
 
// Adapter — this is the ONLY translation unit that includes the Stripe C++ SDK
#include <stripe/stripe.hpp>
 
class StripePaymentAdapter : public IPaymentProcessor {
    std::string apiKey_;
public:
    explicit StripePaymentAdapter(std::string apiKey)
        : apiKey_(std::move(apiKey)) {}
 
    PaymentResult charge(const std::string& customerId,
                         int amountCents,
                         const std::string& currency) override {
        try {
            stripe::ChargeParams params;
            params.amount = amountCents;
            params.currency = currency;
            params.customer = customerId;
            auto result = stripe::Charge::create(params, apiKey_);
            return { result.id, true, "" };
        } catch (const stripe::StripeException& e) {
            throw PaymentException(
                "Stripe charge failed for customer " + customerId + ": " + e.what()
            );
        }
    }
 
    void refund(const std::string& chargeId, int refundAmountCents) override {
        try {
            stripe::RefundParams params;
            params.charge = chargeId;
            params.amount = refundAmountCents;
            stripe::Refund::create(params, apiKey_);
        } catch (const stripe::StripeException& e) {
            throw PaymentException(
                "Stripe refund failed for charge " + chargeId + ": " + e.what()
            );
        }
    }
};

Python equivalent:

# GOOD
# Domain interface via Protocol — no third-party import
from typing import Protocol
from dataclasses import dataclass
 
@dataclass
class PaymentResult:
    charge_id: str
    success: bool
    error_message: str | None = None
 
class PaymentProcessor(Protocol):
    def charge(
        self,
        customer_id: str,
        amount_cents: int,
        currency: str,
    ) -> PaymentResult: ...
 
    def refund(self, charge_id: str, refund_amount_cents: int) -> None: ...
 
# Adapter — this is the ONLY file that imports stripe
import stripe as stripe_sdk
 
class StripePaymentAdapter:
    def __init__(self, api_key: str) -> None:
        self._api_key = api_key
 
    def charge(
        self,
        customer_id: str,
        amount_cents: int,
        currency: str,
    ) -> PaymentResult:
        try:
            charge = stripe_sdk.Charge.create(
                amount=amount_cents,
                currency=currency,
                customer=customer_id,
                api_key=self._api_key,
            )
            return PaymentResult(charge_id=charge["id"], success=True)
        except stripe_sdk.error.StripeError as e:
            raise PaymentError(
                f"Stripe charge failed for customer {customer_id}: {e}"
            ) from e
 
    def refund(self, charge_id: str, refund_amount_cents: int) -> None:
        try:
            stripe_sdk.Refund.create(
                charge=charge_id,
                amount=refund_amount_cents,
                api_key=self._api_key,
            )
        except stripe_sdk.error.StripeError as e:
            raise PaymentError(
                f"Stripe refund failed for charge {charge_id}: {e}"
            ) from e
 
# Switch to PayPal: write PayPalPaymentAdapter — nothing else changes

Comparison / Summary Table

Boundary Strategies

StrategyWhen to UseCoupling to Third-PartyTestabilityMaintenance Cost
Direct use of third-party APIThrowaway prototypes, scriptsHigh — every usage site knows the APILow — must set up real dependency to testVery high — API changes touch everything
Wrapper classWhen you need a focused subset of a broad API (e.g., wrapping Map)Low — wrapper owns all knowledge of the APIHigh — callers test against the wrapper interfaceLow — only the wrapper changes on API updates
Adapter patternWhen your interface and the third-party interface differ; when you want to swap providersLow — adapter owns all knowledge of the APIVery high — domain code tested with mocks; adapter tested separatelyLow — new provider = new adapter only
Learning testsAny time you adopt a new libraryN/A — tests probe the real APIVery high — tests run against real libraryVery low — tests are maintenance themselves
Fake / StubTest environments where the real API is unavailableNone — fake is your own codeMaximum — test runs entirely in memoryLow — keep in sync with the interface

Third-Party Integration Anti-Patterns

Anti-PatternProblemFix
Third-party type in method signaturesEvery caller imports the third-party packageWrap in domain type; expose only your type
Third-party exceptions in domain codeCallers must handle library-specific exceptionsCatch at the boundary; rethrow as local exceptions
Calling SDK directly from multiple servicesAPI upgrade touches N filesCentralize all SDK calls in one wrapper/adapter
No learning testsCan’t verify behavior; upgrades are riskyWrite learning tests before integrating
Interface designed around what existsTightly coupled to current providerDesign the interface you want; write an adapter

When to Apply / Common Exceptions

Apply these rules when:

  • Integrating any third-party library that could be replaced (payment SDKs, HTTP clients, ORMs, logging frameworks)
  • Your team is working on a module that depends on another team’s unfinished API
  • You want to be able to test business logic without a real network or database
  • You anticipate switching providers or upgrading library versions in the future

Common exceptions and nuances:

  • Standard library types: It is acceptable to use java.util.List or Python’s list across boundaries — these are stable, language-owned, and universally understood. The wrapper advice applies to third-party APIs, not the standard library.
  • Ubiquitous frameworks: In Spring Boot or Django applications, the framework type often permeates the codebase (e.g., @RestController, HttpRequest). This is accepted because the framework is the foundation, not a replaceable dependency. Still, wrap framework-specific types at the domain boundary (controllers should not reach into service code with HttpRequest objects).
  • Learning tests for stable, well-known APIs: For libraries with decades of stable API history (e.g., JUnit, pytest), learning tests add less value than for newer or less stable libraries. Still worthwhile for version-specific behavior you depend on.
  • Over-engineering small projects: In a 200-line script that calls one external API once, a full adapter pattern is overkill. Apply proportionally to the expected longevity and complexity of the project.

Checklist

When reviewing boundary code, ask:

  • Does any domain class (service, entity, use case) import a third-party package directly?
  • Are there any third-party types appearing in method signatures inside the domain?
  • Are third-party exceptions propagating into the domain uncaught?
  • Is there exactly one wrapper or adapter per third-party dependency?
  • Were learning tests written when this library was first integrated?
  • Do the learning tests run as part of the CI suite?
  • If the third-party library released a new major version, how many files would need changing? (Answer should be: 1)
  • For interfaces defined against unfinished modules: is there an interface owned by our team and an adapter that bridges to the real API?
  • Can all domain logic be tested without network access, database access, or the real third-party SDK?
  • Is the adapter/wrapper in a dedicated module (infrastructure/, adapters/, external/) separate from domain code?

Key Takeaways

  1. Minimize boundary crossings: Every place where your code calls third-party code is a potential maintenance point. Keep those points as few as possible.

  2. Own the interface: Design the interface that you wish you had — one that fits your application’s vocabulary. Then write an adapter that bridges the gap to the real API. Your application code never knows what the real API looks like.

  3. Learning tests pay compound interest: Writing tests to explore a new library is work you have to do anyway. Making them formal tests means you get the payoff again at every future upgrade.

  4. Learning tests are not optional during integration: They verify your assumptions about library behavior and detect regressions when the library changes. A failing learning test after an upgrade is information you need before deploying.

  5. Define interfaces before APIs exist: When you depend on code that hasn’t been written, define the interface your code needs. Write against that interface. Use an Adapter when the real code arrives. This decouples teams and keeps progress unblocked.

  6. Third-party types should not cross boundaries: If stripe.Charge appears in a service method signature, your service is coupled to Stripe. If the service only sees PaymentResult, it can work with any payment provider.

  7. Adapters and wrappers improve testability for free: When domain code depends on an interface, tests can inject fakes or mocks. This makes every test faster, more deterministic, and runnable without external services.


External references:

  • Martin Fowler — Refactoring (Wrapper / Facade refactoring patterns)
  • Gang of Four — Design Patterns (Adapter pattern, Facade pattern)
  • Michael Feathers — Working Effectively with Legacy Code (Seam model — boundaries are seams)
  • Alistair Cockburn — Hexagonal Architecture / Ports and Adapters (formal formulation of this approach at architecture scale)

Last Updated: 2026-04-14