The StateDefinition
type allows us to define a state, which consists of the following fields:
InitialState
: a boolean that indicates whether the machine can transition from a nil state to the state in questionTransitions
: a list of the names of states it is possible to transition to from the state in questionOn
: A function that is called when transitioning to the state in question. The signature of this function is as follows:func(nextStateName string, args ...interface{}) error
. It receives the previous state name (astring
) as the first argument, an arbitrary list of typeinterface{}
as the second argument and returns an error
someStateDefinition := fsm.StateDefinition{
InitialState: true,
Transitions: []string{
"someOtherState1",
"someOtherState2",
},
On: func(previousState string, args interface{}) error {
// this is just a placeholder function that doesn't do anything
return nil
},
}
Please note that all of the above fields are optional and do not have to be defined for all states.
For example a particular state might not allow further transitions, in which case its Transitions
field would be nil
. Leaving it undefined in such a case would be completely fine.
The same applies to the On
field. If there is nothing further to be done on transitioning to a particular state, its On
field maybe be ignored.
It is worth noting that if InitialState
is not specified, it defaults to false. So if a particular state is not an initial state we may safely leave out its InitialState
property.
A Machine
is simply a collection of states and exists in a particular state at any given time. A machine can be in a nil
state until it is initialized to an initial state. To create a new machine, one must call New()
with 2 arguments and 1 optional argument:
- the first argument is a
string
that indicates which state the machine should occupy when it is first created. This value can be an emptystring
:""
, in which case the machine would occupy anil
state when it is first created - the second argument is a map from state names to
StateDefinitions
and defines all the possible states the machine can occupy over its lifetime - the third argument is an optional function, internally called
reconcileUpdate
, that is called on updating the state of the machine. This is useful when the update in state needs to be reconciled with an underlying store or database. The signature of this function is as follows:func(nextStateName string, args ...interface{}) error
. It receives the previous state name (astring
) as the first argument, an arbitrary list of typeinterface{}
as the second argument and returns an error
The New()
function returns a new machine and an error. The only cases where an error is returned are:
- If any of the states is named the empty
string
:""
, in which case the following error is returned:ErrIllegalStateName
- if any of the state definitions lists an undefined state under
Transitions
, in which case the following error is returned:ErrUndefinedState
machine, err := fsm.New("", map[string]fsm.StateDefinition{
"STATE_1": {
InitialState: true,
Transitions: []string{
"STATE_2",
},
On: func(previousState string, args interface{}) error {
// this is just a placeholder function that doesn't do anything
return nil
},
},
"STATE_2": {
Transitions: []string{
"STATE_3",
},
},
"STATE_3": {},
}, func(nextStateName string, args ...interface{}) error {
return nil
})
We can get the current state of a machine by calling its State()
method. This method returns a string
specifying the name of the current state.
currentStateName := machine.State()
To transition a machine's state, we call the machine's ReconcileForState()
method. This method requires two arguments and returns an error:
- the first argument is a
string
indicating the name of the state to transition to - the second argument is list of type
interface{}
and is passed to both the machine'sreconcileState
function (if it is defined) and the state'sOn
function (if it is defined)
err := machine.ReconcileForState("STATE_1", nil)
If ReconcileForState()
is called with the machine's current state, it will return immediately, since the machine is already in the desired state. Please note that in this case neither the machine's reconcileUpdate
function, nor the state's On
function is called. For this reason, it is sometimes necessary to provide an empty initial state when generating a new machine in order to make sure that the associated On
function is called when the machine eventually assumes the desired initial state.
When ReconcileForState()
is called, it determines if the state transition is allowed. If the transition is not allowed, it will return the following error: ErrUndefinedTransition
.
Alternately, if the current state of the machine is nil
and the next state does not have its InitialState
field set to true
, the following error will be returned: ErrNilToNonInitialTransition
Suppose we wanted to implement the following state machine for some type, call it Order:
We can do so by defining an Order type that embeds a state machine:
// Here we define the Order type which embeds the FSM
type Order struct {
machine *fsm.Machine
}
Before we generate the state machine, it would be handy to have all possible state names defined somewhere as constants:
const (
Shipped = "SHIPPED"
InDepot = "IN_DEPOT"
OutForDelivery = "OUT_FOR_DELIVERY"
Delivered = "DELIVERED"
)
// Function to call when transitioning to the SHIPPED state
func (order *Order) OnShipped(previousState string, args interface{}) error {
return nil
}
// Function to call when transitioning to the IN_DEPOT state
func (order *Order) OnInDepot(previousState string, args interface{}) error {
return nil
}
// Function to call when transitioning to the OUT_FOR_DELIVERY state
func (order *Order) OnOutForDelivery(previousState string, args interface{}) error {
return nil
}
// Function to call when transitioning to the DELIVERED state
func (order *Order) OnDelivered(previousState string, args interface{}) error {
return nil
}
Once the state names and on functions have been defined, we may generate the state machine by calling a method on Order, which in this case is InitializeStateMachine()
:
func (order *Order) InitializeStateMachine() error {
order.machine, err := fsm.New("", map[string]fsm.StateDefinition{
Shipped: {
// Indicates whether the machine can transition from a nil state to this state
InitialState: true,
// A list of possible transitions from this state
Transitions: []string{
InDepot,
},
// An optional function that is called on transition to this state
On: order.OnShipped,
},
InDepot: {
Transitions: []string{
OutForDelivery,
},
On: order.OnInDepot,
},
OutForDelivery: {
Transitions: []string{
InDepot,
Delivered,
},
On: order.OnOutForDelivery,
},
Delivered: {
On: order.OnDelivered,
},
})
return err
}
The state machine transitions state via calls to: ReconcileForState(nextStateName string, args ...interface{})
To continue with our Order
example, new orders can be initialized to the Shipped
state like so:
order := new(Order)
order.InitializeStateMachine()
// Note how the initial state is set by calling ReconcileForState
// This way the OnShipped method is called when the order is
// initialized to the Shipped state
order.machine.ReconcileForState(Shipped, nil)
In the real world we would almost always call ReconcileForState within another method that appropriately represents the business logic of our application:
func (order *Order) Ship(trackingID string) error {
return order.machine.ReconcileForState(Shipped, trackingID)
}
func (order *Order) MarkAsDelivered(signature string) error {
return order.machine.ReconcileForState(Delivered, signature)
}