Skip to main content

Boundaries

Almost no system is built entirely from code its own team wrote. There's a third-party library here, an open-source package there, a component owned by another team down the hall. None of that is a problem by itself - the problem shows up at the seam where your code meets code you don't control. This chapter is about keeping those seams clean so that changes on the other side of the boundary don't ripple uncontrollably through yours.

Third-party code wants to be general, you want to be specific

A library author writes for the widest possible audience: as many methods, as much flexibility, as few assumptions as possible. You, using the library, usually want the opposite

  • a narrow interface that does exactly what your application needs and nothing else. That mismatch is where boundary problems come from.

Take a generic map type. It's powerful: get, put, remove, clear, iterate, and more. Pass that map around your application directly, though, and every recipient inherits capabilities they shouldn't have - including the ability to wipe it out entirely, since clear() is right there next to get(). Nothing enforces the convention that "only this module writes to the map" or "only sensors go in here." The type system won't catch a misuse; only discipline does, and discipline doesn't scale.

The fix isn't to avoid the library - it's to not let its interface leak past one small, well-named wrapper. Hide the general-purpose container behind a class that only exposes the operations your application actually needs.

sensors = {}
 
def register_sensor(sensor_id, sensor):
sensors[sensor_id] = sensor
 
def read_sensor(sensor_id):
# every caller has to know it's stored raw in a dict,
# and nothing stops someone from calling sensors.clear()
return sensors[sensor_id]
 
def remove_all():
sensors.clear() # any caller can do this, anywhere
class Sensors:
def __init__(self):
self._sensors = {}
 
def get_by_id(self, sensor_id):
return self._sensors.get(sensor_id)
 
def register(self, sensor_id, sensor):
self._sensors[sensor_id] = sensor
 
# no clear() exposed - callers only get the operations
# this application actually needs
 
// The raw dict never leaves this class. If the storage
// changes tomorrow, this is the only place that notices.

Now nobody outside Sensors even knows a dict is involved. If the storage needs to change - swap in a different structure, add caching, whatever - there's exactly one place to touch. The rule of thumb: it's fine to use a boundary interface like this, but keep it inside one class or a tight group of related classes. Don't let it show up as a parameter or return type on your public API.

Learning tests: explore a library before you depend on it

Before wiring a new library into production code, it's tempting to just start writing the integration and debug your way to understanding - except now you're debugging two unknowns at once: whether the bug is in your code or in your (still poorly understood) use of theirs.

A cheaper approach: write small, throwaway-looking tests that call the library the way you expect to use it, before any of that code touches your real application. These are learning tests. Each one is a tiny experiment - "if I call it like this, does it do what I think?" - and you keep iterating on them until the library behaves the way your mental model says it should. By the time you're done, you've turned "I read the docs" into a working, executable description of exactly how the library behaves for your use case.

Learning tests keep paying you back

The learning tests cost nothing extra, because you had to learn the API's behavior one way or another - writing it as a test just means you keep the artifact instead of throwing it away. The payoff comes later: when the library gets a new release, rerun the learning tests. If they still pass, you know the parts you rely on didn't change. If they fail, you find out immediately, in an isolated test, rather than discovering it as a mysterious production bug three weeks after the upgrade. A boundary without tests like this tempts a team to stay on an old library version indefinitely, because nobody can say with confidence that upgrading is safe.

Coding against an interface that doesn't exist yet

Sometimes the code on the other side of a boundary genuinely doesn't exist yet - another team hasn't finished designing their API, or a subsystem you depend on is still being built. You don't have to be blocked by that. Write the interface you wish you had: name it, give it the method signature that expresses what you want to say ("key this on a frequency and stream data through it"), and build your side of the system against that.

When the real API finally shows up, you write one adapter class that translates between your wished-for interface and the actual one. Everything else in your codebase never has to know the difference, and in the meantime you can test your side completely using a fake that implements your own interface.

«interface»Transmitter+ transmit(frequency, data)THEApp+ send(freq, data)YOURTransmitterAdapter+ transmit(frequency, data)BUILTExternalTransmitter+ setFrequency(f)+ transmitData(data)THIRD-PARTY,
implementsdependency
Only TransmitterAdapter ever learns the real vendor API's vocabulary.

Clean boundaries

The common thread across all of this: code you don't control is going to change on a schedule you don't control, so the fewer places in your codebase that know the specifics of a third-party type, the smaller the blast radius when that change lands. Whether you get there by wrapping (as with the map) or by adapting (as with the not-yet-built API), the goal is the same - depend on something you control, and let it be the only thing that depends on something you don't.