Pull to refresh

State Management for processes flow

Reading time 12 min
Views 1.1K

Introduction

Most of the processes that people use in their work lives can be represented as some object that goes through some flow, just to name a few examples

  • Software engineers using tasks. The tasks are created first, then they are assigned to an engineer, then planned, then in progress and in the end closed, but can be re-opened later.

  • Delivery order. A customer makes a trackable order, then an item gets dispatched, then delivered to a post office, then collected. 

  • Influencer post. An influencer uploads video to a social platform, updating a draft, open collaboration with someone who makes a set of changes to the video, publication of the video.  

  • Recruitment process.  Can be rejected or continued.

Each of the flows above contains many different states, but ont things they have in common - there is always one state where the flow starts and at least one where the flow ends. That’s actually called a finite-state machine.

In this article I want to suggest one of many approaches to how to effectively handle flow based systems. 

For the sake of concrete example, I will use the recruitment process where candidate applications go through a review flow and I will use C# to express some ideas in code. Please note that it's a very lightweight implementation where I’m not touching communication with databases and don’t go too much into details on the UI part, my main goal is to share the approach.

State

Before going further it’s important to agree on what we understand by a state. Here by state I will mean a description of the object that includes:

  • Position of the object in the flow. 

  • Transitions to move further in the flow.

  • List of allowed mutations within the state. 

  • List of permissions for mutations and views.

Status

Status is the most important attribute of a flow system as it essentially keeps a snapshot of the system. It’s most often stored in a field of an object as enum value, but there are scenarios in which it can be calculated. For now the field approach is used for our candidate application:

public enum CandidateApplicationStatus
{    
  CV_SCREENING,    
  HR_SCREENING,    
  TECHNICAL_INTERVIEW,    
  HIRED,    
  REJECTED,
};

And we can make a status property for our application:

public class CandidateApplication
{    
  public CandidateApplicationStatus Status    
  { get; set; } = CandidateApplicationStatus.CV_SCREENING;
}

Transitions

By transition in this article I mean mutations that move an object from one state to another. For example when a candidate application is reviewed by a recruiter it goes to a technical interview stage.  As we already exposed Status to public API we can directly set it there, but in general transition from one status to another goes along with some other side actions (for example through observer code pattern to send notification) and validation (we cannot skip some states), so it might be better to make status change through a function. 

public class CandidateApplication
{    
  public void moveToHRScreening()    
  {        
    _status = CandidateApplicationStatus.HR_SCREENING;        
    
    /* ... */    
  }    

  public CandidateApplicationStatus getStatus()    
  {        
    return _status;    
  }    

  private CandidateApplicationStatus _status = CandidateApplicationStatus.CV_SCREENING;
  
  /* ... */
}

Actions

Here by mutation I mean a mutation that changes the object, but doesn’t change its  state. Essentially it’s a set of functions that change data within an object.

public class CandidateApplication
{    
  public void saveComment(string newComment)    
  {        
    comment = newComment;    
  }    

  private string? comment;
  
  /* ... */
}

Permissions 

Permissions are a set of rules that dictate what users can do and see. For example, a recruiter cannot leave a note on the candidate application that they’ve successfully passed a technical interview, this should be handled by the interviewer. 

User

Here we need to introduce the concept of the user who will load the applications. I won’t go into much detail, in the scope of this article we only care about the user role.

public enum UserRole
{    
  RECRUITER,    
  TECHNICAL_INTERVIEWER,    
  CANDIDATE,    
  ADMINISTRATOR,
}

And we assign a role to a new user on creation.

public class User
{    
  public User(UserRole role)    
  {        
    Role = role;    
  }

  public UserRole Role    
  { get; init; }    

  /* ... */
}

This user is a representation of an actual user who owns the session. For example a recruiter who opened an “Interview” tool. 

Permission aware object

Now that we have a way to understand who is currently working with the data we can load it in a privacy aware manner. In general it means that when creating an instance of a candidate application we pass the user and before fetching data from a database we check if the user should have access to it at this point of time.

public class CandidateApplication
{    
  public CandidateApplication(User user)    
  {        
    User = user;        

    /* Do (user role, status) check and Load data */    
  }    

  public User User { get; init; }    

  /* ... */
}

It also gives us a way to do permission check in all the functions (both mutation and view) of the candidate application.

public void editTechnicalReviewNote(string updatedReviewNote)
{    
  if (User.Role == UserRole.CANDIDATE)    
  {        
    return;
  }    
  
  if (_status == CandidateApplicationStatus.HIRED || 
      _status == CandidateApplicationStatus.REJECTED)   
  {        
    return;    
  }    
  
  if (User.Role == UserRole.ADMINISTRATOR && 
      _status != CandidateApplicationStatus.HR_SCREENING)    
  {        
    return;    
  }    
  
  if (User.Role == UserRole.TECHNICAL_INTERVIEWER 
      && _status != CandidateApplicationStatus.TECHNICAL_INTERVIEW)    
  {        
    return;    
  }    
  
  /* Update note */
}

