Domain Events, Idempotency, and Auditability

Domain events, idempotency, and auditability solve one concrete Spring Boot problem: a business action often changes local state, needs other parts of the application to react, may be retried by clients or workers, and must later explain what happened. In this lesson the outcome is an order submission flow where one successful command records the order state, emits an order-submitted fact, suppresses duplicate retries, and leaves an audit trail that can be queried without reconstructing intent from log files.

The topic belongs in the architecture section because it crosses aggregate design, transaction boundaries, persistence, and operations. A controller can accept a request, but the application service owns the command. The domain model decides whether the command is valid. Spring Data can publish events from saved aggregates. Transactional event listeners decide when side effects are allowed to observe committed data. Idempotency storage makes retries safe. Audit records make the final history explicit.

Mechanism Inside Spring

A domain event is a named fact from the domain language, such as OrderSubmitted, not a technical notification like EmailShouldBeSent. In Spring applications there are two common publication styles. You can inject ApplicationEventPublisher and publish explicitly, or, with Spring Data repositories, an aggregate can expose event objects through @DomainEvents. After the repository saves the aggregate, Spring Data publishes those events and then calls a method annotated with @AfterDomainEventPublication so the aggregate can clear its in-memory event buffer.

Publication timing matters. A plain @EventListener runs during normal event dispatch and may execute before the surrounding transaction commits. A @TransactionalEventListener binds the handler to a transaction phase. For audit and outbox records that should represent committed business state, AFTER_COMMIT is usually the right phase. For validation that must block the transaction, do not hide it in an after-commit listener; keep it in the command or aggregate path so rollback semantics are clear.

Idempotency is separate from events. It is a persistence contract that says the same logical command, identified by a key, returns the same result without applying the business effect twice. The key may come from an HTTP Idempotency-Key header, a message id, or a caller-generated command id. The record normally stores the key, command fingerprint, status, resulting resource id, response summary, timestamps, and sometimes a failure category. A unique constraint on the key is the real guardrail; checking in memory is insufficient once two requests hit two application instances.

API Anatomy

Piece Role Design note
@DomainEvents Returns events collected by an aggregate. Keep returned objects immutable and meaningful to the domain.
@AfterDomainEventPublication Clears the aggregate event list after publication. Without clearing, a later save can republish old facts.
@TransactionalEventListener Runs a handler at a selected transaction phase. Use AFTER_COMMIT for effects that must describe durable state.
Idempotency table Stores one outcome per command key. Add a unique index and compare command fingerprints on reuse.
Audit table Stores who did what, to which entity, and when. Store bounded identifiers and event names, not entire sensitive payloads.

Example 1: Aggregate-Raised Event

The aggregate records the event at the same moment it changes its own state. The deterministic behavior is that a draft order can be submitted once, its status changes to submitted, and one OrderSubmitted event is made available to the repository publication mechanism. A second call throws before adding another event.

public class PurchaseOrder {
    private final UUID id;
    private OrderStatus status = OrderStatus.DRAFT;
    private final List<Object> events = new ArrayList<>();

    public PurchaseOrder(UUID id) {
        this.id = id;
    }

    public void submit(String submittedBy) {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("order is not draft");
        }
        status = OrderStatus.SUBMITTED;
        events.add(new OrderSubmitted(id, submittedBy, Instant.now()));
    }

    @DomainEvents
    Collection<Object> domainEvents() {
        return List.copyOf(events);
    }

    @AfterDomainEventPublication
    void clearDomainEvents() {
        events.clear();
    }
}

This example keeps the event list private because events are a result of behavior, not an external API for callers to mutate. The event contains the aggregate id, actor, and timestamp. In a real project, OrderSubmitted would be a small immutable record. The aggregate does not send email, write audit rows, or call another service. It only states the business fact that occurred.

Example 2: Committed Audit And Outbox Rows

The next step is a listener that reacts only after the order transaction commits. Expected behavior: if order save rolls back, no audit or outbox row is inserted by this handler. If the commit succeeds, the audit entry records the action and the outbox entry gives a separate dispatcher something durable to deliver to another system.

@Component
class OrderEventHandlers {
    private final AuditEntryRepository auditEntries;
    private final OutboxMessageRepository outboxMessages;

    OrderEventHandlers(AuditEntryRepository auditEntries, OutboxMessageRepository outboxMessages) {
        this.auditEntries = auditEntries;
        this.outboxMessages = outboxMessages;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void recordSubmission(OrderSubmitted event) {
        auditEntries.save(AuditEntry.forOrder(event.orderId(), "ORDER_SUBMITTED", event.submittedBy()));
        outboxMessages.save(OutboxMessage.from(event));
    }
}

The outbox row is deliberately local database state, not an immediate network call. This avoids the classic failure where the order commits but the process crashes before an external broker receives the message. A background publisher can poll undelivered outbox rows, publish them, and mark them sent. That publisher must also be idempotent because it can crash after publishing but before marking the row complete.

Example 3: Idempotent Command Handling

Now add a command-level idempotency record. Expected behavior: two requests with the same key return the same accepted result and only one order submission occurs. A different key for an already submitted order should fail with the aggregate rule, because idempotency is not permission to repeat a business transition under a new identity.

@Service
class SubmitOrderUseCase {
    private final IdempotencyRecordRepository idempotencyRecords;
    private final PurchaseOrderRepository orders;

