Lindsay Edwards

Deactivate, don't destroy: reversible state over destructive migrations

On this page

I was building a system with a pluggable architecture: self-contained modules that can be switched on and off at runtime, each owning a slice of the database. The first design question that actually mattered was the smallest-sounding one. When you disable a module, what happens to its data?

The tempting answer is “clean up after yourself”: drop the module’s tables, leave the database tidy. It is also the answer that quietly eats your users’ data the first time someone toggles a module off and back on to troubleshoot something. Picture switching a feature off, then on again to clear a glitch, and finding everything your customers had put into it gone, and you have the failure mode exactly, triggered by the most ordinary troubleshooting step there is.

Disable is a state, not a delete#

So disabling never drops anything. Each module owns its own database namespace, and disabling it flips a status flag to dormant. The tables stay exactly as they were. Re-enabling flips the flag back and the data is all still there, untouched. Enabling for the first time runs the migrations up to head; disabling just marks it asleep.

This turns a scary, destructive operation into a cheap, reversible one, which changes how you can treat it. Rapid enable-disable cycling went straight into the integration tests, because it is now a safe thing to hammer rather than a data-losing event you tiptoe around.

“Uninstall” should almost never mean “destroy.” Model deactivation as a reversible flag and you get troubleshooting, staged rollouts, and undo for free.

Give each module its own migration history#

The part that makes independent modules actually independent is subtler: each one gets its own migration version history. Not a shared, global list of migrations where module A’s schema change sits in the same timeline as module B’s, but a separate version table per module.

If everything shares one migration history, the modules are not really independent, they just look it. Enabling one drags the whole timeline along; a half-applied global migration can wedge modules that had nothing to do with it. Per-module histories mean each module’s schema evolves on its own clock, and enabling one never touches another’s tables.

Making that work meant building the migration configuration in code at runtime, per module, rather than committing one global config file. That felt like more machinery than a migration setup should need, right up until the first time I enabled a single module and watched precisely its tables appear and nothing else move.

The general version#

Two rules I would take to almost any system with optional parts. First, deactivation should be reversible by default; destroying data should be a separate, loud, deliberate action, never a side effect of turning something off. Second, if parts are meant to be independent, their schema histories have to be independent too, or the independence is a comfortable illusion that fails the day two of them disagree.

Keep reading