CompositionLANGUAGE-SPECIFICCONTESTEDSCALE-SPECIFIC

Interface Versus Implementation

Depend on the narrowest thing that does the job. That is a different rule from "declare an interface for everything", and it more often means asking for less than for an abstraction.

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.

The question

What should a function ask for — the concrete type, an interface, or just the two fields it actually reads?

The requirement

A sendReceipt(order) function takes the full Order aggregate, which drags in line items, the customer, the payment record and their lazy-loaded relations. It is used in tests, in a batch job and in the checkout path.

The obvious build

Pass the whole Order. It has everything anyone could need, the signature stays stable as requirements grow, and nobody has to define another type.

Why it breaks

The signature is stable and the *coupling* is not. sendReceipt now depends on everything Order depends on, so a change to the payment record's shape can break receipt formatting (Kinds of Coupling).

How it breaks as requirements change
  • The signature is stable and the *coupling* is not. sendReceipt now depends on everything Order depends on, so a change to the payment record's shape can break receipt formatting (Kinds of Coupling).
  • Testing it needs a full valid Order, which means a builder, which means the test knows about line items and customers to assert on a total (Testing as Design Feedback).
  • The batch job cannot pass a lightweight projection even though it has exactly the three fields needed, so it loads the aggregate a million times.
  • Nobody can tell from the signature what the function reads, so nobody can safely change Order — the dependency is real, wide, and undeclared (Local Reasoning).
  • The reflex fix — an IOrder interface — changes nothing: it is the same fourteen members with an I on the front, and the coupling is identical (How SOLID Gets Misused).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The Order type is owned by another module and changes roughly monthly (Code Ownership).
  • The batch job runs over a million rows, so loading the full aggregate per row is a real cost (N+1 as a Design Problem).
  • The codebase already has a convention of one interface per service class, which most engineers believe is required.
Invariants
  • A receipt must contain the amount actually charged, not a recomputed one — the input must carry the charged figure (Units in Names and Types).
  • Nothing that formats a receipt may trigger a database read as a side effect of touching a field (Side Effects).

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The *consumer* owns the shape of what it needs. The narrow type belongs beside the function that requires it, not in the provider's module.
  • The provider owns its own concrete type and owes nobody an interface until a second implementation exists (Polymorphism).
  • The composition root owns turning a fat object into a narrow one, which is usually a single destructuring at the call site (Wiring and the Composition Root).
Boundaries
  • The parameter list *is* a boundary, and the most-ignored one. Whatever appears in it is what this code may depend on, and everything else is out of scope by construction.
  • A consumer-defined narrow type puts the boundary on the consumer's side, so the provider can change freely as long as those three fields survive (Stable Boundaries).
  • A provider-defined interface puts it on the provider's side, which is right for a genuine plugin point and wrong for the ordinary case (Dependency Inversion).

Ask for less

The move that improves most signatures is not adding an abstraction — it is subtracting. A function that takes three fields instead of an aggregate has fewer things that can break it, fewer things to construct in a test, and a signature that documents its own dependencies exactly.

This is why "depend on abstractions" is such a misleading summary. The narrow record below is not more abstract than Order; it is *smaller*, and smaller is what actually bought the change locality.

Three parameters, three couplings
1// (1) Everything Order depends on is now a dependency of receipts.
2function sendReceipt(order: Order) { /* reads 3 of 14 fields */ }
3
4// (2) Same coupling, one letter of ceremony. This is not an
5// improvement; IOrder has the same fourteen members.
6function sendReceipt(order: IOrder) { }
7
8// (3) Depends on three field names. Order can be restructured,
9// the batch job passes a projection, the test is a literal.
10type Receiptable = { id: OrderId; chargedAmount: Money; email: Email }
11function sendReceipt(o: Receiptable) { }
12
13// Defined next to sendReceipt, not next to Order:
14// the consumer owns the shape of what it needs.

Version (2) is the one that passes review in most codebases, and it is the one that changes nothing. Ceremony and decoupling are not the same act.

When an interface is premature — and when it is not

The honest position is that the interface question has two clearly different cases and the profession routinely applies one answer to both. Wrapping a payment SDK you cannot change, cannot run in a test and may replace is obviously worth a seam. Wrapping a class you own, in the same module, that has one implementation and always will, is obviously not — and looks identical in the diff.

