The Challenge of Distributed Transactions
In a monolithic system, transactions are typically handled using ACID (Atomicity, Consistency, Isolation, Durability) properties with a single database. However, in a distributed system, traditional approaches like Two-Phase Commit (2PC) become impractical due to:
- Performance bottlenecks (locking resources across services).
- Tight coupling between services.
- Partial failures leading to inconsistent states.
To solve this, the Saga Pattern was introduced as an alternative way to manage long-running, distributed transactions.
What is the Saga Pattern?
A Saga is a sequence of local transactions where each step updates data within a single service. If a step fails, compensating actions (rollback operations) are executed to undo previous changes, ensuring eventual consistency rather than immediate atomicity.
There are two primary approaches to implementing Sagas:
- Event Choreography – A decentralized approach where services communicate via events.
- Saga Orchestration – A centralized approach where a coordinator manages the transaction flow.
What is Event Choreography?
Event Choreography is a decentralized approach where services communicate via events to coordinate a distributed transaction. Instead of relying on a central orchestrator, each service:
- Publishes events when it completes its local transaction.
- Listens for events from other services to trigger subsequent steps.
- Executes compensating actions (if needed) in case of failures.
This approach promotes loose coupling, as services only need to know about the events they produce or consume, not the entire workflow.
How Event Choreography Works
- Service A executes its local transaction and emits an event (e.g.,
OrderCreated
).
- Service B subscribes to this event, processes it, and emits its own event (e.g.,
PaymentProcessed
).
- Service C reacts to the new event and performs its part (e.g.,
InventoryUpdated
).
- If any step fails, compensating events (e.g.,
OrderCancelled
) are triggered to revert changes.
Advantages of Event Choreography
Decentralized – No single point of failure.
Scalable – Services evolve independently.
Flexible – New services can subscribe to events without modifying existing workflows.
Disadvantages of Event Choreography
Complex Debugging – Tracing event flows can be challenging.
Potential Cyclic Dependencies – Poorly designed events may create circular triggers.
Visualizing the Compensation Flow

