Sagas in .NET with NServiceBus are not about eventual consistency; they are about managed consistency across distributed operations.
Let’s watch a saga in action. Imagine an order processing system. A customer places an order, which triggers an OrderPlaced event. This event is handled by our OrderSaga, which is responsible for orchestrating the entire order lifecycle.
public class OrderSaga : Saga<OrderSagaData>,
IAmStartedBy<OrderPlaced>,
IHandleMessages<PaymentProcessed>,
IHandleMessages<ShippingLabelCreated>
{
public override async Task Handle(OrderPlaced message, IMessageHandlerContext context)
{
// Persist the order details and mark the saga as started
Data.OrderId = message.OrderId;
Data.CustomerId = message.CustomerId;
Data.OrderState = OrderState.OrderReceived;
// Initiate payment processing
await context.SendLocal(new ProcessPaymentCommand
{
OrderId = message.OrderId,
Amount = message.OrderTotal
});
// Mark saga as completed when the saga is finished (no more messages to handle)
// This is not strictly required for sagas, but good practice for resource management
MarkAsComplete();
}
public async Task Handle(PaymentProcessed message, IMessageHandlerContext context)
{
if (Data.OrderState == OrderState.OrderReceived)
{
Data.OrderState = OrderState.PaymentConfirmed;
// Initiate shipping label creation
await context.SendLocal(new CreateShippingLabelCommand
{
OrderId = message.OrderId,
ShippingAddress = Data.ShippingAddress // Assuming this was captured earlier
});
}
}
public async Task Handle(ShippingLabelCreated message, IMessageHandlerContext context)
{
if (Data.OrderState == OrderState.PaymentConfirmed)
{
Data.OrderState = OrderState.Shipped;
// Order is now considered complete
Console.WriteLine($"Order {Data.OrderId} has been successfully processed and shipped.");
MarkAsComplete(); // This will trigger saga completion
}
}
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<OrderSagaData> mapper)
{
mapper.MapSaga(sagaData => sagaData.OrderId)
.ToMessage<OrderPlaced>(message => message.OrderId)
.ToMessage<PaymentProcessed>(message => message.OrderId)
.ToMessage<ShippingLabelCreated>(message => message.OrderId);
}
}
public class OrderSagaData : ContainSagaData
{
public Guid OrderId { get; set; }
public Guid CustomerId { get; set; }
public string ShippingAddress { get; set; }
public OrderState OrderState { get; set; }
}
public enum OrderState
{
OrderReceived,
PaymentConfirmed,
Shipped
}
This saga starts when an OrderPlaced event arrives. It then sends a ProcessPaymentCommand. Once PaymentProcessed is handled, it sends a CreateShippingLabelCommand. Finally, when ShippingLabelCreated arrives, the saga marks itself as complete.
The core problem sagas solve is managing state and coordinating a series of independent, often asynchronous, operations that must succeed or fail together as a single business transaction. Without sagas, you’d be drowning in distributed transactions, compensating actions, and complex error handling logic scattered across multiple services. Sagas provide a centralized, declarative way to define and manage these multi-step processes.
Internally, NServiceBus uses a persistent saga data store (like SQL Server, RavenDB, or others) to hold the state of each saga instance. When a message arrives, NServiceBus finds the correct saga instance based on the ConfigureHowToFindSaga mapping, loads its state, and invokes the appropriate Handle method. After the handler runs, the saga’s state is persisted. If MarkAsComplete() is called, NServiceBus removes the saga data from the store.
The exact levers you control are:
- Saga Data: The
OrderSagaDataclass defines what state needs to be tracked for each saga instance. This is crucial for resuming the workflow correctly. - Message Handlers: The
IHandleMessagesinterfaces and their correspondingHandlemethods define the steps of your workflow. - Saga Initiation: The
IAmStartedByinterface defines which message type kicks off a new saga instance. - Saga Correlation: The
ConfigureHowToFindSagamethod is the heart of correlation. It tells NServiceBus how to link incoming messages to an existing saga instance using properties from the saga data and the message. - State Transitions: The logic within your
Handlemethods dictates how theOrderState(or whatever state you track) changes based on received messages. - Outgoing Commands/Events: The
context.SendLocalorcontext.Publishcalls are how the saga orchestrates other parts of the system. - Saga Completion:
MarkAsComplete()signals that the workflow has finished and the saga instance can be garbage collected from the persistence store.
A common pitfall is forgetting to configure the ConfigureHowToFindSaga mapping for all messages that should correlate to the saga. If a message arrives that NServiceBus cannot map to an existing saga instance based on your configuration, it will treat it as a new, unrelated message, potentially leading to duplicate operations or workflow failures. NServiceBus will also try to start a new saga if the incoming message matches an IAmStartedBy handler and no existing saga is found.
The next concept you’ll likely grapple with is handling failures and implementing compensating actions within your sagas.