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.
- Installing
- Declaring States
- Changing States
- Persist State
- Logging Transitions
- Guard Functions
- Before and After Callbacks
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
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 tostate
)states
: AList
of strings representing each state.transitions
: A Map for each state and its allowed next state(s).
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.
To transition a struct to another state, call Machinery.transition_to/3
or Machinery.transition_to/4
.
It takes the following arguments:
struct
: Thestruct
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}
user = Accounts.get_user!(1)
{:ok, updated_user} = Machinery.transition_to(user, UserStateMachine, "completed")
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.
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
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.
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
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.
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"}
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
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 (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.