DEV Community

Odinaka Robert Nnamani
Odinaka Robert Nnamani

Posted on

All DB access lives in one place

One mistake I see in backend codebases:

ORM queries scattered everywhere.

Controllers query the DB. Services query the DB. Helpers query the DB. The little cron job queries the DB. Change one column and you're grep-ing the whole repo, praying you found every query that touches it.

Soon your database logic is impossible to track — and nobody can tell you where a value actually gets read or written.

A better pattern: the repository layer.

All DB access lives in one place. Controllers ask the repository; they never touch the ORM. Schema knowledge — joins, filters, transaction boundaries — lives in one module per entity. The rest of the codebase just calls methods with names that say what they mean: findActiveUsers(), not db.select().where(...).

Three things this buys you:

  1. Cleaner code — business logic stops drowning in query syntax.
  2. Safer schema changes — rename a column in one file, not seventeen.
  3. A clean test seam — the boundary you need to mock is obvious.

The rule I keep: no raw ORM calls outside the repository. No exceptions for "it's just a small helper." Break the rule once and it's no longer a rule.

All DB access lives in one place. That's the whole point.

Next in this series: debugging a distributed mystery. I write about backend systems that look small until they aren't — follow along if that's your world; the longer pieces live on my Substack.

Top comments (9)

Collapse
 
alexcodebytes profile image
Oleksandr •

The "no exceptions for a small helper" rule is where 90% of teams fail. The moment someone writes db.select() in a random utility file because "it's just one quick call," the repository abstraction is dead.

One thing I'd add: keeping the transaction boundaries inside the repository is a massive win for data integrity. If a controller has to orchestrate a multi-table transaction by calling three different repository methods, it usually ends in partial writes and corrupted state. Exposing a single createOrderWithItems() method that handles the transaction internally is a much cleaner design.

Great series idea!

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

so the question is where the boundary lives, inside or outside the repo?

Collapse
 
alexcodebytes profile image
Oleksandr •

It lives outside the repository, but inside the data access layer.

The common mistake is putting transaction logic in the controller or service (e.g., calling repo.begin(), then repo.saveA(), then repo.saveB()). If the controller controls the transaction, it's orchestrating DB logic, which breaks the abstraction.

Instead, the repository should expose an outcome-level method like repo.createUserWithProfile(userData, profileData). The method accepts the domain objects, opens the transaction, handles the commit/rollback, and closes it. The controller just calls that one method and doesn't even know a transaction is happening.

The boundary is the method signature: if it accepts domain data and performs a complete action, it belongs in the repo.

Thread Thread
 
robertpaschal profile image
Odinaka Robert Nnamani •

what breaks first when the outcome method gets big?

Thread Thread
 
alexcodebytes profile image
Oleksandr •

Testability and developer friction. When an outcome method like createUserWithProfile() starts doing 15 different things (inserting 5 tables, sending emails, updating counters), it becomes impossible to mock, and any small change breaks 10 unrelated tests.

The fix is usually to break it down by sub-domain, not by table. Instead of one giant checkout() method, you have reserveInventory(), chargeCard(), and fulfillOrder() — each wrapped in its own transaction. The repository handles the data, but the orchestration moves up to a domain service level.

Thread Thread
 
robertpaschal profile image
Odinaka Robert Nnamani •

The sub-domain split is the part I kept rereading. reserveInventory, chargeCard, fulfillOrder reads like three small rooms instead of one warehouse.

Collapse
 
williamsj04 profile image
Jessica Williams •

The seam for mocking is a real benefit. One thing I would add is how transaction boundaries are handled once a single business action touches more than one entity, for example an order that changes stock and also writes an audit row. If each repository opens its own transaction, those writes can commit separately. Passing a transaction handle, or a small unit-of-work object, into each repository method keeps one commit for the whole action while the queries still live in one place. It also lets a test assert that nothing was written when the second step fails.

Collapse
 
robertpaschal profile image
Odinaka Robert Nnamani •

Good catch, that's the piece #6 left open. The boundary question is where the next one goes.

Collapse
 
ihssmaheel profile image
Mohamed Ismail •

Great post! I'm following the same approach in my projects. I think the repository pattern is pretty common in modern backend development now, but it's still surprising how often database queries end up scattered across the codebase. Keeping everything in one place makes maintenance so much easier.