DEV Community

Yashika Vijayvargiya
Yashika Vijayvargiya

Posted on Originally published at Medium on

Your Rails Transaction Rolled Back. What Happened to the API Call, Email, or Job?

A database transaction gives us a powerful guarantee:

Either the database changes succeed together, or they are rolled back.

But there is an important boundary that is easy to overlook.

A database transaction can roll back database changes. It cannot automatically roll back an HTTP request that has already reached another service, an email that has already been sent, or a background job that has already been enqueued.

Consider this:

User.transaction do
 user = User.create!(name: "Yashika") 
 WelcomeJob.perform_later(user.id) 

 raise ActiveRecord::Rollback 
end
Enter fullscreen mode Exit fullscreen mode

The User record is rolled back.

But depending on when the job was enqueued, the background job may already exist in the queue.

Now the job can execute with an ID for a record that no longer exists.

The same class of problem can happen with external HTTP APIs and email delivery.

This is the problem I wanted to make easier to detect in Rails applications, so I built TransactionGuard.

The problem: database transactions don’t cover external side effects

A typical Rails transaction might look perfectly reasonable:

User.transaction do
 user = User.create! 
 SomeExternalApi.create_user(user) 
 WelcomeMailer.welcome(user).deliver_now
end
Enter fullscreen mode Exit fullscreen mode

At first glance, everything looks like one atomic operation.

But it isn’t.

There are actually multiple systems involved:

┌──────────────────────┐

│ Rails Application │

└──────────┬───────────┘

│

ActiveRecord

transaction

│

┌────────────┴────────────┐

│ │

▼ ▼

Database changes External side effects

│

┌─────────────┼─────────────┐

│ │ │

▼ ▼ ▼

HTTP Email Job

The database can participate in the transaction.

The external systems generally cannot.

So if the transaction fails:

Database

↓

ROLLBACK

HTTP request

↓

Already sent

Email

↓

Already sent

Background job

↓

May already be queued

That’s where subtle production bugs can appear.

Imagine an application that creates a customer and then synchronizes that customer with an external CRM.

Customer.transaction do
 customer = Customer.create!(name: "Acme")
 CRMClient.create_customer(customer) 
 raise ActiveRecord::Rollback 
end
Enter fullscreen mode Exit fullscreen mode

After the rollback:

Database: Customer does not exist
CRM: Customer may already exist
Enter fullscreen mode Exit fullscreen mode

Now the application and the external service disagree.

This can become even harder to debug when the external operation is hidden inside a service object, callback, mailer, or job.

Why background jobs can be particularly tricky

Consider:

Order.transaction do
 order = Order.create! 
 OrderConfirmationJob.perform_later(order.id)
 raise ActiveRecord::Rollback 
end
Enter fullscreen mode Exit fullscreen mode

The database transaction rolls back the order.

But the job enqueue is a separate operation.

The job may later execute:

OrderConfirmationJob.perform(order.id)
Enter fullscreen mode Exit fullscreen mode

and discover that the order doesn’t exist.

TransactionGuard’s README demonstrates this exact class of problem: the database record can be rolled back while the job has already been enqueued.

The problem isn’t that background jobs are bad.

The problem is when they are triggered.

What about emails?

The same problem exists with email delivery.

For example:

User.transaction do 
 user = User.create! 
 UserMailer.welcome(user).deliver_now
 raise ActiveRecord::Rollback
end
Enter fullscreen mode Exit fullscreen mode

The database operation can roll back after the email has already been delivered.

You can end up with:

Database:
User creation rolled back

Email:
Welcome email already delivered
Enter fullscreen mode Exit fullscreen mode

This can create confusing user experiences and difficult-to-reproduce bugs.

TransactionGuard detects both deliver_now and deliver_later when they occur inside an ActiveRecord transaction.

So I built TransactionGuard

TransactionGuard is a Ruby/Rails gem that detects external side effects performed while an ActiveRecord transaction is open.

The current version detects:

  • HTTP requests through Net::HTTP
  • Email delivery through Action Mailer
  • ActiveJob enqueueing
  • ActiveJob immediate execution

The goal isn’t to automatically fix the code.

The goal is to make the problem visible during development and testing.

Repository:

https://github.com/yashika279/transaction_guard

How TransactionGuard works

The basic question is surprisingly simple:

