Designing a Module Interface
The interface is what a caller must know. Design it from what the caller is trying to do, not from what the module happens to have lying around.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
What should a module expose, given that everything exposed becomes something it can never change?
Product wants a "notify me when this is back in stock" feature. The notifications module already exists — it sends order confirmations — and someone has to decide what it should offer.
Expose what the module can do. It has a template renderer, a queue client, a preference table and an SMTP connection, so expose renderTemplate, enqueue, getPreferences, sendRaw and send. Callers can compose what they need and nobody has to come back and ask for a method.
The stock-alert team composes getPreferences + renderTemplate + enqueue, forgets that preferences also carry a quiet-hours window, and sends four thousand emails at 3am. The rule existed; the interface let them assemble a path that skipped it.
- The stock-alert team composes
getPreferences+renderTemplate+enqueue, forgets that preferences also carry a quiet-hours window, and sends four thousand emails at 3am. The rule existed; the interface let them assemble a path that skipped it. - Every exposed primitive is now a contract. The queue is swapped for a different broker and
enqueue's semantics change — at-least-once instead of exactly-once — and four teams have code that assumed otherwise (Information Hiding). - When SMS arrives,
sendRawhas no meaning for it andrenderTemplatereturns HTML. The interface was shaped by the email implementation, so the second channel does not fit and gets its own parallel module (Divergent Change). - The module's owner can no longer change anything. Five methods times four teams is twenty usages, and the ones that matter are the creative compositions nobody predicted.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The notifications module is owned by one engineer and consumed by four teams, so every method added is a promise four teams will hold it to.
- Two of those teams are in other timezones; anything that needs a conversation to use correctly will be used incorrectly.
- Email is the only channel today. SMS and push are on the roadmap in the vague way things are on roadmaps.
- A caller can never send a notification the recipient has unsubscribed from — no route, no flag, no override that is easier to type than the correct call.
- A notification is sent at most once per triggering event, even if the caller retries (Idempotency by Design).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The module owns the *whole* act of notifying: preference check, quiet hours, rendering, delivery, deduplication. Partial ownership is the failure — a rule the module knows but the caller can skip is not owned.
- The caller owns the intent and the data: who, about what, because of which event.
- The module owner owns saying no to method requests, and owes each requester the operation they actually needed instead.
- The boundary is drawn at the point where the domain event becomes a delivery concern.
notifyBackInStock(userId, productId, eventId)is on the domain side of it;renderTemplateis on the wrong side and should not be visible. - Interface width is the real variable: five primitives that compose into invalid states are wider than eight named operations that cannot (Making Illegal States Unrepresentable).
- Anything the caller cannot correctly assemble without reading the module's source belongs inside it (Local Reasoning).
What can the caller assemble that you did not intend?
The useful question about an interface is not whether it is small but what the set of exposed operations can be composed into. Every primitive multiplies with every other primitive, and the products are the paths nobody designed.
The version on the right has more methods and a smaller commitment. Callers cannot reach delivery without going through the rules, so the rules apply to code written after everyone who knew about them has left (Bus Factor).
export interface Notifications {
getPreferences(userId: string): Prefs
renderTemplate(name: string, data: object): string
enqueue(to: string, subject: string, html: string): void
sendRaw(to: string, subject: string, html: string): void
}
// what the stock-alert team wrote, reasonably:
const prefs = notifications.getPreferences(userId)
if (prefs.marketing) {
const html = notifications.renderTemplate('back-in-stock', { product })
notifications.enqueue(user.email, 'Back in stock', html)
}
// skipped: quiet hours, per-kind opt-out, dedup by event idexport interface Notifications {
notify(n: NotificationRequest): Promise<NotificationOutcome>
}
type NotificationRequest =
| { kind: 'order-confirmed'; userId: UserId; orderId: OrderId; eventId: EventId }
| { kind: 'back-in-stock'; userId: UserId; productId: ProductId; eventId: EventId }
type NotificationOutcome =
| { status: 'queued'; id: NotificationId }
| { status: 'suppressed'; reason: 'unsubscribed' | 'quiet-hours' | 'rate-limit' }
| { status: 'duplicate'; original: NotificationId }The rules are no longer optional, because there is no route that reaches delivery without passing them — including the route a new engineer writes next year having read none of this. The eventId is the other half: a rule the module cannot see is a rule it cannot enforce, so deduplication only becomes the module's job once the caller is required to hand it the key. And suppressed is deliberately not an error: it is a normal outcome the caller often needs to log, and modelling it as a thrown exception would push handling back out to the callers (Error Modeling).
Wide, narrow, and the cost of each
There is no default answer here, which is why the axes are worth scoring rather than asserting. A module that owns rules should be narrow; a module that offers mechanism — a date library, a parser, a collection — is usually better wide, because its callers legitimately need compositions nobody enumerated.
The row that decides it most often is migration: a wide interface is cheap to build and expensive to retract, and the asymmetry only shows up years later, in someone else's quarter.
| Option | Simplicity | Flexibility | Testability | Migration cost | Operational | Note |
|---|---|---|---|---|---|---|
| Wide — expose the primitives | Fastest to build and callers never wait for you. The invariant is unenforceable and every primitive is a promise; retracting one is a cross-team negotiation. | |||||
| Narrow — named intents | Rules apply retroactively to every call site including future ones. Callers with an unanticipated need are blocked on the module's owner. | |||||
| Narrow plus a documented escape hatch | Honest about the case the design did not cover. In practice the hatch becomes the main entrance within a year unless its use is reviewed. | |||||
| Two interfaces — intents public, primitives internal | What most mature modules converge on. Costs a real boundary inside the module and a rule that keeps the internal surface internal (Internal Module Contracts). |
caveat The scores assume this module owns a rule that must not be bypassed. Score a mechanism module — a date library, a JSON codec — and wide wins on nearly every axis, because there is no invariant for composition to violate and the flexibility is the entire product. Nothing in these numbers is a measurement; they are a way to argue about the axes in the same units.
Saying no, and what to say instead
Interfaces do not widen because someone decided to widen them. They widen because a colleague asks for one small method, the request is reasonable, and the owner has no argument ready other than taste.
The argument that works is a question: what are you trying to do? A request for renderTemplate is almost never a desire to render a template — it is a desire to send something the interface does not cover yet, and the right answer is a new named operation that keeps the rules inside.
- 1Hear the request
Someone asks for
sendRawbecause they need a mailing the module does not support.fails by Treating the named method as the requirement and shipping it in an afternoon.
- 2Ask for the intent
Find the sentence: "notify the twelve customers affected by the incident".
fails by Skipping this because asking feels like obstruction, which is how most surface area is acquired.
- 3Check the rules
Decide which invariants apply — this one is transactional, so unsubscribe does not apply but rate limits do.
fails by Assuming the caller will apply them, which is the assumption that produced the 3am emails.
- 4Name the operation
Add
notifyIncidentAffected(userIds, incidentId)— specific, rule-carrying, and impossible to misuse.fails by Adding the generic version that also covers nine cases nobody has asked for (Premature Abstraction).
- 5Record the decision
One line saying why the primitive was refused, so the next requester gets the same answer.
fails by Leaving it as folklore, so the third request succeeds because a different person was on call (Decision Records).
The step that is always skipped is the second, and it is the one that turns an interface-widening request into an interface-improving one.
How to build it
Most important first.
- Start from the caller's sentence. "Tell this customer their item is back" is one operation, so the interface has one operation for it — not three parts and an assembly instruction.
- Expose operations, not the module's furniture. If a method exists only because the implementation has that object, it is furniture (Exposing Too Much).
- Make the correct call the easiest call. If a caller can reach delivery without passing the preference check, the interface is designed against its own invariant.
- Take the event id as a parameter so deduplication can live inside. Rules the module cannot see, it cannot enforce (Idempotency by Design).
- Return a result the caller can act on — sent, suppressed by preference, suppressed by quiet hours, deferred — because "suppressed" is not an error and the caller often has to log it (An Error Taxonomy That Survives Contact).
- Add a method when a real caller needs it, and resist the generic one that would have covered it plus nine hypothetical cases (Speculative Generality).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding SMS costs the module plus a channel column in preferences — no caller changes, because no caller ever named a channel. Under the primitive interface it costs four teams, because
renderTemplatereturns HTML and they all call it. - Adding a rule — "never notify a customer more than five times a day" — costs one guard inside the module and applies to every existing call site retroactively. That retroactive application is the property a narrow interface buys and a wide one cannot.
- What did not get cheaper: adding a notification *kind* still touches the caller (which triggers it), the module (which renders it) and the preference model (which lets people opt out). Three edits, and no interface design removes them — they are the essential shape of the feature.
- Named operations mean the module owner is in the path of every new use case. That is a bottleneck, and on a small team with one product it is pure friction (When Design Does Not Pay).
- Callers lose the ability to do something the designer did not imagine. Sometimes what they wanted was legitimate and now costs a week of waiting instead of an afternoon of composing.
- A narrow interface hides cost as well as mechanism.
notifyBackInStockin a loop over fifty thousand users looks like fifty thousand cheap calls, and is not (Cost-Aware Interfaces).
What can go wrong
- The narrow interface is built and a
sendRawescape hatch is kept "for emergencies". Within a year it is the most-called method in the module and the invariant is gone (The Common Module). - The interface is so narrow that a legitimate need — a one-off compliance mailing to a fixed list — has no route, so someone copies the SMTP config into a script. Over-narrowing exports the problem rather than solving it.
- Every caller gets a bespoke method, so the module accumulates forty operations that are each used once. That is not a wide interface, it is an absent abstraction (Module Granularity).
- The mitigation fails on its own: a
NotificationResultunion grows a case per situation until callers switch on eleven variants, at which point the complexity moved rather than disappeared.
- Four teams depend on the module; the module depends on the queue, the template store, the preference table and the transport, and exposes none of them.
- The dependency is on a *vocabulary* — notification kinds and recipients — which changes when the business does, not when the infrastructure does (Stable Boundaries).
- Adding SMS adds a dependency inside the module and none outside it, which is the test that the boundary was drawn correctly.
- "Small interfaces are better." Smaller is not the goal; *narrower in what it commits to* is. Eight named operations that cannot be misused beat three primitives that compose into a violation (Interface Segregation, Critically).
- "So define an interface type for it." A TypeScript
interfaceor Java interface is a language feature; a module interface is the set of things callers can reach, including exported constants, types and the failure modes. Adding aninterfacekeyword to a wide surface narrows nothing (Interface Versus Implementation). - "Expose it now, deprecate it later if it is wrong." Internal deprecation across four teams is a quarter of nagging. Adding a method is cheap and removing one is not, and that asymmetry should decide the default (Deprecation).
- "The caller knows what it needs." The caller knows what it wants to happen. It does not know about quiet hours, dedup keys or the unsubscribe table, and designing as if it does is how those rules get skipped.
- long-parameter-list
- feature-envy
Testing it, and how it ages
- Test the module through its public operations only. If a test needs
renderTemplateto be exported, that is the design telling you the boundary is in the wrong place (Testing as Design Feedback). - Test the invariant through every public route: assert that no exposed operation can deliver to an unsubscribed recipient. This is a test about the interface, not about a function.
- Give callers a fake implementation of the same interface rather than mocking the transport. Mocking what is behind the boundary is how tests come to depend on the decisions you hid (Test Doubles, Precisely).
- A consumer-driven contract test per team catches the day a return-value change breaks one of them (Contract Tests).
- Interfaces widen under social pressure, one reasonable request at a time. The counter-pressure is a named owner and a habit of asking what the requester is trying to do rather than what method they asked for (Code Ownership).
- The first real test comes with the second channel. If SMS fits without changing a caller, the shape was right; if it needs a parallel module, the interface was email's implementation all along.
- Eventually the module splits — transactional versus marketing notifications have different rules, different compliance and different owners — and the split is cheap precisely because callers named intents rather than mechanics (Extract Module).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat everything exposed becomes something you cannot change follows from having callers, so it applies to a package, a class, a service or a library — only the cost of retracting differs, from a rename in one repo to a major version.
- SCALE-SPECIFICWith one team and one consumer, a wide interface costs almost nothing because the same people fix every call site in an afternoon. At four consuming teams the same width is a coordination problem, and past that it is a versioning problem. The advice flips somewhere between one and three consumers.
- CONTESTEDThe strongest opposing view — associated with library designers and with Unix tooling — is that narrow, intent-shaped operations are a guess about use cases, and that exposing sharp composable primitives lets callers solve problems the author never imagined, which is where most of the value in a general-purpose module comes from. That is right for a module whose job is mechanism and has no invariant to protect: a parser, a date library, a collection. It is wrong precisely when the module owns a rule, because composable primitives compose into rule violations.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — the same question at service grain decides whether a call is a chatty sequence of primitives across a network or one intent-shaped request, and there the cost of getting it wrong is measured in round trips.