This is the first part of the State Machine Executor series. For your convenience you can find other parts using the links below:
Part 1 — Introduction
Part 2 — Fault tolerance
In this series we explore how to decouple describing “what to do” from actually doing it. This means that instead of doing Console.WriteLine("Hello world!")
we just describe that we want the Hello world!
to be printed out to the standard output.
Introducing such an abstraction if very beneficial. If we only describe what to do, we get the following:
- The business code doesn’t need to worry about actual implementation details but focus on the business part only
- We can change implementation of “how things are done” without affecting the business code
- It’s easier to add additional layers (e.g., monitoring, logging, scaling) without changing the business code
- We can postpone the actual materialization of the side effects
- We get history and auditing for free, just by checking the description
- Testing is much easier as we don’t need to mock anything or deal with side effects being executed
- It’s much easier to inspect what will happen. We can also change the description if needed
It’s actually yet another form of dependency inversion and introducing higher-level APIs for lower-level operations. However, with each generalization, there comes a price of having to adhere to a specific framework of thought.
Conceptual implementation
Let’s start with some pseudocode describing what we want to do. We would like to have a framework for executing finite state machines. The state machine consists of states and transitions between the state. Importantly, whenever the state machine wants to execute a side-effectful operation, it needs to describe them and ask the framework to get them done.
Conceptually, we have the following:
1 2 3 4 |
class StateMachine { TransitionResult RunTransition(string transitionName) {...} bool IsCompleted(string state) {...} } |
The state machine supports running a transition, and can report whether a given state is the terminal one or not. Each TransitionResult
describes the following:
1 2 3 4 5 |
class TransitionResult { string CurrentState; List<Action> ActionsToExecute; string NextTransition; } |
We see that after running a transition, we get the new state that the machine is in, the list of actions to execute, and the name of the next transition that the state machine would like to run.
Finally, we have the following execution logic:
1 2 3 4 5 6 7 8 9 10 |
void Run(StateMachine machine, string initialTransition){ string state = null; string currentTransition = initialTransition; do { result = machine.RunTransition(currentTransition); state = result.CurrentState; currentTransition = result.NextTransition; ExecuteActions(result.ActionsToExecute); }while(!machine.IsCompleted(state)); } |
We take the state machine and the initial transition, and then we loop until completed. Nothing fancy here.
There are few missing blocks that we will need to provide based on our needs:
- How are actions described? Ideally we’d like to have strongly typed objects and be able to change how the actions are executed. You may need a registery of action executors that can handle specific action types. We can also replace these executors dynamically and change them without touching the business code.
- How is the state machine created? You may need a factory that will create the instance based on some input or whatever.
- How are actions executed? Simple for loop will be a good start, but you may also run them in parallel or even scale-out. Again, we can change that without touching the business code.
At first glance, this approach may look great. It gives many benefits in terms of testing and code organization, we can optimize many aspects without touching the business code, and this can be adapted to various program flows.
However, this programming model is very limiting, which is not obvious initially. In the next parts, we’ll explore various aspects and see how to tackle them.