Replika (Replika v0.1.1)

View Source

Replika 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

action_response()

@type action_response() ::
  {:next_state, atom()} | {:new_data, any()} | {:respond, any()}

Action responses returned by event handlers.

Can be one of:

  • {:next_state, atom()} - Change state
  • {:new_data, any()} - Update data
  • {:respond, any()} - Return a value

Functions

__using__(opts)

(macro)

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 to nil)

Examples

defmodule Unit.AccessState do
  use Replika, initial_state: :no_id_card, initial_data: %{inventory: []}
end

defevent(event)

(macro)

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

defevent(event, opts)

(macro)

Defines an event in the FSM with options but no implementation block.

Parameters

  • event: The name of the event
  • opts: 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]})

defevent(event, opts, list)

(macro)

Defines an event in the FSM with options and implementation block.

Parameters

  • event: The name of the event
  • opts: Options for the event
  • event_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

defeventp(event)

(macro)

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

defeventp(event, opts)

(macro)

Defines a private event in the FSM with options but no implementation block.

Parameters

  • event: The name of the event
  • opts: 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)

defeventp(event, opts, list)

(macro)

Defines a private event in the FSM with options and implementation block.

Parameters

  • event: The name of the event
  • opts: Options for the event
  • event_def: The event implementation block

Examples

# Private event with options and block
defeventp validate_card, data: %{inventory: inventory} do
  :id_card in inventory
end

defstate(state, state_def)

(macro)

Defines a state in the FSM.

Parameters

  • state: The name of the state
  • state_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

next_state(state)

@spec next_state(atom()) :: {:action_responses, [{:next_state, atom()}]}

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

next_state(state, data)

@spec next_state(atom(), any()) ::
  {:action_responses, next_state: atom(), new_data: any()}

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

respond(response)

@spec respond(any()) :: {:action_responses, [{:respond, any()}]}

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

respond(response, state)

@spec respond(any(), atom()) ::
  {:action_responses, next_state: atom(), respond: any()}

Returns a response and transitions to a new state.

Parameters

  • response: The value to return from the event handler
  • state: The state to transition to

Examples

defevent examine_card do
  respond({:card_info, "SECTOR B ACCESS"}, :has_id_card)
end

respond(response, state, data)

@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 handler
  • state: The state to transition to
  • data: The new data value

Examples

defevent select_floor(floor) do
  respond({:travelling_to, floor}, :elevator_accessed, %{inventory: [:id_card]})
end