Skip to content

Saga State Machine

A Saga State Machine is what we will use to handle distributed transactions. MassTransit official documentation on the topic.

We will cover how to declare State Machines on a Suite application and how to use EFCore for persistence.

The Saga State will be persisted on the database. It's recommended you extend from BaseSagaStateMachineInstance, however, you may just implement the interface SagaStateMachineInstance instead.

Infrastructure

C#
1
2
3
4
internal class AssigneeEntity : BaseSagaStateMachineInstance
{
    public virtual string? Name { get; set; }
}

Now, we mention the State is persisted, and we'll use EFCore for that. As usual, we'll need a DbContext and IEntityTypeConfiguration for our entity.

The DbContext:

C#
1
2
3
4
5
6
7
8
[DefaultSchemaName("ServiceOrder")]
internal class ServiceOrderDbContext: SuiteDbContext<ServiceOrderDbContext>
{
    public ServiceOrderDbContext(DbContextOptions<ServiceOrderDbContext> contextOptions)
        : base(contextOptions)
    {
    }
}

The IEntityTypeConfiguration for our entity:

C#
internal class AssigneeEntityConfiguration :
        IEntityTypeConfiguration<ServiceOrderDbContext, AssigneeEntity>
    {
        public void Configure(EntityTypeBuilder<AssigneeEntity> builder)
        {
            builder.ToTable("ServiceOrderAssignees");

            builder.Property(x => x.Name)
                .IsRequired(false);
        }
    }

Note how we are not configuring the base class' fields, like the CorrelationId. The MassTransit Module will take care of that for us automatically.

To finalize the Infrastructure setup, we need to explicitly tell MassTransit to use EFCore for our entity. This will probably be done automatically later on.

C#
internal class SampleSuiteModule : SuiteModule
{
    public override void SetupModule(IModuleBuilder builder)
    {
        base.SetupModule(builder);

        // Configure MassTransit
        builder.DependsOn<MassTransitModule, MassTransitModuleOptions>(ops=>{
            ops.ConfigureMassTransit = c =>
                {]
                    // Register the Saga Entity to use EFCore.
                    c.AddSagaRepository<AssigneeEntity>()
                        .WithSuiteEntityFrameworkCore<AssigneeEntity, MyAppDbContext>();
                };
        });

        // Configure EFCore accordingly.
        builder.DependsOn<EntityFrameworkCoreModule>();
        builder.DependsOn<EntityFrameworkCoreSqlServerModule>();
    }
}

Saga Include Specification

If our Saga has relations, we will need to .Include them. In order to do that we can use an IncludeSpecification. First we define the specification:

C#
1
2
3
4
5
6
7
8
9
internal class AssigneeEntityIncludeSpecification
    : IncludeSpecification<AssigneeEntity>
{
    public AssigneeEntityIncludeSpecification()
    {
        this.Include(s => s.SomeRelation);
        this.Include(s => s.OtherRelation);
    }
}

Then, when we need to configure the MassTransit module to register our specification:

C#
public class AdminCenterApplicationModule : SuiteAspNetApplicationModule
{
    public override void SetupModule(IModuleBuilder builder)
    {
        base.SetupModule(builder);

        builder.DependsOn<MassTransitModule, MassTransitModuleOptions>(ops =>
        {
            // Configure the query MT uses to include our relations
            ops.SetSagaSpecification<AssigneeEntity, AssigneeEntityIncludeSpecification>();
        });
    }
}

State Machine Implementation

Now, let's declare an event that we will publish to start the Saga. This event will be correlated to a possibly existent Saga State in the database by using the CorrelationId property, this is by convention.

C#
1
2
3
4
public interface AssigneeCreationRequested {
    Guid CorrelationId { get; }
    string Name { get; }
}

The basic declaration of our saga, without any behavior would be:

C#
internal class AssigneeLifecycleSaga : MassTransitStateMachine<AssigneeEntity>
{
    public AssigneeLifecycleSaga(ILogger<AssigneeLifecycleSaga> logger)
    {
        // REMARKS: Do not change the state ordering
        // it will change the values used to match against the db.
        InstanceState(x => x.CurrentState, Created);

        // We don't need to configure our events, since we are using conventions.
    }

    public Event<AssigneeCreationRequested> AssigneeCreationRequested { get; private set; }

    public State Created { get; private set; }
}

Let's look at the properties first, we are declaring an Event. We will listen on that event later on, but we need to declare it first. We don't need to specify any correlation logic, since it will be done by convention.

We are also registering an State, this is a valid state that our Saga can be in. All possible States need to be declared as properties, since we will use them for transitioning.

Now, in the constructor we are letting the State Machine know which are our possible states for the Saga. Note that since they are persisted using integers, altering the provided order is not recommended.

The Saga does nothing. Let's add some behavior to it.

We'd like to listen to the AssigneeCreationRequested, when it's published, we will keep the Name and transition to the Created State.

C#
internal class AssigneeLifecycleSaga : MassTransitStateMachine<AssigneeEntity>
{
    public AssigneeLifecycleSaga(ILogger<AssigneeLifecycleSaga> logger)
    {
        // REMARKS: Do not change the state ordering
        // it will change the values used to match against the db.
        InstanceState(x => x.CurrentState, Created);

        Initially(
            When(AssigneeCreationRequested)
                .TransitionTo(Created)
                .Then(ctx=> {

                    // Copy message fields into the saga instance.
                    ctx.Instance.Name = ctx.Data.Name;
                })
        );

    }

    public Event<AssigneeCreationRequested> AssigneeCreationRequested { get; private set; }

    public State Created { get; private set; }
}

So when an AssigneeCreationRequested is published, and we do not have a Saga Instance with the received Correlation ID (that is what the Initially means), we will copy relevant fields from the message and transition to the next state.

In a distributed system world, it is quite possible that we might receive the same message twice. Hence, State Machines should be idempotent.

C#
internal class AssigneeLifecycleSaga : MassTransitStateMachine<AssigneeEntity>
{
    public AssigneeLifecycleSaga(ILogger<AssigneeLifecycleSaga> logger)
    {
        // REMARKS: Do not change the state ordering
        // it will change the values used to match against the db.
        InstanceState(x => x.CurrentState, Created);

        Initially(
            When(AssigneeCreationRequested)
                .TransitionTo(Created)
                .Then(ctx=> {

                    // Copy message fields into the saga instance.
                    ctx.Instance.Name = ctx.Data.Name;
                })
        );

        // If we are told to create an already existent assignee, we ignore it.
        During(
            Created,
            Ignore(AssigneeCreationRequested)
        )

    }

    public Event<AssigneeCreationRequested> AssigneeCreationRequested { get; private set; }

    public State Created { get; private set; }
}

Relations in Sagas

When building Sagas, it's common to have one-to-one or one-to-many, etc, kind of relations.

This can be done by just mapping the entity using Entity Type Configurations as usual. For these kind of entities, we can implement ICorrelatedEntity, which is an interface that includes the CorrelationId.

The CorrelationId is automatically mapped with a decorator, so that we do that properly on all places where we need to use Guid kind of identifier.

All microservices should use ICorrelatedEntity for relations and BaseSagaStateMachineInstance for Sagas.