Stateflows

Structr.Stateflows package provides functionality for modeling behavior of state-driven entities by creating and using state machines in your .NET application. It provides simple yet functional API via Stateflow object that encapsulate methods and properties to monitor available actions and change state of controlled entity.

Installation

Stateflows package is available on NuGet.

dotnet add package Structr.Stateflows

Setup

Configure stateflow services:

services.AddStateflows(typeof(Program).Assembly);

AddStateflows() extension method performs registration of all configurator classes that implement IStateMachineConfigurator and stateflow providers that implement IStateflowProvider.

Param nameParam typeDescription

assembliesToScan

params Assembly[]

List of assemblies to search configurator classes and stateflow providers.

configureOptions

Action<StateflowServiceOptions>

Options to be used by state machine provider.

Additionally configure IStateMachineProvider service by specifying it's type and lifetime used StateflowServiceOptions.

StateflowServiceOptions properties:

Property nameProperty typeDescription

ProviderServiceType

Type

Changes standard implementation of IStateMachineProvider to specified one. Default value is typeof(StateMachineProvider).

ProviderServiceLifetime

ServiceLifetime

Specifies the lifetime of an IStateMachineProvider service. Default value is Transient.

Usage

The basic usage is:

Let's say we have some application where user could create Issue objects containing feedback or comments and publish them:

public class Issue
{
    public int Id { get; set; }
    public IssueState State { get; set; }
    public string Comments { get; set; }
    public int AuthorId { get; set; }
    public int? AssigneeId { get; set; }
}

Every such Issue has one of these states:

public enum IssueState
{
    Draft,
    Submitted,
    InProgress,
    Solved,
    Canceled
}

Each of such states imply different set of actions, some of which lead to change of state (like Submitting or Solving) and some not (like Editing) Create an enum for all possible actions:

public enum IssueAction
{
    Submit, // Change issue state: Draft ---> Submitted.    
    AcceptToWork, // Change issue state: Submitted ---> InProgress; Canceled ---> InProgress.
    Cancel, // Change issue state: InProgress ---> Canceled.
    Solve, // Change issue state: InProgress ---> Solved.
    Edit, // Doesn't change issue state.
    Delete, // Doesn't change issue state.
    ChangeAssignee // Doesn't change issue state.
}

Now u should create configurator class that implements IStateMachineConfigurator for configuring state machine:

// StateMachineConfigurator is a abstract class allowing you to configure state machine synchronously.
// For asynchronously configuring inherit from IStateMachineConfigurator interface instead of StateMachineConfigurator class.
public class IssueStateMachineConfigurator : StateMachineConfigurator<Issue, IssueState, IssueAction>
{
    // As long as IssueStateMachineConfigurator is a normal .NET class with it's own constructor we could inject into it any dependencies needed.
    // Here we are adding some ICurrentUserService to be able to take info about current user while modeling Issue's behavior for its different states.
    private readonly ICurrentUserService _currentUserService;

    public IssueStateMachineConfigurator(ICurrentUserService currentUserService)
        => _currentUserService = currentUserService;

    // You should configure each issue state.
    protected override void Configure(Stateless.StateMachine<IssueState, IssueAction> stateMachine, Issue issue)
    {
        // Getting current user.
        ICurrentUser currentUser = _currentUserService.CurrentUser;
        // Getting info about current user for specified issue.
        bool isAdmin = currentUser.HasRole("Admin");
        bool isAuthor = issue.AuthorId == currentUser.Id;
        bool isAssignee = issue.AssigneeId == currentUser.Id;
        
        stateMachine.Configure(IssueState.Draft)
            .PermitIf(IssueAction.Submit, IssueState.Submitted, () => isAuthor)
            .InternalTransitionIf(IssueAction.Edit, () => isAuthor)
            .InternalTransitionIf(IssueAction.Delete, () => isAuthor);

        stateMachine.Configure(IssueState.Submitted)
            .PermitIf(IssueAction.AcceptToWork, IssueState.InProgress, () => isAdmin);

        stateMachine.Configure(IssueState.InProgress)
            .PermitIf(IssueAction.Cancel, IssueState.Canceled, () => isAssignee)
            .PermitIf(IssueAction.Solve, IssueState.Solved, () => isAssignee)
            .InternalTransitionIf(IssueAction.ChangeAssignee, () => isAssignee || isAdmin);
        
        stateMachine.Configure(IssueState.Canceled)
            .PermitIf(IssueAction.AcceptToWork, IssueState.InProgress, () => isAssignee)
            .InternalTransitionIf(IssueAction.ChangeAssignee, () => isAssignee || isAdmin);

        // IssueState.Solve don't be needed to configuring because this state doesn't have transitions.
    }
}

Then create IssueStateflowProvider that inheriting from IStateflowProvider and provides access to configured state machine and issue object:

public interface IIssueStateflowProvider : IStateflowProvider<Issue, int, IssueState, IssueAction>
{}

public class IssueStateflowProvider : IIssueStateflowProvider
{
    private readonly IDbContext _dbContext;
    // This one is provided by Stateflow package and will give us an instance of the default state machine implementation.
    private readonly IStateMachineProvider _stateMachineProvider;

    public IssueStateflowProvider(IDbContext dbContext, IStateMachineProvider stateMachineProvider)
    {
        _dbContext = dbContext;
        _stateMachineProvider = stateMachineProvider;
    }

