Callbacks, Concerns, and Current: Avoiding Hidden Dependencies in Rails
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:
- Callbacks hide what happens when a record is saved.
- Concerns can hide which object owns each responsibility.
Currenthides what a method depends on.
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
# ...endThese 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
# ...endFor 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:
- Should this happen every time an order is created, from any entry point? That includes the console, imports, tests, jobs, and the admin panel.
- 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}" endendIf 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_laterActivityLog.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
# ...endWe 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) endendPaymentGateway 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:
- It mixes calculations (
amount_paid), persistence (payments.create!), and calls to external systems (the gateway and accounting). - It calls
log_change, which is not defined here: it comes fromAuditable. This only works because both modules end up mixed into the same object. - It hides a callback that reports every sale to the accounting system. Anyone reading
Orderwill miss it.
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:
- Can you explain what it does without naming the model that includes it?
- 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 endendIts contract is small and explicit:
class Product < ApplicationRecord include Searchable searchable_by :name, :skuendAn 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 endendBecause 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)endTo 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:
- Concern for cohesive behavior that applies across models or is peripheral, with a small, explicit contract.
- Dedicated object when the concept needs its own identity, vocabulary, and rules, or is going to grow.
- Neither when the only reason is to give the model fewer lines. Splitting code this way does not separate responsibilities; it just moves them to different files. Keep the code in the model until a concept emerges that deserves its own name.
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 :userend
class ApplicationController < ActionController::Base before_action { Current.user = current_user }endCurrent 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
# ...endIn the web checkout, it checks the expected user: the customer making the purchase. At other entry points, it fails:
- Phone orders. The logged-in user is the customer service agent preparing the customer’s cart. The calculation checks the agent’s loyalty status.
- Subscriptions. A monthly job creates subscription orders using the same pricing calculation. If nobody sets the user inside the job,
Current.userisnil, and&.makes the discount silently disappear.
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 : 0endIf 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:
- Visible actions. In these examples, model callbacks prepare data for the record itself, such as the order number. Everything else (reducing inventory, sending email, logging activity) stops being a callback: the code that creates the order calls it explicitly.
- Clear contracts. A concern stays if it groups coherent behavior and makes its dependencies visible. If the concept has its own vocabulary and rules, it deserves a named object, such as
PaymentLedger. - Who acts versus who the purchase is for. A model gets the information it needs through arguments or its own associations, rather than the request context.
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
-
Which of these
Ordercallbacks is a good candidate to keep in the model?
-
What does it mean to make the actions triggered by creating an order explicit?
-
HasPaymentsmixes calculations, persistence, and a call to accounting, and useslog_changefromAuditable. What is its main problem?
-
A customer service agent prepares a loyal customer's cart over the phone, but the customer pays full price. What is the underlying cause?
-
What is the cost of making actions and dependencies explicit?