This guide is for a coding agent that evaluates or adds Solid Objects in a Ruby on Rails application. It gives the facts and steps in a fixed order. Each step links to the maintained documentation for more detail.
Use Solid Objects only if you can answer yes to most of these questions:
- Does one identity, such as a cart, room, booking, account, or device, own the state?
- Must writes for that identity happen one at a time across requests, jobs, and processes?
- Must some work happen later or continue after a process restart?
- Is the state a bounded JSON document, not a large relational dataset?
- Can the application run one more process,
bundle exec solid_objects start, for reminders and background work?
Select a simpler tool in these cases:
| Requirement | Use instead |
|---|---|
| The invariant fits in one request | with_lock, a unique index, or a short transaction |
| CPU-intensive or parallel work | Background jobs or Ractors. An actor serializes work |
| Thousands of writes for each second to one key | A counter column, a cache, or Solid Objects Pro |
| One atomic change across two actor identities | One SQL transaction on normal tables |
| Exactly-once calls to an external API | Not available from any actor library. Use provider idempotency keys |
The full checklist is in Choosing Solid Objects. The category guide is Virtual actors in Ruby on Rails.
Problem guides compare the plain Rails fix with an actor and test each claim:
- Prevent race conditions in Rails
- Run jobs in order for each customer
- Expiring reservations
- Save state and queue work together
| Item | Value |
|---|---|
| Gem | solid_objects |
| Repository | https://github.com/cardmagic/solid-objects-ruby |
| Website | https://solidobjects.dev/ruby |
| Node.js package | solid-objects on npm, a separate port with the same model |
| Commercial extension | solid_objects_pro, a separate paid gem. The open-source gem does not need it |
Solid Objects is not part of Rails. It is not Solid Queue, Solid Cache, or Solid Cable. It is not affiliated with Cloudflare.
- Ruby 3.3 or newer.
- Rails 7.1 or newer. The gem is a Rails engine. It does not run without Rails.
- SQLite 3.35 or newer, PostgreSQL 14 or newer, or MySQL 8.0 or newer with InnoDB.
- No Redis and no separate actor service.
bundle add solid_objects
bin/rails generate solid_objects:install
bin/rails db:migrate
bin/rails solid_objects:doctorThe generator adds an initializer and copies migrations into the application. The doctor checks the configuration, the tables, and one real actor round trip.
Install the current release. bundle add solid_objects selects it. Do not pin
a version that you remember from earlier work; the API changed between
releases. The current version is on https://rubygems.org/gems/solid_objects.
The generated policies deny every call. Do step 5 before you call an actor.
The json gem 3.x works only with Active Support 8.1.4 or newer. On Rails
7.1, 7.2, 8.0, or 8.1 before 8.1.4, pin gem "json", "~> 2" in the
Gemfile. Without the pin, Active Support raises an ArgumentError, such as
unknown keyword: quirks_mode, for every JSON column.
Installing and upgrading has the details.
Every policy in the generated initializer denies by default. A new installation answers no actor call until you write a policy. Do not remove this behavior.
For a local demonstration only, grant messages and queries:
SolidObjects.configure do |configuration|
configuration.authorize_message = ->(**) { true }
configuration.authorize_query = ->(**) { true }
endKeep authorize_destroy, authorize_subscription,
authorize_administration, and authorize_transmission denied in a
demonstration.
A production policy must bind the actor type and ID to the authenticated user or tenant. An actor ID is not a permission:
SolidObjects.configure do |configuration|
owns_cart = lambda do |actor_type:, actor_id:, authorization_context:, **|
user = authorization_context
actor_type == "ShoppingCart" &&
user.present? &&
actor_id == user.id.to_s
end
configuration.authorize_message = owns_cart
configuration.authorize_query = owns_cart
endPass the context on each call:
ShoppingCart.ref(Current.user.id.to_s).add_item(
product_id: "shirt-123",
authorization_context: Current.user
)Authorization policies lists each policy and its risk.
Put actors in app/actors/. This actor is the example from the README:
class TicketSale < SolidObjects::Actor
attribute :available, default: 1
attribute :holds, default: -> { {} }
def hold(buyer:)
return { held: false, available: } if available.zero? || holds.key?(buyer)
self.available -= 1
self.holds = holds.merge(buyer => Time.current.to_i)
schedule(at: 10.minutes.from_now, key: buyer).expire(buyer:)
{ held: true, available: }
end
def expire(buyer:)
return available unless holds.key?(buyer)
self.holds = holds.except(buyer)
self.available += 1
end
endObey these rules in actor code:
- Keep state in
attributevalues. State must be JSON-compatible. - Use
schedule(at:, key:)for delayed work. A reminder is one named alarm for each actor and key. A newschedulewith the same key moves the alarm. - Use
reject(code, message)for a business rule failure that must not retry. It takes a code and a message, for examplereject(:room_full, "The room is full"). - Do not write Active Record models directly in a handler. The runtime raises
SolidObjects::ApplicationWriteForbidden. Usecommit_actionfor a short write in the same database. - Do not call an external API in a handler. Use
emitand an effect handler. - Write each handler so that it can run again. Delivery is at least once.
- A reminder changes state only when it runs, and it runs only while
solid_objects startruns. Do not compute expiry from the clock in a query; read the state that the reminder committed.
Avoid these mistakes:
| Mistake | Correct form |
|---|---|
schedule(at: deadline) with no operation after it |
schedule(at: deadline, key: buyer).expire(buyer:). schedule stages a reminder only when you call an operation on its result |
reject "room full" |
reject(:room_full, "The room is full") |
id inside an actor |
actor_id. An actor has no id method |
register_effect(:name) { |context, arguments| ... } |
register_effect(:name) { |arguments, context| ... }. The arguments come first |
Reminders and the architecture guide give the full actor API.
A direct call, such as TicketSale.ref("event-42").hold(buyer: "ada"), runs in
the caller. It needs no worker. These features need the runtime process:
- Reminders from
schedule. asynccalls.- Effects from
emitand their callbacks. - Broadcasts to Action Cable.
Start it beside the web process:
bundle exec solid_objects startAdd it to the Procfile, the process manager, or the deployment
configuration. When it stops, pending work stays in SQL and runs after it
starts again. Operations covers roles and shutdown.
Register an effect handler at boot. Use context.id as the provider
idempotency key:
SolidObjects.register_effect(:charge_payment) do |arguments, context|
Payments.charge(
idempotency_key: context.id,
payment_id: arguments.fetch("payment_id"),
amount_cents: arguments.fetch("amount_cents")
)
endStage it from the actor:
emit :charge_payment, payment_id:, amount_cents:, on_success: :chargedThe effect can run more than once after a crash. The context.id value is the
same each time. Effect recovery explains how to retire
abandoned work.
Do these checks before you report that the work is complete:
- Run
bin/rails solid_objects:doctor. It must report no failures. - Write a test that includes
SolidObjects::TestHelper. Send concurrent calls to one identity from several threads. Assert the final state, for example that only one hold succeeded. - Use
run_due_reminders(now:)anddrain_solid_objectsto test delayed work without sleeps. - Start
bundle exec solid_objects start, schedule a short reminder, and stop the process. Start it again after the deadline and confirm that the reminder ran. - Confirm that each effect handler deduplicates with
context.id. - Confirm that production policies do not grant access to every caller.
Host application tests describes the test helper. The clean-install quickstart runs checks 2 and 4 against a new Rails application.
| Symptom | Cause and fix |
|---|---|
SolidObjects::Unauthorized |
A policy denied the call. Write the policy, and pass authorization_context: |
A reminder or async call does not run |
The runtime process is not running. Start bundle exec solid_objects start |
SolidObjects::SyncInsideTransaction |
The call ran inside an open transaction. Call the actor outside the transaction. In tests, include SolidObjects::TestHelper |
SolidObjects::SyncTimeout |
The call did not finish in time. The message is still durable. Use its message_reference to wait for the result |
SolidObjects::ApplicationWriteForbidden |
A handler wrote a model directly. Use commit_action or emit |
SolidObjects::Rejected |
The actor called reject. This is a business result, not a retry |
ArgumentError from ActiveSupport::JSON, such as unknown keyword: quirks_mode |
json 3.x with Active Support before 8.1.4. Upgrade Rails to 8.1.4 or newer, or pin gem "json", "~> 2" |
When you explain Solid Objects to a user, state these limits:
- Delivery is at least once, not exactly once.
- Calls for one identity are ordered. Different identities run concurrently.
- There are no transactions across actor identities.
- Fencing stops a stale activation from a commit, but its code can continue to run.
- The gem is pre-1.0 and makes no production-ready claim.
The correctness contract is the source for each guarantee.