Concerns as a Mechanism

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).

ActiveSupport::Concern as a mixin with three regions: included do, the module body, class_methods included do is the wiring harness: macros that change the host class (callbacks, scopes, associations) the module body is plain instance behavior class_methods do keeps a named constructor next to the trait it builds, so mint/verify can't drift the include line IS the table of contents / the spec you read before any method body a concern can self-register a request gate just by being listed (block_banned_requests) concerns solve the two pain points of plain Ruby mixins: include-order of dependent macros and the awkwardness of adding class-level macros from inside a module give behavior a home: co-locate a trait's wiring, behavior, and constructor (points to P8) campfire by nityeshagarwal

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.

A two-panel Wait-But-Why style hand-drawn stick-figure sketch, black ink on whi…

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.

A clean, teaching-first technical diagram titled 'ActiveSupport::Concern — thre…

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.

A teaching diagram titled 'A concern that secures every endpoint just by being … 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_requests a concern at all, instead of just a before_action in 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 the before_action into ApplicationController would 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 a before_action instead of an after_create_commit doesn't change the shape — included do is 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.rb and 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's message.rb stays 44 lines precisely because every trait lives in app/models/message/<name>.rb instead 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 a self.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 with extend 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 in class_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::Searchable shows all three at once (searchable.rb:4-10 for the wiring, searchable.rb:13 for the behavior).
  • Before reading a single method body, read the include line 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 at app/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 a module 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::Bot keeps create_bot! in its ClassMethods block (bot.rb:10-18) beside bot_key (bot.rb:38-40) and the rest of what a bot is, so you never go hunting in user.rb for "how do I make a bot?" — it's filed under Bot.
  • When a concern exposes a query, declare it with scope so it stays a chainable Relation — don't write def self.search returning an array, because the day you do, anything trying to chain onto it breaks and you fall back to filtering in Ruby. The scope :search in searchable.rb:9 is the only reason Current.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 do registers a before_action, rather than hand-adding that before_action to each controller and forgetting it on the next one you write. Listing the single word BlockBannedRequests in application_controller.rb:2 installs an ambient IP-ban gate across every endpoint, because its included 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.