Tour of brainy

Currently brainy has a very simple API surface. You can create finite state machines, that is having states you are transitioning to thanks to events. When you enter in a state, its list of actions will be called sequentially.

Let's see what is possible to do with brainy.

States

States are the base element of a state machine. A state is the position in which a state machine is. State machines can take a finite number of positions, that we need to define ourselves.

The simplest finite state machine is the on/off state machine, that could represent a light switch. This state machine has two states: on and off.

Let's see how we can implement this state machine with brainy.

Define states

The first step is to import brainy and to define a brainy state machine, thanks to Machine struct.

package main

import "github.com/Devessier/brainy"

func main() {
    lightSwitchStateMachine := brainy.Machine{}
}

We can now add the two states to the state machine:







 
 
 
 



package main

import "github.com/Devessier/brainy"

func main() {
    lightSwitchStateMachine := brainy.Machine{
        StateNodes: brainy.StateNodes{
            "on": brainy.StateNode{},
            "off": brainy.StateNode{},
        },
    }
}

With brainy we define states through StateNodes field. The brainy.StateNodes type is a map[brainy.StateNode]brainy.StateNode. As brainy.StateNode is a custom type that refers to a string, we can use string literals to define our states. Although, outside of prototyping, we would prefer to extract the states names to constants with the type brainy.StateNode, so we know instinctively what their purpose is.

Define initial state

We defined our switch light state machine to have two states, on and off. But what is the initial state? Is the light on or off by default?

With brainy, we need to define the initial state explicitly. It can be done by using the Initial field of Machine type. Let's say that by default, the light is off:







 
 







package main

import "github.com/Devessier/brainy"

func main() {
    lightSwitchStateMachine := brainy.Machine{
        Initial: "off",

        StateNodes: brainy.StateNodes{
            "on": brainy.StateNode{},
            "off": brainy.StateNode{},
        },
    }
}

Call .Init() method

For brainy to go directly to the Initial state, we need to call the .Init() method on the state machine. This method will perform some internal work that is necessary for the state machine to work properly.














 


package main

import "github.com/Devessier/brainy"

func main() {
    lightSwitchStateMachine := brainy.Machine{
        Initial: "off",

        StateNodes: brainy.StateNodes{
            "on": brainy.StateNode{},
            "off": brainy.StateNode{},
        },
    }
    lightSwitchStateMachine.Init()
}

Access current state

State machines allow to encapsulate logic in a single piece of code, and to describe logical combinations explicitly so that the logical state is never in an unknown state. Although having put logic at a single place is great, we still need to know in which state we are. We still need to know in which state our lightSwitchStateMachine is, so that we can update the look of the toggle button linked to it, for example.

With brainy, to know what is the current state of a state machine, we can use the method .Current().



















 
 
 


package main

import (
    "fmt"

    "github.com/Devessier/brainy"
)

func main() {
    lightSwitchStateMachine := brainy.Machine{
        Initial: "off",

        StateNodes: brainy.StateNodes{
            "on": brainy.StateNode{},
            "off": brainy.StateNode{},
        },
    }
    lightSwitchStateMachine.Init()

    currentState := lightSwitchStateMachine.Current()
    fmt.Printf("The current state of the state machine is: %s\n", currentState) // off
}

Events

We saw how to use states to define which positions a state machine can take. To go from one state to another one, to transition from one state to another one, we need another tool: events.

To transition from one state to another one, we need to tell brainy which events are expected to be received for each state. By default, if an event is received and it has not been defined for the current state, nothing happens.

Let's define a toggle event to go from on to off states, and vice versa.

Define events

With brainy, to define the events we want to catch in a given state, we need to list them in On map and say to which state to transition. We must use brainy.Events type that is a map[brainy.EventType]brainy.StateType. brainy.EventType is a string type, and we can use string literals during prototyping.














 
 
 
 
 
 
 
 
 
 








package main

import (
    "fmt"

    "github.com/Devessier/brainy"
)

func main() {
    lightSwitchStateMachine := brainy.Machine{
        Initial: "off",

        StateNodes: brainy.StateNodes{
            "on": brainy.StateNode{
                On: brainy.Events{
                    "toggle": "off",
                },
            },
            "off": brainy.StateNode{
                On: brainy.Events{
                    "toggle": "on",
                },
            },
        },
    }
    lightSwitchStateMachine.Init()

    currentState := lightSwitchStateMachine.Current()
    fmt.Printf("The current state of the state machine is: %s\n", currentState) // off
}

Send events

Now that we defined the transitions, we need to figure out how to send these events to the state machine.

To send an event to the state machine, we call the .Send() method with the event we want to send.






























 
 
 
 
 
 
 
 
 
 


