Why Did We Process the Same Message Twice?
The producer published one event. The consumer handled it—and then handled it again.
The acknowledgement gap
At-least-once delivery chooses a useful failure mode: losing fewer messages at the cost of sometimes repeating them.
The broker cannot prove that the database commit happened. It only knows that no acknowledgement arrived.
Make the effect idempotent
Store the stable message identifier in the same transaction as the business change. A repeated delivery becomes a lookup instead of a repeated effect.
await using var transaction = await db.Database.BeginTransactionAsync();
if (await db.ProcessedMessages.AnyAsync(x => x.Id == message.Id))
return;
db.Shipments.Add(Shipment.From(message));
db.ProcessedMessages.Add(new ProcessedMessage(message.Id));
await db.SaveChangesAsync();
await transaction.CommitAsync();What “exactly once” really means
Exactly-once features usually cover a defined boundary, not every external side effect. An email provider and your database do not automatically share the broker transaction.
The practical rule
Use stable message IDs, make database effects idempotent, and acknowledge only after durable work completes.