Ruby gem  ·  Orleans-style virtual actors  ·  v0.1

Cloudflare Durable Objects,
ported to Rails.

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.

terminalshell
bundle add solid_objects
bin/rails generate solid_objects:install
bin/rails db:migrate
bundle exec solid_objects start
The same object, two runtimes

A distributed counter — Workers on the left, Rails on the right.

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.

Cloudflare Durable Objects · TypeScript
counter.tstypescript
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)
Solid Objects · Ruby on Rails
app/actors/counter.rbruby
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)
…and in a Rails view, the same object renders itself — live
app/views/counters/show.html.erberb
<%= 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) %>

The part you won't find anywhere else

Durable Objects that render themselves.

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.

1 · Declare state and observables
app/actors/shopping_cart.rbruby
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
2 · Use it in a plain ERB template
app/views/carts/show.html.erberb
<%= solid_object ShoppingCart.ref(current_user.id) do |cart| %>
  <p>Items in cart: <%= cart.items_count %></p>
  <p>Subtotal: <%= cart.subtotal_cents %></p>
<% end %>
tell(:add_item) turn commits observable changed Turbo Stream replace every browser updates
3 · Mutate from anywhere
the trigger — a controller, an API, another actorruby
# 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.
4 · Authorize the stream once
config/initializers/solid_objects.rbruby
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
No lost-update divergence. The signed token proves integrity, not authority — the channel still runs your authorize_subscription policy before streaming. And because a reconnect refreshes from current actor state, a missed broadcast is only ever temporary staleness, never permanent drift.

Concept for concept

Every Durable Objects primitive has a Rails home.

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 identityenv.NS.idFromName("id")Actor.ref("id")
Invoke a methodawait stub.method(args)ref.method(args) (declared message / query)
Durable per-object storagethis.ctx.storageattribute :name, default: … (native JSON)
Alarms / timersctx.storage.setAlarm(t)schedule :name, at: t
Serialized executioninput gates (single-threaded)ordered per-actor mailbox
Live updates to clientsctx.acceptWebSocket()reactive ERB via Turbo Streams
External side effectsfetch()emit effects (transactional outbox)
Runtime & infrastructureCloudflare Workers platformyour PostgreSQL · MySQL · SQLite
What's genuinely new in Ruby

Four guarantees Rails didn't have before.

Plenty of gems do background work. None of them combine these four properties — the properties that make an actor durable instead of merely asynchronous.

01
Addressable identity with durable state

An object you can name — that remembers.

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.

the same address, from anywhereruby
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.
02
Ordered per-entity delivery

Messages to one object run in order, one at a time.

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.

Solid Queue — concurrency, not order
debit_job.rbruby
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
Solid Objects — strict per-entity order
account.rbruby
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
03
Fenced activation

At most one valid writer — provably.

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.

Worker Agen 5 · claims leaseruns turn…stalls ⏸
Worker Blease expires →gen 6 · takes overcommit ✓
Worker Awakes, tries to commit →gen 5 ✗ rejected→ rolled back
Combined with strict ordering, this is why the runtime contract can read: messages for one actor are durably enqueued and processed sequentially, at least once, by at most one valid activation at a time.
04
Per-entity reminders

Every object gets its own alarm clock.

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.

reservation.rbruby
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: {}

Durable Objects, the Rails way

The patterns people reach for Durable Objects to solve.

Rate limiting, seat holds, chat rooms, sagas — the canonical stateful-coordination problems, written as Solid Objects actors.

Distributed rate limiter

A token bucket per API key that refills itself — no shared Redis counter, no race.

rate_limiter.rbruby
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

Seat booking

Serialized execution means no double-booking — and the hold expires itself.

show_seats.rbruby
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

Checkout saga

Effects run outside the transaction with a stable idempotency key; outcomes come back as ordered messages.

checkout.rbruby
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

Live chat room

An addressable room that persists its log and streams new lines to every viewer through reactive ERB.

chat_room.rb + room.html.erbruby
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 %>

Where it fits

Not another job queue.

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 alarmsglobal onlyglobal only
Fenced single-writerbare expirybare expiry~ (held lock)
Reactive views built in
Extra infrastructurenonenoneRedisnone

Backed by PostgreSQL 14+, MySQL 8.0+, or SQLite 3.35+. Requires Ruby 3.3+ and Rails 8.0+.


Five minutes

Get started.

It's a Rails engine. Install it, run the migrations, define an actor, and start the runtime.

Add the gem

Solid Objects ships its own migrations and an initializer.

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

Define an actor

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.

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

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

Talk to it, and run the runtime

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.

console + terminalruby
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

Questions

Frequently asked.

Is Solid Objects affiliated with Cloudflare?

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.

What is a "Ruby port of Durable Objects," exactly?

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.

Do I need Redis or any new infrastructure?

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.

How is it different from Solid Queue, Sidekiq, or ActiveJob?

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.

Does it guarantee exactly-once execution?

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.

How do real-time updates work?

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.

Which Ruby and Rails versions are required?

Ruby 3.3+ and Rails 8.0+.

Is it production ready?

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.