In this tutorial, we will use AshStateMachine to extend the ticketing tutorial from the Ash Getting Started Guide. If you are new to Ash, please consider reading that guide first to get familiar with Ash and working with Resources.
What you will learn
This tutorial, we will explore the following topics:
- What a state machine is in Ash
- How states are declared
- How actions trigger transitions
- How transitions are validated
Installation
Add the ash_state_machine dependency to your project's dependencies
Add the following dependency to your mix.exs file:
{:ash_state_machine, "~> 0.2.13"}Next, update your project dependencies by executing mix deps.get
Making a resource into a state machine
A state machine (in this case a "Finite State Machine"), models a
system that can only exist in a single state at one time. Its
power comes from the ability to specify transitions between states.
For example, you might have an order state machine with states
[:pending, :on_its_way, :delivered]. However, you probably can't go from
:pending to :delivered, and so you want to only allow
certain transitions in certain circumstances, i.e :pending -> :on_its_way -> :delivered.
This extension's goal is to help you write clear and clean state machines, with all of the extensibility and power of Ash resources and actions.
Add the AshStateMachine extension to your resource
Next, add AshStateMachine as an extension to any existing resource. In our case, we will add it to the Ticket resource:
defmodule Helpdesk.Support.Ticket do
use Ash.Resource,
domain: Helpdesk.Support
data_layer: Ash.DataLayer.Ets
extensions: [AshStateMachine]
endAdd attributes to your resource if required
This is not a tutorial on Ash resources, so we won't go into detail here, but we will list the attributes that we will use in this tutorial:
# The attributes that model a Ticket's data
attributes do
uuid_primary_key :id
attribute :subject, :string
attribute :description, :string
attribute :additional_information, :string
endNote that the previous tutorial uses an attribute named :status to
track the ticket status. By default, AshStateMachine uses an attribute
named :state that serves the same purpose. For now we are going to
ignore this detail, but we will learn how to change the 'state'
attribute name later.
Planning our future states
In this example we will proceed in a way that helps to illustrate AshStateMachine concepts. There is no single 'correct' process for modelling a domain, and you may choose to follow different steps if that works better for you.
Consider the possible states for your application
We will start by listing the states that we think we might need (and which have been chosen to illustrate some different features) as comments in our code:
# Possible states: [
# :received, :needs_more_info,
# :with_it, :with_hr,
# :will_not_fix, :closed
# ]
...Next, we are going to create some empty actions so that we can think about how we might like to interact with the Ticket resource.
actions do
create :open do
accept [:subject, :description]
end
update :request_more_information do
end
update :assign_to_department do
end
update :deny_request do
end
update :close do
end
endSpecify the initial state for the resource
In our example, when a ticket is created, it will start in the
:received state, awaiting triage. We can add the following block to
specify the initial state:
state_machine do
initial_states [:received]
default_initial_state :received
endTransitioning from one state to another
Ash uses transition_state/1 to requests a state transition. Whether
the transition is allowed is determined later by the
state_machine.transitions configuration.
In our example, we will start with the simple idea that any user can request more information about a ticket at any time.
Use transition_state in your actions
We need to update our :request_more_information action so that it
requests a transition to the :needs_more_info state:
actions do
update :request_more_information do
change transition_state(:needs_more_info)
end
endAdd allowed transitions
The power of AshStateMachine is that we can model which transitions are allowed based upon the current state. To start, we are going to allow users to 'request more information' from any state.
We accomplish this by adding transitions to our resource:
state_machine do
initial_states [:received]
default_initial_state :received
transitions do
# the :request_more_information action can transition from
# :received, :with_it or :with_hr to :needs_more_info.
# we do not allow closed tickets to request more information.
transition :request_more_information,
from: [:received, :with_it, :with_hr],
to: :needs_more_info
end
endThe syntax is: transition (one or more actions), from: (one or more states), to: (one or more states).
Note: You must define transitions for your actions. If you call
change transition_state and there isn't a matching from and
to state, the action will fail.
Conditional state transitions
Sometimes you may need to transfer to one of multiple states depending upon a particular condition, such as a passed-in argument or an application-specific value.
State transitions based upon an argument
In our example, we will let a support user transfer a ticket to the IT
department or the HR department by passing an
argument to
the :assign_to_department action
First we will update our action:
actions do
update :assign_to_department do
argument :department, :atom,
allow_nil?: false,
constraints: [
one_of: [:IT, :HR]
]
if :department == :IT do
change transition_state(:with_it)
else
change transition_state(:with_hr)
end
end
endWe do not want to let the departments transfer tickets to each other,
so we will not allow a transition from :with_it to :with_hr or
vice versa.
Note that the conditional does not bypass any transition rules.
Even when transitions are chosen tynamically, the resulting
state must still be permitted by the transitions block.
state_machine do
transitions do
# assign_to_dept can transition from
# :received or :needs_more_info
# and can transition to
# :with_it or :with_hr
transition(:assign_to_department,
from: [:received, :needs_more_info],
to: [:with_it, :with_hr]
)
end
end- [ ]
Advanced: State transitions based upon changesets
For more complex scenarios, you can also branch based upon the contents of a changeset, as the following example illustrates:
defmodule Start do
use Ash.Resource.Change
def change(changeset, _, _) do
if ready_to_start?(changeset) do
AshStateMachine.transition_state(changeset, :started)
else
AshStateMachine.transition_state(changeset, :aborted)
end
end
end
actions do
update :begin do
# for a dynamic state transition
change Start
end
endDeclaring a custom state attribute
As mentioned earlier, AshStateMachine uses the :state attribute by default.
When AshStateMachine is imported into a resource, the :state attribute is
created on the resource with the following definition:
attribute :state, :atom do
allow_nil? false
default AshStateMachine.Info.state_machine_initial_default_state(dsl_state)
public? true
constraints one_of: [
AshStateMachine.Info.state_machine_all_states(dsl_state)
]
endIn our example, if we wanted to change the name of the attribute from
:state to :status (to match the value from the previous tutorial),
we would do it like this:
state_machine do
initial_states([:pending])
default_initial_state(:pending)
state_attribute(:status) # <-- save state in an attribute named :status
endIf you need more control, you can declare the attribute yourself on the resource:
attributes do
attribute :alternative_state, :atom do
allow_nil? false
default :issued
public? true
constraints one_of: [:issued, :sold, :reserved, :retired]
end
endBe aware that the type of this attribute needs to be :atom or a type
created with Ash.Type.Enum. Both the default and list of values
need to be correct!
Next steps
The true power of AshStateMachine is that it integrates seemlessly with the rest of the Ash ecosystem. You can easily add guards, policies, authorisation and any other Ash concept to your state machines.