Example: Event Choreography in .NET
Let’s implement a simple Order Processing saga using Event Choreography
with .NET and MassTransit.
Scenario:
Let’s Imagine this flow for creating a order and if we don’t have any issue order will create and if any of flow has a problem whole of flow will rollback.
- Order Service creates an order.
- Payment Service processes payment.
- Inventory Service reserves stock.
- If any step fails, compensating actions are triggered.
Implement Order Service (Publisher) and Compensation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
public class OrderService
{
private readonly IPublishEndpoint _publishEndpoint;
public OrderService(IPublishEndpoint publishEndpoint)
{
_publishEndpoint = publishEndpoint;
}
public async Task CreateOrder(Order order)
{
try
{
order.Status = "Pending";
await _dbContext.Orders.AddAsync(order);
await _dbContext.SaveChangesAsync();
await _publishEndpoint.Publish(new OrderCreated(
order.Id,
order.TotalAmount,
order.UserId));
}
catch
{
await _publishEndpoint.Publish(new OrderCancelled(
order.Id,
"Order creation failed"));
}
}
public class OrderCancellationConsumer : IConsumer<OrderCancelled>
{
public async Task Consume(ConsumeContext<OrderCancelled> context)
{
var order = await _dbContext.Orders.FindAsync(context.Message.OrderId);
if (order != null && order.Status != "Cancelled")
{
order.Status = "Cancelled";
order.CancellationReason = context.Message.Reason;
await _dbContext.SaveChangesAsync();
_logger.LogInformation($"Order {order.Id} cancelled: {context.Message.Reason}");
}
}
}
}
|
Implement Payment Service (Consumer) and Compensation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
public class PaymentConsumer : IConsumer<OrderCreated>
{
public async Task Consume(ConsumeContext<OrderCreated> context)
{
try
{
var (success, transactionId) = await _paymentGateway.Charge(
context.Message.Amount,
context.Message.UserId);
await context.Publish(new PaymentProcessed(
context.Message.OrderId,
success,
transactionId));
}
catch
{
await context.Publish(new OrderCancelled(
context.Message.OrderId,
"Payment processing failed"));
}
}
}
public class PaymentCompensationConsumer : IConsumer<OrderCancelled>
{
public async Task Consume(ConsumeContext<OrderCancelled> context)
{
var payment = await _db.Payments
.FirstOrDefaultAsync(p => p.OrderId == context.Message.OrderId);
if (payment != null)
{
await _paymentGateway.Refund(payment.TransactionId);
await context.Publish(new PaymentRefunded(
context.Message.OrderId,
payment.Amount));
}
}
}
|
Implement Inventory Service (Consumer) and Compensation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
public class InventoryConsumer : IConsumer<PaymentProcessed>
{
public async Task Consume(ConsumeContext<PaymentProcessed> context)
{
if (!context.Message.Success)
{
await context.Publish(new OrderCancelled(
context.Message.OrderId,
"Payment failed"));
return;
}
try
{
var reservationSuccess = await _inventoryService
.ReserveItems(context.Message.OrderId);
await context.Publish(new InventoryReserved(
context.Message.OrderId,
reservationSuccess,
_inventoryService.ReservedProductIds));
}
catch
{
await context.Publish(new OrderCancelled(
context.Message.OrderId,
"Inventory reservation failed"));
}
}
}
public class InventoryCompensationConsumer : IConsumer<OrderCancelled>
{
public async Task Consume(ConsumeContext<OrderCancelled> context)
{
var reservedItems = await _db.InventoryLocks
.Where(i => i.OrderId == context.Message.OrderId)
.ToListAsync();
if (reservedItems.Any())
{
await _inventoryService.ReleaseItems(reservedItems);
await context.Publish(new InventoryReleased(
context.Message.OrderId,
reservedItems.Select(i => i.ProductId).ToList()));
}
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
services.AddMassTransit(x =>
{
x.AddConsumer<OrderCancellationConsumer>();
x.AddConsumer<PaymentConsumer>();
x.AddConsumer<InventoryConsumer>();
x.AddConsumer<PaymentCompensationConsumer>();
x.AddConsumer<InventoryCompensationConsumer>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost");
cfg.ReceiveEndpoint("order-service", e =>
{
e.Consumer<OrderCancellationConsumer>();
});
cfg.ReceiveEndpoint("payment-service", e =>
{
e.Consumer<PaymentConsumer>();
e.Consumer<PaymentCompensationConsumer>();
});
cfg.ReceiveEndpoint("inventory-service", e =>
{
e.Consumer<InventoryConsumer>();
e.Consumer<InventoryCompensationConsumer>();
});
});
});
|
What is Saga Orchestration?
Saga Orchestration is a centralized approach where a dedicated orchestrator manages the entire distributed transaction. Instead of services communicating directly via events, the orchestrator:
- Sequentially invokes each service.
- Tracks progress and handles failures.
- Triggers compensating transactions if any step fails.
This approach provides better control over the workflow, making it easier to monitor and debug compared to Event Choreography.
How Saga Orchestration Works
- Orchestrator sends a command to Service A (e.g.,
CreateOrder
).
- If successful, it proceeds to Service B (e.g.,
ProcessPayment
).
- If any step fails, the orchestrator executes compensating actions (e.g.,
CancelOrder
).
- The process continues until all steps succeed or all compensations complete.
Advantages of Saga Orchestration
Centralized Control – Easier to track transaction state.
Simpler Debugging – Single point of logging and monitoring.
Avoids Cyclic Dependencies – Explicit workflow definition.
Disadvantages of Saga Orchestration
Single Point of Failure – Orchestrator downtime affects transactions.
Tighter Coupling – Services must conform to the orchestrator’s commands.
Visualizing the Orchestration Flow

