gem: solid_objects

Cloudflare Durable Objects, ported to Rails.

Solid Objects brings addressable, durable virtual actors to Rails: each identity has persisted state and an ordered mailbox, and its methods run one at a time. Wrap a view in solid_object and it live-updates in every open browser the moment state commits. It runs on SQLite, PostgreSQL, or MySQL — the database you already have. No Redis, no separate actor service.

Early pre-1.0 — expect breaking changes version 0.13.0 license MIT requires Ruby 3.3+ · Rails 8.0+ databases SQLite / PostgreSQL / MySQL
Install

Add the gem and install the schema

Solid Objects installs its tables into your existing database and ships a doctor task to verify the setup.

terminalshell
bundle add solid_objects
bin/rails generate solid_objects:install
bin/rails db:migrate
bin/rails solid_objects:doctor

Write an ordinary Ruby class

app/actors/counter.rbruby
class Counter < SolidObjects::Actor
  attribute :value, default: 0

  def increment(amount: 1)
    self.value += amount
  end
end

# Synchronous, caller-assisted RPC. No worker fleet required.
counter = Counter.ref("global")
count = counter.increment(amount: 5)
current = counter.value

Direct calls and sync durably enqueue the call, then the Rails caller helps execute the actor through the same mailbox, lease, and fencing path as a worker. Run solid_objects start only for background delivery, effects, reminders, and broadcasts.

Reactive ERB

Views that live-update when state commits

Declare which values are observable, wrap the view in solid_object, and render them as methods. When a committed turn changes an observable, Solid Objects re-renders on the server and pushes a Turbo Stream replacement over Action Cable. No channels, no manual broadcasts, no Stimulus.

app/actors/counter.rbruby
class Counter < SolidObjects::Actor
  attribute :value, default: 0
  observable :value

  def increment(amount: 1)
    self.value += amount
  end
end
app/views/rooms/show.html.erberb
<%= solid_object Counter.ref("global") do |counter| %>
  <span class="count"><%= counter.value %></span>
<% end %>

Observables cross the wire to every authorized subscriber, so keep private or subscriber-specific state in personalized payloads or reauthorized component endpoints. See docs/realtime.md.

How it works

How a turn commits

The same database-backed model as the Node package, expressed in idiomatic Rails: one mailbox, one fenced lease, one commit. Actor code runs outside the transaction; its result and everything it stages commit together, and a stale lease holder is rejected at commit time.

Actor commit flow: a call becomes a durable message in the mailbox; a worker claims the actor under a fenced lease; the handler runs outside the transaction; it stages the result and consequences; one fenced transaction commits state and staged work together; a reject or throw rolls back; a stale lease holder is rejected by fencing; external effects run at least once outside the transaction and deduplicate by a stable effect id; then a post-commit wake-up takes the next turn. Actor commit flow, top to bottom: a call becomes a durable message in the mailbox; a worker claims the actor under a fenced lease; the handler runs outside the transaction; it stages the result and consequences; one fenced transaction commits state and staged work together; a reject or throw rolls back; a stale lease holder is rejected by fencing; external effects run at least once outside the transaction and deduplicate by a stable effect id; then a post-commit wake-up takes the next turn.
The commit path for one actor turn. State and staged work commit atomically; external effects are the deliberate exception.

Delivery is ordered per identity and at least once. External effects run outside the transaction and can repeat, so make them idempotent and deduplicate by a stable id. The full contract is in correctness.md and architecture.md.

Guarantees and boundaries

What it promises, and what it does not

Verify every statement against docs/correctness.md and docs/architecture.md.

  • Ordered, at-least-once delivery per identity. Different identities run concurrently.

  • At most one valid lease holder commits. Fencing prevents split-brain writes.

  • State and staged consequences commit atomically in one fenced transaction.

  • !

    Operations and external effects can repeat. Make effects idempotent; deduplicate by a stable id.

  • !

    One hot identity is intentionally serialized and cannot be scaled by adding workers.

  • !

    No general cross-actor transaction. Use a same-database commit action for atomic application writes.

  • !

    Cloudflare's edge placement, global routing, and managed scaling are not reproduced. This runs on your Rails processes and SQL database.