package main

import (
    "fmt"

    "github.com/Devessier/brainy"
)

func main() {
    lightSwitchStateMachine := brainy.Machine{
        Initial: "off",

        StateNodes: brainy.StateNodes{
            "on": brainy.StateNode{
                On: brainy.Events{
                    "toggle": "off",
                },
            },
            "off": brainy.StateNode{
                On: brainy.Events{
                    "toggle": "on",
                },
            },
        },
    }
    lightSwitchStateMachine.Init()

    currentState := lightSwitchStateMachine.Current()
    fmt.Printf("The current state of the state machine is: %s\n", currentState) // off

    lightSwitchStateMachine.Send("toogle")

    stateAfterFirstToggle := lightSwitchStateMachine.Current()
    fmt.Printf("The state of the state machine after the first toogle is: %s\n", stateAfterFirstToggle) // on

    lightSwitchStateMachine.Send("toogle")

    stateAfterSecondToggle := lightSwitchStateMachine.Current()
    fmt.Printf("The state of the state machine after the second toogle is: %s\n", stateAfterSecondToggle) // off
}

Sending an unknown event

The .Send() method returns two things: the state reached after the event has been received, and an error that could occur during the transition.

In general we do not need to take care of these two returns, but the error can be used to know if the event we sent was handled by the state we were in. We can make use of errors.Is to determine if the returned error means the transition was impossible.

import "errors"

nextState, err := lightSwitchStateMachine.Send("toogle")
if err != nil {
    // An error occured during the transition
    if errors.Is(err, brainy.ErrInvalidTransitionNotImplemented) {
        // The event `toggle` is not handled by the state we are in.

        return
    }

    // Another error occured.
    //
    // The only reason an error can be raised in a transition, except that the transition was not implemented,
    // is that an error occured in an `action` of the state we reached.
    // We will see `actions` in the following section.
}

When an unknown event is received, the state of the state machine remains the same. The philosophy behind that is that with state machines, we need to explicitly write what can occur. We must write which states are possible, which events lead to which events. Anything that has not been explicitly described should never modify the state machine.

Actions

Now we can go from one state to another one thanks to events sending. This is great! But in a real world state machine, we would like to perform some side effects when the state machine reaches a state.

To do that, we can define actions on the states of our state machines, through Actions field, of type brainy.Actions:















 
 
 
 
 
 
 
 





 
 
 
 
 
 
 
 






















package main

import (
    "fmt"

    "github.com/Devessier/brainy"
)

func main() {
    lightSwitchStateMachine := brainy.Machine{
        Initial: "off",

        StateNodes: brainy.StateNodes{
            "on": brainy.StateNode{
                Actions: brainy.Actions{
					func(m *brainy.Machine, c brainy.Context) (brainy.EventType, error) {
						fmt.Println("Reached on state")

						return brainy.NoopEvent, nil
					},
				},

                On: brainy.Events{
                    "toggle": "off",
                },
            },
            "off": brainy.StateNode{
                Actions: brainy.Actions{
					func(m *brainy.Machine, c brainy.Context) (brainy.EventType, error) {
						fmt.Println("Reached off state")

						return brainy.NoopEvent, nil
					},
				},

                On: brainy.Events{
                    "toggle": "on",
                },
            },
        },
    }
    lightSwitchStateMachine.Init()

    currentState := lightSwitchStateMachine.Current()
    fmt.Printf("The current state of the state machine is: %s\n", currentState) // off

    lightSwitchStateMachine.Send("toogle")

    stateAfterFirstToggle := lightSwitchStateMachine.Current()
    fmt.Printf("The state of the state machine after the first toogle is: %s\n", stateAfterFirstToggle) // on

    lightSwitchStateMachine.Send("toogle")

    stateAfterSecondToggle := lightSwitchStateMachine.Current()
    fmt.Printf("The state of the state machine after the second toogle is: %s\n", stateAfterSecondToggle) // off
}

Actions are functions that take a pointer to the state machine and the context of the state machine. The context of a state machine is any data structure you want, that contains things related to the state machine, that could not be expressed through finite states. For example, you could store in the context of power in Volt that goes through the light switch. This value can not be expressed with finite states, as by its nature it can take any value.

An action needs to return two things: an inner event to send to the state machine after the action has ended, and an error. By returning an event from an action, you can simulate some XState features, such as guarded transitions.

If you do not want to trigger a transition after an action has been executed, you can return the special event brainy.NoopEvent.

If you return a non-nil error from an action, actions processing will be short-circuited and the error will be returned by call to .Send() that triggered the transition. Although the error is returned, the state of the state machine will remain the same.