How to Implement the Saga Pattern in ASP.NET Core 10 Using MassTransit and RabbitMQ (2026)
A saga coordinates business steps that commit in separate services. When a later step fails, the workflow issues a compensating action such as a refund. In this lab a MassTransit state machine records order progress, uses RabbitMQ for messages, and persists the saga in PostgreSQL. The demo models payment and inventory outcomes without moving money or reserving real stock.
What does the saga own?
The saga owns coordination state. It does not own the payment provider’s ledger. Each service commits its own effect and reports an outcome. The order ID is carried on each message so RabbitMQ can deliver it and MassTransit can find the right saga instance.
Prepare packages and storage
Use .NET 10, a private RabbitMQ instance and a PostgreSQL database. Start with the aligned MassTransit 8.x RabbitMQ and EntityFrameworkCore packages, plus the compatible Npgsql EF provider and EF design package. Check the exact project references in the companion source. Verify the support and licensing terms for the MassTransit major you deploy. The v9 line has a different licensing model. MassTransit source and license · Saga configuration
Create the saga and outbox tables with an EF migration. Set ConnectionStrings__Saga, Rabbit__Host, Rabbit__User and Rabbit__Password through environment configuration or a secret store. The guest credentials in the lab assume direct localhost RabbitMQ access. Bind the lab API to loopback. Production commands must have authenticated producers and authorization against the relevant order.
Pin a compatible package set and create the schema
For the .NET 10 build shown here, MassTransit 8.5.12 uses EF Core 10 dependencies. Use the EF Core 10 line of the Npgsql provider. A 9.x provider fails restore when paired with this MassTransit build. The following commands assume a project named SagaApi and a configured development connection string.
After replacing Program.cs and setting connection configuration, run ./.tools/dotnet-ef migrations add InitialSaga --context OrderDb, review the generated migration, and apply it with ./.tools/dotnet-ef database update --context OrderDb against the development database. Start RabbitMQ before starting the API. In production, apply the migration before consumers begin processing. Preserve history and keep secrets out of the migration command line.
Implement the state machine
The endpoint outbox records incoming message handling and buffers outgoing messages with the saga persistence transaction. Pessimistic locking serializes work on an existing saga row in this configuration. Retries cover bounded transient failures. A handler for RefundRequested still needs to use a stable provider idempotency key and publish Refunded only after checking the real provider outcome. The sample’s refunded endpoint stands in for that handler.
Walk through both outcomes
Use one new UUID for each test. POST start, poll GET until AwaitingPayment, POST paid, then poll until AwaitingStock. POST stock with JSON {“reserved”:true} and expect Completed. Repeat with another UUID and {“reserved”:false}. Expect Refunding. In the lab, POST refunded and wait for RefundedState. An accepted HTTP response means a message was published, so poll rather than assuming the transition has happened.
| Situation | Recovery decision |
|---|---|
| Same start or payment signal redelivered | Existing states ignore the specified duplicates. |
| Order remains in Refunding | Reconcile provider state before resending a refund command. |
| Unexpected or out-of-order signal | Investigate skipped or error messages and enforce producer sequencing or a deliberate buffering strategy. |
| RabbitMQ unavailable | Surface failure at enqueue or retry with a stable operation identifier. |
Trace a missing or duplicate refund
Start with the correlation ID and read the saga row. If it never reached Refunding, inspect the stock decision and its delivery. If it reached Refunding but the downstream provider has no matching operation, inspect the outbox and the refund queue before resending. If the provider reports success but the row still says Refunding, reconcile and emit the missing outcome. Use the same operation key throughout to avoid a second refund.
| Fault injection | Check |
|---|---|
| Stop the saga between payment and stock | A restarted worker loads the existing order state |
| Deliver the same payment event again | The chosen duplicate path leaves the state consistent |
| Reject stock after payment | The saga emits one logical refund request |
| Let the refund provider time out after committing | Reconciliation finds the original provider outcome using the stable key |
| Deliver a stock event before the order exists | The system records and investigates the missing instance or follows its explicit replay policy |
Retry intervals are for short transient handling failures. Delayed retries, operator recovery and reconciliation are separate choices. Route poison messages to review. Retain enough state to diagnose failures without storing full payment details in messages. During a deployment, test the compatibility of both the persisted state names and the event contracts.
Production boundaries and FAQs
Does compensation undo a database transaction? A refund is a new business effect. It can fail independently and needs its own audit trail.
Can the workflow run after a process restart? The database persists workflow state. Configure durable queues and verify actual restart recovery before relying on it.
Why keep final state? Retaining a terminal record helps diagnose outcomes and recognize later duplicates. Establish a retention policy and a deduplication horizon before removing records.
The demo drives outcomes in order. Add fault handling, an escalation deadline, and reconciliation jobs before exposing a real order process. See the transactional outbox tutorial for the separate problem of committing an application change and its pending event.
Comments
Post a Comment