•
7 min read
why your orm cannot give you a global soft-delete filter
orm postgres architecture

A ticket asked for soft delete on chat messages: a deleted message should stop being readable but keep its place in the timeline. A reasonable follow-up question was whether the whole service should get soft delete while we were at it, and whether the ORM should be swapped for one with better support. I spent a week on the research. The answers were: build a narrow tombstone, do not do a database-wide rollout, and do not migrate the ORM. Here is why, with the parts that generalize to any Prisma codebase and most others.

the columns were there and nothing used them

Nine of the service’s models already had a deletedAt column. Counting production read call sites on those nine models: 113 reads, 45 with the deletedAt: null predicate. Forty percent.

The misses were not random. Every batch-by-id method, the path the data loaders and federation entity resolution use, filtered correctly. The security-critical authorization check goes through a filtered loader, so a removed member correctly lost access. The misses were concentrated in list, search, pagination, cron, and cross-domain paths. The main list query built its where from ids, tenant, cursor, member filters, and search, and never added the predicate.

That is the empirical case against “add the predicate at every call site”. On a model where the predicate was already required, it was present forty percent of the time, and the places it was missing were exactly the places where a mistake shows up as a wrong list rather than a failed authorization.

the orm cannot do it globally

Prisma’s client extension hook, $extends, is the documented way to intercept queries. Its documentation says, verbatim, that the query extension type does not support nested read and write operations, and separately forbids the obvious workaround: you cannot mutate include or select because it would change the output type. Confirmed in the source: extension callbacks resolve once per request, keyed on the top-level model and action, and nested reads are expanded downstream by the query compiler and never re-enter that function.

What that leaks:

PathCovered by the extension
Top-level find, count, aggregateyes
include or select of a relationno
Relation filters some, every, noneno, and every and none need a structural rewrite rather than an added AND
Nested create and connectno
Nested _countno
Raw SQLthe hook fires, but there is no where to inject into

The feature request for a built-in global filter has been open since 2019 with hundreds of reactions and carries a label meaning “use a client extension”, and the extension it points at is broken on the current major version. The middleware-based packages died when the middleware API was removed. The runtime model metadata a generic extension would need was removed with no replacement. Plan on this never landing.

There was also a blocker nobody would predict from the outside. The service’s database client class extends the Prisma client. $extends returns a new object and does not mutate the receiver, so it cannot be called in the constructor the way the old middleware could. With several hundred call sites of the form this.prisma.<model>.<method>, converting to a wrapper is a real project on its own. A Proxy over the client turned out to work with zero call-site changes, which is the route to take if the extension approach is ever adopted.

the one piece of good news

The service is a federation subgraph, and its relation reads run through per-field data loaders rather than deep include trees. A data loader’s batch function issues a top-level findMany by ids. Top-level calls are interceptable.

So the architectural rule that makes an extension viable is: never include a soft-deletable relation, resolve it through a data loader. That converts the uncoverable nested case into the coverable top-level case, and it works for a structural reason. GraphQL already decomposes a nested selection into one resolver call per field, and a loader turns each into an independent top-level query. The flattening that a nested-operations extension does by walking the arguments tree, GraphQL does for free.

The rule does not close relation filters in where clauses, nested counts, raw SQL, or nested writes. Counted in production code: 22 relation predicates across six files, six of them none or every needing logical inversion. A hand-fixable list.

It also introduces one failure mode to plan for. Under include, a soft-deleted relation silently appears. Under a filtering loader it silently disappears: the batch function finds no row and returns null. If the schema declares that field non-null, GraphQL propagates the null to the nearest nullable ancestor, and a non-null list of non-null items nulls out entirely with an error. The approach converts a silent correctness bug into a loud availability bug. That is an improvement, and it has to be a deliberate schema decision, so audit nullability on every field pointing at a soft-deletable entity before turning filtering on. In a federated graph, a referencing subgraph that declared its field non-null hits the same cliff in a service you do not own.

migrating the orm was not the cheaper path

Sequelize’s paranoid mode does filter eager-loaded includes, which is the one thing a hand-rolled Prisma extension cannot do. That was the whole argument for migrating. Against it: paranoid mode shares Prisma’s cascade and unique-constraint problems, adds five write-path leaks I reproduced, and sits on a major version in maintenance with the next one in alpha for years. Estimated at 31 to 48 engineer-weeks against 3 to 6 for fixing it in place.

Migrating an ORM to get one feature wins on that feature and ties or loses on everything else, at roughly ten times the effort. There was a real reason to migrate eventually, documented elsewhere. Soft delete was not that reason.

the tables that must never be soft-deleted

Several tables were in the tens of millions of rows, and the largest was in the hundreds of millions. Every one of those needed to keep hard-delete semantics, for a reason outside the service: ten downstream analytics models filtered on a “deleted by the sync connector” column that only becomes true on a physical delete. Soft delete would have turned all ten filters into no-ops in one step, and nothing would have errored.

That is the strongest argument against a blanket rollout. The cost lands where there is no benefit, and the risk lands in a system whose owners were not in the conversation.

what shipped

A tombstone on the chat tables: keep the row, keep created_at, destroy the body. No restore, because nobody wanted one. Explicit predicates plus the data-loader discipline, proportionate to a cluster whose call sites were enumerable. Read-side filters landed before any delete call site was flipped, because with the column always null the filter is a pure plan change, and reversing that order means every user who deletes between two deploys gets notifications about content that no longer exists. Every soft delete is paired with notification suppression in the same transaction, following a pattern the repo already had.

Plus a lint check flagging a read on a soft-deletable model without the predicate, because forty percent is the number you get without one.