Expert Opinion

I moved logic out of the database and took on a harder problem on purpose

I moved logic out of the database and took on a harder problem on purpose

Moving logic out of your database and into your application does not simplify the system. It trades one kind of problem for another, and if you go in expecting a clean win you will be surprised by the bill.

I moved a live SaaS product off its database triggers over about four weeks. Eleven retired, ten deliberately left running. The migration worked. It was also harder after the move than before, in a way I did not fully price in when I started.

Here is the trade, stated honestly, because most writing on this topic sells it as an upgrade.

What the database was actually giving me

A trigger is business logic living in the database, running on every write, in database syntax. The standard complaint is that it is invisible: it does not show up when you search the application code, it produces no stack trace, it sits outside your test suite, and it locks you to one database engine.

All of that is true. I have made the argument myself and I still believe it.

But a trigger has one property that is genuinely hard to replicate, and it is worth naming before you take it away. A trigger runs inside the same statement as the write that fired it. The row and the thing derived from that row cannot disagree, because they commit together or they fail together. There is no window where one is true and the other is not.

That is not a small thing. For any number that gates what a customer is allowed to do, “these two facts can never disagree” is exactly the property you want, and you get it for free.

So the honest framing is not that triggers are bad. It is that triggers buy atomicity and charge you visibility, and at some point the product gets big enough that the visibility matters more.

What I traded it for

Each retired trigger became an application event with listeners attached. The listeners split into two tiers, and choosing the tier for each one was the actual engineering in this project.

Tier one runs synchronously, inside the same transaction as the write. This is for anything where a wrong number is a correctness bug. Running these in the same transaction deliberately reproduces the one good property the trigger had. I did not move these to a queue, and moving them would have been a downgrade dressed up as modernization.

Tier two runs asynchronously on a queue. This is for derived statistics, external syncs, and notifications. None of it needs to be true at the instant of the write. It needs to be true soon, and it must not make the customer wait.

The split matters more than the mechanism. If you move everything to the queue because asynchronous sounds better, you have taken a system where certain facts could never disagree and made them able to disagree for a few hundred milliseconds. Most of the time nobody notices. The times somebody notices are the times a customer was allowed to do something they should not have been.

The part I underestimated

Here is the bill I did not fully price in.

Moving logic out of triggers makes it visible in the codebase. It does not make it visible in production. A background worker running a listener is a different kind of invisible: it is real code with real log lines, but those log lines are disconnected from the request that caused them. The user clicked something at 14:02. A worker did something at 14:02:30. Nothing in the logs connects the two.

So I had replaced logic nobody could read with logic nobody could follow. That is progress, but it is a smaller step than it looks, and for a few weeks it was arguably worse. A trigger at least ran in one place at one time. An asynchronous listener runs later, elsewhere, under a different process.

The fix was to stamp a trace identifier on the event when it is created, carry it through the queue payload, and re-establish it in the worker before the listener runs. Then every log line from the background job carries the identifier of the request that started it, and you can follow one user action across process boundaries.

I would not do this migration again without building that first. Not alongside, not after. First. The trigger migration is worth considerably less if the replacement is only marginally easier to debug, and you will not feel the gap until you are trying to explain a wrong number under pressure.

That is the honest sequence: build the tracing, then move the logic. I did it in the other order and got away with it.

Why I moved eleven and left ten

Ten triggers are still running on that product. That is not unfinished work.

The ones I retired had something in common. They maintained counts and derived values, the rules were understood, and every code path that fired them was reachable from the application. Bounded problems.

The ones still running touch tables where I could not enumerate every writer with confidence. Some of that data arrives through paths outside the normal application flow. Retiring a trigger before you can name every writer is how you quietly lose data integrity, and quietly is the worst way to lose it.

So they stay, and the reasoning is written down next to them. A trigger I understand and chose to keep is a decision. A trigger nobody has read is a liability. Moving items from the second category to the first is most of the actual work, and it is invisible on any progress report.

The version of this story that ends “we eliminated all our technical debt” is a story about a product nobody was using. The real version is: I converted the part I understood, I documented why the rest is still there, and I stopped the pile from growing.

How I sequenced it

Domain by domain, riskiest understanding first, each shipping as its own migration.

The tempting alternative is one release that deletes every trigger and adds every replacement. It is conceptually clean and it is how this work goes wrong. A single cut gives you one moment where everything changes. If a number starts drifting three days later, your suspect list is every trigger you touched and every listener you added, with no way to narrow it. You either roll everything back or debug under pressure while customers watch.

Going domain by domain, a drift means one domain, and the rollback is one migration.

Two details from the sequencing are worth more than the schedule itself. The first is that the groupings were not arbitrary. Triggers touching the same table, or maintaining the same derived value, moved together or not at all. Splitting them across releases would have meant running a window with half the rule in the database and half in the code, which is worse than either end state. One day retired four at once for exactly this reason, and another retired a set of three covering create, update and delete on a single derived value, because leaving any one behind would have made the value wrong on that path.

The second is the orphan. One trigger was firing on a rule the product had stopped using, with no matching code path left anywhere. It cost nothing to run and nothing to remove. It is also the clearest evidence of the real problem, which was that nobody had a list. It had been firing for years after the feature that needed it was gone, and the only reason anyone found out is that I went looking.

Almost every codebase of this age has one. Finding yours tells you more about your situation than any architecture review.

What I would tell someone starting this

Do not start because triggers are bad practice. Start because you cannot answer a question about your own numbers, and be specific about which question.

Build the tracing before you move the logic, not after.

Decide the tier for each listener on whether a wrong value is a correctness bug or a stale statistic, and be willing to keep something synchronous when the answer is correctness. Asynchronous is not the modern choice, it is one of two choices.

And expect the system to get harder to reason about before it gets easier. It did for me. The payoff is real and it arrives later than the work does.

Questions I get asked about this

Is it always right to move business logic out of database triggers?

No. A trigger enforcing a data integrity constraint is reasonable, and a trigger gives you one genuinely useful property: it commits in the same statement as the write, so the row and the value derived from it can never disagree. The case for moving logic out is visibility, testability and portability. That case is strong for business rules and weak for simple integrity guarantees.

What do you lose when you replace a trigger with an application event?

Atomicity, unless you deliberately keep it. A trigger runs inside the write’s transaction. An event handled on a queue does not, so there is a window where the row exists and the derived value does not. For anything that gates what a customer is allowed to do, keep that listener synchronous and inside the transaction. Asynchronous is one of two choices, not the modern one.

What is the biggest mistake in this kind of migration?

Doing it in one release, and building the tracing afterwards. A single cut gives you one moment where everything changed, so a problem three days later has a suspect list you cannot narrow. And an asynchronous listener is invisible in a different way than a trigger was: real code with real logs, disconnected from the request that caused it. Carry a trace identifier through the queue payload before you move any logic.

Should you retire every trigger?

No. I retired eleven and deliberately left ten, because I could enumerate every code path writing to the first group of tables and not the second. Retiring a trigger before you can name every writer is how you quietly lose data integrity. A trigger you understand and chose to keep is a decision. A trigger nobody has read is a liability.

Want to talk about this kind of work?

I am a hands-on senior engineer with 15+ years building and running production systems. I am open to senior engineering and technical lead roles.