Decoupling Domain Modules: Moving from In-Memory Events to RabbitMQ with Spring Modulith

·7 min read·Gwyne Justin Deterala

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:

java
// Anti-pattern: High coupling across domain boundaries
@Service
@RequiredArgsConstructor
public class SocialService {
    private final ConversationService conversationService; // direct dependency
    private final NotificationService notificationService; // direct dependency
    private final SimpMessagingTemplate messagingTemplate; // leaking infrastructure into domain

    public void acceptFriendRequest(UserId userA, UserId userB) {
        relationRepo.markAsFriends(userA, userB);
        // Synchronous side-effects: if conversation creation fails, does the friendship fail?
        conversationService.createDirectChat(userA, userB);
        notificationService.notifyUser(userB, "Friend request accepted");
    }
}

This direct coupling caused two major issues:

  1. Transaction Pollution: If WebSocket delivery or notification creation failed, it rolled back the friendship acceptance or forced awkward try-catch blocks.
  2. 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:

java
package com.gwynejsn.kite.social.domain.events;

public record FriendRequestAcceptedEvent(
    String senderId,
    String recipientId,
    Instant timestamp
) {}

Inside SocialService, we inject Spring's ApplicationEventPublisher:

java
@Service
@RequiredArgsConstructor
public class SocialService {
    private final UserRelationRepository relationRepo;
    private final ApplicationEventPublisher events;

    @Transactional
    public void acceptFriendRequest(String senderId, String recipientId) {
        relationRepo.acceptRequest(senderId, recipientId);
        
        // Pure domain event published within the local transaction
        events.publishEvent(new FriendRequestAcceptedEvent(
            senderId, 
            recipientId, 
            Instant.now()
        ));
    }
}

2. Asynchronous Domain Listeners with `@ApplicationModuleListener`

In Spring Modulith, @ApplicationModuleListener is a transactional, asynchronous listener (@Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)).

java
package com.gwynejsn.kite.conversation.infrastructure.listeners;

@Component
@RequiredArgsConstructor
public class SocialDomainEventListener {
    private final ConversationService conversationService;

    @ApplicationModuleListener
    public void onFriendRequestAccepted(FriendRequestAcceptedEvent event) {
        log.info("Decoupled trigger: Initializing 1-on-1 direct conversation for {} and {}", 
            event.senderId(), event.recipientId());
        conversationService.getOrCreateDirectConversation(event.senderId(), event.recipientId());
    }
}

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.

xml
<dependency>
    <groupId>org.springframework.modulith</groupId>
    <artifactId>spring-modulith-events-amqp</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

Spring Modulith persists published events into an Event Publication Registry backed by MongoDB (spring-modulith-starter-mongodb). It guarantees at-least-once delivery:

  1. When publishEvent is called, the event is saved to MongoDB within the active transaction.
  2. An AMQP routing key is automatically computed from the event class name.
  3. RabbitMQ receives the event and routes it to dedicated durable queues.
  4. 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:

text
java.lang.UnsatisfiedLinkError: failed to load the required native library (io.netty.resolver.dns.macos.MacOSDnsResolver)
at io.netty.resolver.dns.macos.MacOSDnsResolver.<clinit>
Caused by: java.lang.ClassNotFoundException: io.netty.resolver.dns.macos.MacOSDnsResolver

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:

xml
<!-- Native DNS resolution for macOS aarch_64 runtime -->
<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-resolver-dns-native-macos</artifactId>
    <classifier>osx-aarch_64</classifier>
</dependency>

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:

java
@SpringBootTest
class ApplicationModularityTests {

    @Test
    void verifyModularStructure() {
        ApplicationModules modules = ApplicationModules.of(KiteBackendApplication.class);
        modules.verify(); // Fails the build if any illegal cross-package access exists
    }

    @Test
    void generateDocumentation() {
        new Documenter(ApplicationModules.of(KiteBackendApplication.class))
            .writeDocumentation()
            .writeIndividualFilesAsPlantUml();
    }
}

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.