Callbacks, Concerns, and Current: Avoiding Hidden Dependencies in Rails

David Morales David Morales
/
A glass box containing gears connected by threads that were once hidden and are now clearly visible, with a ruby at the center.

An online store built with Rails has one model that attracts everything: Order. Creating an order involves actions such as reducing inventory, sending a confirmation email, and logging activity. Then come new features: payments, search in the admin panel, change auditing, and a discount for loyal customers.

Rails offers three tools that let us add all of this without touching the rest of the application: callbacks, concerns, and Current. All three are convenient, and all three can hide something:

This article follows all three through the same example: when they help, when they create hidden dependencies, and how to make those dependencies explicit.

Callbacks Out of Control

When a Callback Makes Sense

In controllers, callbacks work well as request filters:

class CheckoutsController < ApplicationController
before_action :authenticate_customer!
before_action :load_cart
# ...
end

These callbacks decide whether the request proceeds and prepare what the action needs. Their scope is the HTTP request: they only run when a request reaches this controller, and anyone reading the class can see them in its first few lines.

Active Record callbacks are a different story. They hook into the record’s lifecycle (validation, creation, updates, deletion), and creation callbacks run whenever a record is created, regardless of where it comes from: a controller, the console, a job, a seed script, a test, or a migration script.

Effects That Always Happen

In our store, the purchase process (the checkout) creates an Order from the cart, building its line items before saving it. Rails saves those line items along with the order. Once the order is created, we need to reduce inventory, send the email, and log the activity. Someone decides this should happen whenever an order is created, and callbacks seem like the most direct way to guarantee it:

class Order < ApplicationRecord
belongs_to :customer
has_many :line_items
after_create :decrement_stock
after_create_commit :send_confirmation
after_create_commit :log_activity
private
def decrement_stock
line_items.includes(:product).each do |item|
item.product.decrement!(:stock, item.quantity)
end
end
# ...
end

For a while, this looks like an improvement: the web checkout, the mobile app, and the customer service dashboard all create orders, and everything happens automatically. Until “regardless of where it comes from” becomes a problem.

Imagine the store is migrating from an older platform, and someone writes a Rake task that imports 12,000 historical orders, each with its line items, using Order.create!. That night, 12,000 customers receive an email confirming a purchase from 2021, and inventory for current products drops because of sales that happened years ago. Nobody reading the import task could see this coming, because the task mentions neither inventory nor email.

That is the first problem with model callbacks: hidden side effects.

Skipping Callbacks

After the incident, the developer rewrites the import using bulk inserts: orders and line items are saved separately with insert_all, which is faster and does not run callbacks. Problem solved, apparently.

But insert_all skips every callback, including those that prepare data the model needs, such as its order number. The same applies to update_column, update_all, and delete_all.

Execution Order Is Hard to Follow

The second problem is execution order. In our example, after_create callbacks run in the order they are declared, but you do not declare all of them yourself. has_many registers its own callback to save new line items along with the order, and registers it exactly where that declaration appears.

In our Order, decrement_stock works because has_many :line_items is declared first. If someone reorganizes the class and moves the callbacks above the associations, decrement_stock runs before the line items are in the database. The query finds nothing, and inventory stays unchanged. No errors, no warnings.

The same applies to dependent: :destroy, counter_cache, or any concern that adds its own callbacks: an ordering dependency that is not spelled out anywhere.

They also do not all run at the same point. after_create runs inside the transaction, while after_create_commit runs after it commits. If someone moves the email to after_create and sends it with deliver_now, it goes out before the transaction commits. If the transaction then rolls back, the customer receives confirmation of an order that does not exist. With deliver_later, the outcome depends on whether your version and configuration defer enqueuing the job until after the commit.

Tests Pick Up the Tab

The third problem shows up in tests. Tests that create and save an order (for example, to check how an invoice is displayed) trigger all three callbacks: they reduce inventory for a test product, enqueue an email, and write to the activity log.

The test wanted none of this, but it must prepare for it: create products and account for inventory changes, emails, and the activity log, even though those are not what it is checking. And the test that does want to check inventory reduction cannot call that rule directly. It has to create an entire order and inspect the side effect.

Two Questions to Guide the Decision