    @Transactional
    SubmitOrderResult submit(UUID orderId, String idempotencyKey, String userId) {
        return idempotencyRecords.findByKeyForUpdate(idempotencyKey)
            .map(IdempotencyRecord::toSubmitOrderResult)
            .orElseGet(() -> {
                PurchaseOrder order = orders.findById(orderId).orElseThrow();
                order.submit(userId);
                orders.save(order);
                SubmitOrderResult result = SubmitOrderResult.accepted(orderId);
                idempotencyRecords.save(IdempotencyRecord.completed(idempotencyKey, result));
                return result;
            });
    }
}

The repository method name implies a row lock such as select for update or an equivalent pessimistic lock. Another valid design is to attempt the insert first and rely on the unique constraint to reject the loser. Whichever pattern you choose, keep the command and the idempotency record in the same transaction when possible. Also store a command fingerprint; if the caller reuses the same key with a different order id or body, return a conflict instead of replaying an unrelated result.

Design Choices And Trade-Offs

Explicit publication with ApplicationEventPublisher is easy to see in an application service and works outside Spring Data. Aggregate-collected events keep the event close to the invariant and reduce the chance that a caller forgets to publish. The trade-off is lifecycle coupling: repository save becomes the publication point, so tests need to exercise the repository path or directly inspect aggregate events.

Synchronous listeners are simple and useful for same-process projections, but they add latency and can surprise developers if a handler failure breaks a request. After-commit listeners protect the original transaction from handler work, but they still run in the application process and are not a durable queue. The outbox pattern adds a table and dispatcher, but gives you restartable delivery and a clear operational backlog.

Audit records also require restraint. A useful audit row contains actor id, entity type, entity id, event type, request id, idempotency key, result, and timestamp. Avoid storing full request bodies unless you have a retention, encryption, and redaction policy. For regulated data, auditability includes being able to show access controls and retention behavior, not merely having many rows.

Failure Modes And Troubleshooting

  • Symptom: duplicate emails or downstream messages appear after client retries. Cause: the handler performs the external effect directly and lacks an idempotent outbox or delivery key. Diagnose: query audit rows by idempotency key and compare outbox message ids with external message ids. Correction: write one outbox row per committed event with a unique event id, and make the publisher treat duplicate delivery acknowledgements as success.
  • Symptom: audit rows exist for orders that were never submitted. Cause: a plain event listener ran before transaction rollback. Diagnose: inspect listener annotations and reproduce with a forced exception after event publication. Correction: move nonblocking audit work to @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) or save audit rows inside the same transaction when they must roll back together.
  • Symptom: two concurrent requests both submit the same order. Cause: the idempotency check and insert are not protected by a unique constraint or lock. Diagnose: run a concurrency test with the same key against two application threads and inspect duplicate business rows. Correction: add a unique index on the key, lock the idempotency row, and handle duplicate-key exceptions by loading the stored result.
  • Symptom: event handlers fire repeatedly on unrelated saves. Cause: aggregate event buffers are not cleared after publication. Diagnose: save the same aggregate twice and count emitted event ids. Correction: implement @AfterDomainEventPublication and keep the buffer transient to the aggregate instance.

Reliability, Security, And Performance

Reliability comes from durable identities. Give each domain event a stable event id or derive one from the aggregate id and version. Give each command an idempotency key. Give each audit row a request or correlation id. These identifiers let you answer whether a retry created a new business effect or merely replayed a previous result.

Security decisions are mostly about minimization. Do not place secrets, payment details, authorization tokens, or full personal data snapshots in event payloads unless that is explicitly required and protected. Prefer references to sensitive records, encrypt fields that must be retained, and apply the same authorization checks to audit search screens that you apply to the business data.

Performance costs are real: each command may add an idempotency lookup, an audit insert, and an outbox insert. Index idempotency keys, aggregate ids, and unsent outbox status columns. Keep event payloads compact. Set retention windows for completed idempotency records so the table does not become a permanent high-cardinality cache.

Hands-On Lab

Prerequisites: a Spring Boot application with Spring Web, Spring Data JPA, a relational database, and an order repository; or a test slice that can persist entities transactionally. The lab goal is to submit an order twice with the same idempotency key and prove that the order changes once while audit and outbox state remain explainable.

  1. Create an OrderSubmitted event containing event id, order id, actor id, and occurred-at timestamp.
  2. Add an event buffer to the order aggregate and append OrderSubmitted inside the method that changes status from draft to submitted.
  3. Add @DomainEvents and @AfterDomainEventPublication methods to expose and clear the buffer.
  4. Create an idempotency_record table with a unique key column, command fingerprint, status, response code, response body summary, and timestamps.
  5. Wrap the submit use case in one transaction: lock or insert the idempotency key, load the order, call the aggregate method, save the order, and store the result.
  6. Add an after-commit listener that writes an audit row and an outbox row for OrderSubmitted.
  7. Verification: send two identical HTTP requests with the same Idempotency-Key. Expect the same response body, one order status transition, one completed idempotency record, one audit row for the event, and one outbox message.
  8. Rollback and cleanup: delete the test order, audit row, outbox row, and idempotency record, or roll back the test transaction if the lab is implemented as an integration test.

Assessment Exercises

  1. A client reuses an idempotency key with a different JSON body. What should the service return, and what field must be stored to detect this?
  2. Why can an after-commit event listener be correct for audit rows but wrong for enforcing a domain invariant?
  3. Design a unique key strategy for an outbox table so a publisher can retry after a crash without creating duplicate downstream effects.
  4. Given a report of duplicate audit rows, list the database queries and code annotations you would inspect first.
  5. Choose between aggregate-collected events and explicit publisher calls for a payment capture flow, and justify the transaction boundary you would use.

Summary

In Spring Boot, domain events describe facts created by the domain model, idempotency records make repeated commands converge on one result, and audit rows preserve a queryable history of committed actions. The strongest design keeps event creation inside the aggregate, stores command keys under a database constraint, reacts to committed events at the correct transaction phase, and uses an outbox for restartable integration work.