The Modular Monolith Boundary I Couldn't Take Back

The Modular Monolith Boundary I Couldn't Take Back

5 min read··

If you're building agentic solutions to accelerate modernization, AWS Summit DC offers 350+ sessions with AWS experts and public sector leaders at the cutting edge of cloud infrastructure. Register for this free, in-person event, June 30-July 1, and get hands-on demos, workshops, and real world customer success stories.

AI-generated code is accelerating development, but it's also introducing new security and governance challenges. See how security leaders are adapting their strategies in the AI era with insights from 2,350 security and engineering professionals. Download Checkmarx's latest 2027 AppSec report.

We built the system the way you're supposed to: a modular monolith, one module per business domain, modules talking to each other through events so nothing was tightly coupled.

The system let dealers and customers build an order together on a showroom floor. So giving that order-building collaboration its own module, separate from the product catalog, was an easy decision to defend. They were two distinct domains, and separating them into modules is textbook.

It took about a year, and a piece of business context none of us had at the start, for that decision to quietly stop being reversible.

The Setup

This was the 40-year-old system I wrote about rewriting. The backend is a manufacturing ERP, with the dealer ordering tool layered on top.

Two areas felt obviously distinct. The Catalog module owned products, configurations, options, and pricing. The Collaboration module owned the back-and-forth of building an order: drafts, comments, approvals, revisions. It looked like a clean separation: different responsibilities, different parts of the team.

Day one: Catalog and Collaboration as two cleanly separated modules, linked only by a single asynchronous events flow

Splitting them was the deliberate call, not a reflex, and every choice that followed held up on its own.

The Slow Snowball

The trouble started as ordinary feature work.

A collaboration screen needed product options, so it read from the catalog. Then it needed live pricing too. Then a requirement landed: an order had to reflect catalog changes immediately. None of them looked like an architecture decision.

But every one of them added a thread between Collaboration and Catalog. The two modules I had carefully separated were weaving themselves back together, one feature at a time. The boundary was still there in the folder structure. It had stopped being there in any way that mattered.

Catalog and Collaboration a year later, linked by events, two reads, and a synchronous price check

The Assumption That Aged Badly

The deeper problem was the communication style, and it's the decision I'd still defend hardest. Across the whole rewrite, modules communicated asynchronously, through events. It kept modules decoupled during a high-stakes migration and let us replace the legacy system one piece at a time. Catalog publishes an event, Collaboration reacts when it gets to it, and the two stay independent.

That held up right until the business needed an order to be correct now. A dealer changes a configuration, and the price has to be right the instant they hit save. Eventual consistency had been the right assumption for every requirement we knew about. The requirement that broke it didn't exist yet when we drew the boundary.

Eventual consistency on day one versus the later need for immediate consistency between Collaboration and Catalog

You can't bolt immediate consistency onto an asynchronous boundary. So we did what everyone does: synchronous calls, shared transactions, and workarounds to paper over the gap. Fix by fix, the system was telling us that, given the consistency the business now needed, these two belonged in one module.

The Signals That Didn't Look Like Signals

In hindsight, the signals were all there. At the time, not one of them looked like a signal.

  • Collaboration constantly read Catalog's data. I took it for ordinary cross-module traffic, when it was really the boundary telling me the two belonged together.
  • Nearly every new Collaboration feature reached into Catalog. That feels like healthy growth, right up until you notice it's merge pressure.
  • We kept adding event handlers just to keep the two in sync. It passed for good event-driven design, and it was actually the coupling I wanted to avoid, wearing a disguise.
  • Then came the first "this has to be correct immediately" hotfix. Easy to wave off as a one-off, except it was the eventual-consistency assumption starting to crack.

Any one of these is invisible. Together, over a year, they're the whole story.

What I'd Tell My Past Self

A module boundary is a guess, so treat it like one. You're guessing - so keep testing the guess instead of filing it away as settled.

Start with fewer, coarser modules. You can always split a module once the seam is obvious. When you're unsure, keep things together and let a module earn its independence. It's the same instinct as waiting for the third repetition before you extract an abstraction.

Splitting one coarse module into two is cheap and low risk; merging two modules back into one is expensive and risky

Let consistency draw your boundaries. Eventual consistency across a boundary is a bet that they never will. Make that bet on purpose, not by default.

Watch the cheap decisions, not the expensive ones. We pour deliberation into decisions that are easy to reverse and wave through the ones that quietly aren't. A module boundary feels cheap, because on the day you draw it, it is. The cost shows up later, compounding, which is exactly why nobody's watching when it does.

The Part That's Uncomfortable

None of this means modular monoliths are a trap, or that async messaging is a mistake. I'd build it as a modular monolith again tomorrow. Every call was sound for the context we had, and that context was incomplete in a way nobody could see yet.

The lesson is smaller and harder to sit with: the boundaries you draw earliest are the ones most likely to be invalidated by what you learn later, and the least likely to be revisited once they are. The door wasn't one-way when I walked through it. It became one-way behind me, one reasonable feature at a time.

Summary

  • A module boundary is a guess made when you know the least, so treat it as provisional, not settled.
  • Prefer fewer, coarser modules. Splitting one later is cheap - merging two back together is the expensive, risky direction.
  • Let consistency draw your boundaries. If two things have to be correct at the same instant, they belong on the same side of the line.

If you want a structured way to design module boundaries, and the judgment for when to keep modules together or split them apart, that's exactly what I teach in Modular Monolith Architecture.

Thanks for reading.

And stay awesome!


Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 74,000+ engineers who are improving their skills every Saturday morning.