Every “monolith vs microservices” article eventually produces a number. Team size: 20, or 50, or “two pizzas”. Percentage of companies splitting, or consolidating back: pick a year, pick a blog, pick a number. The honest version of this article has fewer numbers in it than you were expecting, because most of the numbers in circulation do not survive being checked against what they claim to cite โ including one that very nearly made it into an earlier draft of this one.
This post uses the real module boundaries from the order-fulfillment app built for the companion Spring Modulith post as a worked example, works through what a real extraction of one of its modules into a separate service would actually require, and tries to leave you with a decision procedure you can run on your own codebase rather than a threshold you half-remember from a conference talk.
This post is not about a library version. It uses the real module boundaries from the order-fulfillment app built for the companion Spring Modulith post as its worked example โ same four modules, same generated module canvas, same captured output. Where a figure or claim comes from somewhere else, it is sourced to a primary document, not to whichever blog post ranked first; one of those blog posts is used below specifically as an example of what goes wrong when you do not do that.
Two different questions wearing one costume
“Monolith or microservices” is usually asked as one question. It is two, and keeping them separate is most of this post:
- Are the boundaries between my business capabilities well-defined? This is a modeling question. It has nothing to do with deployment. A monolith can get this right; a pile of microservices can get this badly wrong, in which case you have a distributed monolith โ every service’s design-time dependency graph is still tangled, but now every call between them also costs a network round trip.
- Are those boundaries also deployment and runtime boundaries? This is an operations question, and it is the one “modular monolith vs microservices” is actually asking. A modular monolith answers the modeling question well and deliberately answers the second one “no, not yet, or not ever”.
The order-fulfillment app answers question 1 with a real,
enforced “yes” โ ApplicationModules.verify() fails the build the moment
order, inventory, shipping, and notification
stop being properly separated, and the companion post shows that check catching a real violation,
twice. This post is about question 2: given that those four boundaries are real and enforced,
what would it actually cost to also make them four separate deployables, and when is that cost
worth paying?
The honest cost column
Every one of the dashed red arrows in that second diagram is a real thing that did not exist in the first one, and each has a name and a maintenance cost attached to it. This is the column most “decision framework” posts skip, because it doesn’t produce a tidy number:
| What the monolith got for free | What splitting makes you build |
|---|---|
| A method call, same stack, same thread or a managed async hop | A network call: serialization, a client library, retries, timeouts, backpressure |
One transaction manager; a @Transactional boundary is real |
No cross-service transactions; sagas, compensations, or accepted eventual inconsistency |
| One build, one versioned artifact, one rollback | Independent versioning per service, and a contract โ REST schema or event shape โ that two teams now have to agree not to break |
| One log stream, one heap to profile, one JVM to attach a debugger to | Distributed tracing, correlation IDs, and a request that visibly failed in a service that didn’t log anything wrong |
| Local integration tests boot the real dependency | Contract tests, consumer-driven or otherwise, plus a staging environment that has to run all the services at once to mean anything |
| Zero deploy pipelines beyond “the one” | One pipeline per service, and the operational discipline to keep each one actually independent rather than secretly coupled through a shared release train |
None of this is arguable in the abstract โ it is the standard, well-documented “microservices tax”, and it is why Martin Fowler’s advice has been MonolithFirst since before most current “decision framework” posts existed: start as a monolith because you do not yet know where the boundaries should be, and “any refactoring of functionality between services is much harder than it is in a monolith.” Fowler’s piece does not mention team size or Conway’s Law at all โ it is purely an argument about the cost of being wrong about a boundary before you’ve shipped anything.
What is arguable, and worth being skeptical about, is any specific number attached to that tax.
A live example, caught while researching this post. Several 2026 aggregator posts cite a “2025 CNCF survey” finding that 42% of organizations are “consolidating services back into larger deployable units,” linking to a specific PDF as the source. The PDF exists. It is a real CNCF report. It does not contain that statistic, that survey question, or the number 42 in any related context โ it is about cloud-native adoption rates generally. The citation is fabricated, but it has already propagated across several independent blogs, each citing the others as corroboration rather than the original document. Nothing in this post relies on that number, and the lesson generalizes: a specific statistic with a specific source is exactly the kind of claim to open the source for, not trust because three blogs agree on it.
What team size actually predicts
The number that does hold up, with a real mechanism behind it rather than a cited statistic, is about communication overhead, not deployment architecture. It traces to Conway’s Law (1968): a system’s structure mirrors the communication structure of the organization that built it. A team that has to hold a meeting to agree on a shared module’s internals will produce a system shaped like that friction, regardless of which architecture diagram was drawn first.
Amazon’s own “two-pizza team” framing โ a team small enough to be fed by two pizzas, roughly six to ten people โ is the organizational version of the same idea: past a certain size, a team’s internal communication overhead grows faster than its output, and splitting it (and what it owns) into smaller, more autonomous units starts paying for itself. Team Topologies (Skelton & Pais) generalizes this into cognitive load: a team can hold a bounded amount of domain complexity in its collective head, and a service boundary that does not match a team boundary creates a standing coordination cost whether or not it is ever deployed separately.
Translated into something you can actually check against your own codebase: team size is a proxy for a question that matters more directly โ how many people have to agree before a module’s internals can change? If the answer is “one team, informally, in a code review,” the module is a candidate for staying exactly where it is, logically separated and physically together. If the answer is “two teams, a design doc, and a migration plan,” you already have the organizational shape of a service boundary โ the only question left is whether to also give it the operational one.
A concrete extraction sequence, using a real module
Take shipping out of the order-fulfillment
app as the worked example. It is a reasonable candidate on paper: it has its own table
(Shipment), its own event (ShipmentScheduled), and exactly one
inbound listener (on(StockReserved)). Here is what actually has to happen,
in order, and which steps the existing Spring Modulith setup already did for you versus
which ones start from zero.
- Audit the real boundary, not the assumed one. This step is already
done, for free, by the same tooling from the companion post โ the generated module
canvas lists exactly what
shippingexposes and depends on:=== target/spring-modulith-docs/module-shipping.adoc === |Base package |`com.ankurm.modulithdemo.shipping` |Spring components |_Services_ * `c.a.m.s.ShippingManagement` |Events listened to |* `c.a.m.i.StockReserved` (async)Full transcript: output/07-generated-docs.txt โ the same file the companion post’s documentation section cites, re-read for a different purpose here.
One inbound dependency, one outbound event. If this list were long, or ifshippingreached into another module’s internals the way the companion post’sOrderManagementdid before it was fixed, extraction would be the wrong next move โ fix the boundary first, inside the monolith, whereverify()can keep checking it as you go. - Replace the in-process event with a message that survives a process boundary.
@ApplicationModuleListener‘sStockReservedhandoff is a Java object on the heap, delivered by Spring’s event bus inside one transaction manager. Across a network, it has to become a serialized message on a broker (Kafka, in this series โ the subject of the Transactional Outbox post two entries after this one) with its own schema, its own versioning policy, and its own delivery guarantee. The event publication registry already visible in the companion post โ the one tracking PUBLISHED/PROCESSING/COMPLETED/FAILED โ is not a detail you add for this step. It is the mechanism that makes this step safe: an event that fails to reach the new service is exactly the “stuck row” the companion post’s staleness-monitor section describes, and you want that visible before you have two processes depending on it. - Give the extracted service its own data.
Shipmentrows live in the same H2 instance as everything else today. A real extraction means a separate schema at minimum, a separate database in most real deployments โ because “the same database anyway” is precisely the shortcut the companion post’s whole first half exists to show failing, and it fails just as hard across a network as it does across a package boundary, just more expensively. - Replace the test strategy.
@ApplicationModuleTestwithextraIncludesboots the real collaborating modules in one JVM and asserts on aScenario. Onceshippingis a separate deployable, that test can no longer exist in that form โ it becomes a contract test against a schema (consumer-driven or provider-driven), plus a much smaller, slower suite of true end-to-end tests that need every service running at once. You are trading a fast, reliable in-process test for a slower, more realistic one. That trade is not free, and is worth stating plainly rather than assuming the new tests will be as easy to write as the old ones. - Give it an independent deploy pipeline and an on-call rotation. This is the step that is actually about team size, not architecture. A fifth deployable with no dedicated owner is not an extraction, it is an undocumented dependency with extra latency.
Five real steps, and only the first one was already paid for by having used Spring Modulith in the first place. That is the actual argument for building the modular monolith first: not that it makes the eventual split free, but that it does the one step โ finding a boundary worth trusting โ before you pay for the other four.
A decision procedure, not a threshold
Run these in order. Stop at the first “no”.
- Is the boundary you want to extract already enforced, not just drawn? If nothing fails the build when someone violates it, you do not have a boundary, you have a diagram โ extraction will not fix that, it will just make the violation more expensive to notice. Fix this inside the monolith first; it’s what the whole companion post is about.
- Does a single, identifiable team already own that boundary’s internals end to end? If two teams currently negotiate changes to it, you have Conway’s Law telling you something true. If one team owns it informally, extraction mostly adds overhead without removing any real coordination cost, because there was none to remove.
- Does it need to scale, deploy, or fail independently of the rest of the
system?
shippingcalling a slow carrier API is a genuinely different failure and scaling profile fromordertaking writes. If every module in your app scales and deploys together anyway, independence is a cost with no corresponding benefit. - Can you afford the five steps in the previous section, for this one module, without the rest of the roadmap stalling? If the honest answer is “we’ll get to the contract tests and the separate database later,” you are describing a distributed monolith with extra steps, not a service.
Four “yes” answers is a real case for extraction. Three is a real case for staying put, correctly separated, inside one deployable โ which, per the companion post, is already doing more enforcement work than most split systems ever get around to.
If you take one thing from this post, take the order of operations, not a threshold. Enforce the boundary first, inside one process, with a tool that fails the build when it is violated. Let a team actually form around it. Only then ask whether it also needs to be a separate deployable โ and when you do, budget for data, tests, and on-call, not just the network call.
Further reading
- order-fulfillment companion repository โ the real module boundaries this post’s extraction sequence works from
- Spring Modulith 2.1: Enforcing Module Boundaries Inside a Spring Boot Monolith โ this series’ previous post, building and verifying those boundaries
- Martin Fowler โ MonolithFirst
- Amazon Prime Video โ reducing costs by 90% moving a monitoring service to a monolith (nofollow; the original primevideotech.com post has since gone offline and redirects elsewhere โ cited here via the widely-corroborated secondary reporting, noted explicitly because this whole post is about not trusting secondary reporting blindly)
- Team Topologies (Skelton & Pais) โ overview
No Comments yet!