AbstractionGENERALLANGUAGE-SPECIFICCONTESTED

Leaky Abstractions

Repository.save() claims database independence while transaction scope, isolation level, index behaviour and failure modes come straight through. An abstraction hides a mechanism; it cannot erase the physics underneath it.

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

Why do abstractions keep failing exactly when they matter most, and what should I do about it?

The requirement

An order write "just works" in development and deadlocks in production under concurrent checkout. The repository interface says save(order) and gives no hint that a transaction, a lock order or an isolation level exists.

The obvious build

A repository makes persistence an implementation detail. Callers say save(order) and stop thinking about databases, which is exactly what an abstraction is for and is genuinely useful most of the time.

Why it breaks

Transaction scope is a caller concern that the interface cannot express: whether two save calls are atomic depends on an ambient transaction the signature says nothing about (Temporal Coupling).

How it breaks as requirements change
  • Transaction scope is a caller concern that the interface cannot express: whether two save calls are atomic depends on an ambient transaction the signature says nothing about (Temporal Coupling).
  • Isolation level changes the meaning of a read. findById returns different things under read-committed and serializable, and the interface implies neither (Isolation Levels).
  • Performance is not hidden and cannot be. findAll(criteria) may use an index or scan the table, and the difference is four orders of magnitude at the same call site (Cost-Aware Interfaces).
  • Failure modes leak hardest. Deadlock, serialisation failure, constraint violation and connection exhaustion are all database concepts, they all reach the caller, and the interface has modelled none of them (An Error Taxonomy That Survives Contact).
  • The lazy-loading variant is worse: an innocuous property access issues a query, so a loop over ten orders makes eleven round trips and the code shows nothing (N+1 as a Design Problem).
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 was introduced specifically to keep persistence details out of the domain, and that goal is still correct.
  • The database is Postgres and will remain Postgres; nobody is exercising the portability the abstraction nominally provides.
  • The deadlock only appears under concurrency, so it survived review, unit tests and staging (Data Race Is Not Race Condition).
Invariants

Who owns what, and where the seams fall

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

Responsibilities
  • The abstraction owns the *mechanism*: SQL construction, mapping, connection handling. That is a genuine and worthwhile job.
  • It does not own the physics: atomicity, isolation, contention, cost. Those must be represented explicitly, because they change the correctness of the caller (Explicit State).
  • The caller owns the transaction boundary, because only the caller knows which operations form a unit of work (Where the Transaction Boundary Goes).
  • Somebody owns naming which leaks are acceptable. An unlisted leak is one that will be discovered in production (Failure-Aware Feature Design).
Boundaries
  • The boundary should hide what varies without changing meaning — table names, column mapping, dialect — and expose what changes meaning: atomicity, ordering, cost class, failure kind.
  • A useful rule: if a detail can change whether the caller is *correct*, it belongs in the interface; if it can only change how the work is done, hide it (Designing a Module Interface).
  • The leak is a property of the model, not a defect in it. The goal is a model whose leaks are named, not one without leaks — that does not exist (Essential and Accidental Complexity).

What comes through `save(order)`

The interface has one argument and no return value of interest, and each row below is something the caller must nonetheless know. That gap between the signature and the required knowledge is what "leaky" names.

Read the last column. In every case the fix is not a better hiding mechanism — it is representing the concern explicitly, which makes the interface wider and the caller correct.

Leaks through a repository interface
TriggerSymptomCauseResponse
Two saves that must be atomicA partial write survives a crash between themTransaction scope is ambient; the signature cannot say whether these calls share oneMake the unit of work a parameter or a block: withTransaction(tx => ...) (Where the Transaction Boundary Goes).
Concurrent checkout of the last itemBoth succeed; stock goes negativeRead-committed does not prevent the write skew the domain rule assumesChoose the isolation level or the lock explicitly, and treat it as part of the invariant's enforcement (Isolation Levels).
Concurrent updates in opposite ordersDeadlock under load onlyLock acquisition order is decided by the repository and invisible to callersFix an ordering inside the boundary, and surface Conflict as a modelled, retryable outcome (Locks and Deadlocks).
A filter with no supporting indexA page that was 20ms takes 8 seconds as data growsCost is not expressible in the interface, so identical-looking calls differ by orders of magnitudeName cost in the API — findByIdIn versus findAll — and assert query plans for hot paths (Should I Add an Index?).
Iterating a collection propertyOne query becomes elevenLazy loading makes a fetch look like a field accessReturn what was requested; make further fetches explicit calls (Eager Loading and Batching).
A unique constraint firesA generic exception reaches the HTTP layer as a 500The abstraction models success and models failure as "an error"Model the failure kinds the caller acts on differently (An Error Taxonomy That Survives Contact).
Connection pool exhaustedUnrelated endpoints time out togetherA finite shared resource the interface never mentionsTreat the pool as a named dependency with limits and observability, not as plumbing (Connection Pools).

The physics does not care about the interface

