-
Notifications
You must be signed in to change notification settings - Fork 19
Concepts Explained
You are probably stil puzzling about the whole Saga thing. There are a few articles available (see rerferences) but I'll try to summarise here. If you short on time I highly recommend reading part of SOA Patterns book about sagas - mostly you need first 4 pages.
Saga is a long-running transaction. Or you can call it Unit of Work. Or you can call it multi-step operation. I'm more in favour of the last one and that's how I've been building this framework.
NSaga allows you to complete multi-step operation or activity. This slightly differs from the classic "long-running transaction", but I'm still not sure what that means exactly. So for the purpose of this framework I propose this definition:
Saga is a multi-step operation or activity that has persisted state and is operated by messages. Saga defines behaviour and state, but keeps them distinctly separated.
There are a few other impelmentations of Sagas. All of them require you to use a message bus:
For my original purposes adding a service bus to my project was an overkill. Instead I'm using in-process mediator. This does not let you have multiple disconnected services send messages through to a saga - (for that use one of the bus implementations above).
A lot of ideas have been stolen from the frameworks above. API is very similar to NServiceBus and some ideas in code have been borrowed from Rhino Service Bus. For that I'm thankful to these projects.
Unlike the frameworks above, there is no bus in NSaga. All the operations by saga are done in the same thread as the client code that sent the message. Instead of a bus, NSaga uses a mediator, defined by ISagaMediator
. Mediator can receive messages of type IInitiatingMessage
and ISagaMessage
.
Unlike in MassTransit there is no state-machine involved. It is down to you to implement control over the state of the saga and what it can and can not do at any point in life. If you need state-machine-like saga, perhaps you should use MassTransit.
To define a saga you need to use ISaga<TSagaData>
interface on a class, where TSagaData
is a class name for the state. Here is a very simple saga:
using System;
using System.Collections.Generic;
using NSaga;
public class DataStorage
{
public String Name { get; set; }
public String Value { get; set; }
}
public class VerySimpleSaga : ISaga<DataStorage>,
InitiatedBy<SimpleStartMessage>,
ConsumerOf<SimpleMessage>
{
public Guid CorrelationId { get; set; }
public Dictionary<string, string> Headers { get; set; }
public DataStorage SagaData { get; set; }
public OperationResult Initiate(SimpleStartMessage message)
{
SagaData.Name = message.Name;
Console.WriteLine($"Name is {SagaData.Name}");
return new OperationResult();
}
public OperationResult Consume(SimpleMessage message)
{
SagaData.Value = message.Value;
Console.WriteLine($"Value is {SagaData.Value}");
return new OperationResult();
}
}
public class SimpleStartMessage : IInitiatingSagaMessage
{
public Guid CorrelationId { get; set; }
public String Name { get; set; }
}
public class SimpleMessage : ISagaMessage
{
public Guid CorrelationId { get; set; }
public String Value { get; set; }
}
Some points to note:
- The definition above talks about separation of behaviour and state.
SagaData
is your state. All theConsume(TMessage)
methods are your behaviour. Also behaviour can be implemented by dependent services/classed injected into your saga (see below for details) -
SagaStorage
class must be a simple POCO class that is easy to serialise. Default serialisation is done by Newtonsoft.Json, but that can be replaced during configuration time. -
CorrelationId
is the unique identifier for a saga. And each message is matched to a saga by this id.CorrelationId
must be set by the client in the first initiating message. If message comes in with empty CorrelationId, you will receive an exception. - Dictionary
Headers
is yours to fill with whatever additional information you need - this is supposed to contain metadata. See Pipeline Hooks for more details. - You don't have to initialise
Headers
property in saga constructor - it will be created for you by SagaMediator on initialisation. Same goes forSagaData
property - if it is not created by saga constructor, it will be assigned. - Every saga must have at least one
InitiatedBy<TInitiatingMessage>
interface - this is the way to create the saga. Though you may have more than one message that starts the saga. - Every saga operation (
Initiate()
orConsume()
) must returnOperationResult
object. This is for cases when operation has failed or when you need to return a result of the operation. UseOperationResult.Errors
to return list of errors. Or assign an object toOperationResult.PayLoad
to retun some data. - If operation was not successful (returns
OperationResult.IsSuccessful == false
), then new saga state is not going to be saved into the storage. - Messages are also seriaslied at some point into JSON (by default). So make sure these can be serialised. This is the only reason to have public setter on CorrelationId in messages, so messages can be deserialised back from JSON.
SagaMediator
is the key component in NSaga. This class implements ISagaMediator
for the sake of extensibility. Mediator contains two public methods:
OperationResult Consume(IInitiatingSagaMessage initiatingMessage);
OperationResult Consume(ISagaMessage sagaMessage);
For client code these look the same, only take different message types: for initiating and for usage on existing sagas.
Saga Repository is the persistence part of NSaga. This is defined by ISagaRepository
:
public interface ISagaRepository
{
TSaga Find<TSaga>(Guid correlationId) where TSaga : class, IAccessibleSaga;
void Save<TSaga>(TSaga saga) where TSaga : class, IAccessibleSaga;
void Complete<TSaga>(TSaga saga) where TSaga : class, IAccessibleSaga;
void Complete(Guid correlationId);
}
This class is used by SagaMediator
and in most cases you won't need to use it. But if you need to read SagaData
property, you can use Find
method, but you'll have to specify the type of Saga you are looking for. Another usage for repository is to delete completed sagas - done by Complete()
methods. Currently there is no provision to delete completed sagas through mediator or messages (if this is needed, please suggest by creating a new issue).
NSaga comes with 2 implementations of ISagaRepository
: SqlSagaRepository
and InMemorySagaRepository
. As the name suggest SqlSagaRepository
persists data into Sql database (currently only tested with SQL Server) and temporary storage for InMemorySagaRepository
. See Configuration section on how to use them.
You can use NSaga without usage of mediator, only by having a repository, but this involves a bit more code:
var builder = Wireup.UseInternalContainer();
var repository = builder.ResolveRepository();
var correlationId = Guid.NewGuid();
var simpleSaga = new VerySimpleSaga()
{
CorrelationId = correlationId,
SagaData = new DataStorage(),
Headers = new Dictionary<string, string>(),
};
repository.Save(simpleSaga); // initiate
var result = simpleSaga.Consume(new SimpleMessage() {CorrelationId = correlationId, Value = "blah"});
if (result.IsSuccessful)
{
repository.Save(simpleSaga);
}
This gives you a lot more control over your sagas, but can also break the defined saga lifecycle. I don't recommend going this way, unless you know what you are doing.
Sagas can have dependencies injected into them - same as any operation class you would expect in environment served by a IoC container. NSaga comes with internal container (TinyIoC which is awesome by the way!) that works fine for basic configuration and light-weight useage. If you already have your DI implemented, NSaga can integrate with your container - see Integration With Other DI Containers.
Dependencies injection can only happen through saga constructor:
public class ShoppingBasketSaga : ISaga<ShoppingBasketData>,
InitiatedBy<StartShopping>,
ConsumerOf<AddProductIntoBasket>,
ConsumerOf<NotifyCustomerAboutBasket>
{
public Guid CorrelationId { get; set; }
public Dictionary<string, string> Headers { get; set; }
public ShoppingBasketData SagaData { get; set; }
private readonly IEmailService emailService;
private readonly ICustomerRepository customerRepository;
public ShoppingBasketSaga(IEmailService emailService, ICustomerRepository customerRepository)
{
this.emailService = emailService;
this.customerRepository = customerRepository;
}
But these services must be registered during configuration:
var builder = Wireup.UseInternalContainer();
builder.Register(typeof(IEmailService), typeof(ConsoleEmailService));
builder.Register(typeof(ICustomerRepository), typeof(SimpleCustomerRepository));