Give Behavior a Home: Composition via Concerns
A principle tutorial on why a Rails model's include line is its table of contents — how concerns file each trait where it belongs (host-changing wiring in included do, plain behavior in the module body, the named constructor beside the trait), so you know what a class can do before reading a single method. Grounded in Campfire's Message and User models, with Searchable and Bannable as the canonical concerns.
The principle: A model's include list is its table of contents; each concern files a trait where it belongs, with host-changing wiring inside
included do, plain behavior in the module body, and the named constructor next to the trait it builds — so you know what a class can do before reading a single method.
① First principles: where does behavior go to live?
A Message in a real chat app has to do a lot. It carries attachments. It broadcasts itself to live screens. It can be the target of an @mention. It paginates. It's full-text searchable. Each of those is real, shipping behavior — callbacks, scopes, raw SQL, associations, a couple of class methods apiece.
Here's the Message you'd vibe-code first, because every time you needed a message to do one more thing, you opened message.rb and added a method:
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 ...").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; body.body.attachables.grep(User).uniq; end
# ...
# --- pagination ---
scope :last_page, -> { ... }
scope :page_before, ->(m) { ... }
scope :page_after, ->(m) { ... }
# ...four more scopes...
# --- broadcasts ---
def broadcast_create; broadcast_append_to room, :messages; end
# ...append/replace/remove...
end
By the time you've stacked attachments, mentions, six pagination scopes, the FTS callbacks with their inline SQL, and the broadcast methods, the file is three hundred lines. It still works. But now answer one question: how does search work in this app? You can't point at it. You scroll. The search index callbacks are somewhere near the top, the search scope is somewhere in the middle, the plain_text_body it indexes is somewhere else, and the SQL helper is at the bottom. The behavior is real, but it has no home — it's smeared across a file that grew by accretion.
The usual escape hatch makes it worse: you peel the scary parts into a MessageService and sprinkle MessageService.update_index(message) calls into every create, update, and destroy path. Now the trait isn't even in one file — it's a service plus a fan of call sites you have to keep in sync by hand, and forgetting one leaves the index quietly stale.
So derive the actual requirement. The problem isn't length — a feature-rich model is allowed to be rich. The problem is that a trait's pieces are scattered, so they can drift and you can't find them. What you want is the opposite property: every piece of "search" — its callbacks, its scope, its private SQL methods — living together in one place, with the model's first line telling you that place exists. You want to give behavior a home.
That's what a concern is. Each trait moves to a small module at app/models/message/<trait>.rb, and the class declares which traits it has on line one:
class Message < ApplicationRecord
include Attachment, Broadcasts, Mentionee, Pagination, Searchable
That's message.rb:1-2. Before reading a single method body, you know a Message is attachable, broadcastable, a mention target, paginatable, searchable. The include line IS the spec. User reads the same way (user.rb:1-2):
class User < ApplicationRecord
include Avatar, Bannable, Bot, Mentionable, Role, Transferable
A User has an avatar, can be banned, can be a bot, can be mentioned, has a role, can be transferred. You read it in two seconds and you know the shape of the thing.
This isn't a Campfire quirk — Fizzy's Card opens the same way, only longer: a single include runs to twenty-four traits (include Accessible, Assignable, Attachments, Broadcastable, Closeable, ...), wrapping across card.rb:2-4. A second, unrelated 37signals product reaches for the identical move, just at greater scale — so the include line as the spec is the house style, not one app's habit.
"Isn't this just moving code around to make one file shorter — the same complexity, hidden in more files?" No — it's placing it, and the placement carries information. A
MessageService.update_indexcall sprinkled across the codebase hides the trait; a concern namedSearchablethat owns its own callbacks advertises it. The test is the question "how does search work?" In the 300-line model you scroll and reconstruct. With concerns you openmessage/searchable.rband read 25 self-contained lines. The line count barely changed; the findability changed completely.
We're not deriving the mechanism here — how ActiveSupport::Concern solves mixin-ordering and lets a module add class-level macros to its host is see F7: Concerns as a Mechanism's job. This tutorial is about the worldview: why you file behavior this way, and what it buys you.
But the worldview has a second half, and it's the part that makes concerns more than folders. A trait isn't one kind of thing — it's three. There's the wiring that has to modify the host class (a callback, a scope, an association). There's the behavior that's just methods on instances. And there's often a named constructor — the class method that builds one. A concern gives each of those a precise region, and ActiveSupport::Concern gives those regions exact semantics:
included do ... endruns in the context of the host class, so anything you declare there — callbacks, scopes, associations — modifies the host. This is the wiring harness.included dois the wiring harness.- The module's plain instance methods are just behavior added to instances.
class_methods do ... end(ormodule ClassMethods) defines methods on the host class — so the named constructor that builds the trait lives right next to the trait.
Look at the canonical example, Message::Searchable in full (message/searchable.rb:1-28):
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
The three regions are right there. The callbacks and the search scope go inside included do because they have to change the host. The create_in_index / update_in_index / remove_from_index methods are plain behavior, so they sit in the module body. That's the internal grammar of every concern in the app.
Fizzy files its concerns the same way: Card::Taggable (card/taggable.rb:4-21) puts its has_many :taggings/has_many :tags associations and tagged_with scope inside included do, then keeps toggle_tag_with and tagged_with? as plain methods in the body. Same wiring-then-behavior split, a different product, an unrelated trait — the grammar is the house's, not Campfire's.
The payoff of co-location is subtle but it's the whole game: the three pieces of a trait can't desync, because they live a few lines apart. The callback that fires create_in_index is right above the method it calls. Avatar's mint-and-verify pair (user/avatar.rb:8-16) keeps signed_id(purpose: :avatar) and find_signed!(sid, purpose: :avatar) in one tiny module so the two halves of the round-trip can never drift to different purpose: values. Scatter those across a god model and a service object, and "I changed how we index but forgot the destroy path" becomes a bug you ship.
② The beauty in combination: a concern is where other principles come to live
A concern on its own is just tidy filing. What makes it load-bearing is that it's the container the other principles get filed into. Hold P8 next to three of its neighbors and watch them interlock.
With P1 (the model owns the consequence). P1: The Model Owns Its Consequences says a fact that's true for every instance of a record belongs to the model as a callback. P8 answers the next question: where on the model? It belongs in the trait's concern. Banning a user is a Bannable thing, so the consequence of a ban lives in Bannable (user/bannable.rb:19-21):
def remove_banned_content_later
RemoveBannedContentJob.perform_later(self)
end
Searchable owns its index callbacks; Bannable owns its content-removal. P1 decides that the model owns the consequence; P8 decides under which trait it gets filed. A concern is where owned consequences live.
With P3 (security is the shape of your data access). Here's the move that only included do makes possible: a concern can install a guard on its host just by being listed. BlockBannedRequests (block_banned_requests.rb:4-6):
included do
before_action :reject_banned_ip, unless: :safe_request?
end
Add include BlockBannedRequests to ApplicationController and every endpoint in the app is now guarded against banned IPs — no per-controller code, no before_action you have to remember to repeat. The security gate is ambient, and it's ambient because included do runs in the host's context. The safe behavior installs itself. That's P3: Security Is the Shape of Your Data Access expressed as a concern.
With P9 (put work at its right altitude). P9: Put Work at Its Right Altitude says the slow, flaky part of a consequence goes to a background job, and the _later method owns any guard so the job stays a dumb thunk. Where does that _later method live? In the trait's concern, next to its synchronous twin. User::Bot (user/bot.rb:51-57):
def deliver_webhook_later(message)
Bot::WebhookJob.perform_later(self, message) if webhook # the guard lives here
end
def deliver_webhook(message)
webhook.deliver(message) # the work lives here
end
The guard (if webhook) and the work sit four lines apart in Bot, so the job (Bot::WebhookJob) can be a two-line delegate that never re-checks defensively. P9 decides the altitude; P8 decides the trait it's filed under and keeps the guarded wrapper next to the bare method. (see P9: Put Work at Its Right Altitude owns the altitude reasoning; the _later/plain-method pairing is shown there in full.)
Step back and the pattern is clear: P1, P3, and P9 each produce a piece of behavior that has to go somewhere. P8 is the answer to "somewhere."
③ How 37signals did it
The evidence is the two include lines and what's behind them.
The include line as a table of contents. message.rb:1-2 and user.rb:1-2 are the spec, and the folder mirrors them: Message::Searchable lives at app/models/message/searchable.rb, User::Bannable at app/models/user/bannable.rb. The class body that remains is tiny — message.rb is 44 lines, holding only the truly core stuff (the associations, to_key, plain_text_body) while every trait is filed away.
Fizzy's Card makes the same trade more dramatically: twenty-four concerns in one include (wrapping across card.rb:2-4), yet the whole class file is still only 95 lines (card.rb) — the associations, a handful of scopes, and to_param, with every trait filed off to app/models/card/<trait>.rb. The richer the model, the more the include list earns its keep — and 37signals lean on it the same way in both products.
The three-region grammar, shown cleanly. Message::Searchable (message/searchable.rb:1-28) puts its three after_*_commit callbacks and the search scope inside included do, and its create_in_index / update_in_index / remove_from_index methods in the module body. User::Bot (user/bot.rb:4-18) shows the third region — module ClassMethods holding create_bot!, the named constructor, right beside the instance trait it builds:
included do
scope :active_bots, -> { active.where(role: :bot) }
scope :without_bots, -> { where.not(role: :bot) }
has_one :webhook, dependent: :delete
end
module ClassMethods
def create_bot!(attributes)
bot_token = generate_bot_token
webhook_url = attributes.delete(:webhook_url)
User.create!(**attributes, bot_token: bot_token, role: :bot).tap do |user|
user.create_webhook!(url: webhook_url) if webhook_url
end
end
def authenticate_bot(bot_key)
bot_id, bot_token = bot_key.split("-")
active_bots.find_by(id: bot_id, bot_token: bot_token)
end
def generate_bot_token
SecureRandom.alphanumeric(12)
end
end
def update_bot!(attributes)
transaction do
update_webhook_url!(attributes.delete(:webhook_url))
update!(attributes)
end
end
def bot_key
"#{id}-#{bot_token}"
end
def reset_bot_key
update! bot_token: self.class.generate_bot_token
end
def webhook_url
webhook&.url
end
def deliver_webhook_later(message)
Bot::WebhookJob.perform_later(self, message) if webhook
end
def deliver_webhook(message)
webhook.deliver(message)
end
The constructor that makes a bot lives in the same file as bot_key, reset_bot_key, and deliver_webhook — the entire bot trait, wiring and behavior and constructor, in one home.
A whole feature in 25 lines. The headline proof is that Message::Searchable is a complete, production full-text search feature — indexed, kept in sync, authorized when composed, ranked — in about 25 lines.
ruby
Current.user.reachable_messages.search(query).last(100)
That one line is Searchable (the scope), P3: authorization-by-association (reachable_messages), and pagination, all composing — because search was filed as a self-contained, composable trait rather than smeared into the model.
"If
included docan reach into the host, isn't a concern just a way to spook the host class from a distance — action at a distance I can't trace?" It would be, if concerns were anonymous. They're not — they're named after the trait and listed on line one. The host advertises exactly which modules are allowed to wire into it, in declaration order, before any method body. So when search behaves oddly you don't grep forafter_create_commitacross the app; you readinclude ..., Searchableand open one file. The wiring is at a distance, but the index to it is the first thing you read.
The throughline holds, in P8's own slice: count the edge cases this line absorbs for free. include Attachment, Broadcasts, Mentionee, Pagination, Searchable absorbs the "where does feature X live?" scroll-hunt through a god model, the stale-index bug from a forgotten service call, and the drift between a trait's three pieces — all by giving each trait one home and naming it on the first line you read.
Key Takeaways — Patterns to Steal
- Read a model's first line as its spec:
include Attachment, Broadcasts, Mentionee, Pagination, Searchablealready tells you a Message is attachable, broadcastable, a mention target, paginatable, and searchable before you've read one method body. Don't let those traits accrete as methods scattered down a 300-line class where "how does search work?" can only be answered by scrolling — file each trait as a module atapp/models/message/<trait>.rband let the include list be the table of contents. That's exactlymessage.rb:1-2, withuser.rb:1-2reading the same way. - When a model needs to do one more thing, resist opening the class and adding a method — add a concern and add its name to the include list instead. The naive move grows the file by fifty buried lines that nobody can find later; the disciplined move grows it by one readable word on line one. Campfire's
message.rbstays 44 lines holding only the truly core stuff (belongs_to,plain_text_body,to_key) precisely because every trait was filed away under its own name. - Put anything that has to modify the host class — callbacks, scopes, associations — inside
included do, and leave plain instance methods in the module body. The reason isn't style:included doruns in the context of the host, which is the only place ascopeor anafter_*_commitcan actually attach, so that block is the trait's wiring harness. You can see the split cleanly inmessage/searchable.rb:4-10, where the three commit callbacks and thesearchscope sit insideincluded dowhilecreate_in_indexand friends stay in the module body as ordinary behavior. - Keep the class method that builds a trait inside that same trait's concern, in a
module ClassMethods(orclass_methods do) block, not floating in the host class or in someUserFactory. A constructor that drifts away from the thing it constructs is how the two slowly stop agreeing.User::Botputscreate_bot!inmodule ClassMethods(user/bot.rb:10-18) right beside the instance methodsbot_key,reset_bot_key, anddeliver_webhookit works with — the entire bot trait, wiring and behavior and constructor, in one home. - When a trait has two halves that must agree on a value, file them a few lines apart in one tiny module so they physically can't drift to different settings. The trap is putting "mint" on the model and "verify" in some helper, then changing one
purpose:and shipping a token that no longer validates.User::Avatarkeepssigned_id(purpose: :avatar)andfind_signed!(sid, purpose: :avatar)within eight lines of each other (user/avatar.rb:8-16), so the round-trip can never desync. - Let a security gate install itself ambiently by being listed, instead of copy-pasting a
before_actioninto every controller and hoping you never forget one. Becauseincluded doruns in the host's context,BlockBannedRequestsaddsbefore_action :reject_banned_ip, unless: :safe_request?(block_banned_requests.rb:4-6) to every endpoint the moment you writeinclude BlockBannedRequestsinApplicationController. The safe behavior becomes the default rather than a discipline you have to remember per controller. - Build a complete feature as one self-contained, composable concern and you get the whole thing in about 25 lines:
Message::Searchable(message/searchable.rb:1-28) is indexed, kept in sync, ranked full-text search. The trick that makes it compose is keepingscope :searcha chainableRelationrather than a method that returns an array — soCurrent.user.reachable_messages.search(query).last(100)(searches_controller.rb:23) layers authorization-by-association, search, and pagination onto one line. A trait smeared into the model could never chain like that. - Reach for
after_create_commitover plainafter_createwhenever a callback writes to something outside the row's own transaction, because the_commitsuffix means after-durable.Searchable's threeafter_*_commitcallbacks (message/searchable.rb:5-7) mirror each message into the FTS5 index only once the row is committed, so the index never points at a message that got rolled back — a guarantee plainafter_createcan't make.