Maxine

Build Status

State machines as data, for Elixir. Includes lightweight Ecto integration.

What's new

About

After shopping for a simple Elixir state machine package, I liked the approach of Fsm, in that it eschews gen_fsm's abstraction of a separate process in favor of a simple data structure and some functions on it. That said, I had two concerns:

  1. I'd have to roll my own solution for callbacks, which, ok, but:
  2. Fsm is largely implemented in macros, so as to provide a friendly DSL for specifying machines inside of module definitons. Which is great if that's what you need, but the code is frankly difficult to understand, or at least more difficult (and more metaprogramming) than the simplicity of the task seems to warrant. Furthermore, the resulting representation of the machines themselves consists of idiosyncratic DSL code which gets confusing after a while.

Maxine aims to be readable by design. It specifies a data type for state machines instead: They are maps of a certain shape (a %Maxine.Machine{}) that lay out rules for how other maps of a certain shape (%Maxine.State) may be transformed. Note that the nice clean %{data: nil, state: foo} that Fsm functions return only serve the purpose of the latter. Fsm's actual representation of events, states and transitions is obscured by the layer of metaprogramming. In the documentation on "Dynamic definitions", the example defines states and transitions via a simple keyword list, but only the better to feed them to the macros. Maxine makes the simple representation the canonical one, and exposes it.

That last clause is important: Presumably many/most state machine libraries in many/most languages have a data type for a collection of transitions, events and states, and/or implement it with a simple associative structure like a map. The thing here is that instead of treating that structure as an implementation detail, and hiding it behind an API, we expose it, and make it the interface. Benefits include:

This train of thought began a few years ago working on a Rails application that involved writing (a) machines with the state_machine DSL, and (b) Elasticsearch queries with whatever I wanted, because they're plain old JSON objects. (The ES "Query DSL" really just lays out the legal shapes for those objects; as they say in the documentation, "think of the Query DSL as an AST (Abstract Syntax Tree) of queries". So maybe think of Maxine as an AST of state machines.)

Maybe more importantly: The Ruby DSL had decent surface clarity, but as the machines became more complicated it seemed like I systematically understood the ES queries better than I understood the state transitions written in the DSL. Building basic data structures was certainly easier than dealing with a class-level DSL; in this case, data was easier to understand than code. Hence "state machines as data."

Basics

Typically you'll start by defining a machine, like so:

defmodule MyMachine do
  alias Maxine.Machine

  @machine %Machine{
    initial: :off,
    transitions: %{
      power: %{
        on: :off,
        off: :on
      },
      blow_fuse: %{
        on: :inoperative
      },
    },
    groups: %{
      off: :not_on,
      inoperative: [:not_on, :totally_fubar]
    },
    callbacks: %{
      entering: %{
        on: :start_billing,
      },
      leaving: %{
        on: :stop_billing
      },
      events: %{
        *: :log_event
      }
      index: %{
        start_billing: fn(from, to, event, data) -> meter_on(data) end,
        stop_billing: fn(from, to, event, data) -> meter_off(data) end,
        log_event: fn(from, to, event, data) -> log("#{event} happened") end
      }
    }
  }

  spec machine() :: %Machine{}
  def machine(), do: @machine
end

The public API gives three functions, generate/2, advance/3 and advance!/3. Use as follows:

# the second param to generate is an optional initial
# state; e.g., we could:
#   state = generate(MyMachine, :on)
# It's not going to make sure the state exists, so 
# be careful. :)
state = generate(MyMachine)
state.name == :off   # <=== true

{:ok, %State{} = state2} = advance(state, :power, options_are: "optional")
# or
state2 = advance!(state, :power) # raises on any error

state2.name == :on  # <=== true

The %State{} struct represents an actual machine state, and looks like this:

st = %State{
  name: :current_state_name,
  previous: :previous_state_name,
  machine: %Machine{...}
  data: %{
    app: %{},     # a spot for callbacks to put/get data
    tmp: %{},     # like above, but wiped on every event
    options: []   # the keyword list of arguments passed to the most recent event
  }
}

Moving parts

Machines are described in the following terms:

Note that groups can't be used to denote a "to" state, because denoting only the group doesn't tell Maxine what concrete state you actually want; they can't be used to denote events for the same reason.

The transition process

When an event is called on a given state, the following steps are performed by advance/3 and friends:

See the examples for concrete illustration.

Ecto integration

cast_state/3

Use Maxine.Ecto.cast_state/3 to integrate with Ecto changesets thus:

some_record
|> cast_state(my_machine, options)

This function will look in the changeset for the current state under :state (or a field you pass in with the state: option) and the event under :event (or a field you pass in with the event: option). Will call advance/3 on the basis of the record's current state and the given event, setting the value on the field or setting an error on the changeset if the transition is invalid.

In addition to validating the change itself, you can also perform state- and event-dependent validations on the changeset by passing cast_state a module that implements validate_state/2, or an equivalent function. The first argument is the changeset (after the state has been advanced) and the second is a tuple of {event, new_state, old_state}:

defmodule StateValidator do
  def validate_state(changeset, {:navigate, _, :unsaved}) do
    # runs any time the event is :navigate and there are unsaved
    # changes, regardless of the new state
  end
end

cast_workflow/3

As you can see, Elixir's pattern matching makes sophisticated conditional validation pretty easy. Maxine.Workflow captures one such usage pattern in an Elixir behaviour. Workflow modules implement the following three functions: