State Machine Events

An event is something that happened which may result in a state change. In a saga state machine, an event is used to correlate a message to a saga state machine instance. Saga state machine events are used to add behavior to a saga state machine, such as adding or updating saga state machine instance data, publishing or sending messages, and changing the instance's current state.

Declaring Events

Events are declared as public properties on the saga state machine with the Event<T> property type, where T is a valid message type.

In the example below, the SubmitOrder message is configured as an event. The event configuration also specifies the message property used to correlate the event to a saga state machine instance. In this case, the Guid property OrderId is used.

public record SubmitOrder(Guid OrderId);

public class OrderStateMachine :
    MassTransitStateMachine<OrderState>
{
    public Event<SubmitOrder> SubmitOrder { get; private set; } = null!;

    public OrderStateMachine()
    {
        Event(() => SubmitOrder,
            e => e.CorrelateById(x => x.Message.OrderId)
        );
    }
}

Event Conventions

There are several conventions applied when configuring event correlation in a saga state machine. These conventions may reduce the amount of configuration required for an event meeting a convention's criteria.

CorrelatedBy<Guid>

If an event message type implements the CorrelatedBy<Guid> interface, the event will automatically be configured to correlate using the CorrelationId property on that interface.

Property Name

If an event message has a CorrelationId, CommandId, or EventId property and that properties type is Guid, the event will automatically be configured to correlate using the first property found (in that order).

Global Topology

It's also possible to configure the correlation property for a message type using GlobalTopology. This configures the message type globally so that it is automatically available to any saga state machine. However, a saga state machine can override the global settings by explicitly configuring the event correlation.

GlobalTopology.Send.UseCorrelationId<SubmitOrder>(x => x.OrderId);

Initiating Events

The Initial state is the starting point of all sagas. When an existing saga state machine instance cannot be found that correlates to an event behavior defined for the Initial state, a new instance is created.

Events handled in the Initial state are initiating events that result in a newly created saga state machine instance is an instance does not already exist.

public class OrderStateMachine :
    MassTransitStateMachine<OrderState>
{
    public State Submitted { get; private set; } = null!;

    public Event<SubmitOrder> SubmitOrder { get; private set; } = null!;

    public OrderStateMachine()
    {
        Initially(
            When(SubmitOrder)
                .Then(context => 
                {
                    context.Saga.CustomerNumber = context.Message.CustomerNumber;
                })
                .TransitionTo(Submitted)
        );
    }
}

Handling Events

Event can be handled in any state, and can be configured using During and specifying the states in which the event is accepted. In the example below, the AcceptOrder event is handled in the Submitted state.

public class OrderStateMachine :
    MassTransitStateMachine<OrderState>
{
    public State Submitted { get; private set; } = null!;
    public State Accepted { get; private set; } = null!;

    public Event<AcceptOrder> AcceptOrder { get; private set; } = null!;

    public OrderStateMachine()
    {
        During(Submitted,
            When(AcceptOrder)
                .Then(context => 
                {
                    context.Saga.AcceptedAt = context.SentTime ?? DateTime.UtcNow;
                })
                .TransitionTo(Accepted)
        );
    }
}

Multiple states can be specified using During to avoid duplicating behavior configuration. In the updated example below, the AcceptOrder event is also handled in the Accepted state, to add some idempotency to the saga state machine.

public class OrderStateMachine :
    MassTransitStateMachine<OrderState>
{
    public State Submitted { get; private set; } = null!;
    public State Accepted { get; private set; } = null!;

    public Event<AcceptOrder> AcceptOrder { get; private set; } = null!;

    public OrderStateMachine()
    {
        During(Submitted, Accepted,
            When(AcceptOrder)
                .Then(context => 
                {
                    context.Saga.AcceptedAt ??= context.SentTime ?? DateTime.UtcNow;
                })
                .TransitionTo(Accepted)
        );
    }
}

Event Options

Several additional properties can be configured on the event, including:

PropertyTypeDefaultDescription
ConfigureConsumeTopologybooltrueWhen false, the event message type will not be configured on the broker.
InsertOnInitialboolfalseIf true and the event is handled in the Initial state, the saga repository will attempt to insert a new saga instance before trying to load it. This option was introduced to deal with S-RANGE locks on SQL Server that would slow down inserts/updates due to querying for non-existent rows.
ReadOnlyboolfalseWhen true, the saga state machine instance will not be persisted when handling this event.
OnMissingInstancedelegatenullUsed to configure the behavior of an event when no matching instance is found.