This does not mean we should remove every model callback. Two questions help separate reasonable callbacks from risky ones:

  1. Should this happen every time an order is created, from any entry point? That includes the console, imports, tests, jobs, and the admin panel.
  2. Does the callback prepare data for the record itself, or coordinate other actions (other tables, emails, external services)?

Assigning an order number is a good candidate: it prepares data belonging to the record when it is created, and every order needs one, regardless of where it comes from.

class Order < ApplicationRecord
before_create :assign_number
private
def assign_number
self.number ||= "ORD-#{SecureRandom.alphanumeric(8).upcase}"
end
end

If the number must be unique, a unique index in the database guarantees that, not the callback.

Sending an email, reducing inventory, and logging activity pass neither test. They coordinate actions outside the record, and the answer to the first question is “it depends on who creates the order and why.” When the answer is “it depends,” that action is not a property of the model. It belongs to a specific process.

Making Follow-Up Actions Explicit

The alternative is to remove all three callbacks from Order and make inventory reduction a public method, deduct_stock!. From then on, the code that creates the order decides what else happens:

order = Order.create!(order_attributes)
order.deduct_stock!
OrderMailer.confirmation(order).deliver_later
ActivityLog.record(:order_placed, order)

Notice that the inventory rule stays in the model: deduct_stock! is a public method on Order. What changes is who decides to call it. The import creates orders and calls nothing else, just like the tests.

Overusing Concerns

The second tool Rails offers for extracting behavior from models is concerns. As payments, search, and auditing arrive, Order keeps growing:

class Order < ApplicationRecord
include HasPayments
include Searchable
include Auditable
belongs_to :customer
has_many :line_items
# ...
end

We will open one of those files.

Concerns That Hide Code

In this store, payments are recorded as transactions: positive amounts for charges and negative amounts for refunds. Here is what HasPayments looks like inside:

module HasPayments
extend ActiveSupport::Concern
included do
has_many :payments
after_create_commit :report_sale
end
def amount_paid
payments.sum(:amount)
end
def refund!(amount)
PaymentGateway.refund(payment_reference, amount)
payments.create!(amount: -amount)
log_change(:refund, amount)
end
private
def report_sale
Accounting.report_sale(number, total)
end
end

PaymentGateway is a small adapter around whichever payment gateway the store uses. The concern’s name suggests reusable behavior, something any model “with payments” could include. But the code tells a different story:

The fact that only Order includes HasPayments would not be a problem on its own: a concern with a single consumer can group coherent behavior. The problem is that it simply puts Order code in another file instead of grouping cohesive behavior.

Moving Code Does Not Separate Responsibilities

A concern spreads code across several files, but at runtime it all comes back together: Order still responds to amount_paid, refund!, log_change, and everything the other modules bring in. Its public interface is the sum of all its concerns.

The responsibilities remain in the same object. All that has changed is how many files you need to open to understand it. And because one module’s private methods are accessible to the others, concerns can depend on each other without making those dependencies visible anywhere.

Two questions help distinguish a sound concern from a catch-all module:

  1. Can you explain what it does without naming the model that includes it?
  2. Does it have a clear contract with the class that includes it, or does it depend on methods scattered across other modules?

HasPayments passes the first question on paper: “it manages payments and refunds.” It names no model. But it fails the second because it depends on log_change from Auditable and three columns in orders (total, payment_reference, and number) that it declares nowhere. Nothing in its interface says what the model including it must provide. To find out, you have to read the module’s body and the body of Auditable too.

Concerns Versus Dedicated Objects

Searchable passes both questions. It adds text search to any Active Record model with text columns, in exchange for declaring which columns to search.

module Searchable
extend ActiveSupport::Concern
included do
class_attribute :searchable_columns, default: []
end
class_methods do
def searchable_by(*columns)
self.searchable_columns = columns
end
def search(term)
return all if term.blank?
raise ArgumentError, "#{name} has no searchable columns" if searchable_columns.empty?
pattern = "%#{sanitize_sql_like(term)}%"
where(searchable_columns.map { |column| arel_table[column].matches(pattern) }.reduce(:or))
end
end
end

Its contract is small and explicit:

class Product < ApplicationRecord
include Searchable
searchable_by :name, :sku
end