SCALE-SPECIFICUnder low concurrency — an internal tool, a few writes a minute, no contended rows — version (a) is genuinely fine and version (b) is over-engineering; the lost update is possible and will not happen. The point at which this flips is not traffic in general but contention on the same rows, which is why a modest system with one hot inventory row needs (b) and a busy system with well-distributed writes may not.

Both blocks below are the same domain rule with the same repository behind them. The first is what the abstraction encourages: read, decide, write, in domain terms. It is correct in a single-user test and wrong under concurrency, and nothing in the code says so.

The second does not remove the boundary — the domain still constructs no SQL. It makes three things visible that were always present: the unit of work, the concurrency assumption, and the outcome when that assumption is violated.

Same rule, one honest about the machine
1// (a) reads as pure domain logic; wrong under concurrency
2const order = await orders.findById(id)
3const stock = await inventory.findBySku(order.sku)
4if (stock.available < order.qty) throw new OutOfStock()
5stock.available -= order.qty
6await inventory.save(stock) // lost update: two checkouts both pass
7await orders.save(order) // atomic with the line above? unknowable
8
9// (b) the leaks named; the SQL is still hidden
10const result = await db.withTransaction({ isolation: 'repeatable read' },
11 async (tx) => {
12 const stock = await inventory.findBySkuForUpdate(tx, order.sku) // lock
13 if (stock.available < order.qty) return { kind: 'out-of-stock' }
14 await inventory.save(tx, stock.reserve(order.qty))
15 await orders.save(tx, order.confirm())
16 return { kind: 'confirmed' }
17 })
18// caller handles: 'confirmed' | 'out-of-stock' | 'conflict' (retryable)

Version (b) is longer and uglier, and it is the one that is correct. Note what it did *not* do: no SQL in the domain, no ORM entity in a handler. The boundary survived; what changed is that atomicity, the locking decision and the retryable outcome became part of the contract instead of being properties of the machine that nobody wrote down (Optimistic Concurrency Control).

Pricing the honest interface

Widening an interface to name its leaks looks like a step backwards: more concepts, more types, more to learn. The comparison worth making is against the change that the hidden version makes expensive, which is always a concurrency or cost change and always arrives at the worst time.

The cost line below matters more than the module counts. The honest interface is genuinely harder to use for the ninety per cent of code that never contends, and that is a real, recurring price.

Make checkout survive contention
The change

Under a flash sale, concurrent checkouts on the same SKU must not oversell, must not deadlock, and must retry safely where the failure is transient.

Repository hiding transactions, isolation and failure kinds
CheckoutServiceInventoryRepositoryOrderRepositorythe ORM configurationa new global retry middlewareHTTP error mapping
testsA new concurrency test harness, built from nothingEvery existing checkout test re-run against changed transaction semantics
6 modules · 2 test files

The work starts with archaeology: which calls share a transaction, where it is opened, what the isolation level currently is, and which exceptions are retryable. None of that is in a signature, so it is discovered by reading the ORM configuration and the framework's middleware order. The global retry added at the end is the dangerous part — it retries non-idempotent work because there is no per-outcome information to retry on (Retries Are a Property of the Operation).

Unit of work explicit, outcomes modelled, locking decided inside the boundary
CheckoutService (retry on Conflict)InventoryRepository (lock order)
testsA concurrency test that already existed for the Conflict outcomecheckout_test
2 modules · 2 test files

The retry is applied where Conflict is already a modelled outcome, so it retries exactly the operation that is safe to retry. The lock ordering decision lives in one file because the repository owns the mechanism, which is still the right split.

what it cost The honest interface charges continuously and the charge is not small. Every write path — including the ninety per cent with no contention — now carries a transaction block, an isolation choice and an outcome to handle, so simple code is meaningfully noisier and new engineers meet four database concepts on their first ticket. It also gives up the portability story: repeatable read and FOR UPDATE are commitments to a class of engine, stated in the domain's own code. That trade is worth making where correctness depends on it and is over-engineering where it does not, which is why this is a decision per aggregate rather than a policy for the codebase (Consistency Boundaries).

How to build it

Most important first.

  • Make the unit of work explicit. withTransaction(tx => ...) puts atomicity in the type system instead of in an ambient variable nobody can see (Temporal Coupling).
  • Model the failures the caller must act on: Conflict, Deadlock, ConstraintViolated, Unavailable. A retryable serialisation failure and a duplicate key demand opposite responses (An Error Taxonomy That Survives Contact).
  • Expose cost class in the interface. findByIdIn(ids) invites batching; findAll() invites disaster, and the names are doing that work (Cost-Aware Interfaces).
  • Forbid lazy loading across the boundary. Return what was asked for; make additional fetches visible as calls (Eager Loading and Batching).
  • Document the leaks you chose to keep, next to the interface, in one short list. That list is the honest version of "database independent" (Docs Close to Code).
  • Test the leaks against the real database, because the leak is exactly the part a fake cannot reproduce (Test Against the Real Database).

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
  • Next change, leaks hidden: "make checkout resilient to contention" requires finding every place a transaction is implicitly opened, deciding what is retryable, and discovering the lock order — archaeology across the whole call graph, because none of it is in any signature.
  • Next change, leaks named: the same requirement is a retry policy applied where Conflict is already an outcome the callers handle, plus one lock-ordering decision inside the repository. Bounded, and reviewable.
  • What does not get cheaper either way: switching database engines. Isolation semantics, constraint behaviour and index characteristics differ between engines, so the code compiles and the invariants change — which is the concrete reason "database independence" is the least reliable claim an abstraction can make (Choosing the Model).
