Core Java

Event-Driven Architecture in Monoliths: Incremental Refactoring for Java Apps

The conventional wisdom suggests event-driven architecture belongs in microservices architectures, where services communicate asynchronously through message brokers. But some of the most valuable applications of event-driven patterns happen within monolithic applications—places where tight coupling has created maintenance nightmares and where incremental refactoring offers a path toward better architecture without the operational complexity of distributed systems.

Why Events in a Monolith Make Sense

Traditional layered monoliths suffer from a specific problem: direct method calls create rigid dependencies between components. Your order processing code directly calls inventory management, which calls warehouse systems, which trigger email notifications. Each component knows about several others, creating a tangled web where changing one piece requires understanding and testing everything it touches.

Event-driven patterns introduce indirection. When an order is placed, the order service publishes an “OrderPlaced” event. Other components interested in orders—inventory, shipping, notifications—subscribe to this event and react independently. The order service doesn’t know or care who’s listening. Components become loosely coupled even though they live in the same codebase and share the same database.

This approach provides immediate benefits without requiring you to split your application into microservices. You gain testability, flexibility, and clearer boundaries while keeping the operational simplicity of a monolith. When you eventually need to extract services, the event-driven structure makes the transition smoother because components are already communicating through well-defined messages rather than direct method calls.

Starting Point: A Tightly Coupled Order System

Consider a typical e-commerce monolith built with Spring Boot. The order creation flow looks something like this:

@Service
@Transactional
public class OrderService {
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final ShippingService shippingService;
    private final LoyaltyService loyaltyService;
    private final EmailService emailService;
    private final AnalyticsService analyticsService;
    
    public OrderService(
        OrderRepository orderRepository,
        InventoryService inventoryService,
        PaymentService paymentService,
        ShippingService shippingService,
        LoyaltyService loyaltyService,
        EmailService emailService,
        AnalyticsService analyticsService
    ) {
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.shippingService = shippingService;
        this.loyaltyService = loyaltyService;
        this.emailService = emailService;
        this.analyticsService = analyticsService;
    }
    
    public Order createOrder(CreateOrderRequest request) {
        // Validate inventory
        for (OrderItem item : request.getItems()) {
            if (!inventoryService.checkAvailability(item.getProductId(), item.getQuantity())) {
                throw new InsufficientInventoryException(item.getProductId());
            }
        }
        
        // Process payment
        PaymentResult payment = paymentService.processPayment(
            request.getCustomerId(),
            calculateTotal(request.getItems()),
            request.getPaymentDetails()
        );
        
        if (!payment.isSuccessful()) {
            throw new PaymentFailedException(payment.getErrorMessage());
        }
        
        // Create order
        Order order = new Order(
            request.getCustomerId(),
            request.getItems(),
            payment.getTransactionId()
        );
        order.setStatus(OrderStatus.CONFIRMED);
        Order savedOrder = orderRepository.save(order);
        
        // Reserve inventory
        for (OrderItem item : request.getItems()) {
            inventoryService.reserveInventory(item.getProductId(), item.getQuantity());
        }
        
        // Create shipment
        shippingService.createShipment(savedOrder);
        
        // Update loyalty points
        loyaltyService.addPoints(
            request.getCustomerId(),
            calculateLoyaltyPoints(savedOrder)
        );
        
        // Send confirmation email
        emailService.sendOrderConfirmation(savedOrder);
        
        // Track analytics
        analyticsService.trackOrderPlaced(savedOrder);
        
        return savedOrder;
    }
}

This code works but has serious problems. The OrderService knows about seven different services. Testing requires mocking all of them. Adding a new post-order action means modifying this method. If the email service is slow, order creation is slow. If analytics tracking fails, the entire order fails and rolls back.

The transaction boundary is wrong too. Everything happens in a single database transaction, which means temporary email service downtime prevents orders from being placed. The inventory reservation and shipment creation are transactionally coupled even though they’re logically independent operations.

Introducing Spring Application Events

Spring Framework provides a built-in event system that works within a single JVM. It’s synchronous by default, which makes it easy to reason about and debug. Start by defining domain events:

public abstract class DomainEvent {
    private final Instant occurredAt;
    private final String eventId;
    
