package com.example.orders.event;
import java.math.BigDecimal;
import java.time.Instant;
public final class OrderPlacedEvent {
private final Long orderId;
private final BigDecimal total;
private final Instant occurredAt;
public OrderPlacedEvent(Long orderId, BigDecimal total) {
this.orderId = orderId;
this.total = total;
this.occurredAt = Instant.now();
}
public Long getOrderId() {
return orderId;
}
public BigDecimal getTotal() {
return total;
}
public Instant getOccurredAt() {
return occurredAt;
}
}
package com.example.orders.service;
import com.example.orders.domain.Order;
import com.example.orders.event.OrderPlacedEvent;
import com.example.orders.repository.OrderRepository;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher events;
public OrderService(OrderRepository orderRepository, ApplicationEventPublisher events) {
this.orderRepository = orderRepository;
this.events = events;
}
@Transactional
public Order placeOrder(Order order) {
order.markPlaced();
Order saved = orderRepository.save(order);
// Registered with the current tx; delivered only if it commits.
events.publishEvent(new OrderPlacedEvent(saved.getId(), saved.getTotal()));
return saved;
}
}
package com.example.orders.event;
import com.example.orders.messaging.OrderMessagePublisher;
import com.example.orders.repository.OrderAuditRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class OrderEventListener {
private static final Logger log = LoggerFactory.getLogger(OrderEventListener.class);
private final OrderMessagePublisher messagePublisher;
private final OrderAuditRepository auditRepository;
public OrderEventListener(OrderMessagePublisher messagePublisher,
OrderAuditRepository auditRepository) {
this.messagePublisher = messagePublisher;
this.auditRepository = auditRepository;
}
@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) {
log.info("Order {} committed, dispatching notifications", event.getOrderId());
messagePublisher.send(event);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void writeAudit(OrderPlacedEvent event) {
auditRepository.record(event.getOrderId(), event.getTotal(), event.getOccurredAt());
}
}
package com.example.orders.config;
import java.util.concurrent.Executor;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean(name = "eventExecutor")
public Executor eventExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(200);
executor.setThreadNamePrefix("order-events-");
executor.initialize();
return executor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new SimpleAsyncUncaughtExceptionHandler();
}
}
This snippet shows how a Spring Boot service publishes domain events that fire only after the surrounding database transaction has actually committed, avoiding the classic bug where a downstream side effect (an email, a Kafka message, a cache eviction) runs but the transaction later rolls back, leaving consumers acting on state that never persisted.
In OrderPlacedEvent, the event is a plain immutable value object carrying just enough context — the orderId and total — for listeners to react without reaching back into the database. Events are intentionally thin so they can be serialized or forwarded later.
In OrderService, the business logic runs inside a @Transactional method. The order is saved through the JPA repository, and then ApplicationEventPublisher.publishEvent is called. This is the subtle part: publishing an event inside a transaction does not immediately notify @TransactionalEventListener beans. Instead Spring registers the event with the current transaction's synchronization and holds it until a specific phase. Because publication happens while the transaction is still open, the listener sees a consistent view and is guaranteed the commit succeeded before it runs.
In OrderEventListener, the handler is annotated with @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT), so it executes only once the commit is durable. The @Async annotation moves the work off the committing thread so slow I/O like sending to Kafka does not extend the transaction or block the caller. A key pitfall is that AFTERCOMMIT listeners run outside the original transaction, so any database writes they perform need a new transaction — hence `@Transactional(propagation = Propagation.REQUIRESNEW)on the audit write. Another trap: if no transaction is active when the event is published, an AFTER_COMMIT listener is silently skipped unlessfallbackExecution` is enabled.
The trade-off is that AFTER_COMMIT work is best-effort — if the JVM dies between commit and delivery, the event is lost. When at-least-once delivery matters, this pattern is usually paired with a transactional outbox table written in the same transaction. For eviction, notification, and analytics, though, @TransactionalEventListener is the clean, idiomatic Spring choice. AsyncConfig wires the executor that backs @Async so failures surface on a dedicated pool.
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
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
class CreateDeadJobs < ActiveRecord::Migration[7.1]
def change
create_table :dead_jobs do |t|
t.string :jid, null: false
t.string :queue, null: false
t.string :klass, null: false
Background Job Dead Letter Queue (DLQ) Table
Share this code
Here's the card — post it anywhere.