Search code examples
c#design-patternsdrysolid-principlesfsm

Clean Architecture For Creating Different Finite State Machines Within Single Application


Looking for a way to avoid duplication when creating different, flexible FSMs within single application.

I have a concept below, under the heading 0: BEFORE Requirements Change. This concept shows how FSMs of different products can be created, and how an FSM can run. Only one product's FSM can run on a station/computer at any given time, but one station can allow for multiple products (at different times). For context, this is a manufacturing environment, and there are many products which go through a scanning process. Some products have commonalities in their process, like Product A and B (set up batch for product -> scan a part -> apply business logic -> repeat for multiple parts until batch complete, label printed -> set up next batch...). But other products have different processes, like Product C. Products' processes can also require/include/exclude varying components (different devices, databases, business logic); this is all shown under 0: BEFORE Requirements Change.

Now, say the requirements change (which has happened multiple times in the past), and a new step is needed in-between existing steps for multiple products' FSMs (for example, need to trigger a camera and process the image). Furthermore, this additional step might be just a trial phase, and will need to be disabled. I'll now have to go and change every single FSMCreator, as shown under heading 1: AFTER Requirements Change. When there are many products (ALOT more than 3), big process changes like this have been error-prone and difficult to manage.

Is there a better/cleaner way of organizing the architecture or creating the FSMs, so that this duplication is avoided?

The problem stems from how different FSMs can share some common steps, or have some common components, but are not 100% the same. Essentially, there are many different mixing-and-matching variations of components (devices, databases, business logic), states, and transitions. Ultimately, it is the product's process that defines the FSM, so each product needs to know how to create its FSM. This is why I have a different FSMCreator class for each product, to handle the different processes per product. But as shown, this leads to duplication.

0: Before Requirements Change

/* FSM definition */
public class FSM
{
   private Dictionary<IState, Dictionary<string, IState>> _transitions = new Dictionary<IState, Dictionary<string, IState>>();
   private IState _startState;
   private IState _currentState;

   public FSM(IState startState)
   {
      _startState = startState;
   }

   // Instead of State pattern, doing it this way to keep states decoupled, allow for different transitions when creating FSM
   public void Add(IState state, string event, IState nextState)
   {
      Dictionary<string, IState> transition = new Dictionary<string, IState>();
      transition.Add(event, nextState);

      _transitions.Add(state, transition);
   }

   // Using Observer-like pattern to notify FSM from an IState, so FSM knows which next state to transition to
   public void Notify(string event)
   {
      _currentState.Unsubscribe(this); // Unsubscribe from previous state (makes sure FSM is only listening to one state below)

      _currentState = _transitions[currentState][event]; // Move to next state

      _currentState.Subscribe(this); // Subscribe to next state

      _currentState.Run(); // Execute next state
   }

   public void Start()
   {
      _currentState = _startState;

      _currentState.Subscribe(this); // Subscribe to starting state, listening for state to call Notify()
      _currentState.Run();
   }
}

/* Interface definitions */
public interface IState
{
   void Run(); // Executes the logic within state
   void Subscribe(FSM fsm); // FSM listens for state's Notify() call
   void Unsubscribe(FSM fsm); // FSM stops listening for state's Notify() call
}

public interface IFSMCreator
{
   FSM CreateFSM(); // How FSM is created depends on different products' process
}

/* Definitions to create FSM for different products */

