Modular Monolith vs Microservices: How to Decide in 2026
AWS tells you to avoid monoliths; Microsoft tells you to run a readiness assessment first. Here’s how to read both, what a service actually costs per year regardless of traffic, and how to enforce module boundaries so extraction stays cheap.
in Production Stacks9 min readUpdated

The decision usually arrives disguised as a ticket: “split out the notification service.” Nobody has written down which constraint the split removes, and the bill arrives two quarters later as four more CI pipelines, another on-call rotation, and a trace that crosses five hops to render one dashboard. This is for engineers and tech leads at small and midsize SaaS teams who have to defend an architecture choice on both technical and budget grounds.
What AWS and Microsoft actually say in 2026
The two big cloud vendors give advice that looks contradictory until you read where it sits.
AWS is blunt. The Reliability pillar of the Well-Architected Framework, best practice REL03-BP01, states that “Monolithic architecture should be avoided whenever possible. Instead, carefully consider which application components can be broken out into microservices.” The same page names the counter-failure mode: the “microservice Death Star,” where components become so interdependent that one failure cascades, “making the components as rigid and fragile as a monolith” (AWS Well-Architected, REL03-BP01 (opens in a new tab)).
Read that guidance for what it is: a reliability argument about failure domains, not a general architecture ruling. A modular monolith gives you code-level modularity inside a single runtime failure domain. One bad deploy, one memory leak, one thread-pool exhaustion takes down every module at once. That is a genuine cost and the AWS position is the honest statement of it.
Microsoft frames the same decision as a readiness question. The Azure Architecture Center’s microservices assessment guide asks teams to “evaluate whether your organization, teams, and infrastructure are ready for the transition from a monolithic or modular architecture” before adopting microservices, and to re-run the assessment “periodically, such as annually or after a significant incident, to identify architectural drift” (Microsoft Learn (opens in a new tab)). That second half is the part most teams skip. Decomposition is not a one-way door you walk through and forget; drift accumulates in both directions.
Neither vendor treats either pattern as legacy. AWS still publishes a prescriptive-guidance library on decomposing monoliths, and Spring ships an official toolkit for building modular monoliths. Both patterns are supported, current, and appropriate in different situations.
The per-service fixed cost is what actually decides it
The number that should drive this decision rarely appears in architecture reviews: what does one additional deployable unit cost per year, at zero traffic?
Go count it in your own environment. A service that nobody calls still needs at least two replicas to survive a node rotation, a CI pipeline, a container registry repository, a secrets scope, a log stream, a metrics scrape target with its own cardinality, a dashboard, an alert policy, a runbook, a dependency-update cadence, and a human who knows what it does at 3am. Almost none of that scales down with request volume. It scales with service count.
That’s why the cost per request in a fifteen-service system serving modest traffic looks absurd next to the same workload in one process. The compute is not the problem; the floor under the compute is. Observability ingest is often the line item that surprises people, because tracing a request across services multiplies spans and log lines for work that used to be a function call.
Decomposition wins on cost in the opposite situation, and it wins decisively. When two parts of your system have genuinely different scaling profiles, keeping them in one process means paying for the expensive shape everywhere. A GPU-backed inference path and a CRUD settings page do not belong in the same autoscaling group. Batch import workers that need 16 GB of heap should not force your API pods to be 16 GB. If one module’s traffic is spiky and the rest is flat, separating it lets you scale the spike to zero.
There is also a FinOps argument for splitting that engineers underrate: per-service infrastructure makes cost attribution possible. If you need showback by team or per-tenant unit economics by feature area, tagged, separately deployed services give you that data with far less effort than profiling a shared process.
Make the module boundary something a build can enforce
A modular monolith only pays off if the modules are real. The usual failure starts with a shared common or utils package that everyone imports, and a helper in it that reaches into another module’s repository class “just for this one query.” Six months later the module graph is a mesh and extraction requires rewriting half the data access layer.
Boundaries enforced by code review do not hold. Boundaries enforced by a failing build do.
Java and Spring
Spring Modulith is the first-party option here. It graduated out of the spring-projects-experimental GitHub organization and now lives at spring-projects/spring-modulith (opens in a new tab), with releases in the 2.1.x line as of late 2026. If you have bookmarks or internal docs pointing at the experimental repo, or at the older community project named “Moduliths,” update them; the current name is Spring Modulith.
It treats each top-level package under your application’s base package as a module, exposes only the types in that package’s root as public API, and fails a test when another module reaches into a nested internal package.
package com.acme;
import org.junit.jupiter.api.Test;
import org.springframework.modulith.core.ApplicationModules;
import org.springframework.modulith.docs.Documenter;
class ModularityTests {
static final ApplicationModules modules = ApplicationModules.of(AcmeApplication.class);
@Test
void verifiesModularStructure() {
modules.verify();
}
@Test
void writesDocumentationSnippets() {
new Documenter(modules).writeDocumentation();
}
}The generated documentation matters more than it sounds. A module dependency diagram that regenerates on every build is the artifact you bring to the meeting where someone proposes extracting billing.
Everything else
Other ecosystems get there with less ceremony. Go’s internal/ directory is a compiler-enforced boundary and costs nothing to adopt. In .NET, separate assemblies plus internal visibility do the same job. In TypeScript, the cheapest version is a lint rule that blocks imports past each module’s public entry point:
export default [
{
files: ["src/modules/**/*.ts"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["**/modules/*/internal/**", "**/modules/*/db/**"],
message: "Import a module through its index.ts public API."
}
]
}
]
}
}
];Whatever you use, put the boundary check in the required checks that gate merges, not in a nightly job someone mutes. If part of your diff is now written by an agent, that matters more, not less; see how to gate merges when an agent wrote part of the diff. Next.js teams already live with one bundler-enforced boundary of this kind, which is worth studying as a model: where ‘use client’ belongs in the App Router.
Data is the boundary that sets your extraction price
Code boundaries are the easy half. The expensive coupling is in the database, and it is invisible to every static analysis tool listed above.
Give each module its own schema, and let it own its tables outright. No cross-module joins, no foreign keys pointing across a module line, no other module’s ORM entities in your query. When billing needs a customer’s name, it calls the identity module’s API or keeps its own copy populated from an event. That feels wasteful right up until the day you extract billing, and then it’s the only reason extraction takes a week instead of a quarter.
If you want that enforced rather than agreed, run each module on its own database role with grants only on its own schema:
-- Requires a separate connection pool per module role, which is a real operational cost.
CREATE SCHEMA billing;
CREATE SCHEMA identity;
GRANT USAGE ON SCHEMA billing TO app_billing;
GRANT USAGE ON SCHEMA identity TO app_identity;
REVOKE ALL ON SCHEMA identity FROM app_billing;
REVOKE ALL ON SCHEMA billing FROM app_identity;
ALTER DEFAULT PRIVILEGES IN SCHEMA billing
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_billing;That costs you a connection pool per module and complicates migrations, so it’s a judgment call. Teams that expect to extract services within a year should pay it. Teams that don’t can enforce the same rule with a schema-diff test in CI that fails on cross-schema foreign keys.
Triggers that justify extracting a service
Extract when you can name the constraint, measure it, and say what you’d do if the split doesn’t help:
- Divergent scaling profile. One module’s resource shape forces expensive instances on everything else, and you can show the cost difference.
- Deploy contention. Two teams block each other’s releases often enough that you can count the delayed deploys.
- Blast radius you can’t accept. A module regularly degrades the whole process, and its failure mode matters more than the others. This is the AWS reliability argument, applied to a specific module rather than the whole system.
- Compliance or residency isolation. Data that must live in a separate region or under separate access controls.
- Untrusted or third-party code execution. Customer scripts, plugins, or arbitrary payload parsing belong in their own process with their own limits.
- Technology mismatch that a library can’t fix. A real need for a different runtime, not a preference.
What doesn’t make the list: team size alone, a desire to use Kubernetes properly, a slow test suite (fix the test suite), or a codebase that feels large. Those produce distributed monoliths, where services deploy together, share a database, and now fail over a network too.
Start with separate services from day one when the constraint exists before the code does: multiple independently funded teams shipping on separate schedules from week one, a regulated data boundary, or a workload with a fundamentally different runtime profile. Those are real, and they’re less common than the number of microservice repositories in the wild suggests.
The process boundary is not an authorization boundary
Splitting a module into a service does nothing for access control, and assuming otherwise is how internal APIs end up unauthenticated.
In a modular monolith, tenant isolation lives in the query. Scope every read and write by the authenticated tenant at the data access layer, not in a controller check that a background job or a new caller can skip. In a microservice system, every internal endpoint is a public attack surface the moment anything else in the network is compromised: require service identity (mTLS or signed service tokens) and per-request authorization that carries the end-user or tenant context through the call chain. Network reachability is not authentication, and authentication is not authorization.
Decomposition adds a new class of exposure rather than removing one. Budget for it.
The cases this framing doesn’t cover
Some workloads sit outside the binary. Event-driven pipelines built on queues and functions are decomposed by nature, and the modular-monolith question applies only to the synchronous API surface around them. Latency-sensitive paths where a single user action fans out to several services will pay tail-latency costs that no amount of tuning removes, which sometimes argues for collapsing services back together. And large organizations frequently run both: a modular monolith as the system of record, with a handful of extracted services around the edges where the constraints are genuine. Merging two services back into one is a legitimate move, not an admission of failure, and Microsoft’s advice to reassess after a significant incident is the right prompt for it.
Do this before your next split
Write one paragraph in the ADR, before any code moves, that names three things: the constraint being removed, the metric that will show whether it worked, and the conditions under which you’d merge the services back. If you can’t fill in all three, you have a module, not a service.
Then run one check. Try moving the module’s tables into a separate schema and fixing every query that breaks. If that takes more than a day, don’t extract yet; fix the data coupling first, because you’ll have to fix it anyway, and it’s far cheaper to do while both sides still share a transaction.



