# Replika 🦾
A pure functional finite state machine library for Elixir.
## Overview 🔍
Replika implements finite state machines as immutable data structures that transform during state transitions. This approach provides:
* **No Process Overhead** - Simple struct updates instead of message passing
* **Immutable Data Model** - Predictable state transitions without side effects
* **Pattern Matching** - Leverage Elixir's pattern matching for transition logic
* **Embeddable** - Can be stored in ETS, databases, or embedded in processes
* **Clear Error Handling**: Descriptive errors for invalid transitions and states
## Installation 📦
Add Replika to your `mix.exs` dependencies:
```elixir
def deps do
[
{:replika, "~> 0.1.0"}
]
end
```
## Example 🧑🏫
This example models a game character's state machine for accessing secured areas. The character starts with no ID card, can pick one up, approach an elevator, and use the card to gain access.
Each state defines valid transitions to other states, and each transition can include data transformation:
```elixir
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], floors_visited: []})
end
end
defstate has_id_card do
defevent approach_elevator do
next_state(:at_elevator, %{inventory: [:id_card], floors_visited: []})
end
end
defstate at_elevator do
defevent use_card, data: %{inventory: inventory, floors_visited: visited} do
if :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory, floors_visited: visited})
else
next_state(:at_elevator, %{inventory: inventory, floors_visited: visited})
end
end
defevent leave_elevator, data: data do
next_state(:has_id_card, data)
end
end
defstate elevator_accessed do
defevent select_floor(floor), data: %{inventory: inv, floors_visited: visited} do
next_state(:elevator_accessed, %{inventory: inv, floors_visited: [floor | visited]})
end
defevent get_visited_floors, data: %{floors_visited: visited} do
respond(visited)
end
defevent exit_elevator, data: data do
next_state(:has_id_card, data)
end
end
end
```
The state machine above defines:
* Four distinct states: `:no_id_card`, `:has_id_card`, `:at_elevator`, and `:elevator_accessed`
* Multiple events that trigger transitions between states
* Associated data that transforms with each state change
* Conditional transitions based on inventory contents
## Usage 🔧
Interacting with the state machine is straightforward. Each defined event becomes a function you can call on your FSM instance:
```elixir
# Create a new FSM instance
elster = Unit.AccessState.new()
# Progress through the state machine by calling event functions
elster = elster
|> Unit.AccessState.pickup_card()
|> Unit.AccessState.approach_elevator()
|> Unit.AccessState.use_card()
# Inspect the current state
Unit.AccessState.state(elster) # Returns :elevator_accessed
# Events can return values while changing state
{:travelling_to, floor, elster} = Unit.AccessState.select_floor(elster, "b6")
```
The FSM tracks both state and data through transformations. Each event function returns either:
* The updated FSM instance
* A tuple containing a response value AND the updated FSM instance
## Error Handling ⚠️
Replika provides clear error messages when you attempt invalid transitions. If you try to execute an event that isn't defined for the current state, you'll get a helpful error message:
```elixir
try do
# Starting with no card
elster = Unit.AccessState.new()
# Pick up a card, putting us in :has_id_card state
elster = Unit.AccessState.pickup_card(elster)
# Try to pick up a card again - but we're already in :has_id_card state!
# This raises an InvalidTransitionError since pickup_card is only defined
# for the :no_id_card state
Unit.AccessState.pickup_card(elster)
rescue
e in Replika.Error.InvalidTransitionError ->
IO.puts(e.message)
# "Invalid transition: cannot execute event 'pickup_card' with args [] in state ':has_id_card'"
end
```
The error message includes:
* The current state (`:has_id_card`)
* The invalid event (`pickup_card`)
* Any arguments passed to the event (`[]`)
This makes debugging much easier compared to normal function clause errors.
## Pattern Matching and Guards 🧩
Pattern matching lets you handle different cases within the same state based on data conditions. Combined with guards, this provides powerful control flow:
```elixir
defstate at_elevator do
# First clause: match when ID card is in inventory
# The guard clause (when) ensures this only matches when :id_card is in inventory
defevent use_card, data: %{inventory: inventory} when :id_card in inventory do
next_state(:elevator_accessed, %{inventory: inventory})
end
# Second clause: match when ID card is NOT in inventory (fallback case)
# This handles the case where inventory doesn't contain :id_card
defevent use_card, data: %{inventory: inventory} do
# Return an error response and remain in the same state
respond({:error, :missing_id_card}, :at_elevator, %{inventory: inventory})
end
end
```
This pattern lets you:
* Define different behaviors for the same event based on data conditions
* Use guard clauses for precise control over which clause matches
* Return different values or transition to different states based on conditions
* Handle error cases elegantly
## Returning Values 📤
Events can return values to the caller while optionally changing state. This is useful for querying the FSM or implementing commands that produce a result:
```elixir
defstate elevator_accessed do
# Query event: returns data without changing state
defevent get_visited_floors, data: %{floors_visited: visited} do
# First arg is the return value, no state change
respond(visited)
end
# Command event: returns data AND changes state
defevent select_floor(floor), data: %{inventory: inv, floors_visited: visited} do
# Return a tuple, update state, and modify data
respond(
{:travelling_to, floor}, # Return value
:elevator_accessed, # New state (unchanged)
%{inventory: inv, floors_visited: [floor | visited]} # New data
)
end
end
```
Using `respond/1`, `respond/2`, or `respond/3`:
* `respond(value)` - Returns value without changing state or data
* `respond(value, new_state)` - Returns value and changes state
* `respond(value, new_state, new_data)` - Returns value, changes state, and updates data
When using respond, the event call returns a tuple: `{return_value, updated_fsm}`.
## Global Event Handlers 🌐
Sometimes you need to handle unexpected events or provide fallback behavior. The special `_` event can catch undefined events:
```elixir
defmodule SecuritySystem do
use Replika, initial_state: :armed, initial_data: %{alert_count: 0}
defstate armed do
# Defined events
defevent disarm(code) when code == 1234 do
next_state(:disarmed)
end
# Catch-all for any other event in the :armed state
# This will catch any undefined event while in the armed state
defevent _ do
# Increment alert count and remain armed
next_state(:armed, %{alert_count: data.alert_count + 1})
end
end
defstate disarmed do
defevent arm do
next_state(:armed, %{alert_count: 0})
end
end
# Global catch-all for any undefined event in any state
# This only triggers if there's no state-specific handler
defevent _ do
respond({:error, :invalid_operation})
end
end
```
The event handlers are checked in order:
1. Exact event/state match
2. State-specific catch-all (`_` event in the current state)
3. Global catch-all (`_` event outside any state)
This gives you complete control over error handling and fallback behavior.
## Dynamic FSM Creation 🏗️
Replika's macros enable programmatic FSM definition - useful for state machines with regular patterns:
```elixir
defmodule ProtektorElevator do
use Replika, initial_state: :b1, initial_data: %{log: []}
# Define all possible floors
floors = [:b1, :b2, :b3, :b4, :b5, :b6]
# Generate states and transitions dynamically
for current_floor <- floors do
defstate current_floor do
# For each floor, create transitions to all other floors
for target_floor <- floors, target_floor != current_floor do
# Create a "go_to_X" event for each possible target floor
defevent :"go_to_#{target_floor}", data: %{log: log} do
# Log the transition and update state
next_state(
target_floor,
%{log: ["#{current_floor} -> #{target_floor}" | log]}
)
end
end
# Add inspection event to see travel history
defevent :travel_history, data: %{log: log} do
respond(log)
end
end
end
end
```
This generates a complete elevator control system with:
* One state for each floor
* Events to travel between any floors
* Event naming that matches the destination (e.g., `go_to_b6`)
* Automatic logging of all floor transitions
Using metaprogramming this way creates sophisticated state machines with minimal code.
## Performance ⏱️
Replika FSMs are lightweight data structures that make state transitions in constant time. There's no process creation, message passing, or serialization overhead. This makes Replika ideal for:
- High-throughput systems with many FSM instances
- Applications that need to persist state machine state
- Embedding state machines within existing processes
Run the benchmarks to compare with `gen_state_machine`:
```
mix bench
```
or
```
mix run bench/replika_bench.exs
```
## License 📄
This project is licensed under:
[](https://www.mozilla.org/en-US/MPL/2.0/)