Spring Modulith in Practice: Enforcing Architecture in a Growing Monolith

Every growing Spring Boot application reaches a point where someone draws module boundaries on a whiteboard. Boxes with clean arrows. “Venue depends on Site but not on User internals.” Everyone nods. Two sprints later, a developer imports a repository from a module they shouldn’t depend on because it’s the fastest way to fix a bug. Nobody catches it in review. The whiteboard diagram is now fiction.
Spring Modulith turns those whiteboard arrows into build failures.
The Core Idea
Spring Modulith treats every top-level package under your main application class as a module. Each module declares which other modules it can depend on. At test time, Modulith verifies these boundaries — if module A reaches into module B’s internals without permission, the build fails.
@ApplicationModule(
displayName = "Venue Management",
allowedDependencies = {
"common",
"infrastructure :: axon",
"infrastructure :: security",
"user :: domain-model",
"site :: api-queries"
})
package com.example.venue;That allowedDependencies declaration is the critical part. It’s not documentation — it’s a constraint enforced by ArchUnit under the hood. If the Venue module tries to import anything from the Subscription module, the test fails. No exceptions, no “I’ll fix it later.”
Named Interfaces: The Real Boundary
The :: syntax in the dependency declarations above refers to named interfaces — subsets of a module’s public API. This is where Modulith goes from “nice to have” to genuinely powerful.
Consider a Site module that has commands, events, queries, and domain model types. Without named interfaces, depending on the Site module gives you access to everything — its repositories, its projections, its internal services. That’s too much.
Named interfaces let you expose only what other modules need:
@NamedInterface("api-commands")
package com.example.site.api.commands;
@NamedInterface("api-queries")
package com.example.site.api.queries;
@NamedInterface("domain-model")
package com.example.site.domain.model;Now when the Venue module depends on "site :: api-queries", it can read Site data through query objects. It cannot access Site’s repository, projection handlers, or internal services. The boundary is precise.
This is the modulith philosophy: communicate through well-defined interfaces, not shared internals.
Patterns That Work at Scale
The Open Module
Every application has shared types — UserId, PageRequest, common DTOs. Put these in a common module declared as Type.OPEN:
@ApplicationModule(
displayName = "Common",
type = ApplicationModule.Type.OPEN)
package com.example.common;Any module can depend on an open module without explicit declaration. But keep it small. The temptation is to dump everything shared into common — resist it. If a type is shared between only two modules, put it in a named interface on one of them.
Infrastructure as Named Interfaces
Cross-cutting concerns — security, DLQ handling, GraphQL configuration, reactive utilities — belong in an infrastructure module with named interfaces:
infrastructure :: axon
infrastructure :: dlq
infrastructure :: graphql
infrastructure :: security
infrastructure :: reactiveA module that handles events needs infrastructure :: dlq. A module exposing GraphQL endpoints needs infrastructure :: graphql. No module gets all of infrastructure.
Gradual Enforcement
This is the pattern that makes adoption realistic. Don’t try to fix every boundary violation before merging Modulith. That would delay adoption by months.
Instead:
- Add
@ApplicationModuleto every package - Run
modules.verify()in a test — but make it non-blocking at first - Log violations, don’t fail the build
- Clean up modules one by one
- Move clean modules into an enforced set
// Modules with zero tolerance for new violations
static final Set<String> ENFORCED_MODULES = [
'achievement', 'history', 'search', 'tag',
'route', 'site', 'social', 'venue',
// ... grow this list over time
]
// Only these modules still have known violations
def allowedSourceModules = ['legacy-auth', 'infrastructure', 'common']Once a module is in ENFORCED_MODULES, no one can introduce a new boundary violation. The architecture only gets cleaner, never dirtier. This is the mechanical advantage of treating architecture as tests, not guidelines.
What Changed in Spring Modulith 2.0
If you’re starting fresh, use 2.0 (which requires Spring Boot 4). If you’re on 1.x, here’s what the upgrade brings.
Event Publication Registry Overhaul
If you use ApplicationEventPublisher for cross-module communication, the event publication registry tracks which events have been processed. In 2.0, this got a complete lifecycle rewrite:
- Granular publication states beyond just “completed” and “incomplete”
- Staleness monitor — publications stuck too long are automatically flagged as failed, replacing the manual cleanup jobs many teams had built themselves
- Serialized event externalization — if you push events to Kafka or similar, publications are now serialized to prevent ordering issues in multi-instance deployments
Module-Specific Flyway Migrations
Each module can now own its own database migrations:
site/src/main/resources/db/site/migration/V1__create_site_tables.sql
venue/src/main/resources/db/venue/migration/V1__create_venue_tables.sqlNo more coordinating migration numbering across the entire application. Module teams version their schema independently.
Structure Verification at Startup
In 1.x, boundary verification only happened in tests. In 2.0, you can enable it at application startup — if a module accesses something it shouldn’t, the application fails to start. Keep this disabled in production (test-time checks are sufficient, and startup latency matters), but it’s useful for catching issues during local development.
Hexagonal Architecture Verification
2.0 adds automatic verification that modules follow hexagonal architecture patterns, defaulting to lenient mode (warn, don’t fail). This is sensible — not every module benefits from full ports-and-adapters layering, and forcing it on simple CRUD modules would be over-engineering.
Modulith as a Guard Rail for AI-Assisted Development
This is the benefit nobody talks about yet.
AI coding agents are fast and productive, but they have a fundamental weakness: they optimize for making the code work, not for maintaining architectural boundaries. An AI agent asked to “add a notification when a user subscribes” will happily import the subscription module’s internal repository from the notification module if that’s the shortest path to a working solution.
Without Modulith, this kind of cross-module leak goes unnoticed until code review — if you catch it — or until the dependency graph becomes a tangled mess. With Modulith, the build fails. The agent gets a test failure and is forced to find the architecturally correct path: dispatching a query, depending on a named interface, or listening to an event.
Here’s what we’ve observed in practice:
Agents respect boundaries they can see in error messages. When an AI agent tries to import com.example.subscription.internal.SubscriptionRepository from another module and gets a Modulith violation, it self-corrects. The violation message literally tells it what’s allowed.
package-info.java is a prompt for agents. When an AI agent creates a new module, it copies the @ApplicationModule pattern from existing modules. The allowedDependencies declaration forces it to think about what the module actually needs.
The modularity test catches what code review misses. Human reviewers focus on logic, not import statements. An AI agent might generate 500 lines of correct business logic with one illegal cross-module import buried on line 12. The modularity test catches it every time.
Gradual enforcement creates a ratchet. Once a module is enforced, no agent and no human can introduce a new boundary violation. The architecture only gets cleaner over time.
The irony: AI agents make Modulith more valuable, not less. The faster you generate code, the more important automated guardrails become. Manual code review doesn’t scale with AI-assisted velocity. Automated boundary verification does.
What Modulith Doesn’t Do
Modulith enforces structural boundaries — which classes can reference which. It does not enforce:
- Runtime isolation — modules share the same JVM, classloader, and transaction context
- Data isolation — modules can share a database; schema ownership is a convention, not enforced
- API versioning — when you change a shared type, all modules recompile; there’s no independent deployment
- Fault isolation — a stack overflow in one module takes down the entire application
If you need these properties, you need microservices. Modulith is for teams that want architectural discipline without operational complexity.
Should You Adopt It?
Yes, if:
- You have 5+ logical modules in a Spring Boot application
- Multiple developers or teams work in the same codebase
- You want to enforce dependency rules without the operational overhead of microservices
- You use AI coding assistants and want guardrails against architecture erosion
Not yet, if:
- Your application is genuinely small (fewer than 5 modules, 1–2 developers)
- You’re not on Spring Boot 4 yet (use Modulith 1.4.x on Boot 3)
- Your module boundaries are well-maintained by convention alone — and you’re confident they’ll stay that way
The investment is small: a few package-info.java files, one verification test, and a decision about which modules to enforce first. The payoff is that your architecture diagram stops being a whiteboard fantasy and starts being a build constraint.
Cover photo by Martin Woortman on Unsplash.