Example: Saga Orchestration in .NET
Let’s implement the same Order Processing saga using Saga Orchestration
with .NET and MassTransit.
Scenario:
- Orchestrator initiates the order flow.
- Order Service creates an order.
- Payment Service processes payment.
- Inventory Service reserves stock.
- If any step fails, compensations execute in reverse order.
Implement the Saga Orchestrator
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
|
public class OrderSagaState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; }
public int OrderId { get; set; }
public bool PaymentProcessed { get; set; }
public bool InventoryReserved { get; set; }
}
public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
// States
public State CreatingOrder { get; }
public State ProcessingPayment { get; }
public State ReservingInventory { get; }
public State Completed { get; }
public State Compensating { get; }
// Events
public Event<OrderCreated> OrderCreated { get; }
public Event<PaymentProcessed> PaymentProcessed { get; }
public Event<InventoryReserved> InventoryReserved { get; }
public Event<OrderCancelled> OrderCancelled { get; }
public OrderSaga()
{
InstanceState(x => x.CurrentState);
// Define the workflow
Initially(
When(OrderCreated)
.Then(ctx => ctx.Saga.OrderId = ctx.Message.OrderId)
.Send(ctx => new ProcessPayment(ctx.Saga.OrderId))
.TransitionTo(ProcessingPayment)
);
During(ProcessingPayment,
When(PaymentProcessed)
.Then(ctx => ctx.Saga.PaymentProcessed = ctx.Message.Success)
.If(ctx => ctx.Message.Success,
then => then.Send(ctx => new ReserveInventory(ctx.Saga.OrderId))
.TransitionTo(ReservingInventory),
else => then.Publish(ctx => new OrderCancelled(ctx.Saga.OrderId, "Payment failed")))
);
During(ReservingInventory,
When(InventoryReserved)
.Then(ctx => ctx.Saga.InventoryReserved = ctx.Message.Success)
.If(ctx => ctx.Message.Success,
then => then.TransitionTo(Completed),
else => then.Publish(ctx => new OrderCancelled(ctx.Saga.OrderId, "Inventory failed")))
);
// Compensation Flow
DuringAny(
When(OrderCancelled)
.If(ctx => ctx.Saga.InventoryReserved,
then => then.Send(ctx => new ReleaseInventory(ctx.Saga.OrderId)))
.If(ctx => ctx.Saga.PaymentProcessed,
then => then.Send(ctx => new RefundPayment(ctx.Saga.OrderId)))
.TransitionTo(Compensating)
.Finalize()
);
}
}
|
Implement Order Service (Publisher) and Compensation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
public class OrderCommandHandler : IConsumer<CreateOrder>
{
public async Task Consume(ConsumeContext<CreateOrder> context)
{
try
{
var order = new Order { Id = context.Message.OrderId };
await _db.Orders.AddAsync(order);
await _db.SaveChangesAsync();
await context.Publish(new OrderCreated(order.Id, order.TotalAmount));
}
catch
{
await context.Publish(new OrderCancelled(
context.Message.OrderId,
"Order creation failed"));
}
}
}
public class OrderCompensationHandler : IConsumer<CancelOrder>
{
public async Task Consume(ConsumeContext<CancelOrder> context)
{
var order = await _db.Orders.FindAsync(context.Message.OrderId);
if (order != null)
{
order.Status = "Cancelled";
await _db.SaveChangesAsync();
}
}
}
|
Implement Payment Service (Consumer) and Compensation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
public class PaymentCommandHandler : IConsumer<ProcessPayment>
{
public async Task Consume(ConsumeContext<ProcessPayment> context)
{
try
{
var success = await _paymentGateway.Charge(context.Message.Amount);
await context.Publish(new PaymentProcessed(
context.Message.OrderId,
success));
}
catch
{
await context.Publish(new OrderCancelled(
context.Message.OrderId,
"Payment failed"));
}
}
}
public class PaymentCompensationHandler : IConsumer<RefundPayment>
{
public async Task Consume(ConsumeContext<RefundPayment> context)
{
await _paymentGateway.Refund(context.Message.OrderId);
await context.Publish(new PaymentRefunded(context.Message.OrderId));
}
}
|
Implement Inventory Service (Consumer) and Compensation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
public class InventoryCommandHandler : IConsumer<ReserveInventory>
{
public async Task Consume(ConsumeContext<ReserveInventory> context)
{
try
{
var reserved = await _inventoryService.Reserve(context.Message.OrderId);
await context.Publish(new InventoryReserved(
context.Message.OrderId,
reserved));
}
catch
{
await context.Publish(new OrderCancelled(
context.Message.OrderId,
"Inventory failed"));
}
}
}
public class InventoryCompensationHandler : IConsumer<ReleaseInventory>
{
public async Task Consume(ConsumeContext<ReleaseInventory> context)
{
await _inventoryService.Release(context.Message.OrderId);
await context.Publish(new InventoryReleased(context.Message.OrderId));
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
services.AddMassTransit(x =>
{
// Saga Repository (e.g., Redis, SQL Server)
x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
.RedisRepository();
x.AddConsumer<OrderCommandHandler>();
x.AddConsumer<OrderCompensationHandler>();
x.AddConsumer<PaymentCommandHandler>();
x.AddConsumer<PaymentCompensationHandler>();
x.AddConsumer<InventoryCommandHandler>();
x.AddConsumer<InventoryCompensationHandler>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host("localhost");
cfg.ReceiveEndpoint("order-saga", e =>
{
e.StateMachineSaga<OrderSaga>(context);
});
cfg.ReceiveEndpoint("order-service", e =>
{
e.Consumer<OrderCommandHandler>(context);
e.Consumer<OrderCompensationHandler>(context);
});
cfg.ReceiveEndpoint("payment-service", e =>
{
e.Consumer<PaymentCommandHandler>(context);
e.Consumer<PaymentCompensationHandler>(context);
});
cfg.ReceiveEndpoint("inventory-service", e =>
{
e.Consumer<InventoryCommandHandler>(context);
e.Consumer<InventoryCompensationHandler>(context);
});
});
});
|
Key Differences Between Event Choreography and Saga Orchestration
When choosing between Event Choreography and Saga Orchestration, it’s essential to understand their trade-offs in terms of complexity, scalability, and maintainability. Below is a detailed comparison:
Comparison Table: Event Choreography vs. Saga Orchestration
Feature |
Event Choreography |
Saga Orchestration |
Coordination Approach |
Decentralized (Services communicate via events) |
Centralized (Orchestrator manages flow) |
Coupling |
Loose (Services only know about events) |
Tighter (Services must follow orchestrator commands) |
Complexity |
Higher (Harder to debug event chains) |
Lower (Explicit workflow definition) |
Scalability |
Better (No single bottleneck) |
Limited (Orchestrator can become a bottleneck) |
Failure Handling |
Distributed (Each service handles its compensation) |
Centralized (Orchestrator triggers compensations) |
Debugging & Monitoring |
Challenging (Event flows are harder to trace) |
Easier (Single point of control) |
Cyclic Dependencies Risk |
Possible (Poorly designed events can cause cycles) |
Avoided (Orchestrator enforces linear flow) |
Best For |
Highly decoupled systems, event-driven architectures |
Complex workflows needing strict control |
When to Use Event Choreography?
Loose Coupling – Services evolve independently.
Event-Driven Systems – Natural fit for Kafka, RabbitMQ, or Azure Event Grid.
Scalability – No central coordinator bottleneck.
Flexibility – New services can subscribe to events without modifying workflows.
Use Cases:
- Order Processing (e.g., e-commerce checkout).
- Real-Time Notifications (e.g., stock updates triggering alerts).
- Asynchronous Workflows (e.g., background job processing).
When to Use Saga Orchestration?
Controlled Workflows – Explicit step-by-step execution.
Easier Debugging – Centralized logging and monitoring.
Avoiding Cyclic Dependencies – Orchestrator enforces order.
Transactional Integrity – Better for complex rollback logic.
Use Cases:
- Travel Booking (flight + hotel + payment in sequence).
- Banking Transactions (multi-step money transfers).
- Supply Chain Management (order → shipment → delivery tracking).
Conclusion
Both Event Choreography and Saga Orchestration provide effective ways to manage distributed transactions in microservices architectures, but they cater to different needs:
-
Use Event Choreography when you prioritize scalability, loose coupling, and event-driven flexibility. It’s ideal for systems where services need to react to events independently, such as real-time notifications or asynchronous workflows.
-
Use Saga Orchestration when you need strict control, easier debugging, and explicit transaction flows. It’s better suited for complex, multi-step processes where centralized coordination ensures reliability, such as financial transactions or travel bookings.
Final Recommendation
- For dynamic, evolving systems → Event Choreography (decoupled & scalable).
- For structured, mission-critical workflows → Saga Orchestration (controlled & traceable).
In some cases, a hybrid approach (combining both patterns) may be optimal—using choreography for event-driven interactions and orchestration for critical transactional sequences.
By understanding these trade-offs, you can design resilient, maintainable distributed systems that balance flexibility and control.