Decomposing a Monolith Without a Rewrite
Seams before services. How to extract modular services from a legacy monolith incrementally - with the data ownership rule, the strangler pattern, and an honest list of what you give up the moment a function call becomes a network call.
Every proposal to break up a monolith starts the same way: a diagram with six neat boxes and arrows between them. Every one I have seen fail did so for the same reason - the boxes were drawn around what the team wished the system looked like, not around the seams that were actually there.
I have been doing this decomposition at TechForing on a SaaS platform that works and has customers. The constraint that shapes everything: the product ships the whole time. No rewrite, no freeze, no cutover weekend.
Find the seams; don’t invent them
A seam is a place where the system is already nearly separable. You find them by looking, not by designing:
- Which modules import from which? A module nothing imports from is a candidate. A module everything imports from is the shared kernel, and it is not going anywhere.
- Which tables are written by exactly one module? Single-writer tables are a boundary. A table written by four modules is four modules pretending to be one.
- What has a different scaling shape? Something CPU-heavy, or bursty, or slow, buried in a request path that is otherwise fast. That difference is a real reason to separate; “it feels like its own thing” is not.
- What changes on a different cadence? A module deployed on every release alongside one touched twice a year are not the same component.
Notice that all four are observations about the existing code. Draw the boundary where the system already wants to split, and extraction is mechanical. Draw it where you wish it split, and you spend six months fighting the dependency graph.
Modularise first, distribute second
The step people skip: make it a module before you make it a service.
Enforce the boundary inside the monolith first. One package, an explicit public interface, no other module reaching past it into its internals, no other module touching its tables. This is free to do, free to undo, and it surfaces every hidden dependency you would otherwise discover mid-extraction with half the traffic already moved.
If a boundary is painful to hold inside one process - where a violation is a one-line import - it will be significantly worse across a network. A module you cannot keep clean is not ready to be a service. It might just be a module that should stay a module, and that is a completely legitimate outcome.
The data rule
If you take one thing from this: one service owns its tables. Nobody else reads them.
Shared database access is the failure mode that turns “microservices” into “a distributed monolith” - all of the operational cost of services, none of the independence. When two services read the same table, you cannot change its schema without coordinating a deploy, which was the exact problem you were trying to solve.
Which means extraction is usually a data migration, and that is the part that actually takes time. The sequence that works:
- Route all access to the target tables through the module’s interface, still inside the monolith.
- Move those tables to their own schema. Still one database; access is still through the interface.
- Stand the service up against that schema.
- Switch the interface from an in-process call to a network call.
- Only now, if it is worth it, move the schema to its own database.
Every step is independently deployable and independently revertible. Step 4 is the only one where the failure mode changes - and by then, everything else is already proven.
Strangling the old path
For the switchover, the strangler pattern: the new implementation runs alongside the old one, and a flag decides who serves the request.
Start in shadow mode - the new path executes, its result is compared to the old one and logged, and the old result is what the user gets. This is where you find that the new service rounds a currency differently, orders a list differently, or handles a null the old code silently coerced. Reading those diffs for a week is the cheapest confidence you will ever buy.
Then shift traffic by percentage. Then remove the old path - and actually remove it, because a dead code path guarded by a flag that has been at 100% for four months is a trap for the next person who reads the file.
What you give up
Anyone who pitches decomposition without this list is selling something.
A function call becomes a network call. It can now be slow, time out, or partially succeed. Every call site needs a timeout, a retry policy and a decision about what to do when the answer never comes.
Transactions stop at the boundary. Two writes across two services cannot be one atomic operation. You choose eventual consistency with compensating actions, or you keep them in the same service. I keep them in the same service far more often than the architecture diagrams suggest - needing a transaction across a boundary is strong evidence the boundary is in the wrong place.
Debugging gets harder. A stack trace used to contain the whole story. Now you need correlation ids threaded through every call and logs you can query across services. Set that up before the first extraction, not after the first incident.
Local development gets harder. “Run the app” used to be one command. Keep it one command - a Compose file that stands up everything - or you will pay for it in every new engineer’s first week, forever.
The organisational half
Services fail for organisational reasons at least as often as technical ones. Two things I insist on now:
A service has an owner. Not a committee. Someone accountable for its interface, its dependencies and its on-call. Unowned services rot in a specific way - nobody feels entitled to say no to a new dependency.
The interface is documented before it is built. A short design note: what this service owns, what it exposes, what it depends on, what it explicitly does not do. Reviewing that document is dramatically cheaper than reviewing the pull request that implements the wrong thing, and it is where most of the mentoring actually happens.
When not to do this
The honest answer, and one worth saying out loud in the meeting: if your monolith is slow to deploy because tests are slow, extracting services will not fix that - you will have several slow test suites instead of one.
Decomposition solves independent deployment, independent scaling and independent ownership. If none of those is your actual pain, a well-modularised monolith is a better system than a poorly-bounded set of services, and it is a fraction of the operational cost.
The best outcome of a decomposition project is sometimes discovering, at step two, that you needed the modules and never needed the network.