State Machine in Elixir using Erlang’s gen_statem Behaviour

Finite state machine is a common phenomenon in programs that need to maintain multiple states and transition among them based on certain actions. Erlang’s gen_statem provides a generic state machine behaviour. In this article, I will implement a simple ATM withdrawal state machine using gen_statem.

Brief Overview of gen_statem

gen_statem was introduced in OTP 19.0. Previously, Erlang had gen_fsm module that was used to implement state machines. While gen_fsm remains in OTP as is, new code should use gen_statem.

Like any generic behaviour in Erlang, gen_statem module provides a number of APIs and callbacks that need to be implemented by the module using this behaviour. As depicted in the official manual of this behaviour, the relationship between behaviour functions and callbacks is as follows —

gen_statem supports two different callback modes —

  • state_functions — each state is represented by a callback function (this article will use this mode)

  • handle_event_functions — one central callback function to handle all states (you can see an example of this mode in the official manual — erlang.org/doc/man/gen_statem.html)

I find state_functions more intuitive. In this article, I will use state_functions and necessary functions and callbacks to implement the below ATM withdrawal state machine (image src - https://andrealeopardi.com/posts/connection-managers-with-gen-statem/atm-state-machine-diagram.png) —

States and Actions

It’s important to identify the states and actions possible in each state to determine state transitions properly. States and actions are represented using atoms. As per the above diagram, we can list the states and corresponding actions below —

  • :idle (initial state) — actions: :insert_card

  • :pin (waiting for PIN) — actions: :insert_correct_pin, :insert_wrong_pin

  • :cash (waiting for cash) — actions: insert_amount

In each state, I will also have a :message action that returns a user-friendly state-specific message without changing state.

Implementation

The full implementation of the state machine looks as below —

here —

  • line 2: states its implementation of gen_statem behaviour

  • line 13: calls start/4 from gen_statem module. start/4 arguments are server name, callbacks module, and args that will be passed to corresponding init/1 callback, additional options.

  • line 31: implements init/1 callback which is called when start/4 is executed. start/4 will wait until init/1 is finished. As we can see, it’s returning a tuple of the form —

      {:ok, :idle, @idle_message}
    

    Here, it initializes the state machine with :idle initial state. The third value in the tuple is called server data which is not part of the state but can be used to store any server data it needs. Here, it’s returning @idle_message which is defined as a module attribute.

    • line 15: calls stop/1 from gen_statem module. This call results in a corresponding terminate/3 callback.

    • line 33: defines terminate/3 callback that’s used to do any necessary cleanup before gen_statem is stopped/terminated.

    • line 17–28: implements the action APIs defined earlier. Each action results in a corresponding state-specific callback function. I will explain :pin state and the actions attached to it. The rest should be similar and easy to follow.

When the ATM machine is in :pin state, customers can enter PIN using the api -

def insert_pin(pin) when is_binary(pin) do
    case correct_pin?(pin) do
      true -> :gen_statem.call(@name, :insert_correct_pin)
      false -> :gen_statem.call(@name, :insert_wrong_pin)
    end
end

I have a correct_pin?/1 local function which returns true/false and based on that one of the two gen_statem call/2functions are called. call/2 is a synchronous call and waits until a reply arrives.

Corresponding state callback functions look as below —

def pin({:call, from}, :insert_correct_pin, _data),
     do: {:next_state, :cash, @cash_message, [{:reply, from, :cash}]}

def pin({:call, from}, :insert_wrong_pin, _data),
     do: {:next_state, :idle, @idle_message, [{:reply, from, :idle}]}

def pin(event_type, event_content, data),
     do: handle_event(event_type, event_content, data)

As per the above code snippet, if :insert_correct_pin action is received, ATM machine switches to the next state :cash with server data @cash_message. It also replies to the caller (denoted by from) with the next state (:cash).

If :insert_wrong_pin action is received, ATM machine switches to the next state :idle with server data @idle_message. It also replies to the caller (denoted by from) with next state (:idle).

In case some other action is executed, including :message action, the call goes to an internal fallback function — handle_event/3.

  • line 58–62: implements the internal function handle_event/3 .

Since :message action can be called in any state, the first clause will keep the current state and reply with current state-specific data (which can be one of @idle_message/@pin_message/@cash_message).

The second clause is called if some unknown action is taken which also keeps the current state.

defp handle_event({:call, from}, :message, data),
       do: {:keep_state, data, [{:reply, from, data}]}
defp handle_event({:call, from}, _, data),
       do: {:keep_state, data, [{:reply, from, data}]}

Sample Run (valid actions)

A sample run of the state machine with valid actions —

Sample Run (invalid actions)

A sample run of the state machine with some invalid actions —

Additional Notes

gen_statem also provides a linked version of start called — start_link. If this version is used, gen_statem module should be put under the supervision tree for automatic restart. Also, corresponding to call function, there is an asynchronouscast/2 function that returns immediately without waiting for a reply from state callbacks.

Conclusion

In this article, I implemented a sample ATM machine withdrawal state machine using Erlang’s gen_statem behaviour. Full code for this project can be found here — https://github.com/imeraj/Elixir_Playground/tree/master/atm_statem

For more elaborate and in-depth future technical posts please follow me here or subscribe to my newsletter.

Did you find this article valuable?

Support Meraj Molla by becoming a sponsor. Any amount is appreciated!