PerformanceGENERALFRAMEWORK-SPECIFICCONTESTED

Cost-Aware Interfaces

An interface that hides what it costs is a design failure. findAll() looks like a getter and may read the whole table; a signature that cannot express a bound cannot be used safely.

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

How should an interface tell its callers what calling it will cost?

The requirement

"Admins need a page listing customers with unpaid invoices, newest first." The repository already has findAll(), and filtering in memory is four lines.

The obvious build

Expose the natural domain operations — findAll, getOrders, customer.invoices — and let callers work with collections. It reads beautifully, it hides the persistence detail, and hiding detail is what an interface is for.

Why it breaks

Hiding *detail* is what an interface is for. Hiding *cost* is different, and the two get conflated because both are called encapsulation (Leaky Abstractions).

How it breaks as requirements change
  • Hiding *detail* is what an interface is for. Hiding *cost* is different, and the two get conflated because both are called encapsulation (Leaky Abstractions).
  • The signature becomes a lie in slow motion: findAll() is honest at a hundred rows, misleading at a hundred thousand, and dangerous at ten million, and nothing about it changed.
  • The next requirement — an export, a report, a nightly job — reuses the same method because it is the one that exists, and the method now serves callers with completely different cost tolerances.
  • When the fix finally comes, it cannot be local: adding a page parameter changes the return type, and every one of the nine callers has to decide what "there is more" means for them (Change Amplification).
  • Meanwhile customer.invoices as a property is worse than findAll(), because it does not even look like work. A field access that issues a query defeats every instinct a reader has (Naming).
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 repository is shared by nine call sites, so changing its signature is a change to nine files (Fan-in and Fan-out).
  • The admin page is used by staff, not customers, so nobody will complain quickly — the failure will be discovered by a database alert, not a user.
  • The ORM makes the expensive call the shortest one to write, which is a constraint on human behaviour and should be treated as one.
Invariants
  • A caller can predict the order of magnitude of a call's cost from its signature, without reading the implementation.
  • No call path can request an unbounded amount of data by accident — doing it on purpose must be possible and must look deliberate.
  • The bound is enforced where the data is fetched, not applied after it arrives.

Who owns what, and where the seams fall

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

Responsibilities
  • The interface owns declaring the shape of the work: bounded or unbounded, one round trip or many, materialised or streamed.
  • The caller owns deciding how much it wants, because only the caller knows whether it is rendering a page or reconciling a ledger.
  • The implementation owns pushing the bound down to the store rather than fetching and slicing (Database Engineering owns what the store does with it).
Boundaries
  • The line runs between "the callee chooses how much" and "the caller chooses how much". Cost-aware design moves that choice to the caller, and every technique in this lesson is a way of doing that.
  • It is the same boundary at every scale: a function parameter, a repository method, an HTTP endpoint, a service call. The ergonomics differ and the design question does not (What Changes at the Network Boundary).
  • The boundary is *not* the ORM. An interface that returns a lazy query object has moved the cost past the seam without moving the decision, which is the worst of both (Invariant Leaks).

A signature is a claim about cost

The central claim of this module, stated as plainly as it can be: an interface that hides what it costs is a design failure, in the same sense that an interface that hides whether it mutates its argument is a design failure. Both leave the caller unable to reason about a consequence they are responsible for.

What makes it specifically a design failure rather than a performance bug is that no implementation change fixes it. You can make findAll() faster; you cannot make it bounded without changing what it is.

The same read, told honestly and dishonestly
Cost is invisible at the call site
interface InvoiceRepository {
  findAll(): Promise<Invoice[]>
}

// admin/unpaid.ts — reads like a filter, is a table scan
const unpaid = (await invoices.findAll())
  .filter(i => !i.paidAt)
  .sort(byDateDesc)
  .slice(0, 50)

// and the ORM version, which is worse because it looks like a field:
for (const c of customers) render(c.invoices)   // one query per customer
Cost is in the signature
interface InvoiceRepository {
  /** bounded: at most `limit`, plus a cursor when more exist */
  pageUnpaid(q: { limit: number; after?: Cursor }): Promise<Page<Invoice>>

  /** cheap: the store counts, nothing is materialised */
  countUnpaid(): Promise<number>

  /** honest: unbounded work, bounded memory, not for a request */
  streamAllUnpaid(): AsyncIterable<Invoice>
}

// admin/unpaid.ts
const page = await invoices.pageUnpaid({ limit: 50 })

Three operations instead of one, and each caller now picks the one matching what it actually needs — which is information only the caller has. The filtering moved into the query, so the bound is enforced where the rows live rather than after they have been read, serialised and allocated. The dishonest version is not slower because of how it is implemented; it is slower because of what it promised.

