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
bin/rails solid_objects:doctor
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
# synchronous RPC from anywhere — addressed by name:
Counter.ref("global").increment(amount: 5) # => 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|
# every policy denies by default. one ownership predicate gates
# mutations, reads, and the Cable stream:
owns_cart = lambda do |actor_type:, actor_id:, authorization_context:, **|
user = authorization_context&.current_user
actor_type == ShoppingCart.actor_type &&
user.present? &&
actor_id == user.id.to_s
end
config.authorize_message = owns_cart # add_item + other mutations
config.authorize_query = owns_cart # initial server render
config.authorize_subscription = owns_cart # Action Cable stream
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) — synchronous RPC (ref.async(...) to fire-and-forget) |
| 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 — a taken seat is a terminal reject the synchronous caller catches, and the hold expires itself.
class ShowSeats < SolidObjects::Actor
attribute :held, default: -> { {} }
def hold(seat:, user:)
# serialized: no double-booking. reject rolls back — no retry, no dead letter.
reject "seat_taken", "seat already held" if self.held.key?(seat)
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, and solid_objects:doctor verifies your config, schema, policies, and a workerless synchronous round-trip. Every authorization policy denies by default — a fresh install is intentionally inert until you configure policies.
bundle add solid_objects
bin/rails generate solid_objects:install
bin/rails db:migrate
bin/rails solid_objects:doctor
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
Policies deny by default. For a server-only prototype you can allow everything (below); in production, bind actor_type and actor_id to a trusted context — see the reactive example. A synchronous call assists mailbox execution under the same leases and fencing as workers, so no separate process is required. Run solid_objects start only for async, effects, reminders, and broadcasts.
# config/initializers/solid_objects.rb — development / pilot, NOT production:
SolidObjects.configure do |config|
config.authorize_message = ->(**) { true }
config.authorize_query = ->(**) { true }
end
# synchronous RPC — the caller assists execution, no worker needed:
Counter.ref("global").increment(amount: 5) # => 5
Counter.ref("global").value # => 5
# async, effects, reminders & broadcasts need the runtime:
Counter.ref("global").async(:increment, amount: 1)
# $ 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.2.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.