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.
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.
bundle add solid_objects
bin/rails generate solid_objects:install
bin/rails db:migrate
bin/rails solid_objects:doctor
Write an ordinary Ruby class
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.
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.
class Counter < SolidObjects::Actor
attribute :value, default: 0
observable :value
def increment(amount: 1)
self.value += amount
end
end
<%= 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 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.
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.
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.
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.
Live application · Source · Tests · CI
| 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.
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 ProRead the contract
Author and project history
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.