Everyone knows the two ways a monolith decomposition goes wrong. There is the multi-year programme that redraws the architecture in a document and never quite lands, and there is the tool that rewrites the system from scratch and invents boundaries that were never in it.
There is a third failure, and it is the one that fools people, because from the outside it looks like success.
The failure that compiles
We ran our .NET decomposition against nopCommerce, a real monolith of around 380,000 lines. It produced a set of services. Every project built. The pipeline was green.
It had moved 330,896 lines into directories the solution never referenced, and the report said: zero cross-context client calls, zero sagas.
That combination is the tell. Cross-context client calls are the places where one service now has to reach another over a boundary that used to be a method call. Sagas are the multi-step operations that used to sit inside one transaction and now do not. A real decomposition of a real monolith produces both, in quantity, because the whole point is that things which used to be adjacent are now apart.
Zero of either does not mean the split was clean. It means nothing was split. The code changed folders.
When a decomposition reports success, the counts that matter are cross-context calls and sagas. The build only tells you the code still parses.
After the fixes, the same repository produced 899 files and about 56,000 lines ported, with 83 cross-context client calls, 12 typed clients registered for injection, and 8 sagas. The build was green before and after. Only one of those runs had done the work.
Five defects, each hiding the next
Getting from the first result to the second meant fixing five separate bugs, and the reason it took a 380,000-line repository to find them is that every one of them exited zero.
The map and the plan disagreed. The deterministic pass writes a class-to-service map; an optional model refinement rewrites the plan. On this repository the map said libraries, plugins, presentation, tests — the top-level folders, which is what a clustering pass finds when the real domains live six directories down — while the plan said catalog, customers, shipping, payments, orders. Zero overlap between the two, and the porter happily moved files according to one while the solution was built from the other. It now reconciles them, and fails closed when there is no overlap at all.
The porter only indexed classes. .NET injects IProductService, not ProductService, so the declared type at a call site is the interface. Indexing only classes dropped 944 cross-service calls for the orders context alone. Both spellings are registered now.
The saga detector matched a literal string. It looked for SaveChanges, which is Entity Framework's commit point. nopCommerce uses LinqToDB repositories and contains that string zero times. The detector now matches the concept — an EF save, a transaction commit, a TransactionScope completing, a repository insert or update or delete — rather than one framework's spelling of it.
The other two were of the same family: treating the repository root as a bounded context, which generated a typed client to a service that did not exist and wired it into dependency injection; and a reconciliation pass narrow enough that it only matched files declaring a database table, which on this codebase was 95 files out of 3,873.
The lesson that generalises
Months later the same shape came back on a small healthcare repository, and the specific cause is worth stating because it is not obvious.
One of our placement passes matched a folder name against the context name. That works right up until the model refinement renames a context — here, appointments became scheduling. No folder is called scheduling, so the pass placed nothing for it. Fifty-three files that had been placed correctly became eight.
What it lost was exactly the behaviour: the appointment service holding the dual write the saga existed for, every controller, the whole notifications folder. The report came back with zero cross-context calls, zero sagas, and a green build. Eleven monolith files went to fifteen. After the fix, fifteen became a real split with seven cross-context calls, one saga and four typed clients.
Every rule in that chain was keyed on a name the model is allowed to change downstream. Whenever a deterministic pass matches on a name supplied by a plan, ask what happens when something renames it. The failure is always silent, and it always looks like a clean cut.
The boundaries are already in the code
None of this is an argument for asking a model to imagine the architecture. The boundaries of a monolith are recoverable from the monolith.
Our mapping step reads the structural pass — the call graph, the entities, the roles, the routes — and computes the split before any plan is written and before any code moves. Every file lands in exactly one service or in shared infrastructure. Aggregate roots are the entities that have a service or facade in the same context. Sagas are mutating methods that reach three or more contexts, with read verbs filtered out. The strangler order runs from the services with fewest outbound dependencies to the hub.
The map is byte-identical across runs on the same tree, which is what makes it usable as a regression baseline rather than a suggestion. On eShopOnWeb, a much smaller and cleaner codebase, it produces 4 services — catalog, buyers, ordering, carts — owning 9 entities and 6 aggregates across 69 planned files, with one cross-context edge and no sagas. Extraction order: buyers, carts, ordering, catalog.
A model can refine that map afterwards, and often should. The deterministic pass tends to over-segment when a monolith splits one domain across build modules — on Shopizer it produced 16 services where the refinement produced 11, mostly by separating catalog, product and category into services that own no tables between them.
But refinement is the second step, not the first, and the granularity is an answer rather than a control panel. Set it coarse, balanced or fine in the questionnaire and re-run: on Shopizer that gives 10, 11 or 16 services, all of them stable and reproducible.
One check is worth writing into your own review, whoever produced the map: assert that no service owns zero entities. A service owning nothing is not a bounded context, and a map full of them satisfies any count you care to measure.
What breaks when you split, that the compiler will not mention
Two categories, both invisible to a build.
Shared mutable state. Static storage of a mutable structure — a registry, a cache, a lookup table — works in one process and silently stops working in three. Splitting it produces no compiler error at all. And static readonly still counts: readonly pins the reference, not the contents.
Types that cannot cross a boundary. A contract carrying a stream, a span or a provider-bound query tree compiles cleanly and fails at run time. IQueryable is the common one in .NET. It looks like a collection, and it is a query that has not been executed yet, tied to the database connection you just moved to another service.
Both of these are inventory questions rather than opinions, which means they can be answered by reading the code before you split rather than by discovering them in an environment. On nopCommerce that inventory surfaces the singleton registry, the mapper cache, and a table-name map that matters specifically because of database-per-service — alongside roughly 156 sites where a query object leaks across what is about to become a boundary.
Carve one service, then read the output
The last thing that changed how we work: extract one bounded context, look at what actually came out, then re-run naming the next one.
This sounds obvious and it has a trap in it, which we walked into. The scaffolder wrote every file with no-clobber semantics, which is correct for per-service files — a later run must not overwrite code that has been filled in. But nineteen of those files are composition files derived from the list of services: the solution file, the host, the gateway configuration, the compose file, the Helm chart, the ingress, the pipeline, the README.
No-clobber meant that the second service was never added to the solution, never registered with the host, and unreachable through the gateway. Everything still compiled. It was a green build over a half-wired system, which is the same failure as the one at the top of this article wearing different clothes.
"Always overwrite" is equally wrong, because it erases a gateway route somebody added by hand. What works is a ledger: rebuild a file only when its bytes are still the ones we last wrote, treat anything else as foreign, and say in the output which services a preserved file therefore does not describe.
The reassuring part is that piloting costs nothing in the end. Three sequential pilots on a three-service plan converge on exactly the same 41 tasks that one all-services run produces — you just get to read the first service before committing to the other two.
What to ask of a decomposition report
If someone hands you one, three questions get you most of the way:
- How many cross-context calls and how many sagas? If either is zero on a real monolith, nothing was separated.
- Does every service own at least one entity? A service that owns nothing is a folder.
- Which files did not get placed, and where did they go? An unplaced file is not a rounding error; it is usually the behaviour.
The build will be green either way. That was never the question.