What the honest interface buys, priced

The argument is not that the second version is nicer. It is that a specific, entirely predictable change — a customer with a lot of history, or a new consumer of the same data — costs different amounts under the two designs.

Finance wants the unpaid list as a nightly CSV, for every customer
The change

A scheduled job must export every unpaid invoice across all customers to a file, while the admin page keeps working as it does today.

`findAll()` returning an array, filtered in memory
InvoiceRepositoryAdminUnpaidPageExportJobReconciliationReportApiInvoiceListInvoiceCache
testsrepository_testadmin_page_testexport_job_testreport_testapi_test
6 modules · 5 test files

The job reuses findAll() because it is what exists, and now the whole invoice table is materialised in one process's memory every night. Fixing it means changing the shared signature, which drags in five other callers — each of which has to answer "what does truncation mean for you?" before anything can ship.

Three named operations with their costs in the signature
ExportJob
testsexport_job_test
1 module · 1 test file

The job calls streamAllUnpaid(). The admin page is untouched, because it was never using the unbounded read. The design did not make the export cheap — it made the export's cost the export's problem, which is where it belongs.

what it cost Three operations to maintain instead of one, and callers that write more code to say what they want. There is also a subtler cost: the split invites divergence, and in a year pageUnpaid and streamAllUnpaid can disagree about what "unpaid" means unless the predicate is shared — so the design trades one big risk for a small ongoing one (Duplicate Knowledge).

The smell, and when the innocent-looking accessor is fine

PARADIGM-SPECIFICIn a rich domain-model style the bounded-collection-inside-an-aggregate case is common and the smell has many false positives; in a transaction-script or data-mapper style almost every collection access is a query, so the same shape is nearly always worth questioning.

This has a recognisable shape in review, and it is worth naming because the code always looks reasonable — that is the entire problem. Nobody writes an obviously expensive line; they write an ordinary-looking line whose cost was decided somewhere else.

smellCost hidden behind a getter, a property or a plainly-named methodThe accessor that runs a query

looks like customer.invoices, order.items.length, findAll(), getUsers() — expressions with no visible parameters that reach a store, a service or a file system.

suggests The decision about how much work to do has been taken away from the only party that knows the answer. Expect it to be called in a loop somewhere, and expect that loop to look completely innocuous (N+1 as a Design Problem).

fix Separate the questions. A count is a count, a page is a page, and a stream is a stream. Where the cost is genuinely bounded by an invariant, say so in a comment next to the invariant rather than in the method name — and if you cannot state the bound, that is the finding (Invariants).

when this is fine When the collection is genuinely bounded by the domain and already in memory — an order's line items, loaded with the order, where the aggregate boundary guarantees there are a handful. There the property is the right design, and forcing a page() call on it is ceremony that makes the code worse (Aggregates).

How to build it

Most important first.

  • Put the bound in the signature and make it required. An optional limit is a limit that will be omitted, because the shortest call is the one people write (the same argument Security Engineering makes about defaults).
  • Return something that says whether there is more. A bare array cannot express "truncated", so truncation becomes silent, which converts a performance problem into a correctness problem (Optional Values and Absence).
  • Name the expensive thing expensively. streamAllInvoices() and loadAllInvoicesIntoMemory() are both honest; findAll() is not, and naming is the cheapest cost-awareness there is.
  • Offer the cheap question separately. Most callers of a full list want a count, an existence check or the top few, and each of those is a different query rather than a filter over everything (Interface Versus Implementation).
  • Stream when the caller genuinely needs everything — a nightly export does — so the memory cost is bounded even though the work is not (Effect Boundaries).
  • Push filters into the signature rather than expecting callers to filter after loading, because a filter applied after fetch has already paid the cost it was meant to avoid.
  • Where the store cannot bound the work — an aggregate over everything — say so in the name and, if it matters, in the type: an operation that must be run in the background is a different kind of thing from one a request can await (Backend Engineering owns the request-or-background decision).

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
  • Adding a new caller that needs a page: free. It uses the interface as designed, and the bound is already there.
  • Adding a caller that genuinely needs everything: it must ask for the streaming operation by name, which makes the expensive path a visible decision rather than an accident.
  • Retrofitting a bound onto findAll(): nine call sites, each needing a decision about truncation, plus every test that assumed a complete list. This is the change this lesson exists to make cheap, and it is expensive exactly once.
  • What stays expensive under either design: changing the *sort* a page is ordered by, because a cursor encodes the order. Cursor pagination trades that flexibility for stable pages, and that trade is permanent (Versioned Interfaces).
