Replika (Replika v0.1.1)
View SourceReplika is a pure functional finite state machine library for Elixir.
Unlike process-based state machines, Replika implements state machines as immutable data structures that transform as they transition between states. This provides significant performance benefits while simplifying state management.
Key Benefits
- High Performance - State transitions are simple struct updates (O(1))
- No Process Overhead - Works as a lightweight data structure
- Immutable & Pure - Predictable state transitions without side effects
- Embeddable - Can be stored in ETS, databases, or embedded in other processes
- Pattern Matching - Pattern matching is optimized by the BEAM VM
- Deterministic Memory Usage - Low memory footprint at scale
Structure
When you use Replika in a module, it defines a struct with the following fields:
%YourModule{
state: atom(), # The current state of the FSM
data: any() # The data associated with the current state
}
The state
field holds the current state name (atom), and the data
field can hold any associated data for that state.
Example
The following diagram illustrates a simple state machine with four states:
+---------------+
| v
+----------------+ +----------------+ +-------------------+
| :no_id_card |--->| :has_id_card |--->| :at_elevator |
+----------------+ +----------------+ +-------------------+
|
v
+--------------------+
| :elevator_accessed |
+--------------------+
This diagram can then be represented by the following:
defmodule Unit.AccessState do
use Replika, initial_state: :no_id_card, initial_data: %{inventory: []}
defstate no_id_card do
defevent pickup_card do
next_state(:has_id_card, %{inventory: [:id_card]})
end
end
defstate has_id_card do
defevent approach_elevator do
next_state(:at_elevator, %{inventory: [:id_card]})
end
end
defstate at_elevator do
defevent use_card, data: %{inventory: inventory} do
if :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory})
else
# Remain in the same state if missing card
next_state(:at_elevator, %{inventory: inventory})
end
end
defevent leave_elevator do
next_state(:has_id_card, %{inventory: [:id_card]})
end
end
defstate elevator_accessed do
defevent select_floor(floor) do
respond({:travelling_to, floor}, :elevator_accessed, %{inventory: [:id_card]})
end
end
end
Usage:
We can interact with the created state machine like this:
elster = Unit.AccessState.new()
# Progress through the game
elster = elster
|> Unit.AccessState.pickup_card()
|> Unit.AccessState.approach_elevator()
|> Unit.AccessState.use_card()
# Unit can now use the elevator
Unit.AccessState.state(elster) # :elevator_accessed
# Select a floor
{response, elster} = Unit.AccessState.select_floor(elster, "b6")
Working with Data
FSMs often need to carry data alongside their state:
defmodule InventorySystem do
use Replika, initial_state: :empty, initial_data: %{items: []}
defstate empty do
defevent add_item(item), data: %{items: items} do
new_items = [item | items]
if length(new_items) > 0 do
next_state(:has_items, %{items: new_items})
else
next_state(:empty, %{items: new_items})
end
end
end
defstate has_items do
defevent add_item(item), data: %{items: items} do
next_state(:has_items, %{items: [item | items]})
end
defevent remove_item(item), data: %{items: items} do
new_items = List.delete(items, item)
if new_items == [] do
next_state(:empty, %{items: []})
else
next_state(:has_items, %{items: new_items})
end
end
defevent list_items, data: %{items: items} do
respond(items)
end
end
end
Error Handling
Replika provides clear error messages for invalid transitions:
# Trying an invalid transition
inventory = InventorySystem.new()
|> InventorySystem.add_item("Keycard")
# This would cause an error because 'list_items' is only defined in :has_items state
InventorySystem.list_items(inventory)
# You'll see a clear error:
# ** (Replika.Error.InvalidTransitionError) Invalid transition: cannot execute
# event 'list_items' with args [] in state ':empty'
Pattern Matching and Guards
You can leverage Elixir's pattern matching and guards for sophisticated state logic:
defstate at_elevator do
# Different handling based on item in inventory
defevent use_card, data: %{inventory: inventory} when :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory})
end
# Handle case where ID card is missing
defevent use_card, data: %{inventory: inventory} do
respond({:error, :missing_id_card}, :at_elevator, %{inventory: inventory})
end
end
Summary
Types
Action responses returned by event handlers.
Functions
When used, defines a new Replika state machine.
Declares an event in the FSM without implementation.
Defines an event in the FSM with options but no implementation block.
Defines an event in the FSM with options and implementation block.
Declares a private event in the FSM without implementation.
Defines a private event in the FSM with options but no implementation block.
Defines a private event in the FSM with options and implementation block.
Defines a state in the FSM.
Transitions to a new state without changing data.
Transitions to a new state and updates data.
Returns a response without changing state or data.
Returns a response and transitions to a new state.
Returns a response, transitions to a new state, and updates data.
Types
Functions
When used, defines a new Replika state machine.
Options
:initial_state
- The initial state of the FSM (required):initial_data
- The initial data of the FSM (optional, defaults tonil
)
Examples
defmodule Unit.AccessState do
use Replika, initial_state: :no_id_card, initial_data: %{inventory: []}
end
Declares an event in the FSM without implementation.
Parameters
event
: The name of the event (and optionally its arity)
Examples
# Declare a zero-arity event
defevent pickup_card
# Declare a two-arity event
defevent use_item/2
Defines an event in the FSM with options but no implementation block.
Parameters
event
: The name of the eventopts
: Options for the event (must include :do option with the implementation)
Examples
# Event with implementation in the options
defevent pickup_card, do: next_state(:has_id_card, %{inventory: [:id_card]})
Defines an event in the FSM with options and implementation block.
Parameters
event
: The name of the eventopts
: Options for the eventevent_def
: The event implementation block
Examples
# Event with options and block
defevent use_card, data: %{inventory: inventory} do
if :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory})
else
next_state(:at_elevator, %{inventory: inventory})
end
end
Declares a private event in the FSM without implementation.
Works the same as defevent/1
but generates a private function instead of a public one.
Examples
# Declare a private zero-arity event
defeventp internal_transition
Defines a private event in the FSM with options but no implementation block.
Parameters
event
: The name of the eventopts
: Options for the event (must include :do option with the implementation)
Examples
# Private event with implementation in the options
defeventp internal_scan, do: next_state(:scanning)
Defines a private event in the FSM with options and implementation block.
Parameters
event
: The name of the eventopts
: Options for the eventevent_def
: The event implementation block
Examples
# Private event with options and block
defeventp validate_card, data: %{inventory: inventory} do
:id_card in inventory
end
Defines a state in the FSM.
Parameters
state
: The name of the statestate_def
: The state definition block
Examples
defstate at_elevator do
defevent use_card, data: %{inventory: inventory} do
if :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory})
else
next_state(:at_elevator, %{inventory: inventory})
end
end
end
Transitions to a new state without changing data.
This function is used within event handlers to change the state machine's state while preserving its existing data.
Parameters
state
: The target state to transition to (atom)
Returns
An action response tuple {:action_responses, [{:next_state, atom()}]}
that will be processed by the state machine.
Examples
defevent pickup_card do
next_state(:has_id_card) # Transition to :has_id_card state
end
Transitions to a new state and updates data.
This function is used within event handlers to simultaneously change the state machine's state and update its associated data.
Parameters
state
: The target state to transition to (atom)data
: The new data value to store
Returns
An action response tuple {:action_responses, [{:next_state, atom()}, {:new_data, any()}]}
that will be processed by the state machine.
Examples
defevent pickup_card do
next_state(:has_id_card, %{inventory: [:id_card]})
end
Returns a response without changing state or data.
This function is used within event handlers to return a value to the caller without modifying the state machine's state or data.
Parameters
response
: The value to return from the event handler
Examples
defevent list_items, data: %{items: items} do
respond(items) # Return items to caller, no state/data change
end
Returns a response and transitions to a new state.
Parameters
response
: The value to return from the event handlerstate
: The state to transition to
Examples
defevent examine_card do
respond({:card_info, "SECTOR B ACCESS"}, :has_id_card)
end
@spec respond(any(), atom(), any()) :: {:action_responses, next_state: atom(), new_data: any(), respond: any()}
Returns a response, transitions to a new state, and updates data.
Parameters
response
: The value to return from the event handlerstate
: The state to transition todata
: The new data value
Examples
defevent select_floor(floor) do
respond({:travelling_to, floor}, :elevator_accessed, %{inventory: [:id_card]})
end