Skip to content

joaomdmoura/machinery

Repository files navigation

Machinery

Build Status Module Version Hex Docs Total Download License

Machinery

Machinery is a lightweight State Machine library for Elixir with built-in Phoenix integration. It provides a simple DSL for declaring states and includes support for guard clauses and callbacks.

Table of Contents

Installing

Add :machinery to your list of dependencies in mix.exs:

def deps do
  [
    {:machinery, "~> 1.1.0"}
  ]
end

Create a state field (or a custom name) for the module you want to apply a state machine to, and ensure it's declared as part of your defstruct.

If using a Phoenix model, add it to the schema as a string and include it in the changeset/2 function:

defmodule YourProject.User do
  schema "users" do
    # ...
    field :state, :string
    # ...
  end

  def changeset(%User{} = user, attrs) do
    #...
    |> cast(attrs, [:state])
    #...
  end
end

Declaring States

Create a separate module for your State Machine logic. For example, if you want to add a state machine to your User model, create a UserStateMachine module.

Then import Machinery in this new module and declare states as arguments.

Machinery expects a Keyword as an argument with the keys field, states and transitions.

  • field: An atom representing your state field name (defaults to state)
  • states: A List of strings representing each state.
  • transitions: A Map for each state and its allowed next state(s).

Example

defmodule YourProject.UserStateMachine do
  use Machinery,
    field: :custom_state_name, # Optional, default value is `:field`
    states: ["created", "partial", "completed", "canceled"],
    transitions: %{
      "created" =>  ["partial", "completed"],
      "partial" => "completed",
      "*" => "canceled"
    }
end

You can use wildcards "*" to declare a transition that can happen from any state to a specific one.

Changing States

To transition a struct to another state, call Machinery.transition_to/3 or Machinery.transition_to/4.

Machinery.transition_to/3 or ``Machinery.transition_to/4`

It takes the following arguments:

  • struct: The struct you want to transition to another state.
  • state_machine_module: The module that holds the state machine logic, where Machinery is imported.
  • next_event: string of the next state you want the struct to transition to.
  • (optional) extra_metadata: map with any extra data you might want to access on any of the sate machine functions triggered by the state change
Machinery.transition_to(your_struct, YourStateMachine, "next_state")
# {:ok, updated_struct}

# OR

Machinery.transition_to(your_struct, YourStateMachine, "next_state", %{extra: "metadata"})
# {:ok, updated_struct}

Example

user = Accounts.get_user!(1)
{:ok, updated_user} = Machinery.transition_to(user, UserStateMachine, "completed")

Persist State

To persist the struct and state transition, you declare a persist/2 or /3 (in case you wanna access metadata passed on transition_to/4) function in the state machine module.

This function will receive the unchanged struct as the first argument and a string of the next state as the second one.

your persist/2 or persist/3 should always return the updated struct.

Example

defmodule YourProject.UserStateMachine do
  alias YourProject.Accounts

  use Machinery,
    states: ["created", "completed"],
    transitions: %{"created" => "completed"}
  
  # You can add an optional third argument for the extra metadata.
  def persist(struct, next_state) do
    # Updating a user on the database with the new state.
    {:ok, user} = Accounts.update_user(struct, %{state: next_stated})
    # `persist` should always return the updated struct
    user
  end
end

Logging Transitions

To log transitions, Machinery provides a log_transition/2 or /3 (in case you wanna access metadata passed on transition_to/4) callback that is called on every transition, after the persist function is executed.

This function receives the unchanged struct as the first argument and a string of the next state as the second one.

log_transition/2 or log_transition/3 should always return the struct.

Example

defmodule YourProject.UserStateMachine do
  alias YourProject.Accounts

  use Machinery,
    states: ["created", "completed"],
    transitions: %{"created" => "completed"}

  # You can add an optional third argument for the extra metadata.
  def log_transition(struct, _next_state) do
    # Log transition here.
    # ...
    # `log_transition` should always return the struct
    struct
  end
end

Guard functions

Create guard conditions by adding guard_transition/2 or /3 (in case you wanna access metadata passed on transition_to/4) function signatures to the state machine module. This function receives two arguments: the struct and a string of the state it will transition to.

Use the second argument for pattern matching the desired state you want to guard.

# The second argument is used to pattern match into the state
# and guard the transition to it.
#
# You can add an optional third argument for the extra metadata.
def guard_transition(struct, "guarded_state") do
 # Your guard logic here
end

Guard conditions will allow the transition if it returns anything other than a tuple with {:error, "cause"}:

  • {:error, "cause"}: Transition won't be allowed.
  • _ (anything else): Guard clause will allow the transition.

Example

defmodule YourProject.UserStateMachine do
  use Machinery,
    states: ["created", "completed"],
    transitions: %{"created" => "completed"}

  # Guard the transition to the "completed" state.
  def guard_transition(struct, "completed") do
    if Map.get(struct, :missing_fields) == true do
      {:error, "There are missing fields"}
    end
  end
end

When trying to transition a struct that is blocked by its guard clause, you will have the following return:

blocked_struct = %TestStruct{state: "created", missing_fields: true}
Machinery.transition_to(blocked_struct, TestStateMachineWithGuard, "completed")

# {:error, "There are missing fields"}

Before and After callbacks

You can also use before and after callbacks to handle desired side effects and reactions to a specific state transition.

You can declare before_transition/2 or /3 (in case you wanna access metadata passed on transition_to/4) and after_transition/2 or /3 (in case you wanna access metadata passed on transition_to/4), pattern matching the desired state you want to.

Before and After callbacks should return the struct.

# Before and After callbacks should return the struct.
# You can add an optional third argument for the extra metadata.
def before_transition(struct, "state"), do: struct
def after_transition(struct, "state"), do: struct

Example

defmodule YourProject.UserStateMachine do
  use Machinery,
    states: ["created", "partial", "completed"],
    transitions: %{
      "created" =>  ["partial", "completed"],
      "partial" => "completed"
    }

    def before_transition(struct, "partial") do
      # ... overall desired side effects
      struct
    end

    def after_transition(struct, "completed") do
      # ... overall desired side effects
      struct
    end
end

Copyright and License

Copyright (c) 2016 João M. D. Moura

Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.