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
actor_type "counter"
state do
attribute :value, default: 0
end
message :increment do |amount: 1|
state.value += amount
end
observable :value
end
# from anywhere in your app — addressed by name:
Counter.ref("global").tell(:increment, amount: 5)
<%= actor_scope 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").tell(: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 actor_scope, drop in an observable, 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
actor_type "cart"
state do
attribute :items, default: -> { [] }
end
message :add_item do |sku:, quantity: 1|
state.items << { "sku" => sku, "quantity" => quantity }
end
# a named projection of state — what the view watches
observable :items_count do
state.items.sum { |item| item["quantity"] }
end
observable :subtotal_cents do
state.items.sum { |item| item["quantity"] * 2_350 }
end
end
<%= actor_scope ShoppingCart.ref(current_user.id) do |cart| %>
<p>Items in cart: <%= cart.value :items_count %></p>
<p>Subtotal: <%= cart.value :subtotal_cents %></p>
<%# a whole partial, kept live: %>
<%= cart.component :summary %>
<% end %>
# no broadcast call, no channel, no JS:
ShoppingCart.ref(current_user.id).tell(:add_item, sku: "SKU-9")
# actor_scope already rendered a <turbo-cable-stream-source>.
# the committed change replaces just that <span> / component.
SolidObjects.configure do |config|
config.stream_signing_secret = Rails.application.secret_key_base
config.authorize_subscription =
->(actor_type:, 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.tell(...) / ref.ask(...) |
| Durable per-object storage | this.ctx.storage | state do attribute … end (native JSON) |
| Alarms / timers | ctx.storage.setAlarm(t) | remind :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.tell(:add_item, sku: "SKU-9", quantity: 2)
cart.tell(:add_item, sku: "SKU-3", quantity: 1)
cart.ask(: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
actor_type "account"
state { attribute :balance_cents, default: 0 }
message :debit do |cents:|
state.balance_cents -= cents
end
end
Account.ref("acct-1").tell(:debit, cents: 100)
Account.ref("acct-1").tell(: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
actor_type "reservation"
state { attribute :status, default: "held" }
message :hold do
# THIS reservation releases itself in 15 minutes — not a global sweep
remind :release, at: 15.minutes.from_now
end
message :release do
state.status = "released" unless state.status == "confirmed"
end
end
# recurring, per identity: this subscriber, billed every 30 days
# remind :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
actor_type "rate_limiter"
state { attribute :tokens, default: 100 }
message :consume do
state.tokens -= 1 if state.tokens.positive?
end
query :allowed? do
state.tokens.positive?
end
message :refill do
state.tokens = 100
remind :refill, at: 1.minute.from_now
end
end
Serialized execution means no double-booking — and the hold expires itself.
class ShowSeats < SolidObjects::Actor
actor_type "show_seats"
state { attribute :held, default: -> { {} } }
message :hold do |seat:, user:|
return if state.held.key?(seat) # no race: one turn at a time
state.held[seat] = user
remind :release, at: 10.minutes.from_now,
arguments: { seat: seat }
end
message :release do |seat:|
state.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
actor_type "checkout"
state { attribute :step, default: "cart" }
message :place_order do
state.step = "charging"
emit :charge_card, amount_cents: 4_700,
on_success: :charged, on_failure: :declined
end
message :charged do |effect_id:, result:|
state.step = "fulfilling"
send_to Warehouse.ref("main"), :ship, order_id: actor_id
end
message :declined do |effect_id:, error:|
state.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
actor_type "chat_room"
state { attribute :messages, default: -> { [] } }
message :post do |from:, body:|
state.messages << { "from" => from, "body" => body }
end
observable :messages
end
# app/views/rooms/show.html.erb
# <%= actor_scope ChatRoom.ref(@room_id) do |room| %>
# <%= room.component :transcript %>
# <% 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 7.2+.
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
State, messages, queries, and observables — the whole surface is a small DSL.
class Counter < SolidObjects::Actor
actor_type "counter"
state { attribute :value, default: 0 }
message :increment do |amount: 1|
state.value += amount
end
query :value do
state.value
end
observable :value
end
tell is fire-and-forget; ask waits for a durable result. Workers, effects, reminders, and broadcasts all run under one supervisor.
Counter.ref("global").tell(:increment, amount: 5)
Counter.ref("global").ask(: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 remind, 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 state.status == "launched"). Fencing tokens guarantee at most one valid activation can commit, which is what prevents split-brain writes.
Declare an observable, wrap your ERB in actor_scope, and drop in .value or .component. 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 7.2+.
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.