gem · solid_objects
Durable Objects in your Rails app
Prevent race conditions in your Rails app. Give each cart, booking, or job one actor that updates its state one call at a time. Keep its state and recovery logic together, backed by the SQL database you already use.
Read the five-minute guideWhy this library exists
Fixing race conditions in Rails often starts with a model lock or a database constraint. Honeybadger’s guide walks through those options and job deduplication. As a checkout grows, coordination can spread across model locks, service objects, background jobs, and cleanup schedules.
With Solid Objects, a cart’s checkout, expiry, retries, and recovery are one workflow. Solid Objects gives that workflow one actor: its state, operations, reminders, and recovery decisions stay together, with calls running one at a time. Database constraints and payment provider idempotency still belong in the solution.
How this compares
Four ways to keep two requests from corrupting one record. This table does not rank the projects. It shows what each one adds to a Rails app, and where each one stops.
| Solid Objects A gem in your Rails app | Cloudflare Durable Objects A managed platform | celld A daemon on your VMs | SQL transaction with_lock, already in Rails | |
|---|---|---|---|---|
| Runs with no new service | Yes A Redis wake-up is optional | No The Cloudflare platform | No A daemon on every node | Yes |
| State lives in your app database | Yes SQLite, PostgreSQL, or MySQL | No Managed storage for each object | No One SQLite file for each object, in your bucket | Yes |
| One call at a time for each record | Yes | Yes | Yes | No Only inside one transaction |
| Work continues after a restart | Yes | Yes | Yes | No The transaction rolls back |
| Reminders that survive a deploy | Yes Rows, not a cron sweeper | Yes Alarms | Yes Alarms | No A column and a sweeper |
| Effects commit with the state | Yes A staged outbox | No You write it | No You write it | No |
| Turbo Stream updates with no channel code | Yes Reactive ERB | No | No | No |
| Runs inside your Rails process | Yes | No | No | Yes |
| One transaction across two records | No | No | No | Yes In the same database |
| Runs with no vendor account | Yes | No | Yes | Yes |
Solid Objects follows the Solid Queue, Solid Cache, and Solid Cable pattern. It adds
tables to the database you already run, and no new service. Delivery is ordered and at
least once, so an external effect must be idempotent.
If the whole invariant fits inside one request, use
with_lock instead.
The full table, with a primary source for every row, is in
docs/comparisons.md.
Practical answers
Race conditions in Rails: frequently asked questions
Lost updates, duplicate work, and recovery: what to use, what to keep, and where Solid Objects fits.
How do I prevent race conditions in Rails without Redis?
Give each shared resource one durable owner and route its state changes through that owner. Solid Objects provides a Ruby actor for each cart, room, or job identity, with calls ordered one at a time and state stored in your existing SQLite, PostgreSQL, or MySQL database. Different identities can run concurrently. You do not need Redis or a distributed-lock service. Every write path that affects the actor’s invariant must use that actor; installing the gem does not protect code that bypasses it.
How do I prevent double bookings or inventory overselling in Rails?
Put the availability check and reservation in the same actor operation. Choose an identity that owns the scarce resource: one actor per bookable room, event, or stock item, rather than separate carts independently checking the same inventory. The next reservation sees the previous committed reservation. A durable reminder can release an unpaid hold through that same actor. Keep database constraints as appropriate; a cart actor alone cannot enforce inventory rules across other actors. The Rails quickstart demonstrates a ticket hold that expires after a restart.
Why does Rails uniqueness validation still allow duplicate records?
Two requests can both pass a uniqueness validation before either inserts its record. A database unique index is the right foundation for that invariant, with application handling for a conflicting insert. Solid Objects becomes useful when the rule grows into a workflow, such as reserving a name, expiring the reservation, and confirming ownership later. It coordinates those steps per identity; it does not replace your unique index. See the Active Record concurrency guidance.
When should I use Solid Objects instead of with_lock or advisory locks?
Use with_lock, an atomic SQL update, or an advisory lock when a short
transaction covers the whole operation. Solid Objects fits when coordination
continues across requests, jobs, timeouts, or restarts. Its actor keeps state
transitions, durable reminders, and recovery decisions together, while the library
manages ownership and retries. You stop rebuilding the same lock, expiry, and
recovery protocol around each resource. A database lock is still the simpler choice
for a single bounded update.
Can Solid Objects stop Sidekiq jobs from applying the same change twice?
A job can call an actor operation that checks durable state, such as whether an order has already advanced to the next step. Repeated calls then follow the same guarded transition instead of racing through separate read-modify-write paths. Solid Objects does not change Sidekiq’s delivery guarantees, and its own delivery is at least once. Use a stable business request identifier or a durable state guard to recognize repeats. Serialization prevents overlapping state changes; idempotency handles repeated requests.
Does one cart actor prevent duplicate payments?
It can coordinate the cart’s payment state so concurrent checkout requests do not each start an independent payment workflow. Stage an effect with the state change, then handle success, failure, and recovery on the actor. The payment provider still needs a stable idempotency key for the logical payment: an external request can succeed even if the worker crashes before recording the result. Recovery does not cancel an old request or guarantee exactly-once charging. See the correctness contract.
What happens to delayed jobs and recovery when Rails restarts?
Committed reminders, messages, and effects stay in SQL while the runtime is stopped.
When it runs again, due work can resume through the durable mailbox. A successful
turn commits actor state and staged work together, avoiding a separate
save-then-enqueue gap. An uncommitted turn may run again. For an abandoned effect,
an on_recovery callback lets the actor decide its next step after
library-coordinated retirement; a stale heartbeat does not prove the old external
action stopped.
Do I need a new daemon, external service, or cloud subscription?
No Redis server, message broker, hosted actor service, or paid Solid Objects
subscription is required. The core gem is MIT licensed and uses your application’s
SQL database. Background messages, reminders, effects, and broadcasts do require the
library’s runner, normally bundle exec solid_objects start, alongside
your Rails deployment. Direct calls can execute without that worker. This removes a
separate infrastructure product to operate; it does not remove the need to run
application code or pay for your own hosting.
Can an actor update my existing Active Record models atomically?
Yes, for bounded writes in the same database, register a commit action and stage it from the actor. It executes in the actor’s fenced commit transaction so the application write and actor state commit together. Actor handlers may read application records, but must not write them directly. Use effects for external I/O. This gives the workflow one logical home while preserving an explicit boundary between deciding a change and committing it.
When is Solid Objects a good fit for a Rails application?
Choose it when one resource has state that several requests or jobs must update in order, especially when it also needs expiry, retries, or restart recovery. Booking holds, carts, rooms, and per-account workflows are natural examples. Keep a unique index or a short transaction for simpler invariants. Solid Objects is pre-1.0, and it does not provide transactions across actor identities. Start with the runnable Rails guide and test the failure paths your application depends on.