A producer call can return before broker acknowledgment; an asynchronous send failure needs its own observation and retry decision.
Spring Cloud Stream producer failure: a sent message is not an accepted business command
Separate local acceptance from broker acknowledgment
A parcel API that updates a database and then emits an event has two resources. A successful local function call does not prove the broker stored the event. Some binder paths report asynchronous producer failures through an error channel rather than the caller's stack. A producer failure handler can record the failed message, but it cannot retroactively roll back a committed database transaction.
Use a replayable handoff
Write the event to an outbox in the same database transaction as the parcel change, then relay with a stable event ID. Producer completion decides when the relay may acknowledge the outbox row. If acknowledgment is lost after broker acceptance, the relay may resend; consumers still need deduplication.
Test the ambiguous window
Make the broker accept an event and drop the producer acknowledgment, then restart the relay. Verify one business effect after the duplicate event arrives. Also force a definite producer failure and confirm the outbox row remains retryable. A function-level test without a broker cannot cover this window.
Implementation sketch
@Transactional
public void recordParcel(ParcelAccepted command) {
parcels.insert(command.parcelId(), command.units());
outbox.insert(command.eventId(), command.parcelId(), "PARCEL_ACCEPTED");
}Cost and verification
The outbox adds a database write and relay latency. It replaces an unobservable dual-write gap with retained work, retry cost and explicit reconciliation.
Common Mistakes
- Do not treat a successful producer method return as durable broker acceptance.
- Do not acknowledge an outbox row before the broker result is known.
- Do not claim exactly-once delivery from a producer callback alone.
Read next
Spring Kafka producer future: observe broker send completion separately from command success, Spring transactional outbox: commit a receipt and event row together, Spring outbox transaction boundaries: where atomicity ends, Spring Cloud Stream consumer group: scale one logical reader without copying every event.