// Create FSM for Product A
public class FSMCreatorForProductA implements IFSMCreator
{
   public FSM CreateFSM()
   {
      /* Devices needed for Product A process */
      IScanner scanner = new Scanner_Brand1();
      IPrinter printer = new Printer_Brand1();
      
      /* Databases needed for Product A process */
      IPartsDatabase partsDB = new PartsDB_Oracle();
      IShipmentsDatabase inventoryDB = new InventoryDatabase_MySql();

      /* Business logic needed for Product A process */
      IParser parser = new Parser1ForProductA(); // a way to parse the scan
      IProductLogic productLogic = new ProductLogic1ForProductA(partsDB); // business logic to apply to scan for Product A
      IShipmentLogic batchCompleteLogic = new BatchCompleteLogic1(inventoryDB, printer); // general logic when batch is completed, uses inventory database and prints label

      /* Create the states of Product A's process, which use above components */
      IState stateSetup = new SetupState(partsDB);
      IState stateWaitScan = new WaitScanState(scanner);
      IState stateProcessScan = new ProcessScanState(parser, productLogic);
      IState stateCount = new CountState(partsDB); 
      IState stateComplete = new CompleteState(batchCompleteLogic);

      /* THIS is the actual FSM creation. Needed the above states to be defined first, which needed the components (devices, databases, business logic) defined. */
      FSM fsm = new FSM(stateSetup);
      fsm.Add(stateSetup, "OK", stateWaitScan); // sets up batch; if successful, waits for scan (there would be error state if not successful; omitted for brevity)
      fsm.Add(stateWaitScan, "SCAN", stateProcessScan); // when scan occurs, process scan data
      fsm.Add(stateProcessScan, "OK", stateCount); // if processing successful, update/check count within batch
      fsm.Add(stateCount, "CONTINUE", stateWaitScan); // if batch count not complete, wait for next part
      fsm.Add(stateCount, "COMPLETE", stateComplete); // if batch count complete, finalize batch activities
      fsm.Add(stateComplete, "OK", stateSetup); // if final activities successful, set up for next batch
   }
}

// Create FSM for Product B
public class FSMCreatorForProductB implements IFSMCreator
{
   public FSM CreateFSM()
   {
      IScanner scanner = new Scanner_Brand1();
      IPrinter printer = new Printer_Brand1();
      
      IPartsDatabase partsDB = new PartsDB_Oracle();
      IShipmentsDatabase inventoryDB = new InventoryDatabase_MySql();

      /* v DIFFERENT FROM PRODUCT A v */
      IParser parser = new Parser1ForProductB(); // scan has different content, needs to be parsed differently
      IProductLogic productLogic = new ProductLogic1ForProductB(partsDB, inventoryDB); // Scan data needs to be processed differently. Note how Product B's single part logic also uses inventoryDB, whereas Product A did not
      IShipmentLogic batchCompleteLogic = new BatchCompleteLogic2(printer); // Note how Product B's batch completion logic does not do anything with inventory database; only prints label
      /* ^ DIFFERENT FROM PRODUCT A ^ */

      IState stateSetup = new SetupState(partsDB);
      IState stateWaitScan = new WaitScanState(scanner);
      IState stateProcessScan = new ProcessScanState(parser, productLogic);
      IState stateCount = new CountState(partsDB); 
      IState stateComplete = new CompleteState(batchCompleteLogic) 

      /* THIS is the actual FSM creation (same as Product A). Needed the above states to be defined first, which needed the components (devices, databases, business logic) defined. */
      FSM fsm = new FSM(stateSetup);
      fsm.Add(stateSetup, "OK", stateWaitScan);
      fsm.Add(stateWaitScan, "SCAN", stateProcessScan);
      fsm.Add(stateProcessScan, "OK", stateCount);
      fsm.Add(stateCount, "CONTINUE", stateWaitScan);
      fsm.Add(stateCount, "COMPLETE", stateComplete);
      fsm.Add(stateComplete, "OK", stateSetup);
   }
}