    protected DomainEvent() {
        this.occurredAt = Instant.now();
        this.eventId = UUID.randomUUID().toString();
    }
    
    public Instant getOccurredAt() {
        return occurredAt;
    }
    
    public String getEventId() {
        return eventId;
    }
}

public class OrderPlacedEvent extends DomainEvent {
    private final Long orderId;
    private final Long customerId;
    private final List<OrderItem> items;
    private final BigDecimal totalAmount;
    
    public OrderPlacedEvent(Order order) {
        super();
        this.orderId = order.getId();
        this.customerId = order.getCustomerId();
        this.items = new ArrayList<>(order.getItems());
        this.totalAmount = order.getTotalAmount();
    }
    
    // Getters
}

Events should be immutable and contain all information subscribers need. Avoid passing entities directly—copy the relevant data instead. This prevents subscribers from accidentally modifying shared state.

Refactor the OrderService to publish events instead of calling services directly:

@Service
@Transactional
public class OrderService {
    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    private final ApplicationEventPublisher eventPublisher;
    
    public OrderService(
        OrderRepository orderRepository,
        InventoryService inventoryService,
        PaymentService paymentService,
        ApplicationEventPublisher eventPublisher
    ) {
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
        this.paymentService = paymentService;
        this.eventPublisher = eventPublisher;
    }
    
    public Order createOrder(CreateOrderRequest request) {
        // Validate inventory
        for (OrderItem item : request.getItems()) {
            if (!inventoryService.checkAvailability(item.getProductId(), item.getQuantity())) {
                throw new InsufficientInventoryException(item.getProductId());
            }
        }
        
        // Process payment
        PaymentResult payment = paymentService.processPayment(
            request.getCustomerId(),
            calculateTotal(request.getItems()),
            request.getPaymentDetails()
        );
        
        if (!payment.isSuccessful()) {
            throw new PaymentFailedException(payment.getErrorMessage());
        }
        
        // Create and save order
        Order order = new Order(
            request.getCustomerId(),
            request.getItems(),
            payment.getTransactionId()
        );
        order.setStatus(OrderStatus.CONFIRMED);
        Order savedOrder = orderRepository.save(order);
        
        // Reserve inventory synchronously (still critical path)
        for (OrderItem item : request.getItems()) {
            inventoryService.reserveInventory(item.getProductId(), item.getQuantity());
        }
        
        // Publish event for non-critical operations
        eventPublisher.publishEvent(new OrderPlacedEvent(savedOrder));
        
        return savedOrder;
    }
}

The OrderService now depends on only four components instead of eight. More importantly, it only knows about operations critical to order creation—inventory validation, payment processing, and inventory reservation. Everything else happens through events.

Create event listeners for the decoupled operations:

@Component
public class OrderEventListeners {
    private static final Logger logger = LoggerFactory.getLogger(OrderEventListeners.class);
    
    private final ShippingService shippingService;
    private final LoyaltyService loyaltyService;
    private final EmailService emailService;
    private final AnalyticsService analyticsService;
    
    public OrderEventListeners(
        ShippingService shippingService,
        LoyaltyService loyaltyService,
        EmailService emailService,
        AnalyticsService analyticsService
    ) {
        this.shippingService = shippingService;
        this.loyaltyService = loyaltyService;
        this.emailService = emailService;
        this.analyticsService = analyticsService;
    }
    
    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        try {
            shippingService.createShipment(event.getOrderId());
        } catch (Exception e) {
            logger.error("Failed to create shipment for order {}", event.getOrderId(), e);
            // Don't rethrow - other listeners should still execute
        }
    }
    
    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateLoyaltyPoints(OrderPlacedEvent event) {
        try {
            int points = calculatePoints(event.getTotalAmount());
            loyaltyService.addPoints(event.getCustomerId(), points);
        } catch (Exception e) {
            logger.error("Failed to update loyalty points for order {}", event.getOrderId(), e);
        }
    }
    
    @EventListener
    public void sendConfirmationEmail(OrderPlacedEvent event) {
        try {
            emailService.sendOrderConfirmation(event.getOrderId());
        } catch (Exception e) {
            logger.error("Failed to send confirmation email for order {}", event.getOrderId(), e);
        }
    }
    
    @EventListener
    public void trackAnalytics(OrderPlacedEvent event) {
        try {
            analyticsService.trackOrderPlaced(event.getOrderId(), event.getTotalAmount());
        } catch (Exception e) {
            logger.error("Failed to track analytics for order {}", event.getOrderId(), e);
        }
    }
}

