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:
- 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 - Has no tests verifying what the third-party library actually does
- Passes
Mapobjects between unrelated components, allowing any consumer to mutate or clear them - Calls a payment gateway’s SDK directly from a service class with no indirection — switching providers requires rewriting multiple files
- 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 castCore 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 mutationGood — 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 iteratePrinciple 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:
- Third-party types should not appear in method signatures of your domain classes
- Third-party exceptions should be caught and rethrown as local exceptions at the boundary (see ch07-error-handling, Principle 5)
- Wrapper classes should be in a dedicated module or package (e.g.,
infrastructure/oradapters/) - 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 changesComparison / Summary Table
Boundary Strategies
| Strategy | When to Use | Coupling to Third-Party | Testability | Maintenance Cost |
|---|---|---|---|---|
| Direct use of third-party API | Throwaway prototypes, scripts | High — every usage site knows the API | Low — must set up real dependency to test | Very high — API changes touch everything |
| Wrapper class | When you need a focused subset of a broad API (e.g., wrapping Map) | Low — wrapper owns all knowledge of the API | High — callers test against the wrapper interface | Low — only the wrapper changes on API updates |
| Adapter pattern | When your interface and the third-party interface differ; when you want to swap providers | Low — adapter owns all knowledge of the API | Very high — domain code tested with mocks; adapter tested separately | Low — new provider = new adapter only |
| Learning tests | Any time you adopt a new library | N/A — tests probe the real API | Very high — tests run against real library | Very low — tests are maintenance themselves |
| Fake / Stub | Test environments where the real API is unavailable | None — fake is your own code | Maximum — test runs entirely in memory | Low — keep in sync with the interface |
Third-Party Integration Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Third-party type in method signatures | Every caller imports the third-party package | Wrap in domain type; expose only your type |
| Third-party exceptions in domain code | Callers must handle library-specific exceptions | Catch at the boundary; rethrow as local exceptions |
| Calling SDK directly from multiple services | API upgrade touches N files | Centralize all SDK calls in one wrapper/adapter |
| No learning tests | Can’t verify behavior; upgrades are risky | Write learning tests before integrating |
| Interface designed around what exists | Tightly coupled to current provider | Design 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.Listor Python’slistacross 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 withHttpRequestobjects). - 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
-
Minimize boundary crossings: Every place where your code calls third-party code is a potential maintenance point. Keep those points as few as possible.
-
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.
-
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.
-
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.
-
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.
-
Third-party types should not cross boundaries: If
stripe.Chargeappears in a service method signature, your service is coupled to Stripe. If the service only seesPaymentResult, it can work with any payment provider. -
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.
Related Resources
- ch07-error-handling — Wrap third-party exceptions at the boundary using the same principle as Principle 5
- ch09-unit-tests — Learning tests are unit tests; clean test structure applies here
- ch11-systems — Dependency Injection is the mechanism by which adapters are wired in at runtime
- ch06-objects-and-data-structures — Wrapper classes are a form of encapsulation
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