ActiveRecord callbacks hide control flow across models, making processes implicit and hard to debug. dry-monads gives you explicit, sequential operations with built-in error handling.
Why are callbacks the root of all evil?
Callbacks are side effects. By implementing them, you accept that operations fire indirectly, triggered by other operations, creating a domino effect with no central control point.
The core problems:
- Fragmented flow. The process is distributed across an unpredictable number of model classes. One callback triggers another, forcing you to traverse a tree of model associations to understand what happens.
- Execution order is subtle. Can you tell the difference between
after_create,after_save, andafter_commitexecution order on the spot? Each has different transactional guarantees. - Shared side effects. Callbacks fire for every process that touches the model, not just the one you intended. A callback meant for sign-up also fires during admin user creation, CSV imports, and seed scripts.
The alternative: standalone process classes where every operation is explicit, ordered, and visible in one place.
What does the callback version look like?
class User < ApplicationRecord
before_create :generate_confirmation_token, if: :signed_up_with_email?
after_create :send_confirmation_email, if: :signed_up_with_email?
after_create :register_on_3rd_party_service
after_destroy :remove_from_3rd_party_service
after_create :create_default_profile, if: -> { profile.nil? }
end
class Profile < ApplicationRecord
before_create :generate_slug
after_create :create_empty_portfolio
after_touch -> { user.touch }
end
class AuthenticationController < ApplicationController
def sign_up
@user = User.create!(user_params)
render @user
end
private
def user_params
params.require(:user).permit(:first_name, :last_name, :email, :password)
end
end
This looks innocent with a small scope, but the actual sign-up process is scattered across two models and multiple conditional callbacks.
How does the dry-monads version work?
require 'dry/monads'
require 'dry/monads/do'
module Authentication
class SignUp
include Dry::Monads[:result]
include Dry::Monads::Do.for(:call)
def call(params)
ActiveRecord::Base.transaction do
data = yield validate(params)
user = yield create_user(data)
profile = yield create_profile(user)
portfolio = yield create_portfolio(profile)
yield register_on_3rd_party_service(user)
yield send_welcome_email(user)
Success(user)
end
end
private
def validate(params, contract: SignUpContract.new)
contract.call(params).to_monad
end
def create_user(data)
User.create(data, confirmation_token: random_token)
user.persisted? ? Success(user) : Failure(user.errors)
end
def create_profile(user)
profile = Profile.create(user: user, slug: profile_slug(user))
profile.persisted? ? Success(profile) : Failure(profile.errors)
end
def create_portfolio(profile)
portfolio = Portfolio.create!(profile: profile)
portfolio.persisted? ? Success(portfolio) : Failure(portfolio.errors)
end
def register_on_3rd_party_service(user, service: Some3rdPartyService.new)
service.call(user)
Success()
end
def send_welcome_email(user, mailer: SomeMailer.new)
mailer.send(welcome_email(user))
Success()
end
# auxiliary methods
def random_token
# ...
end
def profile_slug(user)
# ...
end
def welcome_email(user)
# ...
end
end
end
class AuthenticationController < ApplicationController
def sign_up(transaction: Authentication::SignUp.new)
@user = transaction.call(params.to_unsafe_h)
render @user
end
end
The models can be cleared entirely of callbacks. The #call method reads top-to-bottom: validate, create user, create profile, create portfolio, register externally, send email. No hidden side effects.
What does the do-notation actually give you?
Each method wraps its result in Success or Failure. As long as operations succeed, execution continues sequentially. The moment any step returns Failure, the entire chain stops. No code runs past the failure point.
This is the key advantage over plain Ruby objects: you get halt-on-failure semantics without manual if/else chains or exception juggling. The Failure wrapper also carries error data you can use for response rendering.
Where to go from here
- Extract repositories. Instead of calling models directly in each method, inject repository objects as keyword arguments. This makes transaction methods leaner and repositories reusable across multiple processes.
- Use contracts for validation. The
#to_monadmethod on dry-validation contracts integrates cleanly with the do-notation pipeline. - Keep transactions thin. Each private method should do one thing. If a method grows beyond 5 lines, it belongs in its own class.
Practical Implementation: The USEO Approach
We introduce dry-monads incrementally in legacy Rails codebases. The first step is always identifying the “callback chains” that span more than two models. These are the processes where bugs hide and where new developers waste the most time tracing execution flow.
We do not rewrite all callbacks at once. Instead, we pick one critical process (usually sign-up or order placement), extract it into a transaction class, and remove the corresponding callbacks from the models. The remaining callbacks continue working until their processes get extracted too. This hybrid state is safe because each transaction class operates on a specific code path, and the callbacks it replaces are removed, not duplicated.
One pattern we found essential is wrapping external service calls (email, third-party APIs) in their own Failure-aware methods with timeout handling. A third-party service timeout should not roll back a successful user creation. We move those steps outside the database transaction block and handle their failures separately, often with a background retry job.