// Create FSM for Product C
public class FSMCreatorForProductC implements IFSMCreator
{
   public FSM CreateFSM()
   {
      /* Product C's station has different scanner brand, different communication method */
      /* Product C's process also does not need a printer */
      IScanner scanner = new Scanner_Brand2(); 
      
      /* Product C uses different partsDB (in Access) */
      IPartsDatabase partsDB = new PartsDB_Access();

      /* Product C using same inventoryDB */
      IShipmentsDatabase inventoryDB = new InventoryDatabase_MySql();

      /* Product C's process has 2 scans instead of 1 */
      IParser parser1 = new Parser1ForProductC();
      IParser parser2 = new Parser2ForProductC();
      IProductLogic productLogic1 = new ProductLogic1ForProductC(partsDB);
      IProductLogic productLogic2 = new ProductLogic2ForProductC(partsDB);

      /* Product C's process has no setup, count, or batch complete states! */
      IState stateWaitScan1 = new WaitScanState(scanner);
      IState stateProcessScan1 = new ProcessScanState(parser1, productLogic1);
      IState stateWaitScan2 = new WaitScanState(scanner);
      IState stateProcessScan2 = new ProcessScanState(parser2, productLogic2)

      /* Product C has different FSM / transitions */
      FSM fsm = new FSM(stateWaitScan1);
      fsm.Add(stateWaitScan1, "SCAN", stateProcessScan1); // when scan of part's first barcode happens, processes scan data
      fsm.Add(stateProcessScan1, "OK", stateWaitScan2); // if processing successful, waits for second barcode scan
      fsm.Add(stateWaitScan2, "SCAN", stateProcessScan2); // when scan of part's second barcode happens, processes scan data
      fsm.Add(stateProcessScan2, "OK", stateWaitScan1); // if processing successful, waits for next/new part scan
   }
}

/* Running FSM */
public void Main()
{
   // GetFSMCreator chooses FSMCreatorForProductA, FSMCreatorForProductB, FSMCreatorForProductC, etc.
   // from user input/selection, or could be configuration file on the station, or some other way. 
   // The implementation of GetFSMCreator() is irrelevant for the question.
   FSM fsm = GetFSMCreator().CreateFSM(); 

   // After getting/creating the right FSM, start the process
   fsm.Start();
}

1: After Requirements Change

/* Definitions to create FSM for different products */

// Create FSM for Product A
public class FSMCreatorForProductA implements IFSMCreator
{
   public FSM CreateFSM()
   {
      IScanner scanner = new Scanner_Brand1();
      IPrinter printer = new Printer_Brand1();
      
      /* Need new device now */
      ICamera camera = new Camera_Brand1(); 
      camera.SetEnabled(GetCameraEnabledSetting()); // Enable/disable based on some setting (GetCameraEnabledSetting() returns true or false)

      IPartsDatabase partsDB = new PartsDB_Oracle();
      IShipmentsDatabase inventoryDB = new InventoryDatabase_MySql();

      IParser parser = new Parser1ForProductA();
      IProductLogic productLogic = new ProductLogic1ForProductA(partsDB);
      IShipmentLogic batchCompleteLogic = new BatchCompleteLogic1(inventoryDB, printer);

      /* Need logic to do something with image */
      IProcessor processor = new ImageProcessorForProductA(partsDB)

      IState stateSetup = new SetupState(partsDB);
      IState stateWaitScan = new WaitScanState(scanner);
      IState stateProcessScan = new ProcessScanState(parser, productLogic);
      IState stateComplete = new CompleteState(batchCompleteLogic) 

      /* Added states */
      IState stateTriggerCamera = new TriggerCameraState(camera);
      IState stateProcessImage = new ProcessImageState(processor);

      /* Transitions have changed as well */
      FSM fsm = new FSM(stateSetup);
      fsm.Add(stateSetup, "OK", stateWaitScan);
      fsm.Add(stateWaitScan, "SCAN", stateProcessScan);

      if (camera.IsEnabled())
      {
         fsm.Add(stateProcessScan, "OK", stateTriggerCamera);
         fsm.Add(stateTriggerCamera, "OK", stateProcessImage);
         fsm.Add(stateProcessImage, "OK", stateCount);
      }
      else
      {
         fsm.Add(stateProcessScan, "OK", stateCount);
      }
      fsm.Add(stateCount, "CONTINUE", stateWaitScan);
      fsm.Add(stateCount, "COMPLETE", stateComplete);
      fsm.Add(stateComplete, "OK", stateSetup);
   }
}

