An event says that something happened. The delivery mechanism determines who can hear it, when listeners run, and what happens if the process stops. A Spring application event and a broker message can carry the same data while providing very different guarantees.
Local events still share a process
ApplicationEventPublisher dispatches objects to listeners such as methods annotated with @EventListener. By default, listeners run synchronously on the publisher's thread. The publisher waits for them, and they can participate in its thread-bound transaction. A slow listener extends the call. A listener failure can propagate back to the publisher.
Local events can separate an operation from optional reactions without adding a broker. They also hide control flow if a required business step exists only in an unexpected listener. Keep invariant-enforcing work visible in the use case rather than depending on incidental listener ordering.
Adding @Async moves a listener to an executor. Its failure no longer reaches the publisher, its thread context needs explicit handling, and its task remains in memory. Async dispatch is not durable messaging.
Commit timing is not delivery assurance
@TransactionalEventListener binds execution to a transaction phase. The default is AFTER_COMMIT, so the listener does not react to work that later rolls back. Without an active transaction, the listener does not run unless fallbackExecution is enabled. That option changes the contract and deserves a reason.
After-commit handling cannot make database writes and external effects atomic. The process can stop after commit but before the listener sends a message. It can also stop after sending but before recording success. A listener failure cannot undo the completed commit.
Database resources may still be accessible during an after-commit callback, but the original transaction has finished. Further changes through those resources do not gain another commit. If the listener needs to persist new work, give that work a separate transaction. Do not confuse accessible entities with an active write boundary.
Store the intent with the change
An outbox closes the lost-intent gap between a database commit and required publication. The business transaction writes both the domain change and an outbox row. A separate relay reads committed rows, publishes their messages, waits for broker confirmation, and records successful publication.
This illustrative PostgreSQL schema stores a small event snapshot. published_at means the broker confirmed publication, not that a consumer completed its work:
CREATE TABLE outbox_event (
event_id uuid PRIMARY KEY,
aggregate_id text NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX outbox_pending ON outbox_event (created_at)
WHERE published_at IS NULL;
Suppose order o42 changes from DRAFT to PLACED. Both writes belong to the same database transaction. This illustrative SQL assumes the order exists and the application has checked that placement is allowed:
BEGIN;
UPDATE orders SET status = 'PLACED' WHERE id = 'o42';
INSERT INTO outbox_event (event_id, aggregate_id, event_type, payload)
VALUES (
'00000000-0000-4000-8000-000000000042',
'o42',
'OrderPlaced.v1',
'{"orderId":"o42","totalMinor":2500,"currency":"USD"}'
);
COMMIT;
The committed row looks like this. Later sections abbreviate its UUID as e42:
| Event | Aggregate | Type | Snapshot | Publication state |
|---|---|---|---|---|
e42 |
o42 |
OrderPlaced.v1 |
totalMinor=2500, currency=USD |
published_at=NULL |
If the relay instead reads mutable current order data, a later update can change the meaning of OrderPlaced. Store enough data to publish the intended fact, and define a schema version for consumers.
Stop the producer at three points
Assume the relay reads only committed rows, retries pending rows after restart, and uses a broker configured for durable publication. These scenarios show why a stable event ID is required even with an outbox.
Crash before database commit
- Write inside the transaction
The producer changes o42 to PLACED and inserts e42. Both writes are uncommitted. Other transactions still see o42 as DRAFT and no e42 row.
- The process stops before commit
The database rolls back both writes. The relay cannot publish an uncommitted row. Durable state is o42=DRAFT, outbox empty, broker empty.
Fig 1Before commit, neither write survivesInspect one part at a timeOrder- Order
The durable order o42 remains DRAFT.
- Outbox
There is no committed e42 row to publish.
- Broker
The broker has no e42. The relay cannot publish an uncommitted outbox row.
The transaction rolls back the order change and outbox entry together.
How to read this model
A transaction tray beside an empty broker magazine. The object is a teaching analogy. Inspecting a part highlights its role; it does not execute or simulate the software. Cabinet trays are records, workpieces are data, and latches are checks. Unless an explanation states a quantity, compartments and sample workpieces do not denote data counts. Part dimensions are not a quantitative scale.
- Order
- Retry the business request deliberately
No event represents a committed placement. The caller must resolve any uncertainty about its request before retrying. An outbox does not deduplicate duplicate order-placement requests.
Crash after commit before publish
- Commit the order and event
The database durably stores o42=PLACED and e42 with published_at=NULL. The broker has not received e42.
- The process stops before publication
A restarted relay can still find e42. An in-memory event or a plain after-commit callback would have no stored publication intent to recover.
Fig 2After commit, publication can recoverInspect one part at a timeDatabase- Database
Order o42 is PLACED after commit.
- Outbox
Event e42 is committed and pending.
- Broker
The broker has no e42 yet. A restarted relay can find the pending row.
The stored publication intent survives when the relay stops before publishing.
How to read this model
A durable outbox holder beside a broker waiting bay. The object is a teaching analogy. Inspecting a part highlights its role; it does not execute or simulate the software. Cabinet trays are records, workpieces are data, and latches are checks. Unless an explanation states a quantity, compartments and sample workpieces do not denote data counts. Part dimensions are not a quantitative scale.
- Database
- Publish from stored intent
The relay publishes e42, receives confirmation, then commits published_at. A send failure leaves the row pending for a later attempt. Publication can be late without losing the committed intent.
Crash after publish before delivery mark
- The broker accepts e42
The relay receives confirmation. The broker has e42, but the database still has published_at=NULL. Consumer completion is independent of this confirmation.
- The relay stops before marking success
After restart, the pending row looks unsent. The relay publishes the same e42 again. The broker can now deliver two copies with the same event ID.
Fig 3A retry can deliver the same event twiceInspect one part at a timePublish- Publish
The relay publishes e42 to the broker.
- Interrupted marking
The relay stops before recording publication success, so the row still appears pending.
- Retry
A restarted relay publishes e42 again. The broker can deliver e42 twice with the same event ID.
Use stable event identity and idempotent consumption; an outbox does not guarantee exactly-once delivery.
How to read this model
A pending outbox holder feeding a duplicate-capable broker magazine. The object is a teaching analogy. Inspecting a part highlights its role; it does not execute or simulate the software. Cabinet trays are records, workpieces are data, and latches are checks. Unless an explanation states a quantity, compartments and sample workpieces do not denote data counts. Part dimensions are not a quantitative scale.
- Publish
- Deduplicate the business effect
The consumer commits its effect and a durable e42 marker together. A repeated e42 skips the effect. The relay eventually commits published_at after confirmation, but that mark does not remove duplicates already published.
The relay must recover unfinished rows after a restart and avoid permanently losing failed publications. Multiple relay workers need coordinated row claims, with recovery if a claim owner crashes. Monitor pending-row count and age. Keep retries, retention, and ordering requirements explicit. An outbox removes a lost-intent window, not every distributed failure.
Redelivery is part of the consumer contract
A broker separates the producer's lifetime from the consumer's, subject to storage, retention, and replication settings. Producer confirmation means the broker accepted publication under its protocol. It does not mean a consumer completed the business operation.
Acknowledge required database work only after it commits. A crash after commit but before acknowledgment causes redelivery. A durable deduplication marker must share a transaction with the business effect. This illustrative PostgreSQL example counts placed orders for a projection named order-stats. Its primary key permits other consumers to process the same event independently:
CREATE TABLE processed_event (
consumer_name text NOT NULL,
event_id uuid NOT NULL,
PRIMARY KEY (consumer_name, event_id)
);
BEGIN;
WITH first_delivery AS (
INSERT INTO processed_event (consumer_name, event_id)
VALUES ('order-stats', '00000000-0000-4000-8000-000000000042')
ON CONFLICT DO NOTHING
RETURNING event_id
)
UPDATE order_stats
SET placed_count = placed_count + 1
WHERE id = 'all-orders'
AND EXISTS (SELECT 1 FROM first_delivery);
COMMIT;
Assume order_stats contains the all-orders row and this transaction owns the whole consumer effect. The handler must treat a missing required projection row as an error, rather than committing a marker with no business update.
| Delivery | Marker insertion | placed_count, initially 10 |
Acknowledgment |
|---|---|---|---|
| First e42 | One new row | 11 after commit | Only after commit |
| e42 redelivered after commit | Conflict, no new row | Still 11 | Safe after the duplicate transaction finishes |
| First attempt rolls back | Marker also rolls back | Still 10 | Do not acknowledge as completed |
The uniqueness constraint arbitrates concurrent copies too. Keeping IDs only in memory fails across restarts and replicas. Keep durable markers for the supported replay window. External effects still need their own idempotency mechanism because a database marker cannot atomically cover an HTTP request.
Bound transient retries and route repeatedly failing messages to an inspectable recovery path. A malformed message should not loop forever, consume the entire worker capacity, or disappear without a record. Replay and ordering requirements belong in the message contract before consumers depend on them.
Continue with transactions, async work, and HTTP clients.