The scored view below is deliberately uncomfortable: the premature interface scores *worse than the concrete type on every axis including testability*, because a small concrete class you own is already easy to test.

Four ways to depend on something
OptionSimplicityFlexibilityTestabilityMigration costNote
Concrete type you ownRight by default inside a module. You can change it, so you do not need a seam to protect you from it.
Narrow consumer-defined shapeThe best default across a module boundary in a structurally-typed language. Costs a type declaration and buys precise, visible coupling.
Interface, one implementation, added earlyThe premature case. Costs a file, a name and a hop; the flexibility is fictional because the interface was traced from its only implementer.
Interface guarding a volatile dependencyPayment providers, clocks, filesystems, third-party SDKs. Earns its cost the first time the vendor changes or a test needs to run offline (Adapter).

caveat These scores assume the dependency is something you can actually change. The whole ranking inverts for anything crossing a team, repository or release boundary, where an interface is not ceremony but the only way to change two things at different times. The numbers also cannot express the political cost: in a codebase where one-interface-per-class is the convention, the narrow shape is the option that will be argued about in review, and that friction is real even though it is not technical (Tone, Disagreement and Receiving Review).

Name it for the need

The name is diagnostic. An interface named after what the caller needs — ChargesCards, Receiptable, Clock — was derived from a requirement. One named after its implementer with a prefix — IStripeService, IUserRepositoryImpl — was derived from a class, and will have that class's shape including the parts that were accidents.

This is the cheapest available check on whether an abstraction is real: try to name it without referring to the thing that implements it. If you cannot, there is only one case and no abstraction has been found yet (What an Abstraction Actually Is).

NameDerived fromWhat it predicts
ClockA need: this code must not read the wall clock directlyWill survive. Two implementations exist on day one — real and fixed — so the variation is real (Time as a Dependency).
ChargesCardsA need: checkout must not know the vendorWill survive a vendor change, because it was never shaped by the vendor (Adapter).
IUserServiceA class called UserServiceWill have every method UserService happens to have, including the two nobody outside calls (Exposing Too Much).
IStripeClientA vendor SDKWill leak vendor concepts — a PaymentIntent, a Stripe error code — so a second vendor cannot implement it (Anti-Corruption Layer).
ReceiptableThe three fields a receipt needsWill still be right after Order is split in two, because it never knew about Order (Stable Boundaries).

How to build it

Most important first.

  • Ask what the function actually reads. Three fields out of fourteen is the common answer and it should be the signature: sendReceipt({ id, chargedAmount, email }).
  • Define the narrow type where it is consumed. In a structural type system this costs one line and no coupling; in a nominal one it costs a small record type and a mapping at the call site.
  • Prefer parameters to dependencies. A function that takes the values it needs is testable, cacheable and reusable; one that takes a service and calls it is none of those without the service (Functional Core, Imperative Shell).
  • Add an interface only when there is more than one implementation, or when the dependency is volatile enough that you need a seam for it — a payment provider, a clock, the filesystem (Volatile Dependencies).
  • When you do add one, name it for the need and not the implementer: ChargesCards, not IStripeService. A name taken from the implementation is evidence the interface was derived from it (Naming).
  • Do not put an interface in front of a type you own and can change. That is the case dependency inversion was never about (Dependency Inversion, Critically).

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.

Cost of the next change
  • Under the fat parameter, "add a field to Order" costs nothing, and "change the shape of the payment record" costs a break in receipt formatting that nobody predicted. The costs are inverted from where the diff looks.
  • Under the narrow parameter, Order can be restructured freely as long as three fields exist; the batch job can pass a projection with no aggregate load; the test is one object literal.
  • The change that got *more* expensive: the receipt needs a fourth field. Under the fat parameter it is one line in the function; under the narrow one it is the type, the two call sites and the projection query. Narrow parameters make the caller pay when the callee's needs grow.
  • A premature interface makes the *next* change expensive in a specific way: every change to it is a coordinated change across all implementers, and with one implementer you get that cost and none of the benefit.
