skip to content

One data-access interface is backed by a tracking mapper in one service and an untracked layer in another; what is not interchangeable?

level: seniorimportance: nice to knowfreq 33%

answer

  1. same types, different rules
  2. shape is not lifecycle
  3. does mutation persist?
  4. hold both to the stricter contract
  5. contract tests must run on both

basics

~20 s

The lifecycle semantics the signature cannot express: whether mutating a result persists it, when writes land, whether associations are live, whether repeated reads share an instance, and whether conflicts are checked. Same types, different caller rules.

solid answer

~40 s

A shared interface makes the shapes match, not the semantics. Under the mapper, mutating a returned object inside the unit of work may already be a write; under the untracked layer the same line is a no-op, so a swap loses data silently. The result may also carry stand-ins that need an open unit of work, repeated reads may or may not hand back one instance, and the concurrency check may or may not be automatic. To make them truly swappable, hold both to the stricter contract: explicit save for every change, fully materialised results, no reliance on identity, and the transaction boundary owned above the interface.

go deeper

for a junior

Remember that an interface fixes what is returned, not what happens to it afterwards. Whether a change needs a save call is not visible in the method signature.

for a middle

Enumerate the divergences: mutation semantics, when writes land, materialisation, identity, and the concurrency check.

for a senior

Show how you would enforce interchangeability — explicit save everywhere, no stand-ins across the boundary, and contract tests run against both implementations.

for a principal

Judge whether the abstraction is worth it at all; two honest interfaces can beat one that hides a difference the callers must respect anyway.

## What a signature carries, and what it hides An interface method that takes an identifier and returns an order looks the same whichever layer implements it. The type system describes the *shape* of what crosses the boundary and says nothing about the **lifecycle rules** attached to it — and lifecycle is exactly where a tracking mapper and an untracked layer disagree. Two implementations can satisfy the same interface, pass the same type checks, and still require callers to be written differently. ## The semantics that differ - **Whether mutation is enough.** Under a mapper, changing a returned object inside the unit of work is often sufficient: the change is detected and written. Under an untracked layer the same code is a no-op. This is the difference that produces silent data loss when an implementation is swapped. - **When the write actually lands.** One implementation sends statements at a flush it decides on; the other sends them at the call. Anything that depends on a row being visible to a second statement in the same transaction behaves differently. - **Whether the returned object is fully materialised.** A mapper may return an object with stand-ins for associations that load on access and require the unit of work to still be open. An untracked layer returns what was selected and nothing more, valid forever. Code that walks an association works under one and either fails or returns nothing under the other. - **Whether repeated reads give the same instance.** With an identity map, two reads of one row inside a unit of work tend to return the same object, so an edit through one path is visible through the other. Without one, they are two objects and the last write wins. - **Whether concurrency is checked.** Automatic version checking under one implementation, hand-written predicate and row-count inspection under the other. The interface reveals neither. | Question a caller must answer | Tracking implementation | Untracked implementation | |---|---|---| | Does mutating the result persist it? | Often yes | Never | | Is a save call required? | Sometimes optional | Always | | Is the result valid after the transaction? | Only partly | Yes | | Do two reads give one object? | Usually | No | | Is a conflicting write detected? | Often automatically | Only if you wrote the check | ## Making them genuinely interchangeable You do not make two implementations interchangeable by giving them the same types. You do it by adopting the **stricter contract on both sides** and holding the tracking implementation to it: 1. **Every change requires an explicit save call.** Mutation alone never persists, even where the mapper would have made it work. This is the one rule that removes the silent-loss class entirely. 2. **Results are fully materialised before they cross the boundary.** No stand-in may escape, so no caller can depend on a live association or an open unit of work. 3. **No caller assumes identity.** Anything that needs one instance threads it through the call chain explicitly rather than relying on the read returning the same object twice. 4. **The transaction boundary sits above the interface**, owned by one component, so neither implementation gets to decide when work is sent. 5. **The concurrency rule is part of the contract**, not of one implementation: state whether a conflicting write raises or reports, and make both implementations do that. ## What the strict contract costs It is not free, and pretending otherwise is the weak version of this answer. You give up most of what the mapper offers on the write path — automatic detection, cascade, the convenience of editing a graph and letting the flush sort it out — and you pay for the discipline with more explicit code. In return you get a boundary a reader can trust and an implementation you can actually swap, per service, without auditing every caller. There is also a testing consequence worth naming. A test suite that exercises only one implementation proves nothing about the other, because the difference lives in behaviour the interface does not express. If both implementations are genuinely in use, the same contract tests must run against both — including a test that mutates a returned object *without* saving and asserts that the row did not change. That single test is what stops a caller from quietly acquiring a dependency on write-back semantics.

  • Which single rule removes most of the swap risk?
    Requiring an explicit save call for every change, enforced even where the tracking implementation would have written it anyway. That collapses the most dangerous divergence — mutation-persists versus mutation-is-a-no-op — into one behaviour, and it is the rule callers are most likely to violate accidentally.
  • What test proves a caller has not acquired a dependency on write-back?
    One that loads through the interface, mutates the returned object, does not call save, commits, then re-reads the row and asserts it is unchanged. Run it against both implementations. It fails loudly the moment someone starts relying on the mapper's change detection through a shared interface.
  • Is hiding the implementation behind one interface always the right goal?
    No. If one service genuinely needs a graph write model and another needs streaming reads, a single interface can force both into a contract that suits neither. Two honest, differently shaped interfaces often beat one that pretends the layers are equivalent.

saying these in an interview costs you the question

  • Assumes identical return types mean identical semantics
  • Relies on mutation alone persisting through a shared interface
  • Lets objects with lazy stand-ins cross an interface both layers implement
  • Tests one implementation and claims the other is covered
  • Puts the transaction boundary inside each implementation separately