    public async Task<Stateflow<Issue, IssueState, IssueAction>> GetStateflowAsync(int id, CancellationToken cancellationToken = default)
    {
        // Getting issue from storage.
        Issue? issue = await _dbContext.Issues.SingleOrDefaultAsync(x => x.Id == id, cancellationToken);
        // Getting state machine.
        IStateMachine<IssueState, IssueAction> stateMachine = await _stateMachineProvider.GetStateMachineAsync<Issue, IssueState, IssueAction>(entity: issue,
            stateAccessor: x => x.State,
            stateMutator: (x, state) => x.State = state,
            cancellationToken: cancellationToken);

        // Creating stateflow instance for given issue.
        var stateflow = new Stateflow<Issue, IssueState, IssueAction>(issue, stateMachine);

        return stateflow;
    }
}

The last step is to inject IIssueStateflowProvider service and use it:

public class IssueService : IIssueService
{
    private readonly IDbContext _dbContext;
    private readonly IIssueStateflowProvider _stateflowProvider;
    private readonly ICurrentUserService _currentUserService;

    public IssueService(IDbContext dbContext, 
        IIssueStateflowProvider stateflowProvider,
        ICurrentUserService currentUserService)
    {
        _dbContext = dbContext;
        _stateflowProvider = stateflowProvider;
        _currentUserService = currentUserService;
    }

    public async Task SubmitAsync(int issueId, CancellationToken cancellationToken = default)
    {
        Stateflow<Issue, IssueState, IssueAction> stateflow = await _stateflowProvider.GetStateflowAsync(issueId, cancellationToken);
        Issue issue = stateflow.Entity;
        IStateMachine<IssueState, IssueAction> stateMachine = stateflow.StateMachine;
        IssueAction action = IssueAction.Submit;

        if (stateMachine.CanFire(action) == false)
        {
            throw new StateflowException("Submit operation is not permitted.");
        }

        // Submitting issue while changing its state to "Submitted".
        stateMachine.Fire(action);

        // Save changes to database.
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    public async Task AcceptToWorkAsync(int issueId, CancellationToken cancellationToken = default)
    {
        Stateflow<Issue, IssueState, IssueAction> stateflow = await _stateflowProvider.GetStateflowAsync(issueId, cancellationToken);
        Issue issue = stateflow.Entity;
        IStateMachine<IssueState, IssueAction> stateMachine = stateflow.StateMachine;
        IssueAction action = IssueAction.AcceptToWork;

        if (stateMachine.CanFire(action) == false)
        {
            throw new StateflowException("AcceptToWork operation is not permitted.");
        }

        // Submitting issue while changing its state to "AcceptToWork".
        stateMachine.Fire(action);

        // Set assignee to issue.
        ICurrentUser currentUser = _currentUserService.CurrentUser;
        issue.AssigneeId = currentUser.Id;

        // Save changes to database.
        await _dbContext.SaveChangesAsync(cancellationToken);
    }

    /* Other actions implementations */
}

To check available actions for issue on the presentation layer (WebUI for example):

// Base permitted actions interface.
public interface IPermittedActions<TAction>
{
    IEnumerable<TAction> PermittedActions { get; }
} 

// Extension for simplify checking available actions.
public static class PermittedActionsExtensions
{
    public static bool CanFire<TAction>(this IPermittedActions<TAction> context, TAction action)
        => context?.PermittedActions?.Contains(action) ?? false;
}

// Issue DTO.
public record IssueDto
{
    public int Id { get; init; }
    public IssueState State { get; init; }
    public string Comments { get; init; }
    public int AuthorId { get; init; }
    public int? AssigneeId { get; init; }
}

// Issue context DTO inherits from IPermittedActions
public record IssueContextDto, IPermittedActions<IssueAction>
{
    public IssueDto Issue { get; init; }
    public IEnumerable<IssueAction> PermittedActions { get; init; }
}

// AutoMapper Profile.
internal class IssueDtoProfile : Profile
{
    public IssueContextDtoProfile()
    {
        CreateMap<Issue, IssueDto>();
    }
}

// Query.
public record IssueContextGetByIdQuery : IOperation<IssueContextDto>
{
    public int Id { get; init; }
}

// Query handler.
internal class IssueContextGetByIdQueryHandler : AsyncOperationHandler<IssueContextGetByIdQuery, IssueContextDto>
{
    private readonly IDbContext _dbContext;
    private readonly IIssueStateflowProvider _stateflowProvider;
    private readonly IMapper _mapper;

    public IssueContextGetByIdQueryHandler(IDbContext dbContext,
        IIssueStateflowProvider stateflowProvider, 
        IMapper mapper)
    {
        _dbContext = dbContext;
        _stateflowProvider = stateflowProvider;
        _mapper = mapper;
    }

    public override async Task<IssueContextDto> HandleAsync(IssueContextGetByIdQuery query, CancellationToken cancellationToken)
    {
        Stateflow<Issue, IssueState, IssueAction> stateflow = await _stateflowProvider.GetStateflowAsync(issueId, cancellationToken);
        Issue issue = stateflow.Entity;
        IStateMachine<IssueState, IssueAction> stateMachine = stateflow.StateMachine;

        IssueContextDto result = new IssueContextDto 
        {
            Issue = _mapper.Map<IssueDto>(issue),
            PermittedActions = issue.PermittedTriggers;
        }

        return result;
    }
}

Then in any place of your presentation layer you can check issue action. For example in controller:

public class IssuesController : Controller
{
    private readonly IOperationExecutor _executor;

    public IssuesController(IOperationExecutor executor)
        => _executor = executor;

    public async Task<IActionResult> Edit(int id)
    {
        IssueContextDto context = await _executor.ExecuteAsync(new IssueContextGetByIdQuery { Id = id });
        if (context.CanFire(IssueAction.Edit) == false)
        {
            throw new AccessDeniedException("Edit operation is not permitted.");
        }

        return View(context);
    }
}

Last updated