skip to content

What is an architectural boundary in a software system, and what is the difference between a module's interface and its implementation?

level: juniorimportance: must knowfreq 72%

answer

  1. interface = what, implementation = how
  2. hide the decision most likely to change (Parnas)
  3. high cohesion inside, low coupling across
  4. arrows point one way; invert to keep them so
  5. unenforced boundary = a drawing

basics

~20 s

A boundary is a line separating parts of a system that change for different reasons. The interface is the small set of operations other parts may call; the implementation is the hidden internals (data structures, algorithms, storage) they must not touch.

solid answer

~50 s

An architectural boundary is a deliberate separation line between two parts of a system, across which dependencies are restricted and explicit. Each side exposes an interface — a named contract of operations, inputs and outputs — while keeping its implementation private: internal data layout, algorithms, database tables, third-party libraries, error details. The point is substitutability and independent change: if callers only depend on the contract, the owner can rewrite internals, swap storage, or re-deploy without breaking anyone. A boundary is real only if it is enforced; if callers can reach around the interface (import an internal class, query the other module's tables, deserialize its private DTO), the boundary is decorative. Good boundaries follow high cohesion inside and low coupling across, and are placed where change axes differ. Boundaries also cost something to cross — serialization, indirection, latency, extra tests — so you place them where the decoupling pays for that cost, not everywhere.

code

pseudocode · 12 lines
pseudocode
// Leaky: caller now depends on the storage schema and the vendor SDK
interface Payments {
  PaymentRow findRow(long id);          // DB row escapes
  void charge(StripeChargeRequest req); // vendor type escapes
}

// Sealed: contract types owned by the module, intent-shaped
interface Payments {
  ChargeResult charge(OrderId order, Money amount);  // ChargeResult/Money are ours
  PaymentStatus statusOf(OrderId order);             // no rows, no vendor errors
}
// Internals (tables, SDK, retry policy) are free to change.

go deeper

for a junior

Define boundary, interface, implementation; give one concrete leak (returning a DB entity) and why it hurts.

for a middle

Add information hiding as 'hide the decision likely to change', cohesion/coupling placement, and one-way dependency direction.

for a senior

Discuss dependency inversion to preserve direction, contract ownership and translation at the edge, and the cost side — why not every seam deserves a boundary.

for a principal

Frame boundaries as options on future change: what independent-change right are we buying, at what coordination and latency cost, and what mechanism makes it real rather than aspirational.

## What a boundary is An **architectural boundary** is a line you draw through a system that says: *code on this side may only talk to code on that side through a declared contract, and only in a declared direction.* Boundaries exist at many scales — a function, a class, a package/module, a library, a process, a service, a whole system — and the same idea applies at each. Two things define a boundary: 1. **A contract (interface)** — the public surface: operation names, parameters, return values, errors, and the semantics/guarantees attached to them (ordering, idempotency, units, validity rules). 2. **A rule about dependencies** — who is allowed to know about whom. Crossing outside the contract is forbidden, and the *direction* of knowledge is controlled. ## Interface vs. implementation - **Interface** = *what* it does, from the caller's point of view. `chargeCard(orderId, amountMinorUnits, currency) -> ChargeResult`. Stable, small, expressed in the caller's vocabulary. - **Implementation** = *how* it does it. Which payment gateway, which retry policy, which table schema, which caching library, whether it is one class or forty. **Information hiding** (David Parnas, 1972) is the underlying principle: modules should be decomposed so that each hides a *design decision likely to change*, and the interface reveals as little of that decision as possible. Parnas's key insight is that you don't decompose by processing steps ("step 1 module, step 2 module") but by *secrets*. What counts as a secret you should hide: - storage technology and schema (tables, indexes, column names) - third-party SDK types and vendor error codes - internal data structures and invariants - concurrency/threading choices - serialization format used internally - whether the work is done in-process, in a queue, or by a remote call ### Leaky interfaces An interface **leaks** when it forces callers to know a hidden decision. Classic leaks: - Returning your ORM entity / database row object. Now every caller depends on your schema, and a column rename is a breaking change. - Throwing vendor-specific exceptions (`SQLException`, `StripeCardError`) across the boundary. - Exposing a `Map<String, Object>` "bag" whose keys are really your internal field names. - Exposing mutable internal collections, so callers can corrupt your state. - CRUD-shaped interfaces (`getRow`, `setField`) instead of intent-shaped ones (`cancelSubscription`). The fix is usually a **translation step at the edge**: the module converts internal representations into a contract type it owns, and converts vendor errors into its own error vocabulary. ## Why boundaries are worth the trouble - **Independent change / replaceability.** You can rewrite the inside without a system-wide ripple. The unit of *replacement* is the unit bounded by an interface. - **Independent reasoning.** A reader can understand one side without loading the other into their head. This is often the biggest practical win. - **Independent testing.** You can substitute a fake implementation of the contract, so tests are fast and deterministic. - **Independent deployment (sometimes).** If a boundary is also a process boundary, the two sides can ship on separate schedules. - **Blast-radius control.** A bug, a bad dependency, or a security-sensitive concern stays on one side. ## Cohesion and coupling The usual heuristic: **high cohesion inside, low coupling across.** Things that change together should live on the same side of the line; things that change for different reasons should be separated. A useful test: look at your last 20 commits or pull requests. If most of them touch both sides of a proposed boundary, the boundary is in the wrong place — you have split something that is really one thing, and now you pay coordination cost for nothing. ## Directionality: dependencies point one way A boundary is much stronger when the dependency arrow only goes one way. If `Orders` calls `Payments` *and* `Payments` calls `Orders`, neither can be understood, tested, or deployed alone — you have a cycle, which is effectively one big module with extra ceremony. When you need a call in the "wrong" direction, **dependency inversion** lets you keep the arrow: the low-level side defines and depends on an interface *owned by* the high-level side (or by a shared contract module), and the concrete implementation is plugged in at composition time. The source-code dependency now points opposite to the runtime call. Callbacks, ports-and-adapters, and event publication are all forms of this. ## Boundaries are not free Every boundary costs: an extra type to map to, an extra indirection to read through, extra tests, sometimes serialization and network latency. Premature boundaries produce "architecture astronaut" code — five interfaces with one implementation each and no independent change ever realized. Draw boundaries where you have evidence of a real change axis (different rate of change, different team, different scaling profile, different failure/security domain, a genuinely replaceable vendor). ## A boundary that isn't enforced isn't a boundary If anything can `import` anything, the line exists only in a diagram. Real enforcement comes from language/module visibility (private, internal, package-private, module exports), build-level module separation, dependency-rule tests (fitness functions), review, and — the strongest and most expensive — physical separation into separate processes with no shared database.

  • If a module returns its own contract type instead of a database row, haven't you just duplicated the fields?
    Often yes, and that duplication is the point: it buys the freedom to evolve the two independently. The internal shape can grow columns, denormalize, or split tables while the contract type stays stable. You only merge them when the module is genuinely trivial and the boundary earns nothing.
  • How can you tell a boundary is in the wrong place?
    Change coupling: most changes touch both sides at once, contracts churn on nearly every feature, or every read requires a chatty back-and-forth across the line. Those are signs one concept was split; move the line or remove it.

A restaurant menu is the interface; the kitchen is the implementation. You order 'soup of the day' and get a bowl. The chef can change supplier, recipe, or the whole stove layout without reprinting your relationship with the restaurant. The moment diners are allowed to wander into the kitchen and grab pans, the menu stops being a boundary.

context