Concerns as a Mechanism
A first-principles tour of ActiveSupport::Concern as Campfire's composition tool — the three regions (included do, the module body, class_methods) and the precise rule for what goes where, grounded in message/searchable.rb, user/bot.rb, user/avatar.rb, and block_banned_requests.rb. The mechanism behind why message.rb is 44 lines and its include line reads like a spec. Foundations-flavored: mechanics + the 37signals way + a pointer to P8 (the worldview).
The 300-line model you'd build first
A Message in a real chat app has to do a lot. It carries an attachment. It broadcasts itself to live screens. It can be the target of an @mention. It paginates. It's full-text searchable. So you start writing message.rb, and every time the message needs to do one more thing, you open the file and add a method. By the time search lands, the file looks like this — and this isn't a strawman, it's the honest first draft you'd vibe-code before the conventions clicked:
class Message < ApplicationRecord
belongs_to :room, touch: true
has_rich_text :body
# --- search ---
after_create_commit :create_in_index
after_update_commit :update_in_index
after_destroy_commit :remove_from_index
scope :search, ->(q) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", q) }
def create_in_index; execute_sql_with_binds("insert into message_search_index ..."); end
def update_in_index; execute_sql_with_binds("update message_search_index ..."); end
# ...
# --- mentions ---
def mentionees; room.users.where(id: mentioned_users.map(&:id)); end
def mentioned_users; body.body.attachables.grep(User).uniq; end
# --- pagination ---
scope :last_page, -> { ... }
scope :page_before, ->(m) { ... }
# ...four more scopes...
# --- broadcasts ---
def broadcast_create; broadcast_append_to room, :messages, target: [room, :messages]; end
# ...attachment block, more callbacks, raw FTS SQL...
end
Three hundred lines, climbing. To answer "how does search work?" you scroll past mentions, pagination, and broadcasts to find the FTS callbacks, which sit next to the FTS SQL methods, which sit next to a search scope forty lines away. The day you add boosts, you wedge them in wherever the cursor happens to be. The file has no shape; it's a junk drawer.
So you reach for the obvious cleanup: pull each chunk into a plain Ruby module and include it. And you immediately hit two walls that have nothing to do with chat and everything to do with how Ruby modules work — the two walls ActiveSupport::Concern exists to knock down.
The mechanics: why a plain module isn't enough
Watch what happens when you try to file search into a plain module. The behavior — create_in_index, mentioned_users — moves cleanly; those are just instance methods, and a module adds instance methods to its host, no problem. But the wiring doesn't move cleanly. The search trait isn't only behavior; it needs to install three callbacks and a scope onto the Message class itself. And from inside a plain module, that's awkward, because after_create_commit and scope are class methods of ActiveRecord::Base — they only exist in the context of the class, not the module. You end up writing the self.included(base) hook by hand:
module Searchable
def self.included(base) # the boilerplate every macro-adding module needs
base.after_create_commit :create_in_index
base.scope :search, ->(q) { ... }
end
def create_in_index; ... end # this part was always fine
end
That self.included(base) ceremony is wall one: a plain module has no graceful way to add class-level macros to its host. Wall two is order. The day Searchable's callbacks need something Attachment set up first, include-order starts to matter, and plain modules give you no help managing dependent includes — they just run in the order you happened to type them and break silently when you reorder the list.
ActiveSupport::Concern exists to delete both problems. You write extend ActiveSupport::Concern at the top of the module, and it hands you three regions with precise, different semantics. Learning concerns is learning what goes in which region — that's the entire mechanism.
Region 1 — included do: the wiring harness
Whatever you put inside included do ... end runs in the context of the host class. So a macro written there — a callback, a scope, an association — modifies Message, exactly as if you'd typed it in message.rb directly. This is the graceful replacement for the hand-written self.included(base) hook: ActiveSupport runs the block against the host for you. included do is the wiring harness — the part of a concern that reaches out and changes the class it's mixed into.
Region 2 — the module body: plain behavior
Methods defined directly in the module body (outside any block) become plain instance methods on the host. No magic, no context-switch — def mentionees in the module is def mentionees on every Message. This is for behavior that an instance has, not wiring that changes the class.
Fizzy does the same with its Mentions concern — mentions.rb keeps mentionable_content and scan_mentionees (mentions.rb:16-22) as bare module-body methods, plain behavior every host gets. And it goes one step further than Campfire could: instead of hard-coding body the way Campfire's Message::Mentionee does (body.body.attachables.grep(User)), it asks the host class which of its rich-text fields to scan — self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::RichText } (mentions.rb:33-35). Because the concern derives its own targets by reflection, the identical file drops onto both Card (card.rb:2-4) and Comment (comment.rb:2) with no per-model edit — the same Mentions trait, two unrelated nouns. That a second 37signals product reaches for a concern here too, and makes it model-agnostic, is the mechanism proving it's the house style, not a Campfire one-off.
Region 3 — class_methods do: the constructor next to the trait
Methods inside class_methods do ... end (or a nested module ClassMethods) become class methods on the host. This is where a named constructor for the trait lives — so User.create_bot! can sit in the same file as the bot instance behavior it produces, instead of marooned in user.rb away from everything it relates to.
"Isn't this just moving the mess into more files? The complexity didn't go away." No — it got placed. The 300-line model and a six-file concern set contain the same behavior; the difference is that in the concern version you can answer "how does search work?" by opening one ~25-line file and reading it top to bottom, with its callbacks, its scope, and its SQL all in one frame. The naive god-model makes you hold the whole file in your head to understand any one trait. Placement isn't hiding; it's the opposite of hiding.
So the rule, which you can apply mechanically forever after: host-changing macros go in included do; instance behavior goes in the module body; a named constructor goes in class_methods do. Once you can read a concern through that three-way split, you can read any concern in the codebase.
The 37signals way
The include line IS the table of contents
Start with the payoff. Here are the first two lines of message.rb (message.rb:1-2) and user.rb (user.rb:1-2):
class Message < ApplicationRecord
include Attachment, Broadcasts, Mentionee, Pagination, Searchable
class User < ApplicationRecord
include Avatar, Bannable, Bot, Mentionable, Role, Transferable
Before reading a single method body you know the entire surface area: a Message is attachable, broadcastable, mentionable, paginatable, searchable; a User has an avatar, can be banned, can be a bot, can be mentioned, has a role, can be transferred. The include line IS the spec. Each name maps to a file at app/models/message/<name>.rb or app/models/user/<name>.rb, so the folder tree mirrors the list. Want search? You don't scroll message.rb (which stays 44 lines because of exactly this) — you open message/searchable.rb. We don't re-derive why this is the right way to organize a domain here; that worldview is see P8: Give Behavior a Home. The mechanics are what make it possible: the include list reads as a table of contents only because each entry is a self-contained concern.
The grammar, shown clean: Message::Searchable
This is the canonical concern in the codebase, and it shows all three regions in 27 lines (searchable.rb:1-27):
module Message::Searchable
extend ActiveSupport::Concern
included do
after_create_commit :create_in_index
after_update_commit :update_in_index
after_destroy_commit :remove_from_index
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
end
private
def create_in_index
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
end
def update_in_index
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
end
def remove_from_index
execute_sql_with_binds "delete from message_search_index where rowid = ?", id
end
def execute_sql_with_binds(*statement)
self.class.connection.execute self.class.sanitize_sql(statement)
end
end
Read it through the three-way split and it's obvious. The included do block holds the four things that change the Message class — three commit callbacks and the search scope (region 1, the wiring harness). The private methods below hold the behavior an instance performs — actually writing a row to the FTS5 index (region 2). That's the whole feature: a self-syncing full-text search that mirrors every message into a SQLite virtual table (create_virtual_table "message_search_index", "fts5", ["body", "tokenize=porter"], schema.rb:182).
Three details worth pausing on. The callbacks are after_*_commit, not after_* — the index never references a rolled-back row (we don't unpack the transaction lifecycle here; _commit means after-durable, and the deep version is F1: the callback lifecycle and see P9: Put Work at Its Right Altitude). The trio is symmetric: create/update/destroy each get a matching index write, so the index can't drift from the table. And scope :search stays a chainable Relation, which is what lets it compose with the auth boundary in searches_controller.rb:23:
@messages = Current.user.reachable_messages.search(query).last(100)
reachable_messages (the authorization-by-association load, see P3: Security Is the Shape of Your Data Access) and .last(100) both chain onto search precisely because the concern declared a scope and not a class method that returns an array. The trait stays a good Rails citizen at its boundary.
The contrast: the naive version stacks those three callbacks and the raw FTS SQL somewhere in the middle of a 300-line message.rb, the search scope drifts forty lines from the methods it depends on, and the moment someone writes def self.search returning an array instead of a scope, reachable_messages.search(q).last(100) stops composing and you're back to filtering in Ruby. The concern keeps the wiring, the behavior, and the chainability in one readable frame.
class_methods do: the constructor that can't drift from its trait
The third region earns its keep when a trait has a factory that belongs next to it. User::Bot (bot.rb:1-68) puts the named constructor right beside the instance behavior it produces:
module User::Bot
extend ActiveSupport::Concern
included do
scope :active_bots, -> { active.where(role: :bot) }
has_one :webhook, dependent: :delete
end
module ClassMethods
def create_bot!(attributes)
bot_token = generate_bot_token
User.create!(**attributes, bot_token: bot_token, role: :bot).tap do |user|
# ...
end
end
end
def bot_key
"#{id}-#{bot_token}"
end
included do wires the host (the active_bots scope, the webhook association). ClassMethods holds create_bot! — the named constructor. The module body holds bot_key — instance behavior. All three regions, one trait, one file. You never go hunting in user.rb for "how do I make a bot?" — it's filed under Bot, next to what a bot is.
The sharpest version of "the constructor lives next to the trait" is the signed-id round-trip. A capability link has two halves that must agree on a purpose: or the link silently fails to verify. User::Avatar keeps them adjacent (avatar.rb:8-16):
class_methods do
def from_avatar_token(sid)
find_signed!(sid, purpose: :avatar) # verify
end
end
def avatar_token
signed_id(purpose: :avatar) # mint
end
Mint (avatar_token, instance) and verify (from_avatar_token, class) sit a few lines apart with the identical purpose: :avatar. User::Transferable does the same with purpose: :transfer (transferable.rb:6-14). Because the concern co-locates them, you cannot rename the purpose on one side and forget the other — they're in the same eyeful. (from_avatar_token is consumed at users/avatars_controller.rb:7, find_by_transfer_id at sessions/transfers_controller.rb:8 — the credential is the URL, no token table; the derive-don't-store reasoning is see P2: Derive, Don't Store.) This is the whole argument for class_methods do in one snippet: a constructor and the trait it constructs that physically cannot drift apart.
A concern that secures every endpoint just by being listed
The most quietly powerful use of included do isn't on a model — it's a request gate that installs itself. BlockBannedRequests (block_banned_requests.rb:1-16):
module BlockBannedRequests
extend ActiveSupport::Concern
included do
before_action :reject_banned_ip, unless: :safe_request?
end
private
def reject_banned_ip
head :too_many_requests if Ban.banned?(request.remote_ip)
end
def safe_request?
request.get? || request.head?
end
end
The included do adds a before_action to whatever controller includes it. And it's included exactly once, in the ApplicationController include line (application_controller.rb:2):
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, ...
That single word BlockBannedRequests in the include list installs an ambient IP-ban gate across every endpoint in the app.
No per-controller
before_action, nothing to remember, nothing to forget on the next controller you write. The include line is a table of contents here too — for a controller, it lists the cross-cutting guards every request passes through — and a concern's included do is the mechanism that lets a security gate register itself by being named. (Secure-by-default-via-included-do as a worldview is see P3: Security Is the Shape of Your Data Access; here it's just the wiring harness doing its one job.)
"Why is
block_banned_requestsa concern at all, instead of just abefore_actionin ApplicationController?" Because the gate is a trait — it owns a behavior (reject_banned_ip), a policy (safe_request?), and the wiring that connects them. Inlining thebefore_actionintoApplicationControllerwould scatter that trait across the file and leave the methods looking for a home. The concern gives the IP-ban guard a single home that wires itself; that the wiring happens to be abefore_actioninstead of anafter_create_commitdoesn't change the shape —included dois the wiring harness whether the host is a model or a controller.
Where this points
Concerns are the mechanism; the worldview that says give every trait a home, file it under the noun it belongs to, and read the include line as the spec is see P8: Give Behavior a Home: Composition via Concerns. The traits you've seen wired here go on to do real work in their own deep homes: the search concern is the spine of see C2: Search, the FTS callbacks' _commit timing is see P9: Put Work at Its Right Altitude, the signed-id round-trips are see P2: Derive, Don't Store, and the self-registering ban gate is see C3: The Ban Arc. Foundations hand you the tool; the principle track tells you why you'd reach for it.
And the throughline, honestly: count the edge cases the include line absorbs for free. include Attachment, Broadcasts, Mentionee, Pagination, Searchable absorbs the entire "where does feature X live, and what can this object even do?" question that a 300-line god-model forces you to answer by scrolling — answered in one line, derived from the framework's own composition rules, never hand-maintained.
Key Takeaways — Patterns to Steal
- When a model starts doing many unrelated jobs — carries an attachment, broadcasts, is searchable, paginates — resist the reflex to keep opening
message.rband adding one more method, because that's how you get a 300-line god-model with no shape, where finding "how does search work?" means scrolling past mentions and pagination to hunt for the FTS callbacks. Pull each job into its own concern and let the file stay small. Campfire'smessage.rbstays 44 lines precisely because every trait lives inapp/models/message/<name>.rbinstead of in the model body. - Reach for
ActiveSupport::Concern, not a plain Ruby module, the moment a trait needs to install class-level wiring — a callback, a scope, an association — onto its host. A plain module can only add instance methods gracefully; to add class macros you're stuck hand-writing aself.included(base)hook, and that boilerplate (plus silent include-order breakage when one trait depends on another) is exactly the two walls Concern knocks down. Every concern in the codebase opens withextend ActiveSupport::Concern(searchable.rb:2) to buy out of that ceremony. - Read a concern through one fixed three-way split and you can read any concern in the codebase: host-changing macros go in
included do, plain instance behavior goes in the bare module body, and a named constructor goes inclass_methods do. Don't scatter a callback into the module body or a factory into the parent model — each region has different semantics on purpose.Message::Searchableshows all three at once (searchable.rb:4-10for the wiring,searchable.rb:13for the behavior). - Before reading a single method body, read the
includeline as the table of contents — it's the spec.include Attachment, Broadcasts, Mentionee, Pagination, Searchable(message.rb:1-2) tells you a Message is attachable, broadcastable, mentionable, paginatable, and searchable, and each name maps to a file atapp/models/message/<name>.rb, so the folder tree mirrors the list. The alternative — scrolling a 300-line model to discover what it can even do — is the cost you're buying out of. - When a trait has a factory, put the named constructor in
class_methods do(here amodule ClassMethods) right next to the instance behavior it produces, instead of marooning it back in the parent model away from everything it relates to.User::Botkeepscreate_bot!in itsClassMethodsblock (bot.rb:10-18) besidebot_key(bot.rb:38-40) and the rest of what a bot is, so you never go hunting inuser.rbfor "how do I make a bot?" — it's filed underBot. - When a concern exposes a query, declare it with
scopeso it stays a chainable Relation — don't writedef self.searchreturning an array, because the day you do, anything trying to chain onto it breaks and you fall back to filtering in Ruby. Thescope :searchinsearchable.rb:9is the only reasonCurrent.user.reachable_messages.search(query).last(100)(searches_controller.rb:23) can compose the auth boundary, the search, and the limit in one line. - When a cross-cutting guard needs to run on every request, make it a concern whose
included doregisters abefore_action, rather than hand-adding thatbefore_actionto each controller and forgetting it on the next one you write. Listing the single wordBlockBannedRequestsinapplication_controller.rb:2installs an ambient IP-ban gate across every endpoint, because itsincluded do(block_banned_requests.rb:4-6) is the wiring harness — and it works the same whether the host is a model or a controller.