home / skills / giuseppe-trisciuoglio / developer-kit / spring-boot-event-driven-patterns

This skill helps you implement event-driven patterns in Spring Boot using domain events, transactional listeners, and Kafka-based messaging.

npx playbooks add skill giuseppe-trisciuoglio/developer-kit --skill spring-boot-event-driven-patterns

Review the files below or copy the command above to add this skill to your agents.

Files (3)
SKILL.md
19.6 KB
---
name: spring-boot-event-driven-patterns
description: Provides Event-Driven Architecture (EDA) patterns in Spring Boot using ApplicationEvent, @EventListener, and Kafka. Use when building loosely-coupled microservices with domain events, transactional event listeners, and distributed messaging patterns.
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
category: backend
tags: [spring-boot, java, event-driven, eda, kafka, messaging, domain-events, microservices, spring-cloud-stream]
version: 1.1.0
---

# Spring Boot Event-Driven Patterns

## Overview

Implement Event-Driven Architecture (EDA) patterns in Spring Boot 3.x using domain events, ApplicationEventPublisher, @TransactionalEventListener, and distributed messaging with Kafka and Spring Cloud Stream.

## When to Use This Skill

Use this skill when building applications that require:
- Loose coupling between microservices through event-based communication
- Domain event publishing from aggregate roots in DDD architectures
- Transactional event listeners ensuring consistency after database commits
- Distributed messaging with Kafka for inter-service communication
- Event streaming with Spring Cloud Stream for reactive systems
- Reliability using the transactional outbox pattern
- Asynchronous communication between bounded contexts
- Event sourcing foundations with proper event sourcing patterns

## Instructions

Follow these steps to implement event-driven architecture patterns in Spring Boot:

### 1. Design Domain Events

Create immutable event classes extending a base DomainEvent class. Include eventId, occurredAt, and correlationId fields for traceability.

### 2. Define Event Publishing

Add ApplicationEventPublisher to services that need to publish events. Publish events after domain state changes complete.

### 3. Configure Transactional Listeners

Use @TransactionalEventListener with phase = AFTER_COMMIT to ensure events are only processed after successful database transaction.

### 4. Set Up Kafka Infrastructure

Configure KafkaTemplate for publishing events to topics. Create @KafkaListener beans to consume events from other services.

### 5. Implement Spring Cloud Stream

Use functional programming model with Consumer bean definitions for reactive event consumption. Configure bindings in application.yml.

### 6. Handle Failure Scenarios

Implement retry logic with exponential backoff. Configure dead-letter queues for failed messages. Make event handlers idempotent.

### 7. Implement Outbox Pattern

Create OutboxEvent entity to store events atomically with business data. Use scheduled job to publish outbox events to message broker.

### 8. Add Observability

Enable Spring Cloud Sleuth for distributed tracing. Monitor event processing metrics through Actuator endpoints.

## Setup and Configuration

### Required Dependencies

To implement event-driven patterns, include these dependencies in your project:

**Maven:**
```xml
<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <!-- Kafka for distributed messaging -->
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>

    <!-- Spring Cloud Stream -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-stream</artifactId>
        <version>4.0.4</version> // Use latest compatible version
    </dependency>

    <!-- Testing -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Testcontainers for integration testing -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <version>1.19.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>
```

**Gradle:**
```gradle
dependencies {
    // Spring Boot Web
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // Spring Data JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // Kafka
    implementation 'org.springframework.kafka:spring-kafka'

    // Spring Cloud Stream
    implementation 'org.springframework.cloud:spring-cloud-stream:4.0.4'

    // Testing
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.testcontainers:testcontainers:1.19.0'
}
```

### Basic Configuration

Configure your application for event-driven architecture:

```properties
# Server Configuration
server.port=8080

# Kafka Configuration
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer

# Spring Cloud Stream Configuration
spring.cloud.stream.kafka.binder.brokers=localhost:9092
```

## Core Patterns

### 1. Domain Events Design

Create immutable domain events for business domain changes:

```java
// Domain event base class
public abstract class DomainEvent {
    private final UUID eventId;
    private final LocalDateTime occurredAt;
    private final UUID correlationId;

    protected DomainEvent() {
        this.eventId = UUID.randomUUID();
        this.occurredAt = LocalDateTime.now();
        this.correlationId = UUID.randomUUID();
    }

    protected DomainEvent(UUID correlationId) {
        this.eventId = UUID.randomUUID();
        this.occurredAt = LocalDateTime.now();
        this.correlationId = correlationId;
    }

    // Getters
    public UUID getEventId() { return eventId; }
    public LocalDateTime getOccurredAt() { return occurredAt; }
    public UUID getCorrelationId() { return correlationId; }
}

// Specific domain events
public class ProductCreatedEvent extends DomainEvent {
    private final ProductId productId;
    private final String name;
    private final BigDecimal price;
    private final Integer stock;

    public ProductCreatedEvent(ProductId productId, String name, BigDecimal price, Integer stock) {
        super();
        this.productId = productId;
        this.name = name;
        this.price = price;
        this.stock = stock;
    }

    // Getters
    public ProductId getProductId() { return productId; }
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public Integer getStock() { return stock; }
}
```