An Auditable with an equally clear contract (recording changes to any model) would fit for the same reason. These behaviors apply across models, require little configuration, and have no business concepts of their own.

HasPayments is different. Payments have their own vocabulary (charges, refunds, amount paid), their own rules, and a clear tendency to grow. To refactor it, we will use concepts with their own identity, such as this one:

class PaymentLedger
def initialize(payments:)
@payments = payments
end
def paid
@payments.sum(:amount)
end
def refundable?(amount)
amount.positive? && amount <= paid
end
end

Because refunds are stored as negative amounts, paid already subtracts them: after charging €100 and refunding €100, nothing remains to refund.

Order only needs to know how to build it:

class Order < ApplicationRecord
has_many :payments
def ledger = PaymentLedger.new(payments: payments)
end

To complete the refactoring, we remove include HasPayments from Order and move refunds and accounting notifications to the processes that coordinate those actions. Here, we only show the extraction of the calculations. Now order.ledger.refundable?(amount) makes it clear which object answers that question, PaymentLedger can be tested without the payment gateway, and Order no longer exposes the methods added by HasPayments.

The resulting guideline is simple:

For more on mixing modules versus composing objects, see Composition: Building With Parts.

Global State and Current

The third tool is Current, the Rails class based on ActiveSupport::CurrentAttributes. It lets us store data about the current execution context (usually the request) and read it from anywhere in the code:

class Current < ActiveSupport::CurrentAttributes
attribute :user
end
class ApplicationController < ActionController::Base
before_action { Current.user = current_user }
end

Current is not an ordinary global variable. Rails isolates its attributes by thread (or by fiber, depending on the configuration) and resets them at the end of each request or job. That prevents one request from seeing another’s data, but it neither carries a request’s user into a job nor makes the dependencies on that user visible.

Convenience Versus Hidden Coupling

Suppose the store launches a loyalty program: loyal customers get a 5% discount. The cart calculates the amount charged in Cart#total, so someone adds the discount there, using the most convenient approach: the model can read who is logged in without receiving that information explicitly.

class Cart < ApplicationRecord
LOYALTY_RATE = 0.05
belongs_to :customer
def total(coupon_code = nil)
subtotal - coupon_discount(coupon_code) - loyalty_discount
end
private
def loyalty_discount
Current.user&.loyal? ? subtotal * LOYALTY_RATE : 0
end
# ...
end

In the web checkout, it checks the expected user: the customer making the purchase. At other entry points, it fails:

Neither failure is visible from the code that calls the price calculation. cart.total(coupon_code) appears to depend only on the cart and the coupon, but it also depends on who is logged in at that moment. The method signature does not tell you that.

Testing and Reasoning

The same thing happens in tests. To check the discount, you must set up global state before calculating the total, and the test must know that Cart reads Current, even though no method signature reveals it. If another model starts reading Current.user, tests that do not set it up can start behaving differently without having changed themselves.

Above all, Current invites us to confuse who is acting with who the purchase is for. The cart already has a belongs_to :customer association identifying the customer the purchase is for. The discount should check that customer:

def loyalty_discount
customer.loyal? ? subtotal * LOYALTY_RATE : 0
end

If we also need to record which agent prepared the order, we pass that agent as an argument, making the dependency visible. For example, where order_attributes contains the rest of the order’s data:

Order.create!(order_attributes.merge(placed_by: agent))

That does not make Current useless. Reading and writing it in controllers and views is reasonable (for example, to display the user’s name). Beyond that boundary, whatever a model needs to know is passed as an argument.

Making the Hidden Visible

All three tools fail in the same way: they make something important disappear from the code that uses it. And all three fixes follow the same principle:

This comes at a cost. The code that creates an order must remember to call each action, and some calls take more arguments than before. In return, reading those calls tells us what happens, tests set up only what they use, and imports or jobs no longer trigger effects nobody asked for.

Test your knowledge

  1. Which of these Order callbacks is a good candidate to keep in the model?

  1. What does it mean to make the actions triggered by creating an order explicit?

  1. HasPayments mixes calculations, persistence, and a call to accounting, and uses log_change from Auditable. What is its main problem?

  1. A customer service agent prepares a loyal customer's cart over the phone, but the customer pays full price. What is the underlying cause?

  1. What is the cost of making actions and dependencies explicit?