Skip to main content

Architecture

The transactional outbox decouples queuing a message from delivering it. Queuing happens inside the caller's database transaction; delivery happens afterwards, either immediately after commit or by a background relay.

Building blocks

ComponentResponsibility
TransactionalOutboxPublic API. Validates the producer contract, serializes the message, persists a DeferredMessage
DeferredMessageJPA entity stored in the deferred_message table (serialized message bytes, topic, state, trace context)
AfterCommitMessageSenderSends immediately-flagged messages once the transaction commits (TxSyncAfterCommitMessageSender)
MessageRelayPolls the table for messages ready to be sent and relays them in batches
MessageRelaySchedulerTriggers MessageRelay.relay() on a fixed delay, guarded by a ShedLock lock
KafkaDeferredMessageSenderSends the stored bytes to Kafka using dedicated byte-array KafkaTemplates
OutboxHouseKeepingDeletes sent/unsent messages past their retention
MicrometerOutboxMetricsRecords gauges, counters and timers (when a MeterRegistry is present)

Messages are stored as already-serialized Avro Kafka bytes. This means the relay can deliver a message even on an instance that does not have the message type's Java binding on its classpath.

Send flow

Immediate vs. scheduled delivery

  • Immediate (sendMessage): the message is sent in the caller's thread right after commit, via a transaction synchronization (TxSyncAfterCommitMessageSender). Lowest latency, scales horizontally with application instances. If the immediate send fails, the message stays in the table and the relay picks it up later — so a relay must always be running as a fallback.
  • Scheduled (sendMessageScheduled): the message is only persisted; a background relay delivers it later. Frees the request thread, but adds latency and is serial (one relay sends at a time).

See Sending messages for the trade-offs and the API.

The relay process

MessageRelay.relay() repeatedly fetches a batch (message-relay-batch-size) of messages ready to be sent and delivers them in insertion order, stopping when the table is empty or continuous-relay-timeout is reached. A relay runs automatically on every instance of a service that includes the outbox, but only one relay sends at a time: MessageRelayScheduler holds a ShedLock lock (outbox-message-relay-tasks) so parallel instances do not relay simultaneously. If the holding instance dies, another takes over once the lock expires. The relay can be disabled with scheduled-relay-enabled=false (for example to run dedicated relay instances).