The Cost of a Convenient Abstraction

Every shortcut has a bill, and most of them arrive in production. On ORMs, server components, caching layers, and the value of knowing what the abstraction is doing.

The cost of a convenient abstraction — Essay 05 in the Architecture notes series, set in white on an aubergine field.
The cost of a convenient abstraction — Essay 05 in the Architecture notes series, set in white on an aubergine field.
On this page

The first time an ORM saves you an afternoon, it feels like a superpower. db.user.findMany() and the data appears. No SQL, no mapping, no connection handling. The second time, you are staring at a query log with forty-one statements for one page and wondering where the afternoon went.

Abstractions are not free. They are a loan against future understanding. The interest is usually reasonable, but it compounds in the places nobody looks: the N+1 query, the cached response that outlived the write, the server component that quietly serialises a database row into the client bundle.

The convenience is real

I am not arguing for hand-written SQL everywhere. The right abstraction removes a class of mistakes and lets a small team move quickly. Drizzle, Prisma, and the query builders around them are better than most of what we had ten years ago. Server components are a real improvement for data-heavy pages. A cache is often the difference between a usable product and a slow one.

The argument is narrower: know what the abstraction is doing, and know where it stops. The cost is not the tool. The cost is the gap between what you think it does and what it actually does.

The bill arrives in three places

1. Query shape. A relational load with three includes can become a dozen queries, or one query with a cartesian product, depending on the ORM and the data. The code looks the same. The performance does not.

the-shape-you-wrote
const users = await db.user.findMany({
  include: {
    invoices: true,
    sessions: true,
  },
});
the-shape-that-runs
-- A naive implementation: one row per user × invoice × session.
select *
from users
left join invoices on invoices.user_id = users.id
left join sessions on sessions.user_id = users.id
where users.id = $1;

The fix is usually not “stop using the ORM.” It is “read the query plan for the three screens that matter, and write the join yourself when the shape is wrong.” The abstraction should be a default, not a prohibition.

2. Cache ownership. A cache is a copy of the truth with a timer attached. The hard question is not “how long should the TTL be?” It is “who is allowed to invalidate this, and what happens if they forget?” If the answer is “the route that writes the data,” the cache belongs to that module. If the answer is “nobody knows,” the cache is a future incident.

3. Serialisation. In a server component, it is easy to pass a database row into a client component and call it a day. That row may contain fields the client should never see: internal flags, another user’s email, a soft-delete marker. The boundary between server and client is a security boundary, not a convenience.

The test I use

For any abstraction, I ask three questions:

  1. What does it do when it fails? A clear error is part of the interface. A stack trace from inside a query builder is not.
  2. Can I see the thing it generates? If not, I need a way to inspect it: a query log, a debug mode, a plan.
  3. What is the escape hatch? There should be a documented path to the layer below. The absence of an escape hatch is how a convenience becomes a cage.

If an abstraction cannot answer those questions, it is not a tool yet. It is a dependency.

AbstractionWhat it savesWhat it hidesThe check
ORMMapping, migrations, typesQuery shape, locks, indexesEXPLAIN the three hottest screens
Server componentsClient bundles, data loadingSerialisation boundaryInspect the RSC payload
CacheLatency, database loadOwnership, stalenessName the invalidation path
API clientAuth, retries, parsingFailure modes, timeoutsSimulate a 503 and a slow response
Framework routerNavigation, prefetchRequest lifecycle, cachingRead the network tab on a cold load

WARNING

The most expensive abstraction is the one nobody on the team can debug. A clever internal library with no docs is not leverage. It is a single point of failure with a nice API.

Know the layer below

The engineers I trust most have a working model of the layer below their favourite tool. They can read a query plan. They know what a 502 from the proxy means. They can explain why a cache key is wrong without opening the cache library’s source. That knowledge is not nostalgia for the pre-framework era. It is what makes the framework safe to use.

The practical version is small:

  • Read the SQL for one page a week. It takes ten minutes and teaches you the shape of your data.
  • Add a query budget to the slowest endpoint. If the budget is eight queries and the page uses forty, the abstraction is lying to you.
  • Write down the invalidation path for every cache. If you cannot draw it, do not add the cache.
  • Treat the server/client boundary like a network boundary. Because it is one.

The bill is worth paying sometimes

None of this means avoiding abstractions. It means choosing them with an eye on the exit. A good abstraction makes the common case simple and the uncommon case possible. A bad one makes the common case simple and the uncommon case invisible until it is expensive.

The convenience is real. So is the bill. Read it before you sign.