Read Only Events

A saga state machine instance is the source of truth for an instance. It's common to expose that state by handling an incoming request event and responding with the current state. To reduce saga repository resource usage, or in some cases to simply avoid updating the instance in the repository when nothing has been updated, a read-only event can be configured.

In the example below, an event handling the request is configured as read-only.

public record GetOrderState(Guid OrderId);
public record OrderState(Guid OrderId, string CurrentState);

public class OrderStateMachine :
    MassTransitStateMachine<OrderState>
{
    public Event<GetOrderState> OrderStateRequested { get; private set; } = null!;

    public OrderStateMachine()
    {
        Event(() => GetOrderState, e =>
        {
             e.CorrelateById(x => x.Message.OrderId);
             
             e.ReadOnly = true;
        });
        
        DuringAny(
            When(OrderStateRequested)
                .RespondAsync(async context => new OrderState(
                    context.Saga.CorrelationId, 
                    await Accessor.Get(context).Name))
        );                
    }
}

Accessor is a saga state machine property that can be used to get the current state from a saga state machine instance.

On Missing Instance

When a non-initiating event (an event without a behavior in the Initial state) is received that does not match an existing saga state machine instance, the message is ignored by default. This can lead to a misunderstanding that messages are being "lost." In many cases, this may be due to message order, concurrency, or even timing.

The missing instance behavior can be configured for an event using the OnMissingInstance method. In the example below the event is configured to respond with OrderNotFound when a saga state machine instance matching the OrderId is not found. This ensures that the request doesn't time out and receives a proper response.

public record RequestOrderCancellation(Guid OrderId);
public record OrderNotFound(Guid OrderId);

public class OrderStateMachine :
    MassTransitStateMachine<OrderState>
{
    public OrderStateMachine()
    {
        Event(() => OrderCancellationRequested, e =>
        {
            e.CorrelateById(context => context.Message.OrderId);

            e.OnMissingInstance(m =>
            {
                return m.ExecuteAsync(x => x.RespondAsync(new OrderNotFound(x.OrderId)));
            });
        });
    }

    public Event<RequestOrderCancellation> OrderCancellationRequested { get; private set; }
}

Other missing instance options include Discard, Fault, and Execute (a synchronous version of ExecuteAsync).

Redeliver on Missing Instance

Another option when a matching saga state machine instance is not found is to redeliver the message. Redelivery allows time for consumption of other events which may create a matching instance, after which the redelivered message would be correlated to the matching instance.

Redelivery can be configured as shown in the example below. The options are the same as configuring redelivery for exceptions.

public record OrderAddressValidated(Guid OrderId);

public class OrderStateMachine :
    MassTransitStateMachine<OrderState>
{
    public OrderStateMachine()
    {
        Event(() => OrderAddressValidated, e =>
        {
            e.CorrelateById(context => context.Message.OrderId);

            e.OnMissingInstance(m => m.Redeliver(r =>
            {
                r.Interval(5, 1000);
                r.OnRedeliveryLimitReached(n => n.Fault());
            }));
        });
    }

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

IF a matching saga state machine instance is not found, the message will be redelivered to the queue five times after which a fault (exception) will be produced if no matching instance is found.

Advanced Options

Like most things in MassTransit, the everyday use case of MassTransit should not need to use these options. But sometimes, you have to really dig in to make things happen.

Setting the Saga Factory

The only time is when using InsertOnInitial and you have required properties that must be present or the insert will fail. Typically with SQL and not null columns.

On events that are in the Initial state, a new instance of the saga will be created. You can use the SetSagaFactory to control how the saga is instantiated.

public class OrderStateMachine :
    MassTransitStateMachine<OrderState>
{
    public Event<SubmitOrder> SubmitOrder { get; private set; } = null!;

    public OrderStateMachine()
    {
        Event(
            () => SubmitOrder, 
            e => 
            {
                e.CorrelateById(cxt => cxt.Message.OrderId)
                e.SetSagaFactory(cxt =>
                {
                    // complex constructor logic
                    return new OrderState 
                    {
                        CorrelationId = cxt.Message.OrderId 
                    };
                });
            }
            
        );
    }
}