// Create FSM for Product B
public class FSMCreatorForProductB implements IFSMCreator
{
   public FSM CreateFSM()
   {
      IScanner scanner = new Scanner_Brand1();
      IPrinter printer = new Printer_Brand1();
      
      /* Need new device now */
      ICamera camera = new Camera_Brand1(); 
      camera.SetEnabled(GetCameraEnabledSetting()); // Enable/disable based on some setting (GetCameraEnabledSetting() returns true or false)

      IPartsDatabase partsDB = new PartsDB_Oracle();
      IShipmentsDatabase inventoryDB = new InventoryDatabase_MySql();

      IParser parser = new Parser1ForProductB();
      IProductLogic productLogic = new ProductLogic1ForProductB(partsDB, inventoryDB);
      IShipmentLogic batchCompleteLogic = new BatchCompleteLogic2(printer);

      /* Need logic to do something with image */
      IProcessor processor = new ImageProcessorForProductB(partsDB)

      IState stateSetup = new SetupState(partsDB);
      IState stateWaitScan = new WaitScanState(scanner);
      IState stateProcessScan = new ProcessScanState(parser, productLogic);
      IState stateComplete = new CompleteState(batchCompleteLogic) 

      /* Added states */
      IState stateTriggerCamera = new TriggerCameraState(camera);
      IState stateProcessImage = new ProcessImageState(processor);

      /* Transitions have changed as well */
      FSM fsm = new FSM(stateSetup);
      fsm.Add(stateSetup, "OK", stateWaitScan);
      fsm.Add(stateWaitScan, "SCAN", stateProcessScan);

      if (camera.IsEnabled())
      {
         fsm.Add(stateProcessScan, "OK", stateTriggerCamera);
         fsm.Add(stateTriggerCamera, "OK", stateProcessImage);
         fsm.Add(stateProcessImage, "OK", stateCount);
      }
      else
      {
         fsm.Add(stateProcessScan, "OK", stateCount);
      }
      fsm.Add(stateCount, "CONTINUE", stateWaitScan);
      fsm.Add(stateCount, "COMPLETE", stateComplete);
      fsm.Add(stateComplete, "OK", stateSetup);
   }
}

// Create FSM for Product C
public class FSMCreatorForProductC implements IFSMCreator
{
   public FSM CreateFSM()
   {
      IScanner scanner = new Scanner_Brand2(); 

      /* Need new device now */
      ICamera camera = new Camera_Brand1(); 
      camera.SetEnabled(GetCameraEnabledSetting()); // Enable/disable based on some setting (GetCameraEnabledSetting() returns true or false)

      IPartsDatabase partsDB = new PartsDB_Access();

      IShipmentsDatabase inventoryDB = new InventoryDatabase_MySql();

      IParser parser1 = new Parser1ForProductC();
      IParser parser1 = new Parser2ForProductC();
      IProductLogic productLogic1 = new ProductLogic1ForProductC(partsDB);
      IProductLogic productLogic2 = new ProductLogic2ForProductC(partsDB);

      /* Need logic to do something with image */
      IProcessor processor = new ImageProcessorForProductC(partsDB)

      IState stateWaitScan1 = new WaitScanState(scanner);
      IState stateProcessScan1 = new ProcessScanState(parser1, productLogic1);
      IState stateWaitScan2 = new WaitScanState(scanner);
      IState stateProcessScan2 = new ProcessScanState(parser2, productLogic2);

      /* Added states */
      IState stateTriggerCamera = new TriggerCameraState(camera);
      IState stateProcessImage = new ProcessImageState(processor);

      /* Transitions have changed as well */
      FSM fsm = new FSM(stateWaitScan1);
      fsm.Add(stateWaitScan1, "SCAN", stateProcessScan1);
      fsm.Add(stateProcessScan1, "OK", stateWaitScan2);
      fsm.Add(stateWaitScan2, "SCAN", stateProcessScan2);

      if (camera.IsEnabled())
      {
         fsm.Add(stateProcessScan2, "OK", stateTriggerCamera);
         fsm.Add(stateTriggerCamera, "OK", stateProcessImage);
         fsm.Add(stateProcessImage, "OK", stateWaitScan1);
      }
      else 
      {
         fsm.Add(stateProcessScan2, "OK", stateWaitScan1);
      }
   }
}

