package com.shop.orders.events;
import java.math.BigDecimal;
import java.time.Instant;
public record OrderPlacedEvent(
Long orderId,
String customerEmail,
BigDecimal amount,
Instant placedAt
) {
public static OrderPlacedEvent from(Long orderId, String email, BigDecimal amount) {
return new OrderPlacedEvent(orderId, email, amount, Instant.now());
}
}
package com.shop.orders;
import com.shop.orders.events.OrderPlacedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher events;
public OrderService(OrderRepository repository, ApplicationEventPublisher events) {
this.repository = repository;
this.events = events;
}
@Transactional
public Order placeOrder(OrderRequest request) {
Order order = new Order(request.customerEmail(), request.total());
order.markPlaced();
Order saved = repository.save(order);
events.publishEvent(
OrderPlacedEvent.from(saved.getId(), saved.getCustomerEmail(), saved.getTotal()));
return saved;
}
}
package com.shop.orders.events;
import com.shop.notifications.MailSender;
import com.shop.readmodel.OrderProjectionWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class OrderEventListeners {
private static final Logger log = LoggerFactory.getLogger(OrderEventListeners.class);
private final MailSender mailSender;
private final OrderProjectionWriter projections;
public OrderEventListeners(MailSender mailSender, OrderProjectionWriter projections) {
this.mailSender = mailSender;
this.projections = projections;
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void sendConfirmationEmail(OrderPlacedEvent event) {
log.info("Sending confirmation for order {}", event.orderId());
mailSender.sendOrderConfirmation(event.customerEmail(), event.orderId(), event.amount());
}
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void updateReadModel(OrderPlacedEvent event) {
projections.recordPlacedOrder(event.orderId(), event.amount(), event.placedAt());
}
}
package com.shop.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;
@Configuration
@EnableAsync
public class AsyncEventConfig {
@Bean(name = "applicationTaskExecutor")
public Executor applicationTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("domain-events-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
This snippet shows the standard Spring way to decouple side effects from core business logic using in-process domain events. Instead of an OrderService directly calling an email sender and a search indexer, it publishes an event and lets independent listeners react. That keeps the write path focused on persistence and its invariants while cross-cutting concerns evolve separately.
In OrderPlacedEvent, the event is a plain immutable value object — a Java record carrying only the identifiers and data a listener needs (orderId, customerEmail, amount). Domain events should be self-contained facts about something that already happened, not references to mutable entities, so listeners never re-read half-committed state.
OrderService performs the real work: it saves the entity inside a @Transactional method and then calls events.publishEvent(...). A key detail is that ApplicationEventPublisher.publishEvent is synchronous by default, so without extra configuration a listener would run inside the same transaction and a failure there could roll back the order. Publishing after repository.save keeps the event tied to the committed intent.
OrderEventListeners is where the interesting behavior lives. The sendConfirmationEmail handler uses @TransactionalEventListener with the default AFTER_COMMIT phase, meaning it only fires once the surrounding transaction actually commits — the confirmation email is never sent for an order that rolled back. It is also annotated @Async so the mail call runs off the request thread and cannot slow down the caller. The updateReadModel handler demonstrates BEFORE_COMMIT, useful when a projection must be written atomically with the order in the same transaction.
AsyncEventConfig enables @EnableAsync and defines a bounded ThreadPoolTaskExecutor, because relying on Spring's default executor gives an unbounded thread pool and no backpressure. The trade-off of @Async AFTER_COMMIT listeners is that they run outside the transaction: if the email fails, the order still exists, so genuinely critical follow-ups need their own retry or outbox mechanism. This pattern shines for best-effort, non-transactional side effects — notifications, cache invalidation, metrics — where loose coupling matters more than guaranteed delivery.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
Share this code
Here's the card — post it anywhere.