Available Hire Me
← All Writing Architecture

Domain Events with Spring ApplicationEvent — Clean Decoupling Without Kafka

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.

Defining a domain event

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.

Publishing events from the service layer

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.

Synchronous listeners

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 — the critical detail

@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 commit
  • AFTER_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 transaction

One 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.

Async listeners

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.

Combining transactional and 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.

When to choose this over Kafka

Spring ApplicationEvent is appropriate when:

  • The events are within a single service — no cross-service communication
  • You don’t need replay or consumer groups
  • Eventual consistency is acceptable or not needed at all
  • The overhead of a message broker is not justified

Kafka is appropriate when:

  • Events cross service boundaries
  • Multiple independent services consume the same events
  • Replay is a first-class requirement (new service catching up, re-processing after a bug fix)
  • You need durability guarantees — events must survive a service restart

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.

ProTips

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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.