There is nothing to roll back
Every Rails app has one of these. Nobody wrote it in one sitting; it accreted over three years, one reasonable-looking line at a time.
def create
ActiveRecord::Base.transaction do
@account = Account.create!(plan: params[:plan])
contact = Contact.create(@account.id) # HTTP
@account.update!(contact_id: contact.id)
Billing.subscribe!(@account) # HTTP, and it charges a card
ActiveRecord::Base.transaction do # joins the outer one
Audit::Entry.create!(account: @account, action: "provisioned") # other database
raise ActiveRecord::Rollback unless @account.valid? # swallowed
end
AccountMailer.welcome(@account).deliver_now # SMTP
OnboardingJob.perform_later(@account.id) # picked up before commit
return render json: @account # commits, on the way out
end
rescue Contact::Error, Billing::Error => e
render json: { error: e.message }, status: :unprocessable_entity
end
It reads like a unit of work: all of this happens, or none of it does. Almost none of that is true.
What the block promises
ActiveRecord::Base.transaction do ... end guarantees exactly one thing, and it is narrower than it looks: the SQL that reaches the database either all lands or all disappears. Everything else in there is ordinary Ruby that has already run by the time anything rolls back. Five separate hazards are live in that method, and each is a different view of the same gap.
Effects that outlive the rollback. The registrar has a contact. Billing charged a card. The welcome email is in somebody’s inbox. Roll back and all three stay exactly where they are; the only thing that disappears is the accounts row that would have explained them. The enqueue is worse than that, because a worker can pick up OnboardingJob and query for an account that hasn’t committed yet, so the job fails on a row that is about to exist.
Locks held for work the database isn’t doing. Two HTTP calls and an SMTP handshake sit inside the block, so the connection and the row locks are held for the p99 of somebody else’s API. Under load the pool goes first.
Nested blocks joining the parent. The inner transaction doesn’t open one. It joins the outer one, so the ActiveRecord::Rollback in it is swallowed and that guard quietly does nothing at all.
Non-local exits that commit. As far as the database is concerned, return render leaves the block normally. It commits.
No atomicity across connections. Audit::Entry is on a second connection, so it was never inside this transaction to begin with. Multi-database writes were never atomic.
And the rescue at the bottom is all five at once. It catches the failure, rolls the two accounts rows back, and hands the caller a 422. From their side the request failed and nothing happened. From billing’s side the card is charged, the registrar has a contact, and the welcome email is already in an inbox. The most honest-looking line in the method is the one that makes the system lie, and the only record of what really happened went to a browser instead of a table.
None of this is obscure, and there’s a standard toolkit: after_commit, short transactions, requires_new when you mean it, a cop or two, isolator, statement_timeout. I’ve used all of it and I’d use it again. But it’s a standing tax on attention, enforced against code that will cheerfully accept the wrong thing without complaining.
The machine I built to survive it
I have been at the far end of that road. Years ago, on an operation that touched several third-party APIs, I wrote a class whose only job was to undo things that had already happened. I called it the Rollbacker:
class Rollbacker
def initialize
@blocks = []
end
def <<(block)
@blocks << block
end
def rollback
@blocks.reverse.each(&:call)
end
end
You registered an undo immediately before each step, and if a later step failed, the caller walked the stack backwards and reversed everything that had already succeeded. A database transaction couldn’t help, because the damage was on the far side of an API; rolling back on our side would have left theirs in a state nobody asked for.
It worked. We shipped on time. I also wrote at the time that the premise felt like a code smell, a sign there was probably a better way to organize the system. I was right about that, and it took me years to work out why.
Vigilance had already failed, so I built a machine to survive the failure, and it cost me an undo for every action, registered in the right order, kept in sync forever, with no guarantee the undos worked either.
Read one back. api_1.rollback_setup! is a description of plumbing. Nobody ever intended to un-setup an API. Every line recorded what I was doing to the machine, and not one recorded what anybody was trying to accomplish.
That’s the tell. When the fix is machinery for surviving a category of mistake, the mistake is structural.
A block is the wrong shape
The block is anonymous, untyped, and never a value. Nothing can name it, check what belongs in it, or look at it before it runs. It accepts everything; the database accepts SQL. Every hazard above is one view of that gap.
If you need a linter to stop people writing something, the thing shouldn’t be expressible.
But there’s a deeper version. A block is a list of operations. It records mechanism: the statements I happened to run, in the order I happened to run them, against the schema as it stood that afternoon. There is nowhere in it to record what any of it was for.
We should be making software according to intent. Someone wanted to provision an account. That will still be true in three years, when the billing provider has been swapped twice and every one of those statements has been rewritten. Mechanism is the part that rots. Intent is the part that doesn’t. Write down only the mechanism and every question worth asking later has to be reverse-engineered from whatever the last UPDATE left behind.
Stop wrapping writes, start recording facts
Here’s the same provisioning job written the way I build things now: commands that emit events, projections folded from those events, and workflows that react once an event has committed.
The command captures one intent and does nothing else:
class ProvisionAccount < SimpleCqrs::Command
def call(account_id:, plan:)
@ctx.track(Account::EVENT_PROVISION_REQUESTED, record_id: account_id, data: { plan: plan })
end
end
That’s the entire write. One fact appended to a log. No HTTP, no mailer, no job, because a command has no syntax for any of them.
The third-party work lives in a workflow, wired to the event rather than to the caller:
class Provisioning < SimpleCqrs::Workflow
on(Account::EVENT_PROVISION_REQUESTED) do |event, ctx|
contact = Contact.create(event.record_id, idempotency_key: event.key)
ctx.track(Account::EVENT_CONTACT_CREATED, record_id: event.record_id, data: { contact_id: contact.id })
rescue Contact::Error => e
ctx.track(Account::EVENT_CONTACT_FAILED, record_id: event.record_id, data: { reason: e.message })
end
on(Account::EVENT_CONTACT_CREATED) do |event, ctx|
Billing.subscribe!(event.record_id, idempotency_key: event.key)
ctx.track(Account::EVENT_SUBSCRIBED, record_id: event.record_id)
rescue Billing::Error => e
ctx.track(Account::EVENT_SUBSCRIPTION_FAILED, record_id: event.record_id, data: { reason: e.message })
end
end
And the state everyone actually wants to read is folded from those facts:
class Account < SimpleCqrs::Projection
collection "account"
EVENT_PROVISION_REQUESTED = "#{collection_name}.provision_requested".freeze
EVENT_CONTACT_CREATED = "#{collection_name}.contact_created".freeze
EVENT_CONTACT_FAILED = "#{collection_name}.contact_failed".freeze
EVENT_SUBSCRIBED = "#{collection_name}.subscribed".freeze
field :id, :plan, :contact_id, :subscribed_at, :failure
on(EVENT_PROVISION_REQUESTED, 1) { |e| with(plan: e.data[:plan]) }
on(EVENT_CONTACT_CREATED, 1) { |e| with(contact_id: e.data[:contact_id]) }
on(EVENT_CONTACT_FAILED, 1) { |e| with(failure: e.data[:reason]) }
on(EVENT_SUBSCRIBED, 1) { |e| with(subscribed_at: e.time) }
def provisioned? = !subscribed_at.nil?
end
Three things worth noticing.
There is no rollback, because there is nothing to roll back. Each step is its own committed fact. Nothing is pending, so nothing needs reversing.
Failure is a fact too. Contact::Error doesn’t propagate up to a caller holding a stack of undos. It becomes contact_failed: a row in the log, foldable into the projection, readable in the UI, and available for another workflow to react to. If you genuinely need compensation, it’s a reaction to a recorded failure rather than a rescue in whoever happened to call you.
Idempotency is free. event.key is already unique and time-sortable, so it’s the key you hand the third party. Retry the reaction as many times as you like. That’s a promise you can keep, which is more than undo ever was.
The intent is written down. ProvisionAccount names what somebody wanted. contact_created and contact_failed name what became true. Change registrars next year and none of those names change, because none of them were ever about the registrar. The mechanism moved and the intent stayed put.
The trade I’m making
The Rollbacker existed because I wanted atomicity across systems that don’t share a transaction. You can’t have that. Nobody can.
So an account can sit here with a contact created and no subscription. Event sourcing doesn’t prevent that state. What it does is give the state a name, a timestamp, an actor, and a place in a projection you can query. Stop buying insurance against a condition you’re going to be in anyway, and name the condition instead. A half-provisioned account you can see and re-drive beats a half-provisioned account you tried to unwind and couldn’t.
Against the list from earlier:
| Hazard | What this does to it |
|---|---|
| Effects that outlive the rollback | Gone. transaction buffers the emits and flushes them on commit, so a reaction can’t fire for an event that rolled back. And a command has no side-effect syntax to begin with. |
| Locks held for work the database isn’t doing | Gone on the write side. A plain Command opens no transaction at all; AtomicCommand wraps N appends and nothing else. Appends are O(1) against one table. Paid for on the read side, see below. |
| Nested blocks joining the parent | Gone. One transaction, owned by AtomicCommand. Nesting isn’t expressible. |
| Non-local exits that commit | Shrinks to one class. A plain Command opens no transaction, so an early return is just an early return. Inside AtomicCommand#call the hazard is still live, but that’s one class name you can grep. |
| No atomicity across connections | Reframed. The log is the outbox and workflows are the sagas, so you get both without adopting either. The caveat is real: the log is still one connection. If your log and your business tables live in different databases, you’re back at square one. |
The bill
Two things get more expensive, and neither is as bad as it first sounds.
You need more loading states. You were handling loading anyway. Anything that talks to a third party already has a spinner, a poll, or a job on a queue, and deliver_later has been in the app since the first week. What changes is that the intermediate states get named and drawn instead of hiding inside a request that only looked synchronous. An account sitting between requested and subscribed was always a real state; the old version just didn’t have a word for it. Mostly this is work you were already doing, done on purpose.
You need a way to recover out of band. When everything ran in one request, failure surfaced immediately and the user was the recovery mechanism: they saw an error and tried again. Now a reaction fails twenty minutes after the request returned 200, with nobody watching. That was already true of every background job you’ve ever enqueued, and most apps handle it badly. The difference here is that the log hands you the material. Re-driving a reaction is replaying an event you already have, and event.key makes it safe to do twice, so a list of accounts stuck in provision_requested, an alert when contact_failed piles up, and a button that runs the reaction again are an afternoon rather than a project. Build them anyway. A quiet failure is worse than a loud one.
The costs I’d actually weigh sit further in. Projections fold on read, so the work you took off the write side lands on the read side, and you’ll want CachedQuery and snapshots sooner than you expect. Events are immutable, so evolving a payload means a higher-version fold rule and an upcast rather than a migration. And the whole team has to learn it, which shows up in code review for months. That last one is the real bill.
If your app is small, boring, and single-database, after_commit and short transactions are genuinely fine. The guardrails are what you reach for when you can’t restructure yet, and every one you add is a note to yourself about which part of the system you couldn’t fix.
The question I’d put to any transaction block in your codebase is the one my Rollbacker was answering the hard way:
If this rolled back right now, what would already have happened?
If you can’t answer by reading the block, the block is the wrong shape. It can’t tell you, because nobody ever told it what it was for.
Write down the intent. Record what became true. The question answers itself.