PhilipAng
← All writing

March 20262 min read

Why I stopped reaching for an ORM abstraction layer

Every project I have worked on eventually grew a repository layer on top of its ORM. Every one of them regretted it. Here is what I do instead.

  • Databases
  • TypeScript

Every project I have worked on past a certain size grew a repository layer on top of its ORM. A UserRepository wrapping the ORM, which was already wrapping the driver, which was already wrapping the wire protocol. Three layers of indirection between "I want this row" and getting it.

I have stopped doing this, and I want to explain why carefully, because the arguments for the pattern are not stupid.

The case for the abstraction

The pitch goes like this: your business logic should not know what database it is talking to. Put an interface in front of it, and you can swap Postgres for something else later, mock it in tests, and keep persistence concerns out of your domain.

Each of those is a real benefit. The problem is that only one of them survives contact with a real codebase.

What actually happens

Nobody swaps the database. In fifteen years I have seen it happen twice, and both times the abstraction layer did not survive the swap — the semantics were too different. The interface had leaked the whole time and nobody noticed until it mattered.

Mocking the repository tests nothing. A test that asserts userRepository.findById was called with the right argument is a test of your own mock. It passes when the query is wrong, when the index is missing, when the join returns duplicates. The bugs that reach production are precisely the ones a mocked repository cannot see.

The abstraction gets in the way constantly. You want a partial index, a lateral join, ON CONFLICT DO UPDATE, a window function. None of them fit through the interface, so you add findByIdWithPostsAndTagsOrderedByDate and pretend that is a domain concept.

What I do instead

A thin data-access module per aggregate. Plain functions, real queries, no interface:

export async function getProjectBySlug(slug: string, options: { includeDrafts: boolean }) {
  return db.query.projects.findFirst({
    where: options.includeDrafts
      ? eq(projects.slug, slug)
      : and(eq(projects.slug, slug), eq(projects.status, "published")),
    with: { projectTags: { with: { tag: true } } },
  });
}

This is the only place SQL is written. Callers get a typed result and never see a query builder. That gives me the one benefit I actually wanted — persistence concerns stay in one place — without the four layers of ceremony.

And the tests hit a real database. Each one creates the rows it needs and deletes them afterwards. They are slower than mocked tests, and they catch things mocked tests structurally cannot.

The honest counterargument

If you genuinely have two storage backends in production right now, you need the interface. Write it. But write it because you have the requirement, not because you might.

The rule I use: an abstraction that has exactly one implementation is not an abstraction. It is a layer of indirection wearing a costume.