Gearbox v0.3.1 Gearbox behaviour View Source

Gearbox is a functional state machine with an easy-to-use API, inspired by both Fsm and Machinery.

Gearbox does not run in a process, so there's no potential for a GenServer bottleneck. This way there's also less overhead as you won't need to setup a supervision tree/manage your state machine processes.

Note: Gearbox is heavily inspired by Machinery, and also took inspiration from Fsm.

Gearbox is very similar to Machinery in term of the API usage, however it differs in the ways below:

  • Gearbox does not use a GenServer as a backing process. Since GenServer can be a potential bottleneck in a system, for that reason I think it's best to leave process management to users of the library.
  • No before/after callbacks. Callback allow you to add side effects, but side effects violate Single Responsibility Principle, and that can bring surprises to your codebase (e.g: "How come everytime this transition happens, X happens?"). Gearbox nudges you to keep domain-logic callbacks close to your contexts/domain events. Gearbox still ships with a guard_transition/3 callback, as that is intrinsic to state machines.
  • Gearbox does not ship with a Phoenix Dashboard view. A really cool and great concept, but more often than not it is not needed and the added dependency can prove more trouble than worth.

Rationale

Gearbox operates on the philosophy that it acts purely as a functional state machine, wherein it does not care where your state is store (e.g: Ecto, GenServer), all Gearbox does is to help you ensure state transitions happen the way you expect it to.

In most cases like for example Order, it is very likely that you don't need a process for that. Just get the record out of the database, run it through Gearbox machine, then persist it back to database.

In some rare cases where you need to have a stateful state machine, for example a traffic light that has an internal timer to shift from red (30s) -> green (30s) -> yellow (5s) -> red, you are better off to use an Agent/GenServer where you have better control over backpressuring/ business logics.

As of now, Gearbox does not provide a way to create events/actions in a state machine. This is because Gearbox is not a domain/context wrapper, Events and actions that can trigger a state change should reside closer to your contexts, therefore I urge users to group these events as domain events (contexts), rather than state machine events.

Gearbox previously shipped with before_transition/3 and after_transition/3 in 0.1.0, but after some discussions I have decided to take a deliberate decision to remove callbacks. This is because callbacks by nature, allow you to add side effects, but side effects violate Single Responsibility Principle, and callbacks can often bring unintended surprises to your codebase (e.g: "How come everytime this transition happens, X happens?").

Therefore, Gearbox nudges you to keep domain/business-logic callbacks close to your contexts/domain events. Gearbox still ships with a guard_transition/3 callback, as that is intrinsic to state machines.

Options

  • :field - used to retrieve the state of the given struct. Defaults to :state
  • :states - list of finite states in the state machine
  • :initial - initial state of the struct, if struct has nil state to begin with. Defaults to the first item of :states
  • :transitions - a map of possible transitions from current_state to next_state. * wildcard is allowed to indicate any states.

Example

defmodule Gearbox.Order do
  defstruct items: [], total: 0, status: nil
end

defmodule Gearbox.OrderMachine do
  use Gearbox,
    field: :status,
    states: ~w(pending_payment cancelled paid pending_collection refunded fulfilled),
    initial: "pending_payment",
    transitions: %{
      "pending_payment" => ~w(cancelled paid),
      "paid" => ~w(pending_collection refunded),
    }
end

iex> alias Gearbox.Order # Your struct
iex> alias Gearbox.OrderMachine # Your machine
iex> Gearbox.transition(%Order{}, OrderMachine, "paid")
{:ok, %Gearbox.Order{items: [], status: "paid", total: 0}}

Ecto Example

{:ok, order} = Gearbox.transition(%Order{status: "pending_payment"}, OrderMachine, "paid")
Repo.insert!(order)

# or even

%Order{status: "pending_payment"}
|> Gearbox.transition!(OrderMachine, "paid")
|> Repo.insert!()

Link to this section Summary

Functions

Transition a struct or a map to a given state.

Transition a struct or map to a given state. If transition is invalid, an InvalidTransitionError exception is raised.

Checks if the transition should be made. Mainly for internal use to support transition/3 and other methods performing transition actions.

Callbacks

Add guard conditions before transitioning.

Link to this section Types

Link to this section Functions

Link to this function

transition(struct, machine, next_state)

View Source
transition(
  struct :: struct() | map(),
  machine :: any(),
  next_state :: state()
) :: {:ok, struct() | map()} | {:error, String.t()}

Transition a struct or a map to a given state.

Returns an {:ok, updated_struct_or_map} or {:error, message} tuple.

Link to this function

transition!(struct, machine, next_state)

View Source
transition!(
  struct :: struct() | map(),
  machine :: any(),
  next_state :: state()
) :: struct() | map()

Transition a struct or map to a given state. If transition is invalid, an InvalidTransitionError exception is raised.

Uses Gearbox.transition/3 under the hood.

Link to this function

validate_transition(struct, machine, next_state)

View Source
validate_transition(
  struct :: struct() | map(),
  machine :: any(),
  next_state :: state()
) :: {:ok, any()} | {:error, String.t()}

Checks if the transition should be made. Mainly for internal use to support transition/3 and other methods performing transition actions.

Returns a tuple containing the outcome:

  • {:ok, nil} if it can transition or,
  • {:error, reason} if transition cannot be made.

Link to this section Callbacks

Link to this callback

guard_transition(struct, from, to)

View Source
guard_transition(struct :: any(), from :: state(), to :: state()) ::
  {:halt, any()} | any()

Add guard conditions before transitioning.

The function receives struct as the first argument, the current state as the second argument, and the desired state as the last argument.

You can guard on both from and to states, e.g:

  • Every time %Order{} transits out of pending, do X
  • Every time %Order{} transits into paid, do Y

If this function returns a {:halt, reason}, execution of the transition will halt. Any other things will allow the transition to go through.

Note: This hook only gets triggered if the transition is valid.