LayersGENERALSCALE-SPECIFICFRAMEWORK-SPECIFIC

When the Repository Is Just Indirection

A repository whose methods are one-line passthroughs to the ORM adds a file, a name and a hop, and removes no decision from anyone.

What actually happensHow to build 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 has a problem.

The question

When does wrapping the ORM cost more than it buys?

The requirement

A code review standard says "no ORM calls outside the repository layer". A new endpoint needs to read a user by id.

The obvious build

Add UserRepository.findById(id) { return this.prisma.user.findUnique({ where: { id } }) }, then call it from the service. It is the rule, it takes thirty seconds, and consistency is worth something.

Why it breaks

The class reaches forty methods, thirty-six of which are one-liners. findById, findByEmail, findAll, create, update, delete, repeated per table.

How it breaks in production
  • The class reaches forty methods, thirty-six of which are one-liners. findById, findByEmail, findAll, create, update, delete, repeated per table.
  • Adding one field to one endpoint now edits four files: the migration, the ORM model, the repository method, the interface it implements. Three of those edits carry no decision.
  • The interface leaks. Someone needs a filter the wrapper does not expose, so findMany(where: Prisma.UserWhereInput) appears. Every caller is now coupled to the ORM *through* the abstraction that was meant to hide it.
  • The features you paid an ORM for become unreachable: partial select, nested relation loading, RETURNING, cursor streaming, upsert. Callers either lose them or bypass the layer, and both happen in the same codebase.
  • The in-memory fake used in tests does not enforce unique constraints, foreign keys, case-insensitive collation, NULL ordering or isolation. The suite is green and the behaviour it proves is not the behaviour production has (Test Against the Real Database).
  • The promised payoff — swapping the database — is never exercised. When it is finally attempted, the blockers are migrations, transaction semantics, JSON operators and generated ids, none of which are behind the interface.
RequirementAPI ContractApplication LogicData AccessExternal DepsConcurrencyFailureSecurityObservabilityDeploymentScale

What is actually happening

  • An abstraction earns its cost when it does at least one of three things: removes a decision from the caller, centralises a definition shared by many call sites, or has a second implementation that is actually used. A passthrough does none of the three.
  • The cost is not the file. It is paid on every read: a reader following placeOrder now opens the service, the repository, the interface, and the ORM model to learn that findById is findUnique.
  • Wrapping does not create decoupling. Coupling is measured by what changes together, and a passthrough guarantees that the caller, the wrapper and the ORM change together every time.
  • The "swap the database" argument fails on semantics, not syntax. Two engines differ on isolation behaviour, uniqueness with NULL, string collation, upsert semantics, returning rows on write, sequence behaviour and JSON support. An interface hides the method names and hides none of that (Isolation Levels).
  • The "swap for tests" argument fails for the same reason: a fake that behaves like a dictionary is not a database, and the bugs you ship are the ones where the difference mattered.

The passthrough, and what replacing it looks like

The concrete version of this lesson is worth reading slowly, because the bad version looks exactly like good practice. It has an interface, dependency injection, a single responsibility and full test coverage. What it does not have is a decision.

The alternative is not "call the ORM everywhere". It is: call the ORM directly for the lookups that carry no definition, and keep repository methods for the queries where the name means something the code does not say.

Forty methods, or four
Wrapping for the rule
interface UserRepository {
  findById(id: string): Promise<User | null>
  findByEmail(e: string): Promise<User | null>
  findAll(): Promise<User[]>
  create(data: Prisma.UserCreateInput): Promise<User>
  update(id: string, data: Prisma.UserUpdateInput): Promise<User>
  delete(id: string): Promise<void>
}

class PrismaUserRepository implements UserRepository {
  findById(id: string) { return this.p.user.findUnique({ where: { id } }) }
  findByEmail(e: string) { return this.p.user.findUnique({ where: { email: e } }) }
  findAll() { return this.p.user.findMany() }          // no limit. ever.
  create(data: Prisma.UserCreateInput) { return this.p.user.create({ data }) }
  // ...30 more like these, across 6 tables
}
Wrapping for a definition
// trivial lookups: call the ORM from the service, directly
const user = await db.user.findUnique({ where: { id } })