Each listener runs in its own transaction (when appropriate) and handles failures independently. If sending email fails, shipment creation still happens. The order creation transaction commits successfully even if analytics tracking throws an exception.

Understanding Transaction Boundaries

The @Transactional(propagation = Propagation.REQUIRES_NEW) annotation is crucial. Without it, all listeners participate in the order creation transaction. If any listener fails, the entire order rolls back—exactly what we’re trying to avoid.

With REQUIRES_NEW, each listener starts a fresh transaction. The order is already committed when listeners run. This means:

  1. Listeners can’t prevent the order from being created
  2. Listener failures don’t roll back the order
  3. Each listener’s work is independently atomic

But there’s a trade-off. If a listener fails, the order exists but some post-processing didn’t happen. You need strategies for handling these partial failures:

@EventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleOrderPlaced(OrderPlacedEvent event) {
    try {
        shippingService.createShipment(event.getOrderId());
    } catch (Exception e) {
        logger.error("Failed to create shipment for order {}", event.getOrderId(), e);
        
        // Record failure for retry
        failedEventRepository.save(new FailedEvent(
            event.getClass().getSimpleName(),
            event.getEventId(),
            "handleOrderPlaced",
            e.getMessage()
        ));
    }
}

A separate background job can retry failed events:

@Component
public class FailedEventRetryJob {
    private final FailedEventRepository failedEventRepository;
    private final ApplicationEventPublisher eventPublisher;
    
    @Scheduled(fixedDelay = 60000) // Every minute
    public void retryFailedEvents() {
        List failures = failedEventRepository.findRetryable();
        
        for (FailedEvent failure : failures) {
            try {
                // Reconstruct and republish the event
                DomainEvent event = reconstructEvent(failure);
                eventPublisher.publishEvent(event);
                
                failure.markRetried();
                failedEventRepository.save(failure);
            } catch (Exception e) {
                logger.warn("Retry failed for event {}", failure.getEventId(), e);
                failure.incrementRetryCount();
                failedEventRepository.save(failure);
            }
        }
    }
}

This pattern provides eventual consistency—the system might be temporarily inconsistent, but it self-heals through retries.

Moving to Asynchronous Events

Spring’s @EventListener is synchronous by default. Event processing happens in the same thread that published the event, and the publisher waits for all listeners to complete. This provides strong guarantees but limits scalability.

Make listeners asynchronous by enabling async support and annotating listeners:

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "eventExecutor")
    public Executor eventExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("event-");
        executor.initialize();
        return executor;
    }
}

@Component
public class OrderEventListeners {
    // ... dependencies ...
    
    @Async("eventExecutor")
    @EventListener
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        shippingService.createShipment(event.getOrderId());
    }
    
    @Async("eventExecutor")
    @EventListener
    public void sendConfirmationEmail(OrderPlacedEvent event) {
        emailService.sendOrderConfirmation(event.getOrderId());
    }
}

With @Async, the createOrder() method returns immediately after publishing the event. Listeners execute concurrently in the thread pool. This dramatically improves response times—order creation no longer waits for email sending or analytics tracking.

But asynchronous events introduce new complexities. The order creation transaction might not be committed yet when listeners execute. Listeners could try to load the order from the database and fail to find it because the transaction is still in progress.

Spring provides @TransactionalEventListener to handle this:

@Component
public class OrderEventListeners {
    @Async("eventExecutor")
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        // This only runs after the order creation transaction commits
        shippingService.createShipment(event.getOrderId());
    }
}

The AFTER_COMMIT phase ensures listeners run only after the publishing transaction successfully commits. If the order creation fails and rolls back, listeners never execute. This prevents processing events for orders that don’t actually exist.

Implementing an Event Store

As your event-driven architecture matures, storing events becomes valuable. An event store provides an audit log, enables debugging, and supports more sophisticated patterns like event sourcing.

Create a simple event store:

@Entity
@Table(name = "domain_events")
public class StoredEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String eventId;
    
    @Column(nullable = false)
    private String eventType;
    
    @Column(nullable = false, columnDefinition = "TEXT")
    private String payload;
    
    @Column(nullable = false)
    private Instant occurredAt;
    
    @Column(nullable = false)
    private Instant storedAt;
    
    @Column
    private String aggregateId;
    
    @Column
    private String aggregateType;
    
    // Constructors, getters, setters
}

@Repository
public interface StoredEventRepository extends JpaRepository<StoredEvent, Long> {
    List<StoredEvent> findByAggregateIdOrderByOccurredAt(String aggregateId);
    List<StoredEvent> findByEventType(String eventType);
}

Intercept and store all domain events:

@Component
public class EventStoreListener {
    private final StoredEventRepository repository;
    private final ObjectMapper objectMapper;
    
    public EventStoreListener(StoredEventRepository repository, ObjectMapper objectMapper) {
        this.repository = repository;
        this.objectMapper = objectMapper;
    }
    
    @EventListener
    @Order(Ordered.HIGHEST_PRECEDENCE) // Store before other listeners
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void storeEvent(DomainEvent event) {
        try {
            StoredEvent stored = new StoredEvent();
            stored.setEventId(event.getEventId());
            stored.setEventType(event.getClass().getSimpleName());
            stored.setPayload(objectMapper.writeValueAsString(event));
            stored.setOccurredAt(event.getOccurredAt());
            stored.setStoredAt(Instant.now());
            
            // Extract aggregate information if available
            if (event instanceof OrderPlacedEvent) {
                OrderPlacedEvent orderEvent = (OrderPlacedEvent) event;
                stored.setAggregateId(orderEvent.getOrderId().toString());
                stored.setAggregateType("Order");
            }
            
            repository.save(stored);
        } catch (JsonProcessingException e) {
            throw new EventStoreException("Failed to serialize event", e);
        }
    }
}

Now every domain event persists before business logic processes it. You can reconstruct what happened in the system by replaying events:

@Service
public class OrderHistoryService {
    private final StoredEventRepository eventRepository;
    
    public List<OrderEvent> getOrderHistory(Long orderId) {
        List<StoredEvent> events = eventRepository.findByAggregateIdOrderByOccurredAt(
            orderId.toString()
        );
        
        return events.stream()
            .map(this::deserializeEvent)
            .collect(Collectors.toList());
    }
    
    private OrderEvent deserializeEvent(StoredEvent stored) {
        // Deserialize based on event type
        try {
            Class<?> eventClass = Class.forName("com.example.events." + stored.getEventType());
            return (OrderEvent) objectMapper.readValue(stored.getPayload(), eventClass);
        } catch (Exception e) {
            throw new EventStoreException("Failed to deserialize event", e);
        }
    }
}

This enables powerful debugging capabilities. When a customer reports an issue with their order, you can see exactly what events occurred and in what sequence.

Sagas and Compensating Actions

Some workflows require coordination across multiple steps where each step might fail. The traditional approach uses distributed transactions, but those don’t scale well and add complexity. Sagas provide an alternative using choreographed events and compensating actions.

Consider a more complex order flow where you need to:

  1. Reserve inventory
  2. Process payment
  3. Create shipment

If payment fails after reserving inventory, you need to release the reservation. Implement this with compensating events:

public class InventoryReservedEvent extends DomainEvent {
    private final Long orderId;
    private final List<ReservationDetail> reservations;
    
    // Constructor, getters
}

public class PaymentFailedEvent extends DomainEvent {
    private final Long orderId;
    private final String reason;
    
    // Constructor, getters
}

@Component
public class InventorySagaHandler {
    private final InventoryService inventoryService;
    
    @EventListener
    public void handlePaymentFailed(PaymentFailedEvent event) {
        // Compensating action: release reserved inventory
        inventoryService.releaseReservation(event.getOrderId());
    }
}

The saga coordinates through events rather than a central coordinator:

@Service
public class OrderSagaService {
    private final ApplicationEventPublisher eventPublisher;
    private final InventoryService inventoryService;
    private final PaymentService paymentService;
    
