Jul 27, 2026
Rails Concerns: When They Help, When They're a Smell
Concerns are the cheapest abstraction in Rails and the most-abused. The line I draw between concerns earning their file and concerns hiding the pile.
I think Rails concerns are a defensible default and a frequent disaster, and the difference is whether they’re factoring out cross-model behavior or intra-model complexity. That distinction is the whole post — but it took me a few years and a few too-large models to articulate it.
Here’s the position I’ve landed on after a decade of reading other people’s Rails apps and writing my own.
The case for concerns
Concerns are Ruby modules with extend ActiveSupport::Concern. They give you included do ... end for class-level setup, class_methods do ... end for class-level methods, and a clean way to share behavior across multiple classes. The pattern is documented well at api.rubyonrails.org.
The case is real. If you have three models that all need a Trashable behavior — soft-delete column, trashed? predicate, default scope to exclude trashed records, an untrash! method — extracting that into app/models/concerns/trashable.rb and include Trashable in each model is correct. You’ve factored out behavior shared across models. The concern is a unit of reuse.
Same for Slugged (every model needs to_param to use a slug column), Searchable (a few models index into the same search backend), Schedulable (multiple models have start/end times and recurrence). These are the canonical use cases. The concern is small (often under 100 lines), it has a single responsibility expressed in its name, and the models that include it pay a clear cost (one include line) for a clear benefit (no duplicated soft-delete logic).
The case against
The concern-as-disaster pattern looks like this. You have a User model. It’s grown to 800 lines. Someone notices it’s too big and decides to “extract concerns.” Six months later the file is 200 lines but the model directory has:
User::Authenticatable(200 lines)User::Profileable(180 lines)User::Notifiable(150 lines)User::Subscribable(220 lines)User::Trackable(90 lines)
The User class itself is now a thin shell that includes all five. Reading it tells you nothing. To understand what User#current_subscription_status does, you have to know which concern owns it (Subscribable, presumably), open that file, then chase the methods it calls into the other four concerns.
You haven’t extracted complexity. You’ve scattered it, while changing User’s line count from 800 to 200 — a metric that suggests you’ve improved things when you haven’t.
The tell
The diagnostic I use: after the extraction, can I read the model and understand it?
For Trashable extracted from a Post: yes. The Post model still has its own logic; Trashable adds soft-delete, which the reader can mentally tag as “this is the soft-delete bit” without opening the file.
For User::Authenticatable extracted from User: usually no. The reader sees include Authenticatable and has to open the concern to know what methods exist on User. The class is now defined across multiple files. The grep-ability of the model is destroyed.
The cross-model concern is additive: each model is itself plus this orthogonal feature. The intra-model concern is partitioning: the model is split across files, none of which makes sense alone.
”But the file was too big”
This is the objection I hear, and it deserves a real answer. Yes, an 800-line User model is too big. No, splitting it into five 200-line concerns doesn’t make it smaller — it makes it the same size, distributed.
The actual moves when a model is too big:
-
Look for a missing class. Is there a
Subscriptionmodel trying to claw its way out ofUser? Often the methods you’d put inUser::Subscribablebelong on aSubscriptionobject that has-one User, not on User itself. Extracting a class moves complexity; extracting a concern just relocates it. -
Look for service objects. Methods like
User#process_signup!that orchestrate eight things probably belong in aSignupService.new(user).callinstead of on the model. The model goes back to representing data; the workflow lives separately. -
Look for value objects. A
Userwithcountry_code,country_name,country_flag,country_phone_prefixmethods doesn’t needUser::Geographic. It needs aCountryvalue object instantiated from the country code. -
Then, and only then, look for concerns. And the concerns you extract should be cross-cutting behaviors, not “the next 200 lines of User.”
The Trashable test
Here’s the rule I apply: would I want to use this concern in another model? If the answer is “I literally can’t imagine where else this would apply,” it’s not a concern — it’s a chunk of one model in a separate file pretending to be reusable.
Trashable passes the test (any model can be soft-deleted). Slugged passes (any model with a string display field). Searchable passes (anything indexed in the search backend).
User::Authenticatable fails. There’s only one user model. The concern is a fig leaf over “User has too many responsibilities.”
The “namespaced concerns” pattern
A specific anti-pattern: namespacing concerns under the model name. User::Authenticatable, User::Profileable, User::Notifiable. The namespace is a tell. If the concern is for User specifically, it’s not a concern; it’s a partition of User. Real concerns aren’t namespaced by their consumer because they’re meant to be consumed by anyone.
When I see app/models/concerns/user/, I know the codebase has done the wrong refactor. The fix is to either inline the methods back into User and confront the actual size problem, or to extract real classes (Subscription, Profile, NotificationPreference) that the User has-many or has-one of.
What concerns are good for, restated
- Cross-cutting model behavior. Soft delete, slugs, search indexing, audit logging, schedulability.
- Controller filters that genuinely cross controllers.
RequiresAuthentication,Pundit::Authorization. Same test: would three+ controllers want this? - Tiny mixins for testing utilities.
with_redis_lock,with_idempotency(Mastodon’sLockable/Redisablefrom article 17 are good examples).
What concerns are bad for: hiding the fact that a model has too many jobs. The concern doesn’t simplify; it relocates. The model is still doing five things; you’ve just made five things to read instead of one.
Closing position
Concerns are the right answer about 20% of the time someone reaches for them. The other 80%, they’re a substitute for the harder refactor — extracting a real class, building a service object, modeling a value object — and they ship anyway because they cost nothing and feel like cleanup.
The cost shows up later. When a concern has 12 callers across 8 models and you need to change one of them, you can’t, because the contract is implicit and the test coverage is “the models that include it work.” When a model is split across 5 concerns and a new feature needs to touch state from three of them, you write a method in a 6th concern and tell yourself it’s fine.
My rule: if I can’t name the concern in one word that describes a behavior orthogonal to whatever model includes it, I’m not extracting a concern. I’m hiding a problem. The model is still where the problem lives. The concern is just where I’ve decided to keep it out of sight.
Defensible default? Yes. Answer to “this model is too big”? No.