Skip to main content

Command Palette

Search for a command to run...

Part 7. Knowing When Your Modular Monolith Is Ready to Split

Updated
4 min read
Part 7. Knowing When Your Modular Monolith Is Ready to Split

By the time you ask whether a modular monolith should be split, you already know the system well enough that the question feels uncomfortable. If it feels academic, you’re not ready. The moment it becomes emotionally charged, when people disagree strongly and for different reasons, that’s usually when the system is starting to tell you something.

What matters is learning to distinguish between structural readiness and organisational impatience.

A modular monolith that is genuinely ready to split already behaves like a distributed system in all the places that matter. The boundary you are considering extracting is already autonomous in practice. It owns its data. It commits independently. It communicates through contracts rather than shared state. Failures inside it are visible and tolerated rather than catastrophic.

When that is true, extraction does not introduce new concepts. It changes where things live.

You can often see this clearly by looking at the execution flow.

If this diagram already represents reality inside your monolith, then moving Billing out of process does not change the mental model. It changes transport, deployment, and observability, but not behaviour.

That’s the first and most important signal.

Contrast that with a system where the flow actually looks like this, even if nobody admits it out loud.

This is not a candidate for extraction. This is a warning. What you really have here is a single consistency boundary pretending to be modular. Pulling it apart will not create independence, it will just expose coupling that was previously hidden by process boundaries.

Another strong signal lives in your codebase, not your diagrams. If you look at a module and its public surface area is small, stable, and boring, that’s a good sign. A module that is ready to be split does not need a rich internal API exposed to the rest of the system. It needs a narrow set of contracts that have already proven themselves under change.

That usually looks something like this.

public interface IBillingEvents
{
    Task Handle(UserCreated @event, CancellationToken stopToken);
}

Notice what isn’t there. There is no shared DbContext. There is no orchestration logic. There is no dependency on Users’ internals. Billing reacts to a fact, not a request to coordinate behaviour.

When a module’s public API starts looking like this naturally, without effort or policing, it’s a sign that the boundary is real.

Operational behaviour is another place where readiness becomes obvious. In a healthy modular monolith, a failure inside one module is already treated as local. Logs are scoped. Metrics are scoped. Alerts are scoped. People don’t panic when Billing misbehaves, because Users continues to function.

If your on-call response already distinguishes between “the system is down” and “that module is down”, then extraction won’t change how incidents are reasoned about. It will only change how they are mitigated.

If, on the other hand, every failure is treated as a system-wide emergency because everything is still tightly coupled, splitting will multiply that pain, not reduce it.

There is also a very practical, almost boring test that I’ve learned to trust. Ask yourself how hard it would be to introduce a network boundary tomorrow. Not actually do it, just introduce it. If replacing an in-process event bus with a real message broker feels like a mostly mechanical change, you are close. If it feels like a redesign that touches business logic, persistence, and error handling all at once, you are not.

That distinction matters more than any architectural diagram.

It’s also worth being explicit about what doesn’t justify splitting. Performance alone rarely does. Scale alone rarely does. The phrase “we’ll need microservices eventually” almost never does. Those are projections, not pressures. The pressures that matter are present tense. Teams blocking each other. Deployment risk that forces coordination meetings. Modules with incompatible uptime or scaling needs that are being artificially flattened by a shared runtime. When those pressures exist and the boundaries are already clean, extraction becomes an act of alignment, not rescue.

One of the most overlooked aspects of this decision is cognitive load. A modular monolith done well reduces it. A distributed system increases it. If your current system already feels heavy to reason about, adding network boundaries will not lighten that load. It will distribute it across logs, dashboards, retries, and failure modes.

If, however, your modular monolith feels calm, predictable, and honest, then splitting a module can actually preserve that calm by preventing future coupling from creeping back in.

From experience, the best extractions I’ve seen were almost boring from a code perspective. The module was already designed as if it were remote. The code barely changed. Most of the work happened in CI pipelines, infrastructure, and observability. That’s exactly how it should be. The worst extractions were dramatic. They required heroics. They broke things in surprising ways. In hindsight, they were not premature because microservices are bad. They were premature because modularity was incomplete.

The point of a modular monolith is not to delay microservices. It’s to make them optional. To give you the ability to say yes or no based on reality, not fashion or fear. If you reach the point where a module can leave without the rest of the system noticing much beyond a configuration change, then you’ve succeeded, regardless of whether you actually do it.