Deployed reference application

MTG Playmat: reactive ERB in a deployed app

MTG Playmat is a deployed two-player Magic: The Gathering table built on Rails and the solid_objects gem. Two people open the same table in separate browsers, load decks, and play in real time. Each table is one durable PlaymatRoom actor; a solid_object view live-updates over Action Cable when the actor commits, and each connection receives its own authorized projection. Its only backing services are relational: Solid Objects, Solid Queue, Solid Cache, and Solid Cable on SQLite. No Redis.

What the deployed application exercises. Every row is verified in its source and tests.
Production concern How MTG Playmat handles it Source
Concurrent writes One PlaymatRoom actor per table serializes every player action into one ordered history. playmat_room.rb
Persistence Actor state is stored in SQLite, alongside Solid Queue, Solid Cache, and Solid Cable. database.yml
Reactive ERB A solid_object view live-updates over Action Cable when the actor commits. show.html.erb
Private realtime state Each connection gets its own payload projection; the opponent's hand and library render as “Hidden card.” Tested in broadcast values, rendered opponent HTML, and a snapshot unit test. room_snapshot.rb
Realtime observables Value broadcasts (turn version, life totals) and invalidation-only signals per seat that carry no card data. playmat_room.rb
Authorization Deny-by-default policies; a non-player receives a 403 from the component-refresh endpoint (tested). solid_objects.rb
Operations A message-retention policy (including a per-actor one-day rule) and OpenTelemetry tracing of the hot actions. opentelemetry.rb

Scope of this evidence. The public deployment runs on one host with SQLite for every Rails database; it demonstrates a real deployed workload, not every deployment topology. This application exercises the actor, reactive-ERB, personalized-payload, authorization, and operational paths. It does not exercise durable effects, actor-to-actor messages, reminders, or state migrations, which the gem itself supports (see the documentation). Evaluate the guarantees against your own workload. The player experience at mtg.craftsmanfounder.com is a standalone application, not an interactive Solid Objects tutorial.

Solid Objects Pro

A hosted, Rails-only companion

Solid Objects Pro is a commercial product for the Rails implementation. The gem itself is MIT licensed and free.

Visit Solid Objects Pro
Documentation

Read the contract

Correctness

correctness.md

Architecture

architecture.md

Realtime

realtime.md

Fit

fit.md

Security

security.md

Why this exists

Author and project history

Author
Lucas Carlson has authored over 20 open source projects with over 10 million downloads and thousands of stars across GitHub projects.
Ruby and TypeScript
The Ruby gem is the original implementation; the Node package ports its programming model to idiomatic TypeScript. The two runtimes share no database schema or wire protocol.
Production
The Ruby implementation is used in a mature Rails codebase with over 100,000 users.
FAQ

Common questions

Do I need Redis or new infrastructure?

No. Like Solid Queue, Solid Cache, and Solid Cable, Solid Objects is backed by the relational database you already run. A PostgreSQL LISTEN/NOTIFY or Redis wake-up adapter is an optional latency layer, not a requirement.

Does it guarantee exactly-once execution?

No. It is at-least-once delivery with strict per-actor ordering, so handlers should be idempotent by guarding on durable state. Fencing ensures at most one valid activation can commit.

Which Ruby and Rails versions are required?

Ruby 3.3 or newer and Rails 8.0 or newer.

Can I use ActiveRecord models inside an actor?

You can read freely, but handlers cannot write to ActiveRecord directly. For an atomic same-database change, register a commit action and stage it from a handler; it runs inside the same fenced transaction. For external systems, use emit effects with a stable idempotency key.