Decoupling Domain Modules: Moving from In-Memory Events to RabbitMQ with Spring Modulith
The Monolith Growth Problem
When architecting Kite, our primary objective was avoiding the premature operational complexity of microservices (distributed tracing, network latency hops, Docker swarm overhead) while preventing the classic "big ball of mud" monolith where every service injects every other service's repository.
In an early iteration, accepting a friend request in the Social domain looked dangerously coupled:
This direct coupling caused two major issues:
- Transaction Pollution: If WebSocket delivery or notification creation failed, it rolled back the friendship acceptance or forced awkward
try-catchblocks. - Circular Dependency Traps: The conversation module eventually needed to verify social status (e.g. "Can User A message User B? Check blocklist in Social"), forming circular references.
Architectural Refactoring with Spring Modulith
Spring Modulith enforces architectural boundaries inside a single JVM. Each top-level package inside com.gwynejsn.kite (conversation, social, media, profile, notification, presence, security) represents an isolated domain module.
Modules can only interact across public APIs or via Application Events.
1. Declaring Domain Events
When friendship is accepted, the social domain merely publishes an immutable domain event:
Inside SocialService, we inject Spring's ApplicationEventPublisher:
2. Asynchronous Domain Listeners with `@ApplicationModuleListener`
In Spring Modulith, @ApplicationModuleListener is a transactional, asynchronous listener (@Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)).
The social module now has zero imports from conversation, eliminating the circular dependency completely.
The Need for AMQP Event Externalization
While in-memory events decoupled Java classes, they had a critical limitation: event volatility. If the server crashed immediately after committing the transaction before the listener executed, the event was lost forever. Furthermore, as we prepared to scale horizontal backend instances behind a load balancer, WebSockets and notification dispatchers needed a centralized broker.
Enter RabbitMQ and spring-modulith-events-amqp.
Spring Modulith persists published events into an Event Publication Registry backed by MongoDB (spring-modulith-starter-mongodb). It guarantees at-least-once delivery:
- When
publishEventis called, the event is saved to MongoDB within the active transaction. - An AMQP routing key is automatically computed from the event class name.
- RabbitMQ receives the event and routes it to dedicated durable queues.
- Once ACKed by the broker, Modulith marks the event publication as completed in MongoDB.
Unexpected Production Roadblock: Netty macOS Apple Silicon DNS
While setting up RabbitMQ and Netty in local development on macOS (Apple Silicon M-series), the application crashed upon starting the AMQP broker listener with a cryptic stack trace:
Because Spring Boot 3 WebSockets and Reactor Netty dynamically check for platform-native DNS resolvers on Darwin platforms, running on Apple Silicon ARM64 required explicitly pulling in the architecture-specific native classifier:
Adding this dependency resolved the native linking mismatch, enabling smooth local development and zero container startup crashes.
Verification & Architecture Testing
One of the greatest features of this setup is automated structural verification. We added a dedicated Modulith test:
Whenever a developer accidentally attempts to call an internal class across domain boundaries, modules.verify() immediately fails during the Maven test phase on GitHub Actions CI.
This hybrid architecture provides the speed and developer ergonomics of a monolithic repository with the clean isolation and event durability of distributed systems.