But in a very cumbersome and hard to maintain way. Here we go to abstractions for help! 

State Management Abstraction 

Some problems with the approach above:

  • Maintainability of mutation permissions.  Just imagine that you need to write that many ifs in every mutation or view function

  • Maintainability of transitions. Similar to permissions they need to check the status and permissions. 

Let’s abstract the permission logic to a state abstraction with three main features:

  • Every state will handle permission for viewing, actions and transitions by giving user specific permission tokens that are valid in some state. 

  • States contain all possible transitions to available states. 

  • Every state maps to one and only one status.

You might notice that this is actually a structure for a directed graph (or a finite state machine)! 

Here is a piece of code that suggests requirements for the states

using TransitionDictionary = Dictionary<CandidateApplicationStatus, Action<CandidateApplication>>;
using UserRolePermissionTokensDictionary = Dictionary<UserRole, List<CandidatePermissionToken>>;

public enum CandidatePermissionToken
{    
  TRANSIT,    
  EDIT_TECHNICAL_REVIEW_NOTES
}

public abstract class CandidateApplicationState
{    
  abstract public UserRolePermissionTokensDictionary gePermissionTokensForUserRole();    
  abstract public TransitionDictionary getTransitions();    
  
  public bool hasPermissionTokenForRole(CandidatePermissionToken permissionToken, UserRole role)    
  {        
    return            
      gePermissionTokensForUserRole()            
      .GetValueOrDefault(role, new List<CandidatePermissionToken>())            
      .Contains(permissionToken);    
  }    
  
  public bool canTransitToStatus(CandidateApplicationStatus status, UserRole role)    
  {        
    return getTransitions().ContainsKey(status) &&            
      hasPermissionTokenForRole(CandidatePermissionToken.TRANSIT, role);    
  }
}

Here I put an example of a CV_SREEENING state implementation

public sealed class CandidateApplicationCVScreeningState : CandidateApplicationState
{    
  public override UserRolePermissionTokensDictionary gePermissionTokensForUserRole()    
  {        
    return new UserRolePermissionTokensDictionary(){                
      {                    
        UserRole.RECRUITER,                    
        new List<CandidatePermissionToken>(){                        
          CandidatePermissionToken.TRANSIT                    
        }                
      },                
      {                    
        UserRole.ADMINISTRATOR,                    
        new List<CandidatePermissionToken>(){                        
          CandidatePermissionToken.TRANSIT,                        
          CandidatePermissionToken.EDIT_TECHNICAL_REVIEW_NOTES                    
        }                
      }            
    };    
  }    

  public override TransitionDictionary getTransitions()    
  {        
    return new TransitionDictionary() {                
      {                    
        CandidateApplicationStatus.HR_SCREENING,                    
        application => application.moveToHRScreening()                
      }            
    };    
  }
}

Now if by status we can get state, the implementation of permission checks would be just a couple of lines that are also very easy to read.  We can do this by just putting status to state transition in one place. 

public sealed class CandidateApplicationFlow
{    
  public static CandidateApplicationState getStateForStatus(        
    CandidateApplicationStatus status    
  ) => status switch    
  {        
    CandidateApplicationStatus.CV_SCREENING =>            
      new CandidateApplicationCVScreeningState(),        
    CandidateApplicationStatus.HR_SCREENING =>            
      new CandidateApplicationHRScreeningState(),        
    CandidateApplicationStatus.TECHNICAL_INTERVIEW =>           
      new CandidateApplicationTechnicalInterviewState(),        
    _ => throw new ArgumentOutOfRangeException(nameof(status)),    
  };
}

With that editTechnicalReviewNote method turn into this

public void editTechnicalReviewNote(string updatedReviiewNote)
{    
  var state = CandidateApplicationFlow.getStateForStatus(_status);    
  if (!state.hasPermissionTokenForRole(CandidatePermissionToken.EDIT_TECHNICAL_REVIEW_NOTES, User.Role))    
  {        
    return;    
  }    

  /* Update note */
}

And we can use canTransitToStatus to do additional validation check when transitioning between states. 

public void moveToHRScreening()
{    
  var state = CandidateApplicationFlow.getStateForStatus(_status);    
  if (!state.canTransitToStatus(_status, User.Role))    
  {        
    return;    
  }    
  _status = CandidateApplicationStatus.HR_SCREENING;    
  
  /* ... */
}

Testing

Representing the system as a finite state machine has a huge advantage when it comes to testing as it allows to easily move the system to the needed state and go from there. 