    public void processOrder(Order order) {
        // Step 1: Reserve inventory
        List<ReservationDetail> reservations = inventoryService.reserve(order.getItems());
        eventPublisher.publishEvent(new InventoryReservedEvent(order.getId(), reservations));
        
        try {
            // Step 2: Process payment
            PaymentResult payment = paymentService.processPayment(order);
            
            if (payment.isSuccessful()) {
                eventPublisher.publishEvent(new PaymentSucceededEvent(order.getId(), payment));
            } else {
                // Trigger compensation
                eventPublisher.publishEvent(new PaymentFailedEvent(order.getId(), payment.getReason()));
                throw new PaymentException(payment.getReason());
            }
        } catch (Exception e) {
            // Trigger compensation
            eventPublisher.publishEvent(new PaymentFailedEvent(order.getId(), e.getMessage()));
            throw e;
        }
    }
}

This pattern maintains consistency without distributed transactions. Each step publishes events documenting what happened. When failures occur, compensating events trigger actions that undo previous steps.

Bridging to External Message Brokers

As your monolith grows, you might want to integrate with external systems or prepare for eventual service extraction. Spring Cloud Stream provides abstractions over message brokers like RabbitMQ or Kafka while maintaining the same event-driven patterns:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-stream-binder-kafka</artifactId>
</dependency>

Configure bindings in application.yml:

spring:
  cloud:
    stream:
      bindings:
        orderPlaced-out-0:
          destination: order.placed
        orderPlaced-in-0:
          destination: order.placed
          group: order-processors
      kafka:
        binder:
          brokers: localhost:9092

Create a bridge between internal events and external messages:

@Component
public class EventPublisher {
    private final StreamBridge streamBridge;
    
    public EventPublisher(StreamBridge streamBridge) {
        this.streamBridge = streamBridge;
    }
    
    @EventListener
    public void publishToExternalBroker(OrderPlacedEvent event) {
        // Publish internal event to external message broker
        streamBridge.send("orderPlaced-out-0", event);
    }
}

@Component
public class ExternalEventConsumer {
    private final ApplicationEventPublisher eventPublisher;
    
    public ExternalEventConsumer(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }
    
    @Bean
    public Consumer<OrderPlacedEvent> orderPlaced() {
        return event -> {
            // Republish external event as internal event
            eventPublisher.publishEvent(event);
        };
    }
}

This pattern lets you selectively publish events externally while keeping internal events local. Critical real-time operations use internal events for low latency. Cross-service communication uses the message broker for reliability and scalability.

Monitoring and Observability

Event-driven systems introduce new observability challenges. Understanding what’s happening requires tracking events through multiple asynchronous processing steps. Implement comprehensive logging and metrics:

@Aspect
@Component
public class EventMonitoringAspect {
    private static final Logger logger = LoggerFactory.getLogger(EventMonitoringAspect.class);
    private final MeterRegistry meterRegistry;
    
    public EventMonitoringAspect(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }
    
    @Around("@annotation(org.springframework.context.event.EventListener)")
    public Object monitorEventListener(ProceedingJoinPoint joinPoint) throws Throwable {
        String listenerName = joinPoint.getSignature().getName();
        Object[] args = joinPoint.getArgs();
        DomainEvent event = (DomainEvent) args[0];
        
        Timer.Sample sample = Timer.start(meterRegistry);
        
        try {
            logger.info("Processing event {} in listener {}", 
                event.getEventId(), listenerName);
            
            Object result = joinPoint.proceed();
            
            sample.stop(Timer.builder("event.listener.duration")
                .tag("listener", listenerName)
                .tag("event_type", event.getClass().getSimpleName())
                .tag("status", "success")
                .register(meterRegistry));
            
            meterRegistry.counter("event.listener.processed",
                "listener", listenerName,
                "event_type", event.getClass().getSimpleName(),
                "status", "success"
            ).increment();
            
            return result;
        } catch (Exception e) {
            sample.stop(Timer.builder("event.listener.duration")
                .tag("listener", listenerName)
                .tag("event_type", event.getClass().getSimpleName())
                .tag("status", "failure")
                .register(meterRegistry));
            
            meterRegistry.counter("event.listener.processed",
                "listener", listenerName,
                "event_type", event.getClass().getSimpleName(),
                "status", "failure"
            ).increment();
            
            logger.error("Error processing event {} in listener {}", 
                event.getEventId(), listenerName, e);
            
            throw e;
        }
    }
}

