Codebase Design
Design deep modules: Put extensive behavior behind a small interface, place the interface on a clear seam, and test through that interface. Use this set of language and principles whenever designing or refactoring code. The goal is to provide leverage for callers, locality for maintainers, and make testing easier for everyone.
Glossary
Use these terms precisely; do not replace them with "component", "service", "API", or "boundary". Consistent language is the key.
Module - Anything that has an interface and implementation. It intentionally does not specify scale: it can be a function, class, package, or cross-layer slice. Avoid: unit, component, service.
Interface - Everything a caller must know to use the module correctly: type signature, plus invariants, ordering constraints, error modes, required configuration, and performance characteristics. Avoid: API, signature (too narrow, refers only to the type-level surface).
Implementation - The body of code inside the module. It differs from Adapter: something can be a small adapter with a large implementation (Postgres repo), or a large adapter with a small implementation (in-memory fake). Talk about adapters when discussing seams; use implementation otherwise.
Depth - Leverage on the interface: how much behavior a caller (or test) can access per unit of interface learned. A module is deep when extensive behavior is hidden behind a small interface; it is shallow when the interface is nearly as complex as the implementation.
Seam (Michael Feathers) - A place where you can change behavior without editing the current location; that is, the location where the module's interface resides. Where to place a seam is an independent design decision, separate from what lies behind the seam. Avoid: boundary (it is overloaded with DDD bounded context).
Adapter - A specific thing that satisfies an interface at a seam. It describes a role (which slot it fills), not substance (what's inside it).
Leverage - The benefit callers gain from depth: more capability per unit of interface learned. An implementation pays off across N call sites and M tests.
Locality - The benefit maintainers gain from depth: changes, bugs, knowledge, and verification are concentrated in one place rather than scattered across callers. Fix it once, and it's fixed everywhere.
Deep vs shallow
Deep module = small interface + lots of implementation:
text
+------------------+
| Small Interface | -> few methods, simple params
+------------------+
| |
| Deep | -> complex logic hidden
| Implementation |
| |
+------------------+
Shallow module = large interface + little implementation (avoid):
text
+-------------------------------+
| Large Interface | -> many methods, complex params
+-------------------------------+
| Thin Implementation | -> mostly pass-through
+-------------------------------+
When designing an interface, ask:
- Can I reduce the number of methods?
- Can I simplify the parameters?
- Can I hide more complexity inside?
Principles
- Depth is a property of the interface, not the implementation. A deep module can be composed of small, mockable, swappable parts internally—they just don't belong to the interface. A module can have both internal seams (private to the implementation, used for its own tests) and an external seam at the interface.
- Deletion test. Imagine deleting this module. If the complexity disappears, it's just a pass-through. If the complexity scatters back to N callers, it's providing value.
- Interface is the test surface. Callers and tests go through the same seam. If you want to test internal details behind the interface, the module's shape is probably wrong.
- One adapter means a hypothetical seam. Two adapters means a real one. Don't introduce a seam unless something will actually change across it.
Designing for testability
Good interfaces make testing natural:
-
Accept dependencies, don't create them.
typescript
// Testable
function processOrder(order, paymentGateway) {}
// Hard to test
function processOrder(order) {
const gateway = new StripeGateway();
}
-
Return results, don't produce side effects.
typescript
// Testable
function calculateDiscount(cart): Discount {}
// Hard to test
function applyDiscount(cart): void {
cart.total -= discount;
}
-
Small surface area. Fewer methods = fewer tests needed. Fewer params = simpler test setup.
Relationships
- A Module has exactly one Interface (the surface it presents to callers and tests).
- Depth is a property of a Module, measured by its Interface.
- Seam is the location where a Module's Interface resides.
- Adapter sits on a Seam and satisfies an Interface.
- Depth generates Leverage for callers and Locality for maintainers.
Rejected framings
- Treating depth as the ratio of implementation lines to interface lines (Ousterhout): This rewards padding the implementation. Here, we use depth-as-leverage.
- Interpreting "Interface" as the TypeScript keyword or class public methods: Too narrow; the interface here includes all facts a caller must know.
- "Boundary": Overloaded with DDD bounded context. Say seam or interface.
Going deeper
- Deepening a cluster given its dependencies - See DEEPENING.md: dependency categories, seam discipline, and replace-don't-layer testing.
- Exploring alternative interfaces - See DESIGN-IT-TWICE.md: Launch parallel sub-agents, design interfaces in several distinct ways, then compare by depth, locality, and seam placement.