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.
Why do abstractions keep failing exactly when they matter most, and what should I do about it?
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.
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.
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).
- Transaction scope is a caller concern that the interface cannot express: whether two
savecalls are atomic depends on an ambient transaction the signature says nothing about (Temporal Coupling). - Isolation level changes the meaning of a read.
findByIdreturns 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).
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 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).
- An order and its line items are written together or not at all (Consistency Boundaries).
- Two concurrent checkouts of the last unit of stock must not both succeed (Optimistic Concurrency: Versions and If-Match).
- The domain layer must not construct SQL — the boundary stays, whatever else changes.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- 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).
- 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Two saves that must be atomic | A partial write survives a crash between them | Transaction scope is ambient; the signature cannot say whether these calls share one | Make the unit of work a parameter or a block: withTransaction(tx => ...) (Where the Transaction Boundary Goes). |
| Concurrent checkout of the last item | Both succeed; stock goes negative | Read-committed does not prevent the write skew the domain rule assumes | Choose the isolation level or the lock explicitly, and treat it as part of the invariant's enforcement (Isolation Levels). |
| Concurrent updates in opposite orders | Deadlock under load only | Lock acquisition order is decided by the repository and invisible to callers | Fix an ordering inside the boundary, and surface Conflict as a modelled, retryable outcome (Locks and Deadlocks). |
| A filter with no supporting index | A page that was 20ms takes 8 seconds as data grows | Cost is not expressible in the interface, so identical-looking calls differ by orders of magnitude | Name cost in the API — findByIdIn versus findAll — and assert query plans for hot paths (Should I Add an Index?). |
| Iterating a collection property | One query becomes eleven | Lazy loading makes a fetch look like a field access | Return what was requested; make further fetches explicit calls (Eager Loading and Batching). |
| A unique constraint fires | A generic exception reaches the HTTP layer as a 500 | The 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 exhausted | Unrelated endpoints time out together | A finite shared resource the interface never mentions | Treat the pool as a named dependency with limits and observability, not as plumbing (Connection Pools). |
The physics does not care about the interface
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.
1// (a) reads as pure domain logic; wrong under concurrency2const order = await orders.findById(id)3const stock = await inventory.findBySku(order.sku)4if (stock.available < order.qty) throw new OutOfStock()5stock.available -= order.qty6await inventory.save(stock) // lost update: two checkouts both pass7await orders.save(order) // atomic with the line above? unknowable8 9// (b) the leaks named; the SQL is still hidden10const result = await db.withTransaction({ isolation: 'repeatable read' },11 async (tx) => {12 const stock = await inventory.findBySkuForUpdate(tx, order.sku) // lock13 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.
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.
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).
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.
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.
- 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
Conflictis 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).
- Naming the leaks makes the interface wider and less elegant. A
withTransactionblock and four error types are more to learn thansave(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
- 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
executeRawSqlescape 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.
- 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).
- "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).
- primitive-obsession
Testing it, and how it ages
- 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 asConflict, 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).
- 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
Resulttype 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.
- — 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.