Every application eventually decides how its objects reach storage. The decision is usually made by picking a library, which means the pattern is chosen implicitly — and its consequences are discovered two years later.
Four patterns cover most of the ground. Repository is the fifth and is covered in low-level design under the service layer; this topic covers what sits beneath and around it.
Active Record: the object is the row
An object maps to a table row and knows how to save itself.
const order = await Order.find(42);
order.status = 'shipped';
await order.save();In its favour: minimal ceremony, obvious mapping, extremely fast for straightforward create-read-update-delete work. For an application whose domain is essentially its schema, it is the right answer and anything else is overhead.
Against it: the domain object depends on the database, so business rules cannot be tested without one — the arrow that hexagonal architecture exists to invert. The object's shape is the table's shape, so a domain concept spanning three tables has no natural home. And there is no unit of work: each save() is its own statement, so a multi-object operation is several writes with no shared transaction unless you add one manually.
Data Mapper: something else moves data between them
The domain object knows nothing about persistence; a separate mapper translates.
const order = await orderRepository.findById(42); // returns a plain domain object
order.markShipped(); // pure domain behaviour
await orderRepository.save(order);In its favour: the domain is testable with no database and free to have a shape unrelated to the schema. This is what makes a rich domain model practical.
Against it: mapping is real work and a real source of bugs, the indirection is heavier, and it is easy to build a "mapper" that simply exposes the ORM — at which point you have the file count without the independence.
Diagram, as text
- Order object knows the table, has save() leads to Database
- Order object pure domain, no persistence leads to Mapper / repository
- Mapper / repository leads to Database
Choosing between them is choosing whether the domain may depend on the database. If the answer is yes — small service, data-centric application, schema-shaped domain — Active Record and its simplicity win. If the answer is no, Data Mapper is the price of that independence.
Unit of Work: one transaction, one flush
A unit of work tracks what changed during a business operation and writes it in one transaction at the end.
Without it, a use case touching three objects issues three writes at three moments. A failure between them leaves the database in a state no business rule permits — an order marked paid with inventory undecremented.
await unitOfWork.run(async (uow) => {
const order = await uow.orders.findById(id);
order.markPaid();
const item = await uow.inventory.findBySku(order.sku);
item.reserve(order.quantity);
}); // one transaction: both writes commit, or neitherThree things it provides:
- Atomicity for a business operation, not just for a statement.
- Fewer round trips, since writes are batched at the flush.
- A defined boundary — the use case, not the repository, decides where a transaction begins and ends. Repositories that each open their own transaction make a multi-object operation impossible to make atomic.
Identity Map: one object per row, per unit of work
Loading the same row twice should give you the same object, not two.
const a = await repo.findById(42);
const b = await repo.findById(42);
a.status = 'shipped';
// Without an identity map, b.status is still the old value — and one of them wins on saveTwo objects for one row is a lost update waiting to happen: whichever saves last overwrites the other. An identity map keeps a per-unit-of-work cache keyed by identity, so both variables reference the same object.
It also removes redundant queries, but correctness is the reason, not caching. The cache must be scoped to the unit of work: a process-wide identity map is a shared mutable cache across requests, with all the staleness and cross-request contamination problems that implies.
What this means for your ORM
Most ORMs are one of these with a name. Knowing which tells you what to expect:
- An ORM where entities have
save()is Active Record. Expect a domain coupled to the schema and no automatic unit of work. - An ORM with a session, context or entity manager is Data Mapper with Unit of Work and Identity Map built in. Expect implicit flushing, change tracking, and confusion about when writes actually happen.
- A query builder with no object mapping is neither, and you write the mapping yourself — which is often the clearest option for a service with genuinely complex queries.
The lazy-loading behaviour of any of them is a virtual proxy, with the N+1 consequences covered under Proxy. That is the single most common performance problem in ORM-based applications.
Check your understanding
Common mistakes
Choosing the pattern by picking a library. The consequences arrive two years later.
Data Mapper whose mapper exposes the ORM. The file count without the independence.
A transaction per repository call. Multi-object operations cannot be made atomic.
A process-wide identity map. A shared mutable cache with cross-request staleness.
Not knowing whether your ORM auto-flushes. Reads that trigger writes explain a large share of confusing behaviour.
Lazy loading on by default. The N+1 problem, arriving only at production data volumes.
Mapping code with no tests. It is real logic and it silently drops fields.
Active Record for a domain that outgrew its schema. Rules end up in services because the objects have nowhere for them.
Practice
A team using Active Record finds that business rules are spread across models, service classes and controllers, and that unit tests need a database. Propose a path forward without a rewrite.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
The symptom is testability. Ask what the smallest change is that makes one rule testable without a database.
Show a strong answer
Do not start by proposing Data Mapper. Replacing the persistence approach across a codebase is a multi-quarter project with no intermediate value, and it will lose to feature work. Start from the symptom they actually named — tests need a database — and fix it for the rules that matter most.
First move: extract pure domain logic out of the models as plain functions or value objects.
- Pricing, eligibility, state transition rules and validation usually depend on values, not on persistence.
calculateTotal(lineItems, taxRule)needs no database and no Active Record object. - The model keeps its persistence role and calls into these functions. Nothing about the ORM changes, no migration is needed, and the rule becomes testable in milliseconds.
- This is the highest value-to-risk change available, and it can be done one rule at a time by whoever is already touching that code.
Second: give the extracted logic somewhere to live. A domain module with no framework imports, and a lint rule that keeps it that way. Without the rule, the module gradually acquires an ORM import and the property is lost.
Third: address the transaction boundary, since scattered rules usually come with scattered writes. Introduce an explicit transaction around each use case so a multi-object operation commits atomically. Most Active Record frameworks support this directly; it does not require changing the pattern.
What I would not do:
- Introduce repositories that wrap the Active Record models. This is the tempting middle step and it is mostly cost — a layer that forwards calls, with the domain still coupled to the ORM behind it.
- Migrate to Data Mapper wholesale. If, after extracting the pure logic, the remaining coupling still hurts, that is the moment to consider it — for a bounded part of the system, not all of it.
How I would know it is working: the proportion of business-rule tests that run without a database, tracked over time. That is the thing they complained about, so it is the thing to measure. If it does not move, the extraction is not reaching the rules that matter.
One thing I would check early: whether the tests are slow because of the database or because each test rebuilds the schema. Sometimes the real problem is fixture management, and it is worth ten minutes to rule that out before restructuring anything.
A service uses an ORM with a session and unit of work. A developer reports that a query returns data reflecting changes that have not been committed, and that a later validation failure rolls everything back — including changes they expected to persist. Explain what is happening and how you would prevent recurrence.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Two behaviours are combining: implicit flush and a transaction boundary wider than expected.
Show a strong answer
Two mechanisms are combining, and neither is a bug.
Implicit flush before query. The ORM knows there are pending changes in the session and that a query might need to see them, so it writes them to the database before executing the query — inside the open transaction. The developer never asked for a write, so the query "seeing uncommitted changes" is exactly right: those changes are in the database, they are simply not committed. This is why statement ordering in the database log does not match the ordering of the code, which is the part that makes it feel like a bug.
A transaction boundary wider than expected. Everything in the session is one unit of work, so a validation failure at the end rolls back every pending change, including ones written much earlier and mentally "done". The developer's model was write-as-you-go; the tool's model is change-then-flush-then-commit.
How I would explain it to them: the session is a set of pending changes, not a sequence of writes. Nothing is durable until commit, and the flush is an implementation detail of keeping queries consistent with pending state. Once that model is in place, both symptoms are predictable rather than surprising.
Preventing recurrence, in order of value:
- Make transaction boundaries explicit and visible at the use-case level. One
runInTransactionper business operation, so the scope of a rollback is a readable property of the code rather than a property of session lifetime. This addresses the second symptom directly. - Validate before mutating. Most of the pain here is from a validation failure arriving after changes accumulated. Checking preconditions at the start of the operation means the rollback rarely fires.
- Split genuinely independent operations into separate transactions. If part of the work should survive a later failure — writing an audit record, say — it does not belong in the same unit of work, and the fact that it is currently rolled back is a real defect rather than a misunderstanding.
- Document the flush behaviour in the codebase, with the one-paragraph explanation above. This is the single highest-leverage artefact, because every new team member hits this and rediscovers it by debugging.
What I would also check: whether anything depends on partial writes being visible to a different connection, such as a background job triggered mid-operation. That is a genuine bug rather than a misunderstanding — the job may read a state that is about to be rolled back — and it is the version of this that causes an incident rather than confusion. Events should be published after commit, for exactly this reason.
Key takeaways
- Active Record puts persistence on the domain object: minimal ceremony, right when the domain is essentially the schema, but rules cannot be tested without a database.
- Data Mapper keeps the domain free of persistence, enabling a rich model and fast tests, and charges genuine mapping work.
- The choice is really whether the domain may depend on the database.
- Unit of Work hoists the transaction boundary to the business operation — the only level at which multi-object invariants are expressible.
- Identity Map exists for correctness, not caching: two objects for one row is a lost update. Scope it to the unit of work.
- Know whether your ORM auto-flushes. Reads that trigger writes explain much of what looks like ORM misbehaviour.
- Most ORMs are one of these patterns with a product name; lazy loading is a virtual proxy with N+1 consequences.
- Extracting pure domain functions is the cheapest way to make Active Record code testable without changing the persistence approach.
Further reading
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — the source for Active Record, Data Mapper, Unit of Work and Identity Map.
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003) — repositories and aggregate boundaries.
- Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013).
- Joe Celko, SQL for Smarties, fifth edition (Morgan Kaufmann, 2014).
- Martin Fowler, "OrmHate" (2012) — on the object–relational impedance mismatch and what an ORM can and cannot resolve.