The Monolith-to-Modular Migration Playbook We Actually Use
Dana Whitfield · 2025-05-12 · 3 min read
Every monolith-to-modular engagement starts with the same request: "break this apart before it breaks us." And every one fails the same way if the team starts by drawing service boundaries on a whiteboard. Boundaries drawn from an org chart or a diagram rarely survive contact with the actual data. The boundaries that survive are the ones discovered by tracing real write paths through the code that exists today.
Start with the database, not the code
The monolith's schema is the real architecture document. Application code lies about coupling — a clean-looking service layer can still share three tables with a module on the other side of the codebase. We start every migration with a dependency graph built from actual foreign keys, shared transactions, and join patterns pulled from query logs, not from the ORM models. This graph almost always reveals two or three tables that everything touches — usually users, accounts, or a generic events table — and those tables determine where the seams can go. If you can't cleanly own a table, you can't cleanly own the service around it.
Extract along transaction boundaries, not feature boundaries
Product teams think in features. Databases think in transactions. A "checkout" feature might span inventory, pricing, tax, and fulfillment — four different data owners that happen to commit together today for consistency, not for architectural reasons. Extracting "checkout" as a single service just relocates the monolith. We instead extract along the boundaries where a transaction can be safely split into a saga or an eventual-consistency handoff without changing observable business behavior. That's a smaller, more mechanical unit of work, and it's the one that actually reduces coupling.
Strangle, don't stop
We've never seen a big-bang rewrite land on schedule, and we don't run them. The strangler fig pattern — routing an increasing share of traffic to the new module while the old code path stays live as a fallback — is the only approach we've found that keeps a system shippable for the entire migration. Concretely: put a routing layer in front of the capability being extracted, dual-write during the transition window, verify the new path against production traffic with a shadow comparison, then cut over and delete the old path. Each cutover is small enough to roll back in minutes, which means the team keeps shipping unrelated features the whole time instead of freezing the codebase for a quarter.
The database splits last, not first
Teams that split the database before the code stabilizes end up debugging distributed transactions across a boundary that isn't finished being designed. We keep the database shared — behind strict per-module schema ownership and no cross-module joins in new code — until the service boundary has run in production for at least one full business cycle. Only then do we physically separate the data store, because by that point the access patterns are proven rather than guessed.
What actually breaks
The failure mode we see most often isn't technical, it's organizational: a team extracts a service, then keeps needing "just one more column" from the old table because the boundary was drawn a layer too shallow. The fix is almost always to widen the extracted service to include the data it actually needs exclusive ownership of, even if that makes the first module larger than the org chart suggests. A slightly oversized module with a clean data boundary outlives a perfectly-scoped module that leaks state.
Modularization is a sequencing discipline first and an architecture exercise second. Get the order of operations right and the diagrams draw themselves at the end, not the beginning.