How to implement domain events in Spring Boot using ApplicationEvent and ApplicationEventPublisher — in-process decoupling, transactional event listeners, and when this pattern is the right choice over Kafka.
Not every system that benefits from domain events needs Kafka. Kafka is the right choice when events need to cross service boundaries, when you need durability guarantees beyond a single transaction, or when consumer groups need independent replay. But for decoupling components within a single Spring Boot service — separating the business action from its side effects — Spring’s built-in event mechanism is simpler, lighter, and easier to reason about.
The pattern is: your domain action publishes an event; downstream listeners react to it. The publisher doesn’t know what listeners exist. New reactions can be added without touching the publishing code.
Domain events should be immutable value objects that describe what happened in past tense:
public record OrderPlaced(
String orderId,
String customerId,
BigDecimal totalValue,
Instant occurredAt
) {
public OrderPlaced {
Objects.requireNonNull(orderId, "orderId");
Objects.requireNonNull(customerId, "customerId");
Objects.requireNonNull(totalValue, "totalValue");
Objects.requireNonNull(occurredAt, "occurredAt");
}
public static OrderPlaced of(String orderId, String customerId, BigDecimal totalValue) {
return new OrderPlaced(orderId, customerId, totalValue, Instant.now());
}
}
Spring’s ApplicationEvent base class is no longer required — since Spring 4.2, any object can be an event. Use plain records. The ApplicationEvent wrapper adds nothing except an unnecessary coupling to Spring in your domain model.
Inject ApplicationEventPublisher into your service:
@Service
@Transactional
public class OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher eventPublisher;
public OrderService(OrderRepository repository,
ApplicationEventPublisher eventPublisher) {
this.repository = repository;
this.eventPublisher = eventPublisher;
}
public Order placeOrder(PlaceOrderCommand command) {
var order = Order.create(command.customerId(), command.items());
repository.save(order);
eventPublisher.publishEvent(OrderPlaced.of(order.id(), order.customerId(), order.total()));
return order;
}
}
publishEvent is synchronous by default — the event is dispatched in the same thread, within the same transaction. Listeners run before the outer @Transactional method completes. This is important to understand.
The simplest listener:
@Component
public class OrderNotificationListener {
private final EmailService emailService;
@EventListener
public void onOrderPlaced(OrderPlaced event) {
emailService.sendOrderConfirmation(event.customerId(), event.orderId());
}
}
@EventListener matches on the parameter type. No registration required — Spring discovers all @EventListener methods at startup. Multiple listeners can handle the same event; they run sequentially in undefined order by default.
The problem with synchronous listeners in a transaction: if the email service throws, it rolls back the order. If you want the order persisted regardless of whether the notification succeeds, you need @TransactionalEventListener.
@TransactionalEventListener defers execution until after the transaction commits:
@Component
public class OrderAuditListener {
private final AuditRepository auditRepository;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlaced event) {
auditRepository.record(new AuditEntry(event.orderId(), "ORDER_PLACED", event.occurredAt()));
}
}
AFTER_COMMIT is the most useful phase — the listener runs only if the transaction that published the event committed successfully. If the outer transaction rolls back (because the order failed a validation), the audit entry is never written. This is the behaviour you almost always want.
The phases available:
AFTER_COMMIT — runs after successful commitAFTER_ROLLBACK — runs after rollback (useful for compensation or cleanup)AFTER_COMPLETION — runs after commit or rollback (for cleanup that must always happen)BEFORE_COMMIT — runs before the commit; exceptions here still roll back the transactionOne important caveat: @TransactionalEventListener only fires if there is an active transaction. If you publish an event outside a @Transactional boundary — in a test, or from a component that isn’t transactional — the listener is silently skipped. You can set fallbackExecution = true to handle the non-transactional case if needed.
For side effects that should run without blocking the caller — sending notifications, updating read models, calling external APIs — make the listener async:
@Component
public class OrderSearchIndexListener {
private final SearchIndexService searchIndex;
@Async
@EventListener
public void onOrderPlaced(OrderPlaced event) {
searchIndex.indexOrder(event.orderId());
}
}
Enable async support on your application class:
@SpringBootApplication
@EnableAsync
public class OrderApplication { ... }
Async listeners run in Spring’s task executor. They operate outside the original transaction — there’s no transactional guarantee between the order being saved and the search index being updated. For eventually-consistent read models this is acceptable. For anything requiring atomicity, use @TransactionalEventListener with a new transaction, not @Async.
For side effects that should run after commit but in a separate thread:
@Component
public class OrderReportingListener {
private final ReportingService reportingService;
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderPlaced(OrderPlaced event) {
reportingService.updateDailySummary(event.customerId(), event.totalValue());
}
}
REQUIRES_NEW creates a fresh transaction for the listener. This ensures the listener’s database writes are committed independently — a failure here doesn’t affect the original order, and a retry of the listener will re-attempt its own transaction.
Spring ApplicationEvent is appropriate when:
Kafka is appropriate when:
The outbox pattern sits between these two: for events that must reliably cross a service boundary without coupling to Kafka synchronously, write events to an outbox table within the same transaction, then relay them to Kafka asynchronously.
Don’t put domain logic in listeners: Listeners should delegate to a service, not contain business rules. A listener that grows business logic is a sign the event model is in the wrong layer.
Name events in past tense: OrderPlaced, not PlaceOrder. The name signals that the thing already happened — it’s a fact, not a command.
Test listeners in isolation: An @EventListener method is just a method. Test it directly by constructing the event and calling the method, without needing a Spring context or publishEvent.
Use condition filters for selective listening: @EventListener(condition = "#event.totalValue > 1000") uses SpEL to filter events at the framework level, avoiding unnecessary listener invocations.
If you’re untangling service dependencies and want to introduce domain events to decouple components within a bounded context, get in touch.