Durable Objects for Elixir - persistent, single-instance objects accessed by ID.

This library provides a programming model for stateful, persistent actors in Elixir, leveraging native GenServer capabilities, Ecto for persistence, and the Spark DSL for a declarative developer experience.

Features

  • Global Uniqueness: One instance per (module, object_id) pair across the cluster
  • Persistent State: State survives process crashes and restarts via Ecto
  • Automatic Lifecycle: Processes hibernate after inactivity, optionally shut down
  • Alarm Scheduling: Built-in support for future work with database-backed persistence
  • Declarative DSL: Define objects with Spark DSL for clean, expressive code
  • Distribution Ready: Optional Horde integration for multi-node clusters

Installation

Add durable_object to your dependencies:

def deps do
  [
    {:durable_object, "~> 0.1.0"},
    # Optional: for distributed mode
    {:horde, "~> 0.10"},
    # Optional: for Oban-based alarm scheduling
    {:oban, "~> 2.17"}
  ]
end

Quick Setup with Igniter

mix igniter.install durable_object

Manual Setup

  1. Generate and run the migration:
mix durable_object.gen.migration
mix ecto.migrate
  1. Configure DurableObject in your application:
# config/config.exs
config :durable_object,
  repo: MyApp.Repo,
  registry_mode: :local,  # or :horde for distributed
  object_keys: :strings,  # :strings | :atoms! | :atoms — controls map key conversion on load
  scheduler: DurableObject.Scheduler.Polling,
  scheduler_opts: [
    polling_interval: :timer.seconds(30),
    claim_ttl: :timer.seconds(60)
  ]

Usage

Define a Durable Object

defmodule MyApp.Counter do
  use DurableObject

  state do
    field :count, :integer, default: 0
    field :last_incremented_at, :utc_datetime
  end

  handlers do
    handler :increment, args: [:amount]
    handler :get
    handler :reset
  end

  options do
    hibernate_after :timer.minutes(5)
    shutdown_after :timer.hours(1)
  end

  def handle_increment(amount \\ 1, state) do
    new_count = Map.get(state, :count, 0) + amount
    new_state = %{state | count: new_count, last_incremented_at: DateTime.utc_now()}
    {:reply, new_count, new_state}
  end

  def handle_get(state) do
    {:reply, Map.get(state, :count, 0), state}
  end

  def handle_reset(state) do
    {:reply, :ok, %{state | count: 0}}
  end
end

Use the Generated Client API

The DSL automatically generates client functions:

# Increment by 5
{:ok, 5} = MyApp.Counter.increment("user-123", 5)

# Get current count
{:ok, 5} = MyApp.Counter.get("user-123")

# Reset
{:ok, :ok} = MyApp.Counter.reset("user-123")

Or Use the Generic API

{:ok, 5} = DurableObject.call(MyApp.Counter, "user-123", :increment, [5])
{:ok, 5} = DurableObject.call(MyApp.Counter, "user-123", :get)

Alarms

Schedule work to happen in the future:

defmodule MyApp.RateLimiter do
  use DurableObject

  state do
    field :requests, :integer, default: 0
    field :window_start, :utc_datetime
  end

  handlers do
    handler :check, args: [:limit]
  end

  # Schedule initial alarm when object is first loaded
  @impl DurableObject.Behaviour
  def after_load(state) do
    if is_nil(state.window_start) do
      {:ok, %{state | window_start: DateTime.utc_now()},
       {:schedule_alarm, :reset_window, :timer.minutes(1)}}
    else
      {:ok, state}
    end
  end

  def handle_check(limit, state) do
    if state.requests < limit do
      {:reply, :allowed, %{state | requests: state.requests + 1}}
    else
      {:reply, :rate_limited, state}
    end
  end

  @impl DurableObject.Behaviour
  def handle_alarm(:reset_window, state) do
    # Reset the window and reschedule
    {:noreply, %{state | requests: 0, window_start: DateTime.utc_now()},
     {:schedule_alarm, :reset_window, :timer.minutes(1)}}
  end
end

Distribution with Horde

For multi-node clusters, enable Horde:

# config/config.exs
config :durable_object,
  registry_mode: :horde

This ensures:

  • Only one instance of each object exists across the cluster
  • Objects are automatically migrated when nodes join/leave
  • Alarms fire exactly once (singleton poller)

Telemetry

DurableObject emits telemetry events for observability:

  • [:durable_object, :storage, :save, :start | :stop | :exception]

  • [:durable_object, :storage, :load, :start | :stop | :exception]

  • [:durable_object, :storage, :delete, :start | :stop | :exception]

License

MIT License - see LICENSE for details.