Why we chose Spring Modulith
Our customers couldn't run a dozen microservices, but our monolith was getting tangled. Spring Modulith gave us boundaries without the containers.
A problem
Every application I’ve worked on started small and simple. A few packages, a handful of features, one team that knew every line.
Then it grows. More features, more business domains, more people. And somewhere along the way, without anyone deciding it, the codebase turns into a ball of dependencies. The onboarding code calls into the billing code. The billing code reaches into a repository that belongs to identity. Change one thing, and something three packages away breaks.
We were at that point. And the textbook answer, “split it into microservices”, didn’t fit us at all.
Why microservices weren’t an option
Our application is deployed on the customer’s side, into their infrastructure. That changes everything:
- Customers have limited flexibility in what they can run. Some can barely handle one application and a database.
- Every extra container is something they have to operate, monitor and upgrade. A dozen services would be a dozen support tickets.
- We wanted to keep one deployable application, but still be ready to grow: more projects sharing the same portal later.
So the question became: can we get clear boundaries inside a monolith?
Packages don’t protect anything
The obvious first answer is “just organise it into packages”. We already had that:
com.example.app
├── onboarding
├── identity
├── billing
└── notification
But in plain Java and Spring Boot, packages are organisational, not architectural. Any public class can be used from anywhere. Nothing stops billing from injecting a repository from identity. The boundaries only exist in the heads of the people who drew them, and over time they fade.
What I wanted was boundaries that are checked, not just agreed on.
What Spring Modulith gives you
Spring Modulith is a library from the Spring team for building a modular monolith. It’s a convention plus some tooling:
1. Each direct sub-package of your main package is a module. onboarding, identity, billing above are four modules.
2. A module’s top-level package is its public API. Everything in sub-packages is internal.
billing/
├── BillingService.java ← public: other modules may use this
├── InvoiceCreated.java ← public: an event other modules can listen to
└── internal/
├── InvoiceRepository.java ← internal: off limits to other modules
└── PricingCalculator.java
3. You can verify it in a test:
class ModularityTests {
ApplicationModules modules = ApplicationModules.of(Application.class);
@Test
void verifiesModularStructure() {
modules.verify();
}
}
If onboarding reaches into billing.internal, or two modules depend on each other in a cycle, this test fails. The architecture stops being a diagram on a wiki and becomes something CI enforces.
How modules talk to each other
If modules can’t freely call each other’s internals, how do they cooperate? Two ways.
Through a module’s public API, when one module really needs an answer from another right now:
billingService.currentPlan(customerId);
Through events, which is what we use most. When something happens in one module, it publishes a domain event, and other modules react to it:
// in onboarding
@Transactional
public void complete(Customer customer) {
// ... finish onboarding
events.publishEvent(new OnboardingCompleted(customer.id()));
}
// in billing
@ApplicationModuleListener
void on(OnboardingCompleted event) {
createTrialSubscription(event.customerId());
}
onboarding doesn’t know billing exists. It just announces what happened. That keeps coupling low, and each module can change on its own.
One thing I had to clear up for myself: these events are Spring application events under the hood, not a message broker. Spring Modulith adds an event publication registry on top: the event is stored in the database in the same transaction as the business change, and marked complete once the listener succeeds. If the app crashes in between, the event is retried on restart. It’s essentially a transactional outbox, built in, which was exactly what we needed for reliable events between modules.
The extras
A few more things that made it worth it:
- Documentation from the code. Spring Modulith can generate diagrams of the modules and how they depend on each other, always up to date.
- Testing a module in isolation with
@ApplicationModuleTest, which starts only that module and its direct dependencies instead of the whole application. - Keeping the architecture honest over time, because every pull request runs the verification test.
What’s still hard
Spring Modulith doesn’t do the thinking for you:
- Good boundaries still need good design. If you draw the modules in the wrong place, the tool will faithfully enforce the wrong thing.
- It’s not airtight. Some violations, like certain bean injections or a misleading package name, can still slip through. Team discipline matters.
- Don’t over-modularise. Ten tiny modules that all talk to each other are worse than three sensible ones.
- It’s still a monolith. One deployment, one database, one process. If you need independent scaling or deployment per domain, this isn’t that.
What I took away
The choice isn’t really “monolith or microservices”. It’s “how do we keep boundaries clear”. Microservices get you boundaries by putting a network between modules. Spring Modulith gets you most of the same discipline without the network, which for an app our customers deploy themselves was the right trade.
I first put these notes together for a sharing session with my team. Writing them down helped me realise that the most useful part wasn’t the library. It was finally having a shared, checkable answer to “where does this code belong?”
Discussion
Enjoy!