### 2. Aggregate Root with Event Publishing

Implement aggregates that publish domain events:

```java
@Entity
@Getter
@ToString
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
    @Id
    private ProductId id;
    private String name;
    private BigDecimal price;
    private Integer stock;

    @Transient
    private List<DomainEvent> domainEvents = new ArrayList<>();

    public static Product create(String name, BigDecimal price, Integer stock) {
        Product product = new Product();
        product.id = ProductId.generate();
        product.name = name;
        product.price = price;
        product.stock = stock;
        product.domainEvents.add(new ProductCreatedEvent(product.id, name, price, stock));
        return product;
    }

    public void decreaseStock(Integer quantity) {
        this.stock -= quantity;
        this.domainEvents.add(new ProductStockDecreasedEvent(this.id, quantity, this.stock));
    }

    public List<DomainEvent> getDomainEvents() {
        return new ArrayList<>(domainEvents);
    }

    public void clearDomainEvents() {
        domainEvents.clear();
    }
}
```

### 3. Application Event Publishing

Publish domain events from application services:

```java
@Service
@RequiredArgsConstructor
@Transactional
public class ProductApplicationService {
    private final ProductRepository productRepository;
    private final ApplicationEventPublisher eventPublisher;

    public ProductResponse createProduct(CreateProductRequest request) {
        Product product = Product.create(
            request.getName(),
            request.getPrice(),
            request.getStock()
        );

        productRepository.save(product);

        // Publish domain events
        product.getDomainEvents().forEach(eventPublisher::publishEvent);
        product.clearDomainEvents();

        return mapToResponse(product);
    }
}
```

### 4. Local Event Handling

Handle events with transactional event listeners:

```java
@Component
@RequiredArgsConstructor
public class ProductEventHandler {
    private final NotificationService notificationService;
    private final AuditService auditService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductCreated(ProductCreatedEvent event) {
        auditService.logProductCreation(
            event.getProductId().getValue(),
            event.getName(),
            event.getPrice(),
            event.getCorrelationId()
        );

        notificationService.sendProductCreatedNotification(event.getName());
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductStockDecreased(ProductStockDecreasedEvent event) {
        notificationService.sendStockUpdateNotification(
            event.getProductId().getValue(),
            event.getQuantity()
        );
    }
}
```

### 5. Distributed Event Publishing

Publish events to Kafka for inter-service communication:

```java
@Component
@RequiredArgsConstructor
public class ProductEventPublisher {
    private final KafkaTemplate<String, Object> kafkaTemplate;

    public void publishProductCreatedEvent(ProductCreatedEvent event) {
        ProductCreatedEventDto dto = mapToDto(event);
        kafkaTemplate.send("product-events", event.getProductId().getValue(), dto);
    }

    private ProductCreatedEventDto mapToDto(ProductCreatedEvent event) {
        return new ProductCreatedEventDto(
            event.getEventId(),
            event.getProductId().getValue(),
            event.getName(),
            event.getPrice(),
            event.getStock(),
            event.getOccurredAt(),
            event.getCorrelationId()
        );
    }
}
```

### 6. Event Consumer with Spring Cloud Stream

Consume events using functional programming style:

```java
@Component
@RequiredArgsConstructor
public class ProductEventStreamConsumer {
    private final OrderService orderService;

    @Bean
    public Consumer<ProductCreatedEventDto> productCreatedConsumer() {
        return event -> {
            orderService.onProductCreated(event);
        };
    }

    @Bean
    public Consumer<ProductStockDecreasedEventDto> productStockDecreasedConsumer() {
        return event -> {
            orderService.onProductStockDecreased(event);
        };
    }
}
```

## Advanced Patterns

### Transactional Outbox Pattern

Ensure reliable event publishing with the outbox pattern:

```java
@Entity
@Table(name = "outbox_events")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OutboxEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private String aggregateId;
    private String eventType;
    private String payload;
    private UUID correlationId;
    private LocalDateTime createdAt;
    private LocalDateTime publishedAt;
    private Integer retryCount;
}

@Component
@RequiredArgsConstructor
public class OutboxEventProcessor {
    private final OutboxEventRepository outboxRepository;
    private final KafkaTemplate<String, Object> kafkaTemplate;

    @Scheduled(fixedDelay = 5000)
    @Transactional
    public void processPendingEvents() {
        List<OutboxEvent> pendingEvents = outboxRepository.findByPublishedAtNull();

        for (OutboxEvent event : pendingEvents) {
            try {
                kafkaTemplate.send("product-events", event.getAggregateId(), event.getPayload());
                event.setPublishedAt(LocalDateTime.now());
                outboxRepository.save(event);
            } catch (Exception e) {
                event.setRetryCount(event.getRetryCount() + 1);
                outboxRepository.save(event);
            }
        }
    }
}
```

