This spring boot training deep dive implements a transactional outbox that survives broker outages, duplicate delivery, and concurrent publishers without pretending distributed transactions are reliable.
Spring Boot Training: Transactional Outbox Patterns That Survive Failures
Spring Framework Training: Make the Database Commit the Source of Truth
In a spring framework training project, put the business-row mutation and the integration-event insert in the same local database transaction. Do not publish directly from an @Transactional method: a broker acknowledgement can arrive before the database commit, and a database rollback can then leave another service acting on an order that does not exist. For PostgreSQL, create an outbox table with an immutable event id, an aggregate sequence, and a polling index:
CREATE TABLE outbox_event (
id UUID PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id UUID NOT NULL,
sequence_no BIGINT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
attempts INTEGER NOT NULL DEFAULT 0,
lock_token UUID,
locked_until TIMESTAMPTZ,
published_at TIMESTAMPTZ,
UNIQUE (aggregate_type, aggregate_id, sequence_no)
);
CREATE INDEX outbox_ready_idx
ON outbox_event (occurred_at)
WHERE published_at IS NULL;Use the aggregate's optimistic-lock version as sequence_no, rather than calculating MAX(sequence_no) + 1; the latter races under concurrent writes. The unique constraint turns a sequence mistake into a visible transaction failure instead of silently producing ambiguous per-aggregate ordering. Retain the event type as a stable contract name such as orders.order-created.v1; changing a Java class name is not a safe event-contract migration.
Spring Boot Training: Claim Rows Without Holding Locks During Broker I/O
A polling publisher needs competing workers without duplicate active ownership. On PostgreSQL, claim a small batch with FOR UPDATE SKIP LOCKED, commit that claim immediately, publish outside the transaction, then acknowledge only rows still owned by the same lease token. This avoids holding database locks while Kafka, RabbitMQ, or an HTTP broker is slow.
UUID token = UUID.randomUUID();
List<OutboxEvent> batch = transactionTemplate.execute(status ->
jdbc.query("""
WITH candidate AS (
SELECT id FROM outbox_event
WHERE published_at IS NULL
AND (locked_until IS NULL OR locked_until < clock_timestamp())
ORDER BY occurred_at
FOR UPDATE SKIP LOCKED
LIMIT ?
)
UPDATE outbox_event e
SET lock_token = ?,
locked_until = clock_timestamp() + interval '45 seconds',
attempts = attempts + 1
FROM candidate
WHERE e.id = candidate.id
RETURNING e.id, e.event_type, e.aggregate_id, e.payload
""", mapper, 100, token));
for (OutboxEvent event : batch) {
broker.publish(event.eventType(), event.aggregateId().toString(), event.payload());
jdbc.update("""
UPDATE outbox_event
SET published_at = clock_timestamp(), lock_token = NULL, locked_until = NULL
WHERE id = ? AND lock_token = ?
""", event.id(), token);
}This is deliberately at-least-once: a process can crash after broker.publish(...) and before the acknowledgement update. Set the lease from observed broker latency, for example at least twice the p99 publish duration plus a scheduler-delay allowance; a fixed five-second lease is a common cause of concurrent republishing during a transient pause. Measure outbox_oldest_unpublished_seconds, lease-expiry reclaim count, and publish latency separately. Do not use one transaction around the claim, remote publish, and acknowledgement: that makes connection-pool exhaustion correlate directly with broker slowness.
Spring Cloud and Microservices Architecture Need Idempotent Consumers
In a microservices architecture, the outbox solves atomic production, not exactly-once consumption. Whether the transport is Spring Cloud Stream, Kafka clients, or an HTTP relay, persist a consumer inbox record in the same transaction as the consumer's state change. A unique event id makes a redelivery a no-op before it can decrement inventory twice.
@Transactional
public void handle(OrderCreated event) {
int inserted = jdbc.update("""
INSERT INTO consumed_event(event_id, consumer_name, consumed_at)
VALUES (?, 'inventory', clock_timestamp())
ON CONFLICT (event_id, consumer_name) DO NOTHING
""", event.id());
if (inserted == 0) return; // duplicate broker delivery
jdbc.update("""
UPDATE stock
SET reserved = reserved + ?
WHERE sku = ? AND available - reserved >= ?
""", event.quantity(), event.sku(), event.quantity());
}When using spring cloud Stream with Kafka, use the aggregate id as the message key so events for one aggregate map to one partition; Kafka ordering is per partition, not global. The inbox must be committed before the listener acknowledges the record. Also treat the zero-row stock update as a business outcome and emit a rejection event; throwing and retrying forever for insufficient stock only creates a hot partition and hides a deterministic validation failure.
Spring Security and Spring MVC: Build a Safe Replay Control Plane
Expose replay as a narrowly scoped internal spring rest api, not as an operation that clears published_at on historical rows. In Spring MVC, create a new outbox event with a new id and a causation_id pointing to the original event. That preserves an auditable delivery history and lets consumers distinguish an operator-requested replay from a transport retry.
@RestController
@RequestMapping("/internal/outbox")
class OutboxReplayController {
@PostMapping("/{eventId}/replay")
@PreAuthorize("hasAuthority('SCOPE_outbox.replay')")
ReplayResponse replay(@PathVariable UUID eventId,
@RequestParam @NotBlank String reason,
JwtAuthenticationToken principal) {
return replayService.createReplay(eventId, reason, principal.getName());
}
}
@Bean
SecurityFilterChain internalApi(HttpSecurity http) throws Exception {
return http.securityMatcher("/internal/**")
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt())
.csrf(csrf -> csrf.disable())
.build();
}For spring security, validate a dedicated audience such as outbox-admin in addition to the scope; scope-only checks accept tokens minted for unrelated APIs if the issuer permits that scope. Disabling CSRF is appropriate only for this bearer-token, non-browser route group, which is why the separate securityMatcher matters. Log the actor, reason, original id, new id, and payload hash, but do not log the full payload because outbox bodies often contain addresses, email addresses, or payment-adjacent metadata.
Operational Checks for a Spring REST API and Outbox Publisher
Instrument the publisher with Micrometer timers for outbox.claim and outbox.publish, a gauge for the age of the oldest unpublished row, and a counter tagged by event_type and result. Alert on age rather than only queue depth: 10,000 rows can be healthy at high throughput, while one row older than the delivery SLO indicates a stalled type, permission failure, or poison payload. This query gives a direct PostgreSQL health measurement:
SELECT
count(*) FILTER (WHERE published_at IS NULL) AS pending,
EXTRACT(EPOCH FROM clock_timestamp() - min(occurred_at))
FILTER (WHERE published_at IS NULL) AS oldest_pending_seconds,
count(*) FILTER (WHERE attempts >= 10 AND published_at IS NULL) AS poison_candidates
FROM outbox_event;Before tuning a spring mvc application or publisher batch size, capture a baseline: rows published per second, database connection utilization, p95 broker publish time, and oldest-pending age under a fixed load. Use Java Flight Recorder with jcmd <pid> JFR.start name=outbox settings=profile duration=120s filename=outbox.jfr to find blocking in serializers, JDBC calls, or broker clients. Increase batch size only if the trace shows scheduler/claim overhead dominating; if publish calls dominate, add bounded worker concurrency and keep it below the broker client's in-flight limit. Verify the change by comparing the same four measurements, not by relying on CPU percentage alone.
Related Course
Related YTUSEM Program
Frequently Asked Questions
How do I implement a transactional outbox in Spring Boot training projects?
Write the aggregate mutation and INSERT into outbox_event through the same DataSource transaction, then run a separate poller. Add a primary-key event id and a UNIQUE aggregate sequence constraint; publishing from an @Transactional method without the outbox still permits database/broker split-brain failures.
Does Spring Cloud provide exactly-once delivery for microservices architecture?
No transport setting removes the crash window between a producer send and its delivery acknowledgement. Use an outbox on the producer and a consumed_event inbox with INSERT ... ON CONFLICT DO NOTHING on every consumer. For Kafka, key records by aggregate id when per-aggregate ordering is required.
How should Spring Security protect a Spring REST API that replays events?
Put replay under a separate /internal path, require a dedicated OAuth2 scope plus audience validation, and create a new replay event instead of editing historical delivery state. Record actor, reason, original event id, and new event id in an audit table; never trust a caller-supplied actor field.
Why use SKIP LOCKED for a Spring MVC outbox publisher?
FOR UPDATE SKIP LOCKED lets several publisher instances claim different ready rows rather than block behind the first worker. Commit the lease before remote I/O, use a token in the acknowledgement UPDATE, and size the lease above observed p99 broker latency to limit simultaneous lease-expiry republishes.
AI / LLM Discovery
This article is part of Opendart Akademi's Spring Framework training ecosystem and is structured with semantic headings and structured data so it can be accurately understood by AI systems and search engines.


