The modular monolith was supposed to be the pragmatic middle ground, a way to keep your sanity while microservices evangelists preached their gospel of distributed complexity. You split your codebase into neat modules, drew crisp boundaries, and patted yourself on the back for avoiding the operational nightmare of service meshes and Kubernetes. Then you tried to make those modules actually talk to each other, and the illusion shattered.
Inter-module communication is where modular monoliths go to die. It’s the architectural equivalent of a demilitarized zone: everyone agrees boundaries exist, but nobody respects them. The patterns you choose here, dependency injection, domain events, shared services, don’t just affect coupling. They determine whether your “modular” architecture remains maintainable in six months or collapses into a monolithic disaster with extra steps.
The Core Dilemma: You’re Solving the Wrong Problem
Most developers approach modular monolith communication as a technical problem. They ask: “How do I call Module B from Module A?” This is backwards. The real question is: “Should Module A know Module B exists at all?”
Consider the Reddit developer building an e-commerce system with modules for orders, payments, and inventory. The instinct is to inject PaymentService directly into OrderService because hey, orders need payments. This creates an immediate coupling problem: your order module now depends on the entire surface area of the payment module. When payment logic changes, order tests break. When you need to extract payments to a separate service later, you discover your “modular” architecture is actually a tightly coupled dependency graph with a fancy folder structure.
The fundamental tension is this: synchronous, in-process calls are seductively simple but create tight coupling, while asynchronous, decoupled communication adds complexity that feels like overkill for a single deployment unit. You’re either building a distributed system inside one process or pretending your monolith is still a monolith.
Pattern 1: Direct Dependency Injection, The Path of Least Resistance (And Most Regret)
Direct DI is the default choice. Your order service gets a payment service interface in its constructor, calls it synchronously, and moves on. The compiler is happy, your IDE auto-completes everything, and you ship features fast.
But this pattern has three fatal flaws that don’t show up in tutorials:
Circular Dependency Hell
The Reddit thread’s most upvoted comment warns: “Just be careful you don’t make circular dependencies.” This is the polite version of a horror story. Module A depends on B, B depends on C, and C needs something from A. Your dependency injection container throws up at startup, and suddenly you’re extracting interfaces into “shared” modules, creating a dependency graph that looks like a plate of spaghetti.
Spring Modulith enforces this constraint brutally: no cyclic dependencies, not even in domain event subscriptions. The framework literally won’t let you shoot yourself in the foot. In standard Spring Boot, you can limp along with @Lazy annotations and other hacks. In Spring Modulith, you’re forced to confront the design smell head-on.
The Leaky Abstraction Problem
When you inject PaymentService into OrderService, you get the entire payment module’s API. Maybe you only need processPayment() but you can also call refundPayment(), getPaymentHistory(), and deletePaymentMethod(). This violates the Interface Segregation Principle and creates a hidden coupling surface area.
The solution, adapter classes, is architecturally sound but culturally unpopular. One commenter suggests: “If you don’t want to use the entire payment service, then you can write a small adapter class. But that’s getting a bit anal.” The subtext is clear: we’d rather accept tight coupling than write boilerplate adapters. This is how technical debt accrues in broad daylight.
Testability Is a Mirage
Yes, DI makes unit testing easier in theory. In practice, mocking entire service layers creates brittle tests that break when implementation details change. Your order service tests mock payment service calls, but when the payment module refactors its internal logic, your tests don’t fail, your integration does. You’ve achieved test coverage without achieving confidence.
Code Example: The Circular Dependency Trap
// Module A
@Service
public class OrderService {
private final PaymentService paymentService, // Depends on Module B
public Order createOrder(OrderRequest request) {
paymentService.processPayment(request.getPayment());
// ...
}
}
// Module B
@Service
public class PaymentService {
private final OrderService orderService, // Circular dependency!
public void processRefund(PaymentRefund refund) {
Order order = orderService.getOrder(refund.getOrderId());
// ...
}
}
This fails at startup in Spring Modulith. In regular Spring Boot, you might not notice until production.
Pattern 2: Domain Events, The Async Promise That Complicates Everything
Domain events promise decoupling: Module A publishes an OrderCreated event, Module B subscribes and processes payment. No direct dependencies, clean boundaries, eventual consistency. Perfect, right?
Except now you’re building an event-driven architecture inside a single process. You need an event bus (even in-memory), you lose transactionality across modules, and debugging becomes a nightmare of stack traces that end at publishEvent(). The developer explicitly rejected events: “The application is being designed for a local shop, won’t have much traffic so I consider implementing a queue message will be adding unnecessary complexity.”
They’re right. For low-traffic systems, domain events are often premature optimization. But here’s the spicy take: even in high-traffic systems, domain events in a modular monolith are a transitional architecture smell. If your modules need to be this decoupled, why are they in the same deployment unit? You’re building a distributed system’s complexity without getting the scalability benefits.
Spring Modulith’s event system is asynchronous by default, which changes everything. Your events aren’t processed in the same transaction, so you need to design for failure, retries, and eventual consistency, inside what was supposed to be a simple monolith. The framework’s documentation admits this is “a bit different than working with default Spring eventing”, which is the understatement of the year.
Code Example: Event-Driven Complexity
// Module A publishes event
@AggregateRoot
public class Order {
@DomainEvent
public OrderCreated publishEvent() {
return new OrderCreated(this.id, this.total);
}
}
// Module B subscribes - but wait, no cyclic dependencies allowed!
@ApplicationModuleListener
public class PaymentEventListener {
// This can't depend on OrderService, so how does it get order details?
// You're forced to include all data in the event, making them fat
}
The constraint against cyclic event subscriptions forces you to design events that carry complete data, turning them into fat DTOs that violate encapsulation.
Pattern 3: The Gateway/Orchestration Layer, The Honest Approach
The most architecturally honest solution is to admit that some operations span modules and need orchestration. Create a use-case controller (sometimes called a “Manager” layer) that sits above the modules and coordinates them.
One developer described this: “I’m building another ‘Manager’ layer where its purpose is to call the operation in each service. The manager layer will call multiple operations in multiple modules so then I can keep the concerns separated.”
This is essentially the Mediator pattern. The orchestrator knows about multiple modules, but the modules don’t know about each other. It’s clean, testable, and reflects reality. But it has two problems:
- It feels like duplication. Your manager layer methods mirror service layer methods, just composing them. Developers hate writing code that “just calls other code.”
- It centralizes knowledge. The orchestrator becomes a god object that knows everything about every module. When you extract a module to a microservice, you need to rewrite the orchestrator.
The commenter rkaw92 nails this: “This is now your actual service layer. This is the crux of your application that clients will interact with. They don’t really see the underlying modules anymore, they focus on the desired behavior or the “what”, not the “how”.”
Code Example: The Orchestration Layer
// This is your REAL API
@UseCaseController
public class OrderPlacementOrchestrator {
private final OrderModule orderModule;
private final PaymentModule paymentModule;
private final InventoryModule inventoryModule;
public OrderResult placeOrder(OrderRequest request) {
// Check inventory
inventoryModule.reserveItems(request.getItems());
// Process payment
paymentResult = paymentModule.processPayment(request.getPayment());
// Create order
return orderModule.createOrder(request, paymentResult);
}
}
The modules remain isolated, but the orchestrator contains the business process. This is the pattern Spring Modulith’s documentation implicitly encourages, even if it doesn’t shout about it.
The Framework Enforcement Problem
Spring Modulith is the architectural equivalent of a strict parent. It enforces Vernon’s aggregate design rules, makes DDD building blocks explicit via jMolecules annotations, and absolutely forbids cyclic dependencies. The framework uses ByteBuddy to generate code at compile time, requiring special IDE configuration that “is a bit cumbersome, but it works.”
This enforcement is both a blessing and a curse. On one hand, it prevents the gradual decay that turns modular monoliths into big balls of mud. On the other hand, it makes simple things hard. You can’t just inject services willy-nilly. You need to think about aggregate boundaries, domain events, and module dependencies from day one.
The “Amundsen” repository, a real-world Spring Modulith project from a master’s thesis, demonstrates this trade-off. It’s clean, well-structured, and would be a nightmare to refactor into microservices. The modularity is so explicit that extracting a module would require rebuilding half the orchestration logic.
The Decision Framework: When to Use What
Forget the generic advice. Here’s the controversial, actionable framework:
Use Direct DI When:
– You’re building a true monolith that will never split into services
– Modules are in the same bounded context and change together
– You can tolerate tight coupling for development speed
– But: Extract adapter interfaces immediately, even if they feel like overkill
Use Domain Events When:
– You genuinely need eventual consistency (e.g., sending emails after order creation)
– The event has multiple subscribers in different modules
– You’re prepared to debug asynchronous flows in a single process
– But: If you have more than 3-4 events, question whether you need a message queue and actual microservices
Use Orchestration When:
– Operations span multiple modules with clear business process boundaries
– You anticipate extracting modules to services later
– You want to keep modules completely isolated
– But: Accept that your orchestrator is the real API and will need rewriting during extraction
Never Use Internal HTTP Calls. The developer considered this, but it’s the worst of both worlds: you get network latency and failure modes without getting independent scalability. It’s a sign your modules should be separate services.
The Uncomfortable Truth
Modular monoliths don’t fail because of technical limitations. They fail because developers can’t resist the temptation to take shortcuts. The thread is a perfect case study: every pattern has obvious downsides, so the developer is paralyzed by choice. The reality is that any of these patterns work if you commit to it and enforce it ruthlessly.
The spicy take? Your modular monolith’s communication strategy matters less than your discipline in enforcing it. Spring Modulith succeeds not because it’s technically superior, but because it removes choice. You can’t cheat. In a regular Spring Boot app, you’ll gradually erode module boundaries until they’re meaningless. With Spring Modulith, the framework fights back.
Actionable Takeaways
- Start with orchestration. It’s the most honest pattern and easiest to refactor. Create a
usecasespackage that contains your real business operations. - Extract adapters on day one. When Module A needs Module B, create
ModuleBAdapterin Module A’s namespace. It’s boilerplate that saves your future self. - Ban cyclic dependencies in code review. Don’t wait for framework enforcement. Use ArchUnit or similar tools to fail builds on cycles.
- Question every cross-module call. If two modules chat constantly, they might be one module. Boundaries are wrong.
- If you’re using Spring Modulith, commit fully. The framework’s strictness is a feature, not a bug. Don’t fight it with workarounds.
The modular monolith communication debate isn’t about finding the “best” pattern. It’s about accepting that every choice has trade-offs, and the real work is maintaining architectural integrity when deadlines loom. Your future self will thank you for choosing the slightly more complex pattern today, because it preserves options tomorrow.

References:
– Reddit Discussion on Intra-Module Communication
– Getting Started with Spring Modulith
– Understanding Dependency Injection in .NET
– Amundsen Spring Modulith Repository
– Tactical DDD Workshop Repository