TransactionGuard::Transaction.open?
Enter fullscreen mode Exit fullscreen mode

If an ActiveRecord transaction is currently open, TransactionGuard can identify it and let the relevant detector report the external operation.

Conceptually:

External operation
       │
       ▼
Is TransactionGuard enabled?
       │
       ▼
Is an ActiveRecord transaction open?
       │
       ├── No → Continue normally
       │
       └── Yes
             │
             ▼
       Report the operation
             │
             ▼
       warn / raise
Enter fullscreen mode Exit fullscreen mode

This keeps the responsibility focused:

TransactionGuard detects the potentially unsafe operation.

It doesn’t silently change your application’s behavior.

Detecting HTTP requests

One of the first detectors I implemented was for Net::HTTP.

For example:

User.transaction do
  Net::HTTP.get(URI("https://example.com"))
end
Enter fullscreen mode Exit fullscreen mode

TransactionGuard detects the request because it occurs while the transaction is open.

The HTTP detector wraps common methods such as:GET POST PUT PATCH DELETE HEAD OPTIONS

GET
POST
PUT
PATCH
DELETE
HEAD
OPTIONS
Enter fullscreen mode Exit fullscreen mode

Internally, the detector checks whether a transaction is open before reporting the operation.

There is also protection against reporting the same underlying request multiple times when one HTTP method eventually delegates to another internal method.

That matters because instrumentation should provide useful signals without producing a wall of duplicate warnings.

Detecting email delivery

The email detector covers:

UserMailer.welcome(user).deliver_now
Enter fullscreen mode Exit fullscreen mode

and:

UserMailer.welcome(user).deliver_later
Enter fullscreen mode Exit fullscreen mode

inside a transaction.

An important detail here is avoiding duplicate reporting.

deliver_later internally involves ActiveJob, but from the application's perspective the operation is an email delivery.

TransactionGuard therefore reports it as an email side effect instead of producing an additional warning for the internal ActiveJob enqueue.

Detecting ActiveJob operations

TransactionGuard also hooks into ActiveJob operations.

For example:

WelcomeJob.perform_later(user.id)
Enter fullscreen mode Exit fullscreen mode

inside a transaction is reported as a job enqueue operation.

It also detects:

WelcomeJob.perform_now
Enter fullscreen mode Exit fullscreen mode

as a job execution operation.

The current 0.1.0 implementation specifically targets ActiveJob APIs; direct Sidekiq, Resque, and similar non-ActiveJob APIs are outside the current detector scope.

Three modes: warn, raise, and off

I wanted the gem to be useful in different development workflows.

TransactionGuard currently supports three modes:

:warn :raise :off
Enter fullscreen mode Exit fullscreen mode

The default mode is:

Warn mode

TransactionGuard.configure do |config|
  config.mode = :warn
end
Enter fullscreen mode Exit fullscreen mode

The application continues running, but TransactionGuard reports the detected side effect.

This is useful when introducing the gem into an existing application because you can discover problematic code without immediately breaking development or tests.

Raise mode

For stricter enforcement:

TransactionGuard.configure do |config|
  config.mode = :raise
end
Enter fullscreen mode Exit fullscreen mode

Now an unsafe external side effect causes TransactionGuard to raise an error.

This can be useful in tests when you want a transaction-side-effect violation to fail fast.

Off mode

Detection can also be disabled:

TransactionGuard.configure do |config|
  config.mode = :off
end
Enter fullscreen mode Exit fullscreen mode

The Rails integration currently defaults to :warn in development and test, and :off in production.

Installation

Add the gem to your Gemfile:

gem "transaction_guard"
Enter fullscreen mode Exit fullscreen mode

Then:

bundle install
Enter fullscreen mode Exit fullscreen mode

For local development, you can also point Rails to a local checkout:

gem "transaction_guard", path: "../transaction_guard"
Enter fullscreen mode Exit fullscreen mode

The project is open source and available under the MIT license.

What does the warning look like?

Suppose we have:

User.transaction do
  user = User.create!

  WelcomeJob.perform_later(user.id)
end
Enter fullscreen mode Exit fullscreen mode

TransactionGuard can report that a job enqueue happened while an ActiveRecord transaction was open.

The important information is the operation and the caller location.

That makes the warning actionable:

Transaction detected external side effect:
Job enqueue

