Tutorial
In this tutorial, we will go through the process of designing a DSL for a finite state machine. We will use such state machine to express the lifecycle of a payment.
A state machine for payments
defmodule MyApp.Payments.Payment do
use MyApp.Fsm
fsm do
state :pending do
on event: :created do
action SendToGateway
next state: :sent
end
end
state :sent, timeout: 60 do
on event: :success do
action NotifyParties
next state: :accepted
end
on event: :error do
action NotifyParties
next state: :declined
end
on event: :timeout do
action NotifyParties
next state: :declined
end
end
state :accepted do
end
state :declined do
end
end
endThe Fsm library module
First, lets define the MyApp.Fsm library module:
defmodule MyApp.Fsm do
use Diesel,
otp_app: :my_app,
dsl: MyApp.Dsm.Dsl # optional
endThis module will import the api offered by the actual dsl, to be implemented in this example by module MyApp.Fsm.Dsl.
The :dsl key is optional. If omitted, it will default to the caller module, suffixed by Dsl. The above example is equivalent to:
defmodule MyApp.Fsm do
use Diesel, otp_app: :my_app
endDefining the DSL
We will need the following elements of the language:
fsm: the root tag of the dslstate: a definition of a stateaction: an action to be triggered as soon as we enter a stateon: the definition of an event, in a given statenext: the next state to transition into
defmodule MyApp.Fsm.Dsl do
use Diesel.Dsl,
otp_app: :my_app,
root: MyApp.Fsm.Dsl.Fsm, # optional
tags: [
MyApp.Fsm.Dsl.Action,
MyApp.Fsm.Dsl.Next,
MyApp.Fsm.Dsl.On,
MyApp.Fsm.Dsl.State
]
endThe :root key is optional. If omitted, a naming convention will be applied, so that the
above example is equivalent to:
defmodule MyApp.Fsm.Dsl do
use Diesel.Dsl,
otp_app: :my_app,
tags: [
...
]
endIn the next sections, we will define these as structured tags by relying on the Diesel.Tag built-in dsl.
The fsm root tag
The fsm tag is the root of our DSL. It supports one or many state children tags:
defmodule MyApp.Fsm.Dsl.Fsm do
use Diesel.Tag
tag do
child :state, min: 1
end
endThe state tag
The state tag requires:
- a
nameattribute (this is the default attribute name in Diesel). The accepted values are:pending,sent,accepted,declined. - an optional
timeoutattribute - zero, one or multiple
onchildren
defmodule MyApp.Fsm.Dsl.State do
use Diesel.Tag
tag do
attribute :name, kind: :atom, one_of: [:pending, :sent, :accepted, :declined]
attribute :timeout, kind: :number, required: false
child :on, min: 0
end
endThe :name attribute of any given tag is implicit. For example, the following notation:
state :pending do
...
endis equivalent to:
state name: :pending do
...
endThe action tag
The action supports the name of an Elixir module as its only child:
defmodule MyApp.Fsm.Dsl.Action do
use Diesel.Tag
tag do
child kind: :module, min: 1, max: 1
end
endWhen there is a single child involved, the notation
action SomeModuleis equivalent to:
action do
SomeModule
endThe on tag
The on tag supports:
- the
nameof the event, as an attribute - exactly one
nextchild, as the next state - zero, one or multiple
actionmodules to execute as part of the state transition
defmodule MyApp.Fsm.Dsl.On do
use Diesel.Tag
tag do
attribute :event, kind: :atom
child :next, min: 0, max: 1
child :action, min: 0
end
endThe next tag
The next tag only supports the next state to transition to, as the name attribute:
defmodule MyApp.Fsm.Dsl.Next do
use Diesel.Tag
tag do
attribute :state, kind: :atom, one_of: [:pending, :sent, :accepted, :declined]
end
endParsing the DSL
We can convert the resulting dsl into an alternative representation, for easier consumption during code generation.
For example, if we define a custom Transition struct to model state transitions:
defmodule MyApp.Fsm.Transition do
@moduledoc """
A custom representation of a state transition
* `from` is the source state
* `to` is the target state
* `event` is the triggering event
* `actions` is a list of Elixir modules to execute as side effects
"""
defstruct [:from, :to, :event, :actions]then we can define a parser module that transforms the original raw definition (a tree of tuples) into a plain list of Transition structrs:
defmodule MyApp.Fsm do
use Diesel,
otp_app: ...,
dsl: ...,
parsers: [
MyApp.Fsm.Parser
]
endPlease check the Fsm.Parser module included in test/support/fsm.ex.
The :parsers key is optional. If omitted, a default parser will be used, by appending
the Parser suffix to the caller module. The above example is equivalent to:
defmodule MyApp.Fsm do
use Diesel,
otp_app: ...,
dsl: ...,
endIn reality, parsers are optional. If you wish to skip them entirely, you can set an empty list:
defmodule MyApp.Fsm do
use Diesel,
otp_app: ...,
dsl: ...,
parsers: []
endGenerating code
Once our state machine is parsed into a list of transitions, we can then generate any custom code of our choice and inject it into our MyApp.Fsm module.
Generated code is provided by implementations of the Diesel.Generator behaviour. A generator returns one or more Elixir quoted expressions from its generate/2 callback.
For example, in order to generate a diagram/0 function that returns a Graphviz diagram for our state machine, we could make use of module Fsm.Diagram, also included in test/support/fsm.ex:
defmodule MyApp.Fsm do
use Diesel,
otp_app: ...,
dsl: ...,
parsers: ...,
generators: [
Fsm.Diagram
]
end