Idempotent message processing is the key to preventing duplicate transactions in a saga, even if a message gets delivered multiple times.
Let’s say you have a CreateOrder saga. It starts with an OrderCreated event.
{
"type": "OrderCreated",
"orderId": "ORD123",
"customerId": "CUST456",
"items": [
{"productId": "PROD789", "quantity": 2}
]
}
When this event arrives, the saga needs to perform several actions: reserve inventory, process payment, and send a confirmation. If the OrderCreated message is delivered twice (maybe due to a network glitch or a retry mechanism), you don’t want to reserve inventory twice or charge the customer twice.
Here’s how the saga handles the OrderCreated event. The isDuplicate check is crucial.
public class CreateOrderSaga :
Saga<CreateOrderState>,
IHandleMessages<OrderCreated>,
IHandleMessages<InventoryReservedEvent>,
IHandleMessages<PaymentProcessedEvent>
{
public override Task Handle(OrderCreated message, IMessageHandlerContext context)
{
// This is the critical part for duplicate detection
if (IsDuplicate(message.MessageId))
{
return Task.CompletedTask; // Simply ignore if it's a duplicate
}
// Mark message as processed to prevent future duplicates
MarkAsProcessed(message.MessageId);
// 1. Reserve Inventory
return context.SendLocal(new ReserveInventoryCommand
{
OrderId = message.OrderId,
Items = message.Items
});
}
// ... other handlers for InventoryReservedEvent and PaymentProcessedEvent
}
The IsDuplicate and MarkAsProcessed methods are the magic. They rely on a persistent store (like a database table or a dedicated cache) to keep track of message IDs that have already been successfully processed by this specific saga instance.
Here’s a simplified look at what IsDuplicate and MarkAsProcessed might do behind the scenes. They interact with a MessageIdempotencyStore.
// Inside the Saga base class or a dedicated service
private readonly IMessageIdempotencyStore _idempotencyStore;
private bool IsDuplicate(Guid messageId)
{
// Atomically check if the message ID exists in the store.
// This operation *must* be atomic to avoid race conditions.
return _idempotencyStore.Exists(messageId);
}
private void MarkAsProcessed(Guid messageId)
{
// Atomically insert the message ID into the store.
// If it already exists, this operation should either succeed
// (if the store handles unique constraints) or be ignored.
_idempotencyStore.Add(messageId);
}
The IMessageIdempotencyStore is typically implemented using a database table with a unique constraint on the MessageId column. When MarkAsProcessed is called, it attempts to insert the messageId. If the messageId already exists, the database will throw a unique constraint violation, but the transaction can be considered successful from the saga’s perspective because the message has, in fact, already been processed.
This ensures that even if the OrderCreated message arrives multiple times, only the first one triggers the ReserveInventoryCommand. Subsequent identical messages are detected as duplicates and silently dropped.
The most surprising true thing about saga duplicate detection is that the "duplicate" check isn’t just about the message content but about the unique MessageId that the messaging system (like NServiceBus, Kafka, etc.) assigns to each message when it’s sent. This MessageId is what your saga uses to track idempotency.
To see this in action, imagine a real-time scenario.
- Client sends order: A client application sends an order request. The backend generates a
CreateOrderCommandwith a uniqueCommandId. - OrderCreated published: The
CreateOrderSagareceives the command, starts, and publishes anOrderCreatedevent. This event also has its own uniqueMessageId(often derived from the command’sCommandIdor a new GUID). - Network hiccup: The
OrderCreatedevent is published, but before all consumers acknowledge it, the network connection drops. The publisher, assuming it failed, retries sending the exact sameOrderCreatedevent, including its originalMessageId. - First arrival: The
CreateOrderSagareceives theOrderCreatedevent for the first time. It checks its idempotency store, finds no record of thisMessageId, marks it as processed, and sendsReserveInventoryCommand. - Second arrival: The retried
OrderCreatedevent arrives. TheCreateOrderSagachecks its idempotency store, finds theMessageIdalready exists, and immediately returns, doing nothing further. TheReserveInventoryCommandis not sent again.
The core problem this solves is ensuring that distributed transactions, which are inherently unreliable due to network issues and component failures, behave like atomic transactions from the business perspective. You can’t have half an order processed. Idempotency is the mechanism that allows your saga to be resilient to transient message delivery failures without causing data corruption or unintended side effects.
A common pitfall is relying on message content for idempotency checks. If two different orders happen to have identical item lists and customer IDs, and you only check content, you’d incorrectly flag the second as a duplicate. Using the unique MessageId assigned by the transport layer is the correct, robust approach.
The next logical problem you’ll encounter is handling sagas that fail after a message has been marked as processed but before the entire saga instance has completed its work.