Caller:
app/services/users/create.rb:...
Enter fullscreen mode Exit fullscreen mode

Instead of discovering the problem later through a production incident, you can catch it while developing or testing.

How should we fix these problems?

TransactionGuard intentionally doesn’t automatically move operations outside the transaction.

That’s an application design decision.

A common approach is to perform the external operation after the database transaction succeeds.

For example:

user = User.create!

User.transaction do
  user.update!(status: "active")
end

WelcomeJob.perform_later(user.id)
Enter fullscreen mode Exit fullscreen mode

Rails also provides mechanisms such as after_commit, which can be appropriate when an operation should happen only after a successful database commit.

For more complex distributed workflows, patterns such as:

  • transactional outbox
  • reliable event publishing
  • post-commit job triggering

can be considered.

The important idea is:

Database transaction
        │
        ▼
Commit succeeds
        │
        ▼
External side effect
Enter fullscreen mode Exit fullscreen mode

rather than:

Database transaction
        │
        ├── Database change
        │
        └── External side effect
                │
                ▼
          Transaction rolls back
Enter fullscreen mode Exit fullscreen mode

TransactionGuard doesn’t decide which pattern your application should use. It tells you where the potentially unsafe boundary exists.

Why not just use after_commit everywhere?

after_commit is useful, but it isn't a universal solution.

Applications often have:

  • service objects
  • callbacks
  • mailers
  • background jobs
  • API clients
  • third-party integrations
  • shared libraries

An external operation can be introduced far away from the transaction that eventually rolls back.

The purpose of TransactionGuard is therefore not to replace Rails transaction patterns.

It provides a development-time safety net that helps identify these boundaries.

Current limitations

TransactionGuard is intentionally small in its first version.

The current 0.1.0 implementation focuses on:

Net::HTTP
Action Mailer
ActiveJob
Enter fullscreen mode Exit fullscreen mode

Direct clients such as:

Faraday
HTTParty
httpx
Enter fullscreen mode Exit fullscreen mode

are not directly hooked in the current version, although clients built on top of Net::HTTP may be detected. Similarly, direct Sidekiq and Resque APIs are not currently detected.

These are areas that can evolve as the project grows.

Testing the gem

The project uses RSpec for its test suite and RuboCop for code quality.

Run the tests with:

bundle exec rspec
Enter fullscreen mode Exit fullscreen mode

Run RuboCop with:

bundle exec rubocop
Enter fullscreen mode Exit fullscreen mode

The repository also includes integration coverage around the detectors.

What I learned building it

The interesting part of this project wasn’t just writing three detectors.

It was thinking about where the boundary of a database transaction actually ends.

In Rails, this code:

User.transaction do # ... end
Enter fullscreen mode Exit fullscreen mode

can look like an atomic business operation.

But if that block interacts with systems outside the database, the operation is no longer truly atomic.

That distinction becomes especially important in applications that integrate with:

  • payment providers
  • CRMs
  • messaging platforms
  • email providers
  • analytics systems
  • background job queues
  • external APIs

A rollback can undo your database state.

It cannot necessarily undo what another system has already observed.

The bigger lesson

A transaction is not the same thing as a distributed transaction.

This:

User.transaction do
  user = User.create!
  ExternalAPI.create_user(user)
end
Enter fullscreen mode Exit fullscreen mode

contains two different worlds:

        Database
           │
       transaction
           │
        rollback
           │
           X

       External API
           │
       request sent
           │
       cannot magically
       be rolled back
Enter fullscreen mode Exit fullscreen mode

Once you recognize that boundary, you can make a deliberate architectural decision about what should happen before commit, after commit, or through a more reliable event-driven workflow.

That’s the problem TransactionGuard is designed to make visible.

Try TransactionGuard

If you’re working on a Rails application with external integrations, you can try the gem here:

GitHub: https://github.com/yashika279/transaction_guard

I’d especially like feedback on:

  • additional external side effects worth detecting
  • integrations with other HTTP clients
  • background job adapters
  • false positives
  • better reporting
  • Rails version compatibility

If you’ve ever had a transaction roll back while an email, API call, or background job had already escaped the transaction, this is exactly the kind of problem TransactionGuard is trying to catch earlier.

Originally published at https://railswithyashika.hashnode.dev on September 22, 2026.

Top comments (0)