## Testing Strategies

### Unit Testing Domain Events

Test domain event publishing and handling:

```java
class ProductTest {
    @Test
    void shouldPublishProductCreatedEventOnCreation() {
        Product product = Product.create("Test Product", BigDecimal.TEN, 100);

        assertThat(product.getDomainEvents()).hasSize(1);
        assertThat(product.getDomainEvents().get(0))
            .isInstanceOf(ProductCreatedEvent.class);
    }
}

@ExtendWith(MockitoExtension.class)
class ProductEventHandlerTest {
    @Mock
    private NotificationService notificationService;

    @InjectMocks
    private ProductEventHandler handler;

    @Test
    void shouldHandleProductCreatedEvent() {
        ProductCreatedEvent event = new ProductCreatedEvent(
            ProductId.of("123"), "Product", BigDecimal.TEN, 100
        );

        handler.onProductCreated(event);

        verify(notificationService).sendProductCreatedNotification("Product");
    }
}
```

### Integration Testing with Testcontainers

Test Kafka integration with Testcontainers:

```java
@SpringBootTest
@Testcontainers
class KafkaEventIntegrationTest {
    @Container
    static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));

    @Autowired
    private ProductApplicationService productService;

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
    }

    @Test
    void shouldPublishEventToKafka() {
        CreateProductRequest request = new CreateProductRequest(
            "Test Product", BigDecimal.valueOf(99.99), 50
        );

        ProductResponse response = productService.createProduct(request);

        // Verify event was published
        verify(eventPublisher).publishProductCreatedEvent(any(ProductCreatedEvent.class));
    }
}
```

## Best Practices

### Event Design Guidelines

- **Use past tense naming**: ProductCreated, not CreateProduct
- **Keep events immutable**: All fields should be final
- **Include correlation IDs**: For tracing events across services
- **Serialize to JSON**: For cross-service compatibility

### Transactional Consistency

- **Use AFTER_COMMIT phase**: Ensures events are published after successful database transaction
- **Implement idempotent handlers**: Handle duplicate events gracefully
- **Add retry mechanisms**: For failed event processing

### Error Handling

- **Implement dead-letter queues**: For events that fail processing
- **Log all failures**: Include sufficient context for debugging
- **Set appropriate timeouts**: For event processing operations

### Performance Considerations

- **Batch event processing**: When handling high volumes
- **Use proper partitioning**: For Kafka topics
- **Monitor event latencies**: Set up alerts for slow processing

## Examples and References

See the following resources for comprehensive examples:

- [Complete working examples](references/examples.md)
- [Detailed implementation patterns](references/event-driven-patterns-reference.md)

## Troubleshooting

### Common Issues

**Events not being published:**
- Check transaction phase configuration
- Verify ApplicationEventPublisher is properly autowired
- Ensure transaction is committed before event publishing

**Kafka connection issues:**
- Verify bootstrap servers configuration
- Check network connectivity to Kafka
- Ensure proper serialization configuration

**Event handling failures:**
- Check for circular dependencies in event handlers
- Verify transaction boundaries
- Monitor for exceptions in event processing

### Debug Tips

- Enable debug logging for Spring events: `logging.level.org.springframework.context=DEBUG`
- Use correlation IDs to trace events across services
- Monitor event processing metrics in Actuator endpoints

## Constraints and Warnings

- Events published with `@TransactionalEventListener` only fire after transaction commit; ensure this matches your consistency requirements.
- Avoid publishing large objects in events as this can cause memory pressure and serialization issues.
- Be cautious with async event handlers as they execute in separate threads and may cause concurrency issues.
- Kafka consumers must handle duplicate messages by implementing idempotent processing.
- Event ordering is not guaranteed in distributed systems; design handlers to be order-independent.
- Never perform blocking operations in event listeners that run on the main transaction thread.
- Monitor for event processing backlogs as they can indicate system capacity issues.

## Examples

### Input: Monolithic Order Processing (Anti-Pattern)

```java
@Service
public class OrderService {
    @Transactional
    public Order processOrder(OrderRequest request) {
        Order order = orderRepository.save(request);
        inventoryService.reserve(order.getItems());
        paymentService.charge(order.getPayment());
        shippingService.schedule(order);
        emailService.sendConfirmation(order);
        return order;
    }
}
```

### Output: Event-Driven Order Processing

