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
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
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
↓
ROLLBACKHTTP request
↓
Already sent
↓
Already sentBackground 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
After the rollback:
Database: Customer does not exist
CRM: Customer may already exist
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
The database transaction rolls back the order.
But the job enqueue is a separate operation.
The job may later execute:
OrderConfirmationJob.perform(order.id)
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
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
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?
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
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
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
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
and:
UserMailer.welcome(user).deliver_later
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)
inside a transaction is reported as a job enqueue operation.
It also detects:
WelcomeJob.perform_now
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
The default mode is:
Warn mode
TransactionGuard.configure do |config|
config.mode = :warn
end
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
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
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"
Then:
bundle install
For local development, you can also point Rails to a local checkout:
gem "transaction_guard", path: "../transaction_guard"
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
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:...
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)
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
rather than:
Database transaction
│
├── Database change
│
└── External side effect
│
▼
Transaction rolls back
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
Direct clients such as:
Faraday
HTTParty
httpx
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
Run RuboCop with:
bundle exec rubocop
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
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
contains two different worlds:
Database
│
transaction
│
rollback
│
X
External API
│
request sent
│
cannot magically
be rolled back
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)