This aspect automatically tracks every event listener’s execution time and success rate. Combined with tools like Prometheus and Grafana, you can visualize event processing patterns and identify bottlenecks.

Add correlation IDs to trace events through the system:

public abstract class DomainEvent {
    private final Instant occurredAt;
    private final String eventId;
    private final String correlationId;
    
    protected DomainEvent(String correlationId) {
        this.occurredAt = Instant.now();
        this.eventId = UUID.randomUUID().toString();
        this.correlationId = correlationId != null ? correlationId : UUID.randomUUID().toString();
    }
    
    // Getters
}

Propagate correlation IDs through event chains:

@EventListener
public void handleOrderPlaced(OrderPlacedEvent event) {
    MDC.put("correlationId", event.getCorrelationId());
    
    try {
        // Do work
        
        // Publish follow-up events with same correlation ID
        eventPublisher.publishEvent(new ShipmentCreatedEvent(
            event.getOrderId(),
            event.getCorrelationId()
        ));
    } finally {
        MDC.clear();
    }
}

Now all log messages related to a single order flow share a correlation ID, making it trivial to trace the entire workflow across multiple async operations.

Testing Event-Driven Code

Event-driven architecture requires different testing strategies. Traditional unit tests work for individual listeners, but integration tests become more important for verifying event flows:

@SpringBootTest
@TestConfiguration
public class OrderEventIntegrationTest {
    @Autowired
    private ApplicationEventPublisher eventPublisher;
    
    @Autowired
    private ShippingService shippingService;
    
    @Autowired
    private EmailService emailService;
    
    @Test
    public void shouldProcessOrderPlacedEventCompletely() throws Exception {
        // Given
        Order order = createTestOrder();
        OrderPlacedEvent event = new OrderPlacedEvent(order);
        
        // When
        eventPublisher.publishEvent(event);
        
        // Wait for async processing
        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> {
            // Then
            verify(shippingService).createShipment(order.getId());
            verify(emailService).sendOrderConfirmation(order.getId());
        });
    }
}

For unit testing, inject a spy event publisher to verify events are published correctly:

@ExtendWith(MockitoExtension.class)
public class OrderServiceTest {
    @Mock
    private OrderRepository orderRepository;
    
    @Mock
    private InventoryService inventoryService;
    
    @Mock
    private PaymentService paymentService;
    
    @Spy
    private ApplicationEventPublisher eventPublisher = new SimpleApplicationEventPublisher();
    
    @InjectMocks
    private OrderService orderService;
    
    @Test
    public void shouldPublishOrderPlacedEventAfterCreatingOrder() {
        // Given
        CreateOrderRequest request = createValidRequest();
        
        when(inventoryService.checkAvailability(any(), anyInt())).thenReturn(true);
        when(paymentService.processPayment(any(), any(), any()))
            .thenReturn(PaymentResult.successful("txn-123"));
        when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
        
        // When
        orderService.createOrder(request);
        
        // Then
        verify(eventPublisher).publishEvent(argThat(event -> 
            event instanceof OrderPlacedEvent
        ));
    }
}

The Migration Journey

Refactoring a monolith to use event-driven architecture isn’t an all-or-nothing proposition. Start with one workflow—often the one causing the most pain. Identify direct service calls that could be event-driven and introduce events incrementally.

Begin with synchronous events to minimize behavioral changes. Once events flow correctly, switch to asynchronous processing for non-critical listeners. Add an event store when you need audit trails or debugging capabilities. Integrate external message brokers only when you need cross-service communication or are preparing to extract microservices.

The goal isn’t achieving perfect event-driven architecture. It’s reducing coupling, improving testability, and creating clearer boundaries between components. Even partial adoption provides value—a monolith with some event-driven patterns is more maintainable than one with none.

This incremental approach lets you deliver value continuously rather than embarking on a multi-month refactoring project that delivers nothing until it’s complete. You learn what works for your specific domain and team, adjusting your approach based on real experience rather than theoretical ideals.

Useful Resources

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button