```java
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher eventPublisher;

    @Transactional
    public Order processOrder(OrderRequest request) {
        Order order = Order.create(request);
        orderRepository.save(order);

        // Publish event after transaction commits
        eventPublisher.publishEvent(new OrderCreatedEvent(
            order.getId(),
            order.getItems(),
            order.getPayment()
        ));

        return order;
    }
}

@Component
public class OrderEventHandler {
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderCreated(OrderCreatedEvent event) {
        // These execute asynchronously after the order is saved
        inventoryService.reserve(event.getItems());
        paymentService.charge(event.getPayment());
    }
}
```

### Input: Synchronous External Service Call

```java
@Service
public class NotificationService {
    public void sendOrderNotification(Order order) {
        emailClient.send(order); // Blocking call
    }
}
```

### Output: Asynchronous Event-Driven Notification

```java
public class OrderCreatedEvent extends DomainEvent {
    private final OrderId orderId;
    private final String customerEmail;
    private final BigDecimal total;

    // Constructor and getters
}

@Component
public class NotificationEventHandler {
    @KafkaListener(topics = "order-events")
    public void handleOrderEvent(OrderCreatedEventDto event) {
        // Process asynchronously without blocking the order flow
        emailService.sendOrderConfirmation(event);
    }
}
```

### Input: Event without Traceability

```java
eventPublisher.publishEvent(new ProductCreatedEvent(productId, name));
```

### Output: Event with Full Traceability

```java
public class ProductCreatedEvent extends DomainEvent {
    private final EventId eventId = EventId.generate();
    private final ProductId productId;
    private final String name;
    private final Instant occurredAt = Instant.now();
    private final CorrelationId correlationId = CorrelationId.generate();

    // Includes metadata for distributed tracing
    public Map<String, String> getMetadata() {
        return Map.of(
            "eventId", eventId.toString(),
            "correlationId", correlationId.toString(),
            "timestamp", occurredAt.toString()
        );
    }
}
```

Overview

This skill provides Event-Driven Architecture (EDA) patterns for Spring Boot applications using ApplicationEvent, @EventListener/@TransactionalEventListener, and Kafka/Spring Cloud Stream. It shows how to publish domain events, handle transactional listeners, implement the outbox pattern, and wire distributed messaging for loosely-coupled microservices. The patterns focus on reliability, traceability, and testability for domain-driven systems.

How this skill works

The skill defines immutable domain event classes and aggregates that collect domain events. Services publish events via ApplicationEventPublisher or an outbox table, and local handlers use @TransactionalEventListener(AFTER_COMMIT) to process events only after successful transactions. For inter-service messaging, it maps events to DTOs and publishes to Kafka via KafkaTemplate or Spring Cloud Stream functional Consumers. Failure handling uses retries, dead-letter queues, idempotent handlers, and observability through tracing and metrics.

When to use it

  • When you need loose coupling between microservices using events
  • When domain changes must emit traceable domain events in a DDD design
  • When event handlers must run only after DB transactions commit
  • When you need reliable cross-service delivery using Kafka or Spring Cloud Stream
  • When you require the transactional outbox for atomic event persistence and publish
  • When building reactive/event-streaming integrations across bounded contexts

Best practices

  • Model immutable domain events with eventId, occurredAt, and correlationId for traceability
  • Publish via ApplicationEventPublisher for intra-process events and use the outbox for reliable broker delivery
  • Use @TransactionalEventListener(phase = AFTER_COMMIT) to avoid processing on rolled-back transactions
  • Make handlers idempotent and add retry/exponential backoff plus dead-letter queues for failures
  • Instrument events with distributed tracing (Sleuth/OpenTelemetry) and expose metrics via Actuator
  • Use Testcontainers for Kafka integration tests and unit tests for domain event emission

Example use cases

  • Product aggregate emits ProductCreatedEvent on creation; local listeners update audit and send notifications after commit
  • Use outbox pattern to persist events atomically with business data and a scheduled processor to publish to Kafka
  • Expose Kafka topic product-events and use Spring Cloud Stream Consumer beans to update other bounded contexts (orders, inventory) reactively
  • Implement idempotent order handling when consuming ProductStockDecreasedEvent to avoid duplicate effects
  • Write integration tests with Testcontainers Kafka to verify end-to-end publish/consume flows

FAQ

When should I use ApplicationEvent vs Kafka?

Use ApplicationEvent/ApplicationEventPublisher for in-process, synchronous/asynchronous local handlers. Use Kafka/Spring Cloud Stream for cross-service, distributed communication and long-lived events.

How do I guarantee events are published only after DB commit?

Emit domain events from aggregates, store them in the DB, and handle publishing with @TransactionalEventListener(AFTER_COMMIT) or use the transactional outbox pattern and a separate publisher that reads only committed outbox rows.