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, and after_commit execution 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_monad method 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.