Solution

  • You have to always edit your code as your requirements always change. And it looks like you will always have to change your code if you will stick with this approach.

    So we've figured out that your workflow always changes. Our goal is to make minimum changes in code.

    What we can do? We can move your workfow in storage and based on this data we can run your FSM. This is how Jira workflow works.. They have many users and it would be really hard to edit code per workflow and it is not possible. How they solved their problem? Jira stores workflow like data and they edit data, not code.

    This is a rough example, not a complete solution, however it will show the direction of how to write solution that will be fit to open closed principle.

    So, you can store your workflow in json file:

    static string products = @"{
            ""products"":
            [
                {
                    ""key"": ""A"",
                    ""components"":
                    {
                        ""scanners"": [""scannerBrand_1"", ""scannerBrand_2""],
                        ""printers"": [""printerBrand_1"", ""printerBrand_2""],
                        ""partsDb"": [""partsDbBrand_1"", ""partsDbBrand_2""],
                        ""inventoryDb"": [""mySql_1""],
    
                        ""parser"": [""parserProduct_A""],
                        ""producLogic"": [
                            { ""key"": ""A"", ""partsDb"": 0}],
                        ""batchCompleteLogic"": [
                            {""key"": ""batchCompleteLogic_1"", 
                               ""parameters"": [""inventoryDb"", ""printers""]
                            }
                        ],
                        ""states"": [
                            { ""key"": ""setupState"", 
                               ""parameters"": [{""key"": ""partsDb"", ""value"": 0}]}
                        ]
                    }
                }
            ]
        }";
        
    

    And it is possible to create mapping classes based on your json:

    public class Product
    {
        public string Key { get; set; }
        public Components Components { get; set; }
    }
    
    public class SomeStateMachine
    {
        public List<Product> Products { get; set; }
    }
    
    public class ProducLogic
    {
        public string Key { get; set; }
        public int PartsDb { get; set; }
    }
    
    public class BatchCompleteLogic
    {
        public string Key { get; set; }
        public List<string> Parameters { get; set; }
    }
    
    public class Parameter
    {
        public string Key { get; set; }
        public object Value { get; set; }
    }
    
    public class State
    {
        public string Key { get; set; }
        public List<Parameter> Parameters { get; set; }
    }
    
    public class Components
    {
        public List<string> Scanners { get; set; }
        public List<string> Printers { get; set; }
        public List<string> PartsDb { get; set; }
        public List<string> InventoryDb { get; set; }
        public List<string> Parser { get; set; }
        public List<ProducLogic> ProducLogic { get; set; }
        public List<BatchCompleteLogic> BatchCompleteLogic { get; set; }
        public List<State> States { get; set; }
    }
    

    Then deserealize your data:

    SomeStateMachine someStateMachine = JsonConvert.DeserializeObject<SomeStateMachine>(products);
    

    Then based on your data of SomeStateMachine, you can create factories of all your components such as Scanners, Printers, PartsDb and then States:

    public class ScannerFactory
    {
        Dictionary<string, Scanner> GetInstance = new()
        {
            { "scannerBrand_1", new Scanner_A() }
        };
    }
    
    public abstract class Scanner
    { }
    
    public class Scanner_A : Scanner
    { }
    

    And then in FSM class you will iterate through your States and add instances to FSM:

    public void Add() 
    {
        foreach (State state in States)
        {
            // all your complicated logic of whether it should be added or not can 
            // be extracted in separated class. E.g. if `camera.IsEnabled()`
            // fsm.Add(...);
        }
    }
    

    EDIT:

    You can create a section in json file and call it "common":

    "common": 
    {
        "state": ["fooState"]
    }
    

    and then write a method which will iterate through all products and add this state.