Back to archive

Engineering

Give the ORM a Boundary, Not Authority

A boundary model for using ORMs where they help without letting them own domain behavior.

Give the ORM a Boundary, Not Authority

ORM debates often collapse into identity. One side treats the ORM as a productivity tool that keeps teams moving. The other side treats it as a trap that hides the database until production reminds everyone that storage is real.

Both sides are partly right. The useful question is what authority the ORM is allowed to have.

The thesis

An ORM is a good persistence adapter and a poor owner of domain behavior. The right way to use one is to make the persistence boundary explicit: let the ORM map data, compose ordinary queries, and handle repetitive storage mechanics, but do not let it decide transaction shape, aggregate boundaries, migration strategy, or business rules.

Most teams benefit from the speed of an ORM early, then suffer when the ORM becomes the only language anyone has for data access.

The production pattern

A familiar pattern starts cleanly. Models are declared. Queries are short. Developers can move quickly without repeating boilerplate. The first few features feel inexpensive because the storage shape is hidden behind objects and associations.

Then the system grows. A page loads dozens of related records because lazy loading looked convenient. A transaction wraps more work than anyone intended because a service method reused an object graph. A migration tries to express a domain change through generated files that nobody reviews deeply. A retry replays code that was not written to be idempotent. A background job passes ORM objects across boundaries where stable identifiers would have been safer.

None of these failures prove that ORMs are bad. They prove that the system never drew a line between object convenience and data responsibility.

The model

I use a boundary model with three layers.

Domain layer holds business language, invariants, and decisions. It should not need to know whether data came from an ORM, a hand-written query, a cache, or a replayed event. Domain objects can be plain objects, records, or small structures. The important property is that they do not carry accidental persistence behavior.

Persistence layer owns mapping, fetching, saving, and schema-aware concerns. This is where the ORM belongs. It can translate between tables and application objects, but it should expose explicit methods that describe the data access intent: find active subscriptions for renewal, load account summary for billing, reserve pending work items.

Query layer owns performance-shaped reads. When a use case needs joins, projections, pagination, locking, or tuned filters, I prefer explicit query objects or repository methods over a chain of hidden association walks. The query should reveal cardinality and selection, not ask the reader to infer them from object navigation.

The checklist I use is simple:

  • Explicit queries: can a reviewer see which data is fetched, filtered, and ordered?
  • Transaction boundary: can a reviewer name where the transaction starts, what it protects, and where it ends?
  • Domain isolation: can domain behavior be tested without a database connection?
  • Migration ownership: does the migration express a storage change with rollback, backfill, and compatibility in mind?
  • Lazy loading policy: is lazy loading disabled, constrained, or at least visible in high-risk paths?
  • Escape hatch: does the codebase allow raw SQL or lower-level access where the ORM is the wrong tool?

The point is to make persistence intent visible where correctness and cost depend on it.

Where this goes wrong

The common overreaction is to ban the ORM and replace it with hand-written query code everywhere. That can improve transparency, but it can also create duplication, inconsistent mapping, and slow feature work. A small product surface with ordinary CRUD behavior does not need a miniature database framework built by the application team.

The other overreaction is to trust the ORM as the architecture. That fails when relationships become policy. Associations are not aggregate boundaries. Cascades are not business workflows. Callbacks are not reliable orchestration. Generated migrations are not migration strategy.

N+1 queries are the obvious failure mode, but they are not the deepest one. The deeper failure is that the code stops saying what it is doing to the database. Lazy loading, implicit flushes, broad transactions, and default cascades all make change look local while the blast radius is data-wide.

What I do now

I start by deciding what the ORM is allowed to do in the codebase. I want that decision written down because unwritten boundaries degrade under delivery pressure.

For write paths, I make transaction boundaries boring and visible. A command handler or service method should show the unit of work. If the transaction includes remote calls, sleeps, user input, or broad object graphs, I treat that as a design smell.

For read paths, I favor projection-shaped queries. If a screen needs a summary, load a summary. If a job needs identifiers, load identifiers. Hydrating a forest of objects because the ORM makes it pleasant is usually a cost leak.

For migrations, I review generated files as production code. A safe migration has compatibility, backfill, verification, rollback posture, and ownership. The fact that a tool generated it does not reduce the operational responsibility.

The principal-engineer lens is boundary discipline. The ORM is not the enemy. Unbounded authority is the enemy.

Closing takeaway

Use an ORM as a mapper and persistence adapter. Do not let it become the place where domain rules, transaction intent, query shape, and migration risk go to hide.