// the repository holds only the queries that carry domain meaning
interface UserQueries {
  /** active = verified, not suspended, seat not revoked */
  findBillableSeats(tx: Tx, orgId: OrgId): Promise<Seat[]>
  /** case-insensitive, honours the citext index; used by signup AND invite */
  findByEmailForSignup(tx: Tx, email: Email): Promise<User | null>
  /** batched: one query for N orgs, so callers cannot N+1 */
  countMembersByOrg(tx: Tx, orgIds: OrgId[]): Promise<Map<OrgId, number>>
}

The second version puts a file boundary exactly where a definition is shared and nowhere else. findBillableSeats centralises a rule that four call sites would otherwise each re-derive; countMembersByOrg makes batching structural so a caller cannot loop. findById centralises nothing — inlining it removes a hop from every reader's path and costs no guarantee. The first version's Prisma.UserCreateInput in the interface also means the "swap the ORM" story it was built for was never true.

The tests that pass for the wrong reason

The strongest-sounding argument for a repository interface is "we can substitute a fake in tests". It is worth taking seriously, because it is where the pattern does the most quiet damage: the fake is a dictionary, and a dictionary agrees with a database on none of the things that break in production.

Each row below is a real class of bug that a fake-backed suite reports as green. None of them are exotic; the first two are ordinary signup flows.

What an in-memory fake repository cannot reproduce
TriggerSymptomCauseResponse
Two concurrent signups with the same emailDuplicate users in production; fake test passesThe fake has no unique index, so check-then-insert always looks safeEnforce uniqueness in the database and test against a real engine (Database Constraints)
Email stored as Alice@x.com, looked up as alice@x.com"Account not found" for a real accountJS map keys are case-sensitive; Postgres collation and citext are notDecide case semantics in the schema, and let the test see the schema
Deleting an org that still has projectsOrphan rows and a later 500 on a null joinThe fake has no foreign keysForeign keys with an explicit ON DELETE policy (Relationships, Keys and Constraints)
Sorting a nullable columnPage 1 and page 2 contain the same rowNULL ordering and tie-breaking differ from array sortOrder by a unique tiebreaker; use keyset pagination (Pagination That Survives a Large Table)
Two requests updating the same rowA lost update nobody can reproduce locallyThe fake is single-threaded and has no isolation level at allVersion column or SELECT ... FOR UPDATE (Optimistic Concurrency)
A method that loops over idsp99 doubles after a data growthThe fake makes 200 lookups free; the database makes them 200 round tripsBatch in the query; count queries per request in tests (The N+1 Query Problem)

A criterion you can apply in review

The decision is not "repository or no repository" for a codebase. It is per query, and it can be settled in one question: what does this method name tell a reader that the ORM call does not?

Answering honestly produces a codebase with a small number of well-named repository methods and a lot of direct data access — which is a stable, defensible end state, not a compromise.

Does this query deserve a repository method?

What would the wrapper actually remove from the caller?

A shared domain definition

when "Active", "billable", "overdue" — the predicate encodes a business rule more than one place needs.

cost One indirection, paid by every reader of every caller. Worth it past two or three callers.

A batching guarantee

when The method returns data for many ids at once so no caller can write a loop.

cost A less obvious signature (maps rather than entities) and callers that must handle missing keys.

A mandatory scope

when Tenant id, soft-delete filter, or a security predicate that must never be forgotten (Tenant Isolation).

cost A required parameter everywhere, including in places where it is obviously redundant.

A tuning surface

when The query is a known hotspot you expect to reshape — index, replica, cache, materialised view.

cost Speculative if the hotspot is imagined. Extract it when the slow-query log names it.

Nothing — it is `findUnique` with a new name

when Never, on these grounds. Keep it only if a stated team convention (findability, a lint rule) is the actual reason.

cost A file, a name, a hop, and an interface that will leak the ORM the first time someone needs a filter.

How to build it