What the recommended approach costs
  • Narrow types cost mapping code and a second name for the same idea, and in a nominal type system that cost is not small.
  • Refusing to declare interfaces early means occasionally retrofitting one under deadline, and retrofitting across many call sites is genuinely worse than having had it.
  • Consumer-defined types push the definition away from the data, so a reader of Order cannot see who depends on which fields without a search.

What can go wrong

Failure modes
  • The narrow types multiply until each function has its own three-field record and mapping code outnumbers logic. That is over-application and it has a real cost (Over-Decomposition).
  • The narrow type drifts from the source: chargedAmount is renamed on Order and the mapping compiles because both are numbers (Units in Names and Types).
  • An interface is extracted from one class, so it has that class's shape — including a method that only makes sense for it — and the second implementation cannot fit it (Leaky Abstractions).
  • The mitigation itself fails: a codebase-wide rule of "always narrow" produces a layer of DTOs whose only job is to be a slightly smaller copy of the model, and the mapping layer becomes the thing that breaks (The Anemic Domain Model).
Dependencies, and their direction
  • A narrow consumer-defined type creates a dependency on three field names and their types, and on nothing else. That is the smallest dependency available short of no parameter.
  • A shared interface creates a dependency on a contract that both sides must now agree to change, which is worth it exactly when both sides genuinely need to vary independently.
  • Depending on a concrete type you own, in the same module, is fine and common. Most advice to the contrary is generalising from the volatile-dependency case (Dependency Direction).
Misreads
  • "Program to an interface, not an implementation means declare interfaces." The original advice means depend on the *contract* rather than the internals; a concrete class's public methods are already a contract. Declaring IThing for every Thing is a different, weaker idea that borrowed the sentence (How SOLID Gets Misused).
  • "Narrow means primitives." No — sendReceipt(id: string, amount: number, email: string) is narrow and awful, because three positional strings are trivially swappable. Narrow means a small *typed* shape (Primitive Obsession).
  • "So delete all our interfaces." Interfaces guarding volatile external dependencies are earning their keep. The ones to question are the ones wrapping types you own and could simply change (Dependency Inversion).
  • "The interface makes it swappable." Only if the interface was designed from more than one case. An interface shaped by its single implementation is not a seam, it is a tracing of one (Premature Abstraction).
Smells this explains
  • feature-envy
  • long-parameter-list

Testing it, and how it ages

What to test, and at which boundary
  • The test for a narrowly-typed function is an object literal with three fields and no builder. If your test needs a factory, the signature is too wide (What a Unit Is).
  • Test that the projection used by the batch job and the aggregate used by checkout both satisfy the narrow type — in a structural language the compiler does this for free.
  • Where an interface exists for a volatile dependency, test the real implementation against the contract separately from the code that uses it (Contract Tests).
How this design ages
  • Narrow types age well: they document what each consumer needed at the time, which is exactly the information you want when restructuring the provider years later.
  • An interface with one implementation either gains a second within about a year — in which case it was right — or never does, in which case it should be deleted, and almost never is (Speculative Generality).
  • The convention "one interface per service" survives long after anyone can justify it, because deleting an interface feels like removing safety. It is worth revisiting explicitly rather than by drift (Revisit Triggers).

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.

  • LANGUAGE-SPECIFICStructural typing (TypeScript, Go, OCaml) makes consumer-defined narrow types nearly free: declare what you need and any value with those members satisfies it, with no mapping and no declaration on the provider. In nominal systems (Java, C#) the same move costs a record type plus explicit construction at every call site, which is why the fat-parameter habit is much stronger there and the advice must be applied with a heavier hand.
  • CONTESTEDThe strongest case for the one-interface-per-service convention is not testability but *build* structure: in large Java and C# codebases interfaces in a separate module let the compiler and the build graph break cycles and parallelise, and teams that have lived through a fifteen-minute rebuild do not consider that ceremony. The counter is that most codebases adopting the convention have neither the size nor the build problem, and are paying the ceremony for a benefit they never measure (Dependency Cycles).
  • SCALE-SPECIFICBelow roughly ten engineers, depending on a concrete type you own is fine because changing it is a conversation. Across team boundaries the same dependency becomes a negotiation, and that is where the interface starts to earn its cost — the trigger is organisational, not technical.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — the same subtract-first instinct at the service grain: a service that needs three fields should be sent three fields, and the aggregate-shaped request is where over-fetching and coupling both begin.