2

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);
      }
   }
}
  • Also, I'm not looking for answers that only suggest a specific design pattern; I looked at many commonly suggested design patterns, and they don't entirely solve the problem. Can't use Builder (a new method might be needed in the interface to add an additional state/component). Can't use Template Method (not all processes are the same, and even similar processes have varying components; too many dependencies to inject). Can't use CoR (FSM not exactly a linear sequence, states can route back to previous states and have many potential next states). – user18463824 Mar 24 '22 at 22:18
  • 2
    Clearly you need to use external data to construct the FSM. Database / json / something.... Constructing the arguments for each `fsm.Add(...)` call, and all dependencies. – Jeremy Lakeman Mar 25 '22 at 00:14
  • @JeremyLakeman Thank you for your comment. I've thought about this as well, but doesn't it ultimately lead to the same problem? If the external data (whether its database, json, etc.) has some representation of the different processes, and then a common step needs to be included to all processes, doesn't each process need to be changed individually? For the person managing the data for multiple products, they'd still have to replicate the change for each product. The problem just shifts from the program to the data? – user18463824 Mar 25 '22 at 15:06
  • Could you show please where code duplication is? Maybe could you comment duplications? Thanks! – StepUp Mar 25 '22 at 22:07
  • Does it exist at least a pair of products which have the exact same FSM ? If yes, does it represent a rather common or uncommon case (comparing to every possible product pair) ? – Spotted Mar 28 '22 at 07:13
  • @Spotted Can you elaborate on what you mean by "exact same FSM"? For example, in my question, Product A and Product B have the exact same FSM in terms of states and transitions, but the dependencies are different. If this is what you mean, then yes, most products are actually like Product A and B i.e. same FSM, but different dependencies which go into the states. No product will have exactly the same dependencies (because each product has *some* product-specific logic). – user18463824 Mar 28 '22 at 12:25

1 Answers1

1

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.

StepUp
  • 36,391
  • 15
  • 88
  • 148
  • Thank you for your answer. I understand that edits are required when requirements change. The main problem is making same/similar edits in **multiple** places. Your answer is good, but it does not really show how to deal with that issue. For example, in your answer, it looks like there is one JSON object for each product in the products array - that's fine. Now if the requirements need us to add a new "batchCompleteLogic_2" or another state to ALL products, we will have to make the same addition for all product JSON objects. If we have over 50 product JSON objects, have to do this 50 times. – user18463824 Mar 27 '22 at 21:37
  • 1
    @user18463824 yeah, you are right! You need to add to all products a new “batchCompleteLogic_2”. However, it is much easier to add new json value than editing code in all places, recompile and publish again. In addition, it violates clean architecture and SOLID rules especially open closed principle. I've updated my answer about adding another state to ALL products. And as a rule of stackoverflow, you do not need to write that answer is "good", [just upvote](https://meta.stackoverflow.com/questions/352126/is-it-the-correct-way-to-upvote-an-answer). This is a recomendation of so. – StepUp Mar 28 '22 at 07:05
  • The biggest problem which your answer does not yet address is the transitions of the states. You only have an "Add( )" which iteratively adds state, but each state needs to transition to another state. Each product knows these transitions (to make its "workflow"). The logic of whether or not to add the state, I understand from your Add( ) code. But transitions are still needed. This is what I was trying to show in my question between "0: Before Requirements Change" and "1: After Requirements Change", that the transitions themselves would change with the addition of new states. – user18463824 Mar 28 '22 at 12:32
  • @user18463824 it looks like your question is above than one answer. I mean you want a complete solution. So the full technical task is necessary to understand your goals and transitions. What I wanted to show that you can manage your program by json file or data from database. That was my goal. – StepUp Mar 28 '22 at 12:56
  • @user18463824 and if you want to add transitions, you can also edit json file and that’s all. – StepUp Mar 28 '22 at 15:33
  • I mention transitions because it's not as simple as adding another "transitions" property. They are not like "components". If Products A and B have "states": ["setupState", "waitScanState", "processScanState", "completeState"], we might think we can put it under "common". But Product C has "states": ["waitScan1State", "processScan1State", "waitScan2State", "processScan2State"]. There are certain transitions to get from one state to another state. We cannot just make "transitions" common, we need "transitions" for each product. *This* is when the duplication happens. – user18463824 Mar 28 '22 at 17:00
  • Now if a new state "triggerCameraState" is needed for all products, sure, we can put it under "common". But there needs to be transition between the existing state -> "triggerCameraState" -> the next state (which can be different between products, as I show in my question). If we put "triggerCameraState" in "common", how can we transition to it from "processScanState" for Product A and B, and from "processScanState2" for Product C, without duplication? We have to add this transition for multiple products in the json file or database then, because the transitions link *different* states? – user18463824 Mar 28 '22 at 17:04
  • @user18463824 duplication of data is better than duplication of code and re-editing code again and again. So I would think about some properties to desired products and handle them as transitions. – StepUp Mar 28 '22 at 19:42