Most important first.

  • Wrap behaviour, not syntax. If the method body is one ORM call with no domain logic in it, the method is a rename.
  • Extract on the second or third shared caller, not on the first. The trigger is a definition being duplicated, not a rule being followed.
  • Let simple reads call the ORM directly from the service, and keep a small repository for the queries that carry a domain definition. A mixed codebase is the honest result of applying the rule where it pays.
  • If you keep a repository interface, keep its types yours. The moment the ORM's where type appears in a signature, the interface is decoration (Three Models, Not One).
  • Test against a real engine in a container. That removes the strongest remaining argument for a fake-swappable interface (Test Against the Real Database).
  • If the real goal is "I want to see every query this service runs", that is an instrumentation problem — query tagging, a per-request counter, ORM logging — and it is cheaper than a layer (Tracing From the Backend's Side).

What can go wrong

Failure modes
  • Two abstractions stacked: an ORM that already abstracts SQL, wrapped in a repository that abstracts the ORM. Debugging a slow query means walking down through both (What an ORM Actually Does).
  • A generic BaseRepository<T> with findAll, findOne, save, delete. It is a worse ORM, written in-house, with no query planner awareness and no documentation.
  • Batching becomes impossible: the interface is entity-at-a-time, so a loop over ids becomes a loop over queries and the N+1 is structural (The N+1 Query Problem).
  • Real logic hides in a fake repository because it was the only way to make a test pass, and now the fake is the specification.
  • Bypass fragmentation: half the codebase honours the rule and half does not, so the layer provides neither the guarantees nor the convenience.
What can race
  • An entity-at-a-time interface pushes callers into read-modify-write loops, which is the shape that loses updates. A single UPDATE ... SET n = n + 1 cannot be expressed through it (Atomic Operations, Optimistic Concurrency).
Security
  • A wrapper that adds nothing also adds no tenant scoping. The chokepoint benefit of a repository is real and is exactly what a passthrough does not deliver (Tenant Isolation).
  • A generic find(where) re-opens the door the layer was supposed to close: user-controlled filter objects reaching the query builder, including operators the caller never intended to expose (Mass Assignment and Over-Posting).
  • A generic update(id, data) passing a request body straight into the ORM is mass assignment with extra steps — role: 'admin' included.
Misreads
  • "Repositories are an anti-pattern." They are not. A repository carrying a shared domain definition is one of the most useful structures in this module (The Repository Layer). The anti-pattern is the empty one.
  • "This proves you should not abstract data access." It proves that an abstraction should remove a decision. Some do.
  • "We need it for testing." Test against a real engine in a container and see whether the need survives (A Test Strategy Chosen by What Each Layer Can Prove).
  • "We might swap databases." Write down what would actually be involved. If migrations, isolation behaviour and dialect are not on your list, the list is incomplete.
  • "Consistency is worth the cost." Consistently applying a rule that buys nothing produces a codebase that is consistently harder to read.

Operating it

How you see it in production
  • Count methods whose body is a single ORM call. That ratio is the cheapest available measurement of how much of the layer is doing work.
  • Count call sites per method. Methods with exactly one caller and one line are candidates for inlining; methods with eleven callers are the ones earning their place.
  • Look at the trace: if findById produces a span containing exactly one query span with the same name, the intermediate frame is not telling you anything new.
What changes at 10x and 100x
  • The runtime cost is one function call and does not matter at any scale. The cost is entirely in change velocity and in the ORM capabilities that become unreachable.
  • At 100x rows the missing capability starts to matter concretely: no streaming means a report loads 400,000 rows into memory because the interface returns an array (Memory Leaks in Backend Services).
  • At large team size a thin layer can still be worth keeping purely as a convention that makes queries findable — which is an argument about people, and should be stated as one.
What this costs
  • Dropping the rule means ORM calls appear in services, and someone will write a bad one where nobody sees it. The mitigation is review and query tagging, not a wrapper.
  • A mixed codebase — some queries behind repositories, most not — needs a stated criterion, or it degenerates into personal preference per file.
  • Inlining an existing passthrough layer is a large, boring diff that fixes no bug. Deleting it is usually right and rarely urgent.

Where this applies

Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.

  • GENERALThe reasoning — an abstraction must remove a decision, centralise a definition, or have a used second implementation — is language-independent.
  • SCALE-SPECIFICFlips on shared call sites. Under three callers a wrapper is pure cost; past ten, or once the definition has changed twice, the same wrapper is where a change can land completely. Team size shifts it too: a twenty-engineer codebase may keep thin repositories purely so that queries are findable, which is a legitimate reason as long as it is the stated one.
  • FRAMEWORK-SPECIFICThe argument is weakest with an ORM whose models are already a data-access layer with a query API — Django's managers and Rails' scopes give you named queries without a separate class, so the "repository" is a manager method. It is strongest with a bare driver or a query builder like Knex or sqlc, where there is no other place for a named query to live.

Where the depth lives

This domain teaches the application-side mechanism and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — the general form of this problem: a test double that is easier than the real thing is also weaker than the real thing.