Most design discussions I sit in start with the wrong question. Someone asks which abstraction we should introduce: a repository, a service layer, an event bus. I try to ask a different one first: where does one part of the system stop being allowed to know about another?

That question has a more durable answer. Abstractions are guesses about what will vary. Boundaries are decisions about who is allowed to depend on whom. The second kind of decision is cheaper to make early and far more expensive to make late.

What a boundary actually is

A boundary is a line in the code across which only a narrow, named set of things may pass. It is not a folder. It is not a package name. It is a rule that the build, the tests, or a review checklist can enforce.1

Three things define a boundary for me:

  1. The direction of dependency. Which side imports the other. One direction only.
  2. The vocabulary that crosses it. Plain data, not live objects with behaviour.
  3. The failure it contains. If the far side is broken, what does the near side observe?

If I cannot answer all three, I do not have a boundary. I have a suggestion.

Why abstractions come second

An abstraction introduced before a boundary tends to leak. A UserRepository interface that sits in the same module as the SQL implementation is a formality: nothing stops a caller from reaching past it. The same interface placed on a boundary, with the implementation on the far side, becomes real, because the dependency direction now forbids the shortcut.

This is the order I follow:

// 1. Decide the line. Core may not import from infra.
// Enforced by a lint rule, not by hope.
//
// 2. Name what crosses it. Plain data in, plain data out.
export interface OrderSnapshot {
id: string;
lines: ReadonlyArray<{ sku: string; qty: number }>;
placedAt: string; // ISO 8601
}
// 3. Only now write the abstraction the core needs.
export interface OrderStore {
load(id: string): Promise<OrderSnapshot | undefined>;
save(order: OrderSnapshot): Promise<void>;
}

The interface in step three is small because the boundary in step one forced the question of what the core genuinely needs. Without the boundary I would have written a store with fourteen methods, twelve of them convenience.

The picture I draw

When I need to argue for this in a meeting, I draw the same diagram. Two boxes, one arrow, and a note about what the arrow carries.

corerules, typesinfrasql, http, queuesdepends onplain data crosses here

The dashed line is the boundary. Everything else in the design is negotiable; the dashed line is not.

Enforcing it

A boundary that lives in a document is decoration. I enforce mine in three places, in order of preference:

  • The compiler or linter. Import restrictions, module visibility, package-private types. Fails fast and cannot be argued with in review.
  • An architecture test. A unit test that walks the dependency graph and fails when core imports infra. Slower feedback, but it works in any language.
  • The review checklist. Last resort. Humans are excellent at many things and poor at noticing a single import line in a 400-line diff.

When I break the rule

I break it for prototypes that I have promised to delete, and I write the deletion date in the commit message. I have kept that promise about half the time, which is a fair measure of how much the rule is worth.2

Notes

  1. The enforcement part matters more than the naming part. A boundary nobody checks is a boundary nobody has.

  2. The other half taught me to make the prototype smaller next time.