What the recommended approach costs
  • Every call site is more verbose, forever, including all the ones over small tables where the bound will never bind.
  • A page type is a coupling: callers now handle cursors, "has more", and empty pages, and that logic is duplicated in every consumer unless something absorbs it (DRY: Knowledge, Not Lines).
  • Making cost visible in signatures leaks a persistence concern upward on purpose. That is a deliberate violation of "hide the detail", and the justification is that cost is not a detail — but it is a real cost of the position, not a free win.

What can go wrong

Failure modes
  • Pagination is added and the implementation fetches everything then slices in memory. The signature is now honest and the cost is unchanged, and nobody will notice because the interface looks right.
  • A default limit is chosen, callers omit it, and results are silently truncated. The system is fast and wrong, which is strictly worse than slow and right (Swallowed Errors).
  • Every interface in the codebase grows a cursor, including the ones over configuration tables with six rows, and the ceremony discredits the idea (Over-Design and Under-Design).
  • Offset pagination is used over a large, actively-written table, so deep pages get slower and rows shift between pages. The bound is real and the interface is still lying about its cost (Backend Engineering owns the offset-versus-cursor mechanics).
  • The interface is cost-aware and one caller loops it to exhaustion to rebuild the full list, which is the same unbounded read with extra round trips.
Dependencies, and their direction
  • Callers now depend on a page type rather than on an array, which is a real coupling to a shape and the main ergonomic cost of this design.
  • The repository depends on the store's ability to bound and sort — pagination over an unindexed sort is a bound that does not bound anything (Database Engineering owns whether the sort can be indexed).
  • Nothing in the domain should depend on the paging mechanism; a cursor is a persistence concept, and letting it into domain types spreads a detail everywhere (Encapsulation).
Misreads
  • "So every method needs pagination." No. The claim is that a signature should not mislead about cost. An operation over a table that is bounded by design — countries, plans, feature flags — is honest as it is, and adding a cursor to it is noise (Premature Abstraction).
  • "This breaks encapsulation." It surfaces one specific thing — how much work happens — while still hiding how. Callers still know nothing about SQL, indexes or the store (Information Hiding).
  • "The ORM handles it." The ORM is why the expensive call is the shortest to write. Lazy loading in particular converts a property access into a query, which is the strongest possible form of a cost-hiding interface (N+1 as a Design Problem).
  • "We will catch it in review." Review sees invoices.filter(...) and reads it as a filter. Nothing in the diff says the list came from a full table scan; the cost lives in a method defined in another file, written a year ago (Local Reasoning).
Smells this explains
  • primitive-obsession
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Assert the bound reaches the store. A test that the generated query contains a limit is unglamorous and catches the fetch-then-slice failure, which is the one that survives review (Database Engineering owns reading the plan to confirm it).
  • Test the boundary condition of the page: exactly at the limit, one over, and empty — because "there is more" is the flag every caller will get wrong.
  • Test with more rows than the page size. A fixture of three rows makes every pagination bug invisible (Testing as Design Feedback).
  • A structural test that no production caller invokes the unbounded operation from a request path is legitimate and cheap (What to Automate Out of Review).
How this design ages
  • Interfaces get more cost-aware over time under incident pressure, and the cheapest version of that history is one where the parameter existed from the start even if it was always the same value.
  • The pressure runs the other way too: cost-aware interfaces get wrapped in convenience helpers that restore the unbounded call, usually named something like getAllForTest that then gets used in production (The Utility Dumping Ground).
  • As data grows, the honest answer for some reads stops being pagination and becomes "this is a background job that produces a file" — and a design whose expensive operations already had distinct names makes that a rename rather than a rewrite.

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.

  • GENERALThe claim is about signatures rather than storage, so it applies to a repository, an HTTP endpoint, a gRPC method and an in-memory service equally; only the cost of getting it wrong differs, and it rises with the distance the data travels.
  • FRAMEWORK-SPECIFICIn an ORM with lazy associations — Rails, Hibernate, Django — the cost-hiding is built into the idiom and the fix is fighting the framework, so teams there need explicit eager-load declarations and query-count assertions; with a query-builder or hand-written SQL the cost is already visible at the call site and this lesson is largely about naming.
  • CONTESTEDThe strongest opposing view: making every read bounded pushes cost handling into every caller and produces a codebase where a simple domain operation is buried in cursor plumbing — several respected designers argue the right answer is a rich collection abstraction that is lazy and bounded internally, so callers write domain code and the infrastructure decides how much to fetch. That works well when the abstraction is genuinely good, and its failure mode is severe: when it guesses wrong, the cost is invisible again and now it is also someone else's code.

Where the depth lives

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

API Designpagination
Domains that do not exist yet
  • System Design — the same question at service granularity is whether an endpoint can be called in a way that makes another team's database do unbounded work, which is why public list endpoints without a maximum page size become an availability problem rather than a latency one.