One of the ways it can be implemented is by creating an object in the start state and then using state transitions this object can be moved to the target state. For finding a path of transitions we can traverse the state machine with bfs:

namespace DemoCandidateApplication
{    
  using TransitionDictionary = Dictionary<CandidateApplicationStatus, List<Action<CandidateApplication>>>;    
  
  public class CandidateApplicationTestPromoter    
  {        
    public CandidateApplicationTestPromoter(CandidateApplication testApplication)        
    {            
      _application = testApplication;        
    }        
    
    public CandidateApplication promote(CandidateApplicationStatus status)        
    {            
      var transitionsToStatus = transitionsToStatusBFSMap(_application.Status);            
      foreach (var transition in transitionsToStatus[status])            
      {                
        transition(_application);            
      }            
      return _application;        
    }        
    
    private TransitionDictionary transitionsToStatusBFSMap(CandidateApplicationStatus startStatus)        
    {            
      var statusQueue = new Queue<CandidateApplicationStatus>();            
      statusQueue.Enqueue(startStatus);
      
      var transitionsToStatus = new TransitionDictionary() {                
        {startStatus, new List<Action<CandidateApplication>>()}            
      };            
      
      while (statusQueue.Count > 0)            
      {                
        var currentStatus = statusQueue.Dequeue();                
        var currentState = CandidateApplicationFlow.getStateForStatus(currentStatus);                
        foreach (var entry in currentState.getTransactions())                
        {                    
          if (transitionsToStatus.ContainsKey(entry.Key))                    
          {                        
            continue;                    
          }                       
                               
          transitionsToStatus.Add(                            
            entry.Key,                            
            transitionsToStatus[currentStatus].Append(entry.Value).ToList()                        
          );                    
                          
          statusQueue.Enqueue(entry.Key);                
        }            
      }            
      
      return transitionsToStatus;        
    }        
    
    private CandidateApplication _application;    
  }
}

This makes it extremely easy to quickly transition to a valid state of the candidate application and then use it to test some specific features. 

public class CandidateApplicationTransitionTest
{    
  [Fact]    
  public void ApplicationPromotionPathTest()    
  {          
    var user = new User(UserRole.RECRUITER);        
    var application = new CandidateApplication(user);        
    var promoter = new CandidateApplicationTestPromoter(application);        
    
    promoter.promote(CandidateApplicationStatus.HR_SCREENING);        
    Assert.Equal(CandidateApplicationStatus.HR_SCREENING, application.getStatus());        
    
    promoter.promote(CandidateApplicationStatus.TECHNICAL_INTERVIEW);        
    Assert.Equal(CandidateApplicationStatus.TECHNICAL_INTERVIEW, application.getStatus());    
  }
}

Combination with UI 

This approach is extremely beneficial when you start working with UI, because all actions and transitions are gated with permission tokens and you don’t have to duplicate logic on frontend and backend to tell when some button should be available or not, you just need to expose permission tokens (say with graphql) and check for need token presence. 

Multiple active states  

There are certain cases, when you want your state machine to have active not one, but two or more active states. In this case one of the approaches is to not store the status as a field, but calculate it based on the change log of the object and do small modification to state code to unify data from different states. 

One approach that I find interesting for this case, but to be fair quite hard to support future migrations is using gating for start and finish in the form of Disjoint Normal Form. For example if we add a new state to a candidate application called DESIGN_INTERVIEW that we want to handle simultaneously with TECHNICAL_INTERVIEW we can represent this with history gating like this

  • Status is DESIGN_INTERVIEW if history includes (CV_SCREENING_FINISH & DESIGN_INTERVIEW_STARTED) records and doesn’t include (DESIGN_INTERVIEW_FINISHED_WITH_SUCCESS) | (DESIGN_INTERVIEW_FINISHED_WITH_FAILURE).

  • Status is TECHNICAL_INTERVIEW if history includes (CV_SCREENING_FINISH & TECHNICAL_INTERVIEW_STARTED) records and doesn’t include (TECHNICAL_INTERVIEW_FINISHED_WITH_SUCCESS) | (TECHNICAL_INTERVIEW _FINISHED_WITH_FAILURE).

  • Status is HIRED if history includes (TECHNICAL_INTERVIEW_FINISHED_WITH_SUCCESS & DESIGN_INTERVIEW_FINISHED_WITH_SUCCESS). 

But again, here we sacrifice a lot on maintainability, I just find this method interesting. 

Summary 


In this article I shared an approach to work with flow systems representing them as finite state machine that is pretty easy to maintain, test and leverage on the UI. This approach can be extended even further removing dependency of object from its state definition by abstracting mutation in a separate builder class and checking mutation on commitments but this topic might be worth of another article! 

Tags:
Hubs:
0
Comments 1
Comments Comments 1

Articles