What the recommended approach costs
  • Naming the leaks makes the interface wider and less elegant. A withTransaction block and four error types are more to learn than save(order).
  • It also concedes portability in public, which can be politically awkward on a team that justified the abstraction with it.
  • Some leaks genuinely should stay hidden: exposing every database concept produces an interface that is just SQL with more ceremony, which is the failure at the other end (Exposing Too Much).

What can go wrong

Failure modes
  • A deadlock reaches the domain layer as a generic exception, is caught by a broad handler, and becomes a silent partial write (Swallowed Errors).
  • Retry logic is added at the wrong level: the whole request is retried, including the non-idempotent parts, converting a deadlock into a duplicate order (Retries Are a Property of the Operation).
  • The abstraction is "fixed" by adding an executeRawSql escape hatch, which every caller then uses for the hard cases, so the boundary exists only for the easy ones (Exposing Too Much).
  • The mitigation fails in a specific way: exposing atomicity and failure kinds makes the interface wider and more intimidating, so a subsequent tidy-up simplifies it back and the leaks go unnamed again.
Dependencies, and their direction
  • The caller depends on the model *and*, unavoidably, on the physics: it must handle conflict, and it must know what is atomic. Pretending otherwise moves the dependency without removing it (Hidden Global State).
  • An ambient transaction is a hidden dependency of the worst kind — the behaviour of a function depends on a context established elsewhere and invisible in the signature (Local Reasoning).
  • Depending on the ORM's entity types in the domain layer re-couples the two ends after all the work of separating them (What an ORM Buys and What It Costs).
Misreads
  • "So abstractions are useless." A leaky abstraction still removes SQL construction, mapping and dialect handling from the domain — which is most of the daily benefit. The claim is only that it cannot remove the physics (What an Abstraction Actually Is).
  • "A better abstraction would not leak." No abstraction over a database hides contention, cost or atomicity, because those are properties of the machine and not of the interface. Choosing which leaks to expose is the design work (Choosing the Model).
  • "Just use raw SQL then." That trades one set of leaks for another and loses the mapping benefit. The useful move is a boundary with named leaks, not no boundary (Raw SQL in Application Code).
  • "Repositories are an anti-pattern." A thin repository over a query builder is often the right amount. The anti-pattern is the one that promises database independence and hides the unit of work (When the Repository Is Just Indirection).
Smells this explains
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • Test contention against the real engine with two concurrent transactions. This is the single highest-value test in a transactional system and it is almost never written (Test Against the Real Database).
  • Assert the failure taxonomy: a duplicate key surfaces as ConstraintViolated, a serialisation failure as Conflict, and neither as a bare exception (An Error Taxonomy That Survives Contact).
  • Add an assertion on query count for hot paths, so an accidental N+1 fails a test instead of a customer (The Comb: N+1 as a Visible Shape).
  • Do not test transaction semantics against an in-memory fake. The fake reproduces the model and the leak is precisely what it cannot reproduce (Test Doubles, Precisely).
How this design ages
  • Leaks are discovered under load, so an abstraction that looked clean for two years can fail on its first busy day. That is normal and it is why the leak list should be revisited when traffic profile changes (Revisit Triggers).
  • Interfaces tend to grow escape hatches over time. The health check is whether the hatches are used by two callers or by twenty (API Stability).
  • The abstraction stops fitting when the physics changes underneath — read replicas, sharding, an event-sourced store — because each of those changes what atomicity and read-your-writes mean (Read-After-Write: Letting a User See Their Own Change).

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.

  • GENERALEvery abstraction over a physical mechanism — a filesystem, a network, a database, a cache — leaks its cost and failure characteristics, because those are properties of the machine rather than of the interface; only which specific properties leak varies.
  • LANGUAGE-SPECIFICA language with checked exceptions or a Result type can force the caller to acknowledge that a write may conflict, which turns one leak into a compile-time obligation. Where failure is an unchecked exception the same design is a convention plus documentation, and the leak becomes a production discovery instead — the same interface, a materially different guarantee.
  • CONTESTEDThe strongest opposing view is that repositories and ORMs earn their keep precisely by hiding this, and that exposing transactions, isolation and failure taxonomies to application code re-imports the complexity the boundary existed to remove — teams that go down this road end up with domain services that read like database programming with extra types. There is real evidence for it: most applications never hit serialisation failures, and the elaborate version costs everyone to protect against something few will meet. The position here is scoped rather than universal — expose the leaks that change caller *correctness* under the concurrency you actually have, and keep hiding the rest.

Where the depth lives

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

Domains that do not exist yet
  • System Design — every leak here reappears at the service boundary as partial failure and retry ambiguity, where the physics is a network rather than a lock manager.