Solid Objects is a Ruby gem that brings addressable objects with durable state to Rails — the same virtual-actor model behind Cloudflare Durable Objects and Microsoft Orleans. No new infrastructure: it runs on the database you already have, following the same Solid Queue principles as the rest of the Solid family.
bundle add solid_objects
bin/rails generate solid_objects:install
bin/rails db:migrate
bundle exec solid_objects start
Durable Objects give a program an object it can address by name, whose state survives, and whose methods run one at a time. Solid Objects gives you that exact shape in Ruby.
import { DurableObject } from "cloudflare:workers"
export class Counter extends DurableObject {
async increment(amount = 1) {
const value = (await this.ctx.storage.get<number>("value")) ?? 0
await this.ctx.storage.put("value", value + amount)
return value + amount
}
}
// from your Worker — addressed by name:
const id = env.COUNTER.idFromName("global")
const stub = env.COUNTER.get(id)
await stub.increment(5)
class Counter < SolidObjects::Actor
attribute :value, default: 0
def increment(amount: 1)
self.value += amount
end
observable :value
end
# from anywhere in your app — addressed by name:
Counter.ref("global").increment(amount: 5)
<%= solid_object Counter.ref("global") do |counter| %>
<p>Live count: <strong><%= counter.value(:value) %></strong></p>
<% end %>
<%# increment from anywhere — every browser showing this page updates: %>
<%# Counter.ref("global").increment(amount: 1) %>
You just saw the counter own its state. Now watch it own its view. A Durable Object can hold a WebSocket and push updates; Solid Objects does the Rails-native version — but it's declarative in your template. Wrap ERB in solid_object, render an observable as a method, and the object's committed state streams itself to every connected browser. No channel classes. No manual broadcasts. No Stimulus controller. This is the easiest reactivity Rails has ever had.
class ShoppingCart < SolidObjects::Actor
attribute :items, default: -> { [] }
def add_item(sku:, quantity: 1)
self.items << { "sku" => sku, "quantity" => quantity }
end
# observables are the reactive targets the view renders — recomputed on every commit
observable :items_count do
self.items.sum { |item| item["quantity"] }
end
observable :subtotal_cents do
self.items.sum { |item| item["quantity"] * 2_350 }
end
end
<%= solid_object ShoppingCart.ref(current_user.id) do |cart| %>
<p>Items in cart: <%= cart.items_count %></p>
<p>Subtotal: <%= cart.subtotal_cents %></p>
<% end %>
# no broadcast call, no channel, no JS:
ShoppingCart.ref(current_user.id).add_item(sku: "SKU-9")
# solid_object already rendered a <turbo-cable-stream-source>.
# the committed change replaces just that <span> — nothing else.
SolidObjects.configure do |config|
config.stream_signing_secret = Rails.application.secret_key_base
config.authorize_subscription =
->(actor_id:, authorization_context:, **) do
authorization_context.current_user&.id.to_s == actor_id
end
end
If you already think in Durable Objects, you already know Solid Objects. The vocabulary maps almost one-to-one — it just runs inside your Rails app, against your relational database.
| Idea | Cloudflare Durable Objects | Solid Objects (Rails) |
|---|---|---|
| Addressable identity | env.NS.idFromName("id") | Actor.ref("id") |
| Invoke a method | await stub.method(args) | ref.method(args) (declared message / query) |
| Durable per-object storage | this.ctx.storage | attribute :name, default: … (native JSON) |
| Alarms / timers | ctx.storage.setAlarm(t) | schedule :name, at: t |
| Serialized execution | input gates (single-threaded) | ordered per-actor mailbox |
| Live updates to clients | ctx.acceptWebSocket() | reactive ERB via Turbo Streams |
| External side effects | fetch() | emit effects (transactional outbox) |
| Runtime & infrastructure | Cloudflare Workers platform | your PostgreSQL · MySQL · SQLite |
Plenty of gems do background work. None of them combine these four properties — the properties that make an actor durable instead of merely asynchronous.
There is no Ruby equivalent at all. Jobs are stateless. Models have state but no isolated behavior or serialized execution. In-memory actor gems (Celluloid, concurrent-ruby) die with the process. Solid Objects gives you a stable address whose JSON state lives in your database, is created on first message, and vanishes from memory when idle — without losing its identity or its state.
cart = ShoppingCart.ref("user-42") # cheap value — no object activated yet
cart.add_item(sku: "SKU-9", quantity: 2)
cart.add_item(sku: "SKU-3", quantity: 1)
cart.total_cents # => 4700
# Restart every server. Come back tomorrow. The address still resolves,
# the state is still there, and it activates on demand when you speak to it.
Solid Queue's limits_concurrency caps how many jobs run for a key — but it explicitly makes no promise about order. Two jobs for the same record can still interleave or run backwards. Solid Objects assigns each actor a durable sequence: messages are processed earliest-first, one turn at a time, and a retrying failure blocks later messages until it resolves or dead-letters.
class DebitJob < ApplicationJob
limits_concurrency key: ->(acct) { acct.id }
# caps concurrency — NOT ordering
end
DebitJob.perform_later(acct, 100)
DebitJob.perform_later(acct, 50) # may run first
class Account < SolidObjects::Actor
attribute :balance_cents, default: 0
def debit(cents:)
self.balance_cents -= cents
end
end
Account.ref("acct-1").debit(cents: 100)
Account.ref("acct-1").debit(cents: 50) # runs 2nd
Every other lease you can reach for uses bare expiry: a timeout hands the lease to a new worker, but a slow or paused old worker can wake up and still commit stale writes. That's split-brain. Solid Objects stamps each activation with a monotonic generation — a fencing token. Every state commit is conditional on owner + generation + an unexpired database-time lease. A stale owner's commit fails the predicate and the entire turn rolls back: state, effects, reminders, messages.
In Rails, recurring work is a single global schedule — Solid Queue recurring tasks, cron, whenever — that then has to scan for which records need attention. Solid Objects lets each instance schedule wake-ups for itself. Each occurrence arrives as an ordinary, in-order mailbox message, so it inherits the same sequencing and retry rules. It's Durable Objects' setAlarm(), per identity.
class Reservation < SolidObjects::Actor
attribute :status, default: "held"
def hold
# THIS reservation releases itself in 15 minutes — not a global sweep
schedule :release, at: 15.minutes.from_now
end
def release
self.status = "released" unless self.status == "confirmed"
end
end
# recurring, per identity: this subscriber, billed every 30 days
# schedule :charge, at: 30.days.from_now, every: 30.days, arguments: {}
Rate limiting, seat holds, chat rooms, sagas — the canonical stateful-coordination problems, written as Solid Objects actors.
A token bucket per API key that refills itself — no shared Redis counter, no race.
class RateLimiter < SolidObjects::Actor
attribute :tokens, default: 100
def consume
self.tokens -= 1 if self.tokens.positive?
end
query :allowed? do
self.tokens.positive?
end
def refill
self.tokens = 100
schedule :refill, at: 1.minute.from_now
end
end
Serialized execution means no double-booking — and the hold expires itself.
class ShowSeats < SolidObjects::Actor
attribute :held, default: -> { {} }
def hold(seat:, user:)
return if self.held.key?(seat) # no race: one turn at a time
self.held[seat] = user
schedule :release, at: 10.minutes.from_now,
arguments: { seat: seat }
end
def release(seat:)
self.held.delete(seat)
end
end
Effects run outside the transaction with a stable idempotency key; outcomes come back as ordered messages.
class Checkout < SolidObjects::Actor
attribute :step, default: "cart"
def place_order
self.step = "charging"
emit :charge_card, amount_cents: 4_700,
on_success: :charged, on_failure: :declined
end
def charged(effect_id:, result:)
self.step = "fulfilling"
send_to Warehouse.ref("main"), :ship, order_id: actor_id
end
def declined(effect_id:, error:)
self.step = "payment_failed"
end
end
SolidObjects.register_effect(:charge_card) do |arguments, context|
Stripe::Charge.create(amount: arguments["amount_cents"],
idempotency_key: context.id)
end
An addressable room that persists its log and streams new lines to every viewer through reactive ERB.
class ChatRoom < SolidObjects::Actor
attribute :messages, default: -> { [] }
def post(from:, body:)
self.messages << { "from" => from, "body" => body }
end
observable :messages
end
# app/views/rooms/show.html.erb
# <%= solid_object ChatRoom.ref(@room_id) do |room| %>
# <%= room.messages %>
# <% end %>
Solid Objects sits above the tools you already know. Here's what only it gives you.
| Capability | Solid Objects | Solid Queue | Sidekiq / ActiveJob | AR + row locks |
|---|---|---|---|---|
| Addressable identity | ✓ | ✗ | ✗ | ~ (the row) |
| Durable per-object state | ✓ | ✗ | ✗ | ✓ |
| Strictly ordered execution | ✓ | ✗ | ✗ | ~ (blocks) |
| Per-entity alarms | ✓ | global only | global only | ✗ |
| Fenced single-writer | ✓ | bare expiry | bare expiry | ~ (held lock) |
| Reactive views built in | ✓ | ✗ | ✗ | ✗ |
| Extra infrastructure | none | none | Redis | none |
Backed by PostgreSQL 14+, MySQL 8.0+, or SQLite 3.35+. Requires Ruby 3.3+ and Rails 8.0+.
It's a Rails engine. Install it, run the migrations, define an actor, and start the runtime.
Solid Objects ships its own migrations and an initializer.
bundle add solid_objects
bin/rails generate solid_objects:install
bin/rails db:migrate
bundle exec solid_objects start
Every public method you define becomes a durable message handler. Attributes are auto-exposed as ordered read queries and writable through self inside handlers; declare an observable for anything a reactive view should render. The block form message :name do … end stays available for dynamic definitions.
class Counter < SolidObjects::Actor
attribute :value, default: 0
def increment(amount: 1)
self.value += amount
end
end
A declared message becomes an async method on the ref (a durable tell); attributes and queries read back through ask as read-only snapshots. Workers, effects, reminders, and broadcasts all run under one supervisor.
Counter.ref("global").increment(amount: 5)
Counter.ref("global").value # => 5
# then, in a process next to your web server:
# $ bundle exec solid_objects start
No. Solid Objects is an independent, open-source Ruby gem. It is inspired by Cloudflare Durable Objects and Microsoft Orleans, and brings the same virtual-actor model to Rails. "Durable Objects" is a trademark of Cloudflare, Inc.
An addressable object with durable state and serialized execution. You reference it by type and id (Counter.ref("global")), send it messages, and it owns its own JSON state and its own single-threaded mailbox. The object is activated on demand and can be evicted from memory when idle without losing its identity or state — the defining properties of a virtual actor.
No. Like Solid Queue, Solid Cache, and Solid Cable, Solid Objects is backed by the relational database you already run: PostgreSQL 14+, MySQL 8.0+, or SQLite 3.35+. There is no Redis and no separate service to operate.
Those run stateless jobs — no identity, and no ordering guarantee (Solid Queue's limits_concurrency throttles concurrency but explicitly not order). Solid Objects gives you addressable, stateful objects with strictly ordered per-object execution, per-object alarms via schedule, and fenced single-writer safety so a stale worker can never commit over a newer one.
No — and no honest distributed system does. It is at-least-once delivery with strict per-actor ordering, so handlers should be idempotent by guarding on durable state (return if self.status == "launched"). Fencing tokens guarantee at most one valid activation can commit, which is what prevents split-brain writes.
Declare an observable on your actor, wrap your ERB in solid_object, and render it as a method (for example cart.items_count). When a message commits and changes that observable, Solid Objects renders a Turbo Stream replacement and pushes it over Action Cable automatically. You write no channel, no broadcast call, and no Stimulus.
Ruby 3.3+ and Rails 8.0+.
It's early — this is v0.1. The correctness model (leasing, fencing, ordering, outbox, migration, reconciliation) is documented in depth in the repository's architecture notes and ADRs. Evaluate it carefully for your workload and expect the API to evolve before 1.0.