Polymorphism Over Conditionals

Polymorphism Over Conditionals

A principle tutorial in the Beautiful Rails (Campfire) series. Teaches that every if-this-type/elsif-that-type branch is a polymorphism you haven't named yet — and how pushing the difference into an STI subclass, an ordered enum, or a super call makes the case statement disappear along with the bugs hiding in its branches. Grounded in Campfire's three room subclasses, the involvement enum, and the bot controller that inherits the human create path.

polymorphism you haven't named yet the branch was a missing abstraction STI: one table, a type column, subclasses that override only the seam enum as a query vocabulary (prefix:) capability by subtraction controller inheritance + super (the bot path IS the human path) type_previously_changed? as free dirty-tracking becomes! to recast a record's subclass muted as the absence of a .merge campfire by nityeshagarwal

The principle: Every if-this-type / elsif-that-type branch is a polymorphism you haven't named yet — push the difference into a subclass, an enum, or a super call, and the case statement disappears along with the bugs that hid in its branches.

① First principles: the case statement nobody admits is an object

Three things in Campfire vary by kind, and each is a trap.

Rooms come in flavors: a one-to-one DM behaves a little differently from an open channel everyone's in, which behaves a little differently from an invite-only room. Notifications come in tiers: you want everything from this room, only @mentions from that one, nothing from a third, and you've muted a fourth entirely. And a bot posting a message is almost — but not quite — a human posting a message: same body, same room, same broadcast, but the request arrives over an API with a key instead of a session.

Here's the version you'd vibe-code first, and it's not a strawman — it's the honest first draft. You reach for a column that names the kind, and then you branch on it everywhere the kind matters:

# Rooms for direct message chats between users. These act as a singleton, so a single set of users will
# always refer to the same direct room.
class Rooms::Direct < Room
  class << self
    def find_or_create_for(users)
      find_for(users) || create_for({}, users: users)
    end

    private
      # FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
      # which could be to store the membership id list as a hash on the room, and use that for lookup.
      def find_for(users)
        all.joins(:users).detect do |room|
          Set.new(room.user_ids) == Set.new(users.pluck(:id))
        end
      end
  end

  def default_involvement   # <-- the ONLY behavioral override; the class-level methods above are construction, not per-kind behavior
    "everything"
  end
end

That if kind == is not contained. It metastasizes. The same three-way branch reappears in the controller (if @room.kind == "direct" to skip the admin check), in the view (unless room.kind == "direct" to hide a button), in the pusher (if kind == "open"). The day you add a fourth room type, you don't add a file — you go hunting for every if kind == in the codebase and pray you found them all. Miss one and the new type silently inherits the wrong behavior, no error, just a button that shouldn't be there.

Notifications get the parallel treatment. The kind isn't a string here, it's a fistful of booleans:

class Membership < ApplicationRecord
  # muted:boolean, mentions_only:boolean, hidden:boolean
end

Three booleans encode four states — except three booleans encode eight states, so half of them are impossible-but-typeable: muted: true, mentions_only: true means what, exactly? The query to find "who gets a push for this message" becomes a thicket of where(muted: false).where(hidden: false).or(...) that you get subtly wrong, and there is no single place that says these states are mutually exclusive, because nothing makes them so.

Now the bot. The naive instinct is a parallel controller — Api::MessagesController — that re-implements room lookup, message creation, and broadcasting for the API caller. It works on Tuesday. By Friday the human create grew attachment handling and the API one didn't, and now bots can't post images, and the two creation paths have quietly forked into two features that are supposed to be one.

Step back and look at what all three have in common. The if kind == branch is a missing abstraction. When you write if type == "direct", you are hand-rolling, badly, the one thing object-oriented languages do for free: dispatch on type. The runtime already knows what kind of object it's holding. The branch re-asks a question Ruby can answer by itself — and every place you re-ask it is a place the answer can drift.

So the derivation: a difference that depends on the kind of a thing belongs to the kind, not to a conditional that re-discovers the kind. Give the kind a name — a subclass, an enum value, a parent method you super past — and the conditional evaporates, because the right behavior is selected by which object you're holding, not by an if that interrogates it.

This is polymorphism you haven't named yet. The case statement was always an object wearing a disguise.

A square 1:1 technical poster on an off-white (#FAF8F3) ground, in the visual s…

② The beauty in combination

This principle is almost worthless alone and transformative in company. Three other principles are what make "push the difference into a subclass" actually pay off.

With P1 (the model owns the truth). Once the model owns its consequences, a new room type is a new subclass overriding exactly one method — the model absorbs the variation and nothing downstream changes. The controller doesn't branch on type because the type's behavior already lives where the type lives. You don't get thin controllers by being disciplined about controllers; you get them because see P1: The Model Owns Its Consequences put the per-kind behavior on the kind, leaving the controller nothing to branch on.

With P2 (derive, don't store) and P3 (security as shape). Here's the move that makes the whole thing sing: one enum declaration becomes a query vocabulary that drives several unrelated surfaces. The involvement enum decides the notification tier in the pusher, the visibility of a room in the sidebar query, and — through the same generated scopes — composes straight onto the membership-scoped query you load records through. One declaration, many surfaces, and not one of them re-derives the others. We don't re-explain how enum generates predicates and scopes here — that's see F1: The Rails Model & Active Record's job (the enum-generates-a-family mechanics); the worldview point is that a single named vocabulary replaces a dozen scattered string comparisons, and "muted" turns out to be the absence of a .merge — silence implemented by silence. (How that vocabulary composes onto a member's reachable records without leaking is see P3: authorization-by-association.)

With P5 (one renderer). Because a bot is just a User with role: :bot, it isn't a special case anywhere. Current.user.memberships works for it. Message authorship works for it. The broadcast works for it — a bot's reply rides the exact same broadcast_create rails a human's message does (see P5: One Renderer), so it appears on every screen like any other message, with zero bot-specific render code. Capability wasn't added by writing a new path; it was added by subtraction — one skipped before_action, one super. Capability by subtraction is the recurring shape: the special case is the base case minus one filter, not the base case plus a new branch.

The reader's payoff: you stop seeing a case statement as code and start seeing it as a smell that names a class you haven't extracted yet.

And the move isn't a model-layer trick — Fizzy proves it holds at the view altitude Campfire never has to demonstrate: rendering an event picks its partial by name, not by branch — <% if lookup_context.exists?("events/event/eventable/_#{event.action}") %> … <%= render "events/event/eventable/#{event.eventable_type.demodulize.underscore}" %> (events/_event.html.erb:3-7), and a search result renders straight off the record with link_to result.source (searches/_result.html.erb:2). Dozens of event types, zero case; adding a new one is dropping a file, not editing a branch. So this is 37signals doctrine, not a Campfire one-off: a branch is a missing polymorphism whether it lives in a model or an .erb.

A clean technical diagram, white background, generous even margins on a grid. T…

"Isn't this just over-engineering? A three-line if is simpler than a whole subclass." It's simpler at one call site. The cost isn't the if; it's the copies. A kind string forces the same three-way branch into the model, the controller, the view, and the pusher — four copies that must agree forever. The subclass writes the difference once, in the one file named after the difference, and every call site dispatches to it for free. You're not adding a class; you're deleting the other three copies of the branch.

③ How 37signals did it

STI: one table, three subclasses, no if direct? anywhere

Open room.rb. It's an STI base class — one rooms table, a type column, three subclasses. The base holds everything common; each subclass holds only its difference. The default notification tier lives as a plain method on the base (room.rb:63-65):

  def default_involvement
    "mentions"
  end

And Rooms::Direct overrides that one method — and nothing else behavioral (direct.rb:19-21):

  def default_involvement
    "everything"
  end

That is the entire behavioral difference between a DM and a channel: a DM defaults you to "everything" because if someone messages you directly, you want to know. There is no if room.direct? deciding the default anywhere — the right room subclass simply returns the right string. Meanwhile Rooms::Closed is a literally empty class (closed.rb:2-3):

class Rooms::Closed < Room
end

Read that for a second. The empty body is the lesson: "closed" is the base behavior, so the subclass that represents it adds nothing. The naive if kind == "closed" branch would have been pure noise — it was always describing the default. A subclass lets the default be silent.

Even the predicates refuse to store a flag (room.rb:51-61):

  def open?
    is_a?(Rooms::Open)
  end

  def closed?
    is_a?(Rooms::Closed)
  end

  def direct?
    is_a?(Rooms::Direct)
  end

open? doesn't check a column; it asks the object what it is. The type isn't data to compare — it's the class, and is_a? reads it straight.

Fizzy reaches for the same trick to erase is_a? itself: every node in its graph answers #card without a type check — the Card is its own card (def card; self; end, card.rb:50-52), while its Event and Mention forward the question polymorphically (delegate :card, to: :eventable, event.rb:29; delegate :card, to: :source, mention.rb:11) and a Comment simply holds the association (belongs_to :card, comment.rb:5) — so source.card needs no if source.is_a?(Card) branch anywhere. 37signals do this in both products: when you want the right behavior, ask the object, never interrogate a stored kind.

The conversion case — the one that always breaks the naive version — is the most elegant part. Editing a closed room and saving it as open should re-grant membership to everyone. The naive version flips a kind column and then remembers to null and re-grant memberships by hand. Campfire recasts the actual Ruby object. The controller's before_action does it (opens_controller.rb:39-41, force_room_type):

    def force_room_type
      @room = @room.becomes!(Rooms::Open)
    end

becomes!(Rooms::Open) returns the same row as an instance of the new subclass, so on save the new subclass's callbacks fire. And the re-grant is guarded by free dirty-tracking (open.rb:2-8):

class Rooms::Open < Room
  after_save_commit :grant_access_to_all_users

  private
    def grant_access_to_all_users
      memberships.grant_to(User.active) if type_previously_changed?(to: "Rooms::Open")
    end
end

type_previously_changed?(to: "Rooms::Open") fires the grant only when the room actually converted to Open — not on every subsequent save of an already-open room. No manual flag, no "did we already grant?" bookkeeping. The framework tracked the type change; the callback reads it.

"type_previously_changed? — that's _commit-era dirty tracking, isn't it?" Yes. After a save commits, Rails keeps a record of what changed in that save, and attribute_previously_changed?(to:) lets a callback ask "did this attribute just become X?" Here it's the difference between "grant access because we just became Open" and "re-grant access every time anyone edits the room name." One method, and the re-grant can't double-fire.

The contrast: the naive kind string scatters if kind == "direct" through model, controller, view, and pusher, makes default_involvement a ternary, and turns "convert closed to open" into a manual column-flip-plus-remember-to-re-grant. Campfire's version puts the one difference in the one file named after it, makes becomes! perform the conversion with the right callbacks, and lets type_previously_changed? decide when to act — every branch absorbed by the class system that was sitting there the whole time.

Fizzy turns the same instinct into a reusable template-method loop its event ledger rides: a single Eventable concern builds the action name from the class — board.events.create!(action: "#{eventable_prefix}_#{action}", …) — and exposes an empty event_was_created hook plus a should_track_event? template method (concerns/eventable.rb:8-24), so each model reacts to its own events by overriding one method instead of a case eventable_type sitting in the ledger (card/eventable.rb:12-27). The pattern is the house's, not one app's: the class supplies the name and the behavior; no central conditional re-derives the kind.

Polymorphism at every altitude: Fizzy's event ledger

The eventable concern is worth slowing down on, because it shows the case-erasing move surviving a full round trip — from the model callback that records an event, all the way to the view that renders it — without a single case eventable_type appearing at either end. Campfire never has to demonstrate this; Fizzy's activity ledger does, and it's the cleanest proof that "let the type pick the behavior" is one idea, not two.

Start at the recording end. A board, a card, a comment — any noun that wants a history — includes one tiny concern, and that concern is a template-method loop, not a dispatcher (concerns/eventable.rb:8-24):

  def track_event(action, creator: Current.user, board: self.board, **particulars)
    if should_track_event?
      board.events.create!(action: "#{eventable_prefix}_#{action}", creator:, board:, eventable: self, particulars:)
    end
  end

  def event_was_created(event)
  end

  private
    def should_track_event?
      true
    end

    def eventable_prefix
      self.class.name.demodulize.underscore
    end
end

Read what's missing. There is no case self when Card then "card" when Comment then "comment" building the action name — eventable_prefix asks the object its own class name (self.class.name.demodulize.underscore) and the action string assembles itself. There is no if self.is_a?(Card) && published? gate deciding whether to record — should_track_event? is an empty-bodied template method that defaults to true, and any model that records differently overrides it on its own turf. And event_was_created(event) is an empty hook sitting in the base for the same reason: the ledger fires it, but what happens next belongs to the noun, not to a branch in the concern.

A Card fills in exactly those two seams and nothing else (card/eventable.rb:12-27):

  def event_was_created(event)
    transaction do
      create_system_comment_for(event)
      touch_last_active_at unless was_just_published?
    end
  end

  private
    def should_track_event?
      published?
    end

That is the whole per-kind difference for a card: it only tracks events once it's published?, and when one lands it writes a system comment and bumps its activity clock. A comment or a board that wants different behavior overrides the same two methods. The base concern never learns any of their names. Adding a brand-new eventable noun is include ::Eventable plus, optionally, two method overrides — never a new arm on a case that the ledger would otherwise have to grow forever.

Now follow the same event to the other altitude — the view. When the timeline renders, it dispatches each event by name, not by branch (events/_event.html.erb:3-7):

  <% if lookup_context.exists?("events/event/eventable/_#{event.action}") %>
    <%= render "events/event/eventable/#{event.action}", event: event %>
  <% else %>
    <%= render "events/event/eventable/#{event.eventable_type.demodulize.underscore}", event: event %>
  <% end %>

The partial name is computed from event.action (and falls back to the eventable's demodulized type) — the exact mirror of how eventable_prefix computed the action on the way in. There is no case event.action when "card_created" then render(...) when "comment_created" then render(...) in the view. Dozens of event types resolve to dozens of partials, and the template that picks among them is the same three lines no matter how many you add. The naive Rails version you'd vibe-code here is a giant case event.eventable_type in the model and a parallel case event.action in the view — two conditionals that must agree forever, drifting the day someone adds an event type to one and forgets the other. Fizzy writes neither: the type names its own behavior at the model callback layer, and the action names its own partial at the view layer. The same branch-erasing instinct holds at every altitude — the conditional you'd have written is replaced, both times, by the string the object already knows about itself.

enum: one line becomes a query vocabulary, and "muted" is the absence of a merge

Notifications are an ordered enum, not a fistful of booleans (membership.rb:9):

  enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in

Four states, exhaustive and mutually exclusive by construction — you cannot type the impossible muted-and-mentions-only combo, because there is one column and it holds one of four words. The mechanics of how enum generates the predicate, the setter, and the scope are see F1: The Rails Model & Active Record (the enum-generates-a-family mechanics); what matters here is what that generated vocabulary unlocks. The pusher composes it like English (message_pusher.rb:48-53):

    def push_subscriptions_for_users_involved_in_everything
      relevant_subscriptions.merge(Membership.involved_in_everything)
    end

    def push_subscriptions_for_mentionable_users(mentionees)
      relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
    end

Each notification tier is one .merge of a generated scope. And now the punchline: where is the code that handles muted? There isn't any. invisible and nothing have no .merge written for them, so a member at those tiers is simply never in the set the pusher iterates. Muting is the absence of a .merge — silence implemented by silence. The naive three-booleans version needs explicit where(muted: false) everywhere and still leaks an impossible state; here the muted case is handled by no code at all, which is the only kind of code that can't have a bug.

The same enum value drives the sidebar's visible scope too (membership.rb:14):

  scope :visible, -> { where.not(involvement: :invisible) }

One declaration; the notification query, the visibility query, and the membership predicates all read from it. There is no second place that defines "what muted means" to drift from the first.

The contrast: three booleans give you eight typeable states for four real ones, a .or thicket at every call site, and four hand-written def everything? predicates with string literals copy-pasted across files. The ordered enum gives you four exhaustive states, English-reading scopes for free, and a muted tier that is implemented by writing nothing.

Controller inheritance: the bot path is the human path

A bot posting a message is the case that most tempts a parallel implementation, and Campfire refuses it. Messages::ByBotsController inherits the human controller and calls super (by_bots_controller.rb:1-24):

class Messages::ByBotsController < MessagesController
  allow_bot_access only: :create

  def create
    super
    head :created, location: message_url(@message)
  end

  private
    def message_params
      if params[:attachment]
        params.permit(:attachment)
      else
        reading(request.body) { |body| { body: body } }
      end
    end

    def reading(io)
      io.rewind
      yield io.read.force_encoding("UTF-8")
    ensure
      io.rewind
    end
end

Look at what it overrides: message_params, the one seam where a bot genuinely differs — it sends a raw request body (read via the small local reading helper) instead of a form field. Everything else — finding the room through the membership in set_room, create_with_attachment!, and the broadcast_create call — runs inside the inherited MessagesController#create reached via super. The push/unread fan-out rides along too, because it's an after_create_commit -> { room.receive(self) } on the Message model, not controller code at all. The human create (messages_controller.rb:20-28) grows a feature; the bot path gets it for free, because there is no second path. The bot path IS the human path, minus one overridden method (plus its small private helper).

A square 1:1 'annotated code screenshot' poster on an off-white (#FAF8F3) groun…
And the bot itself is the same subtraction move. It's not a new model — it's a User with role: :bot (role.rb:5):

    enum :role, %i[ member administrator bot ]

so Current.user.memberships, message authorship, and broadcasting all just work for it. The only thing a bot needs is to be let through one door, and that door is opened by subtraction. Authentication denies bots everywhere by default (authentication.rb:7):

    before_action :deny_bots

and allow_bot_access only: :create (the line at the top of the bot controller) is literally skip_before_action :deny_bots, only: :create (authentication.rb:18-20). One filter removed, on one action. The bot's reply, when its webhook responds, round-trips back through the identical rails — room.messages.create!(body: text, creator: user).broadcast_create (webhook.rb:60) — so it appears on every screen exactly like a human message. Capability by subtraction: a bot is a user minus a session, with one filter skipped, riding the human path through one super. Nobody wrote a bot messaging system; they wrote one filter and one method override.

"If ByBotsController is mostly super, why have a separate controller at all?" Because the seam is real and small: bots authenticate by key not session (so CSRF is skipped for them and the deny_bots default is lifted), and they send a raw body not a form. A separate controller names exactly those two differences and inherits everything else. The alternative — one controller with if request.bot? ... else ... inside create — is the if kind == smell again, this time wearing a request object.

The contrast: the naive Api::MessagesController re-implements auth, room lookup, and creation, and drifts the day the human path grows a feature the API one doesn't. Inheritance makes drift impossible — there is one create, one model-level fan-out, one broadcast, and the bot rides all of it.

The whole picture: three branches, three escapes

The same move solves all three. A room's per-kind behavior → an STI subclass overriding one method. A membership's notification tier → one ordered enum value driving every query. A bot's create path → a subclass calling super, differing by one seam. In every case the conditional you'd have written is replaced by which object you're holding, and the bugs that lived in the branches — the missed if kind ==, the impossible boolean combo, the forked API path — can't exist because there are no branches to forget. Count the edge cases this absorbs for free: every place you would have re-asked "what kind are you?" and answered wrong.

Key Takeaways — Patterns to Steal

  • When behavior changes with the kind of a thing, don't reach for a kind column and an if kind == "direct" that you then copy into the model, the controller, the view, and the pusher — that branch metastasizes and one day you add a fourth kind and miss a copy. Push the difference into an STI subclass that overrides exactly one method, so the runtime dispatches on type for free. Campfire's whole behavioral gap between a DM and a channel is Rooms::Direct#default_involvement returning "everything" (direct.rb:19-21) over the base's "mentions" (room.rb:63-65) — no conditional anywhere re-asks what kind the room is.
  • When one of your kinds is just "the normal one," resist writing the elsif kind == "closed" branch that only ever restates the default — represent that kind with a subclass that has an empty body. The empty class says, out loud, "this one adds nothing." Campfire's Rooms::Closed (closed.rb:2-3) is a literally empty class, and that silence is the lesson: the default doesn't need a branch, it needs a subclass that lets the default stay silent.
  • Write your direct? / open? / closed? predicates as is_a? checks against the class, not as kind == "direct" comparisons against a column — because a stored string can drift out of sync with the object you're actually holding, and then your predicate lies. Campfire's def direct?; is_a?(Rooms::Direct); end (room.rb:59-61) reads the type straight from the class, so there's no second copy of "what kind is this" to disagree.
  • To convert a record from one type to another, don't flip a kind column and then remember to null-and-re-grant the downstream consequences by hand — recast the actual Ruby object with becomes!, so on save the new subclass's callbacks fire on their own. Campfire turns a closed room open in a before_action: @room = @room.becomes!(Rooms::Open) (opens_controller.rb:39-41), and the conversion's re-grant rides the new subclass's after_save_commit (open.rb:3) instead of manual bookkeeping.
  • When a thing has a handful of mutually exclusive states, don't model them as three separate booleans — three booleans give you eight typeable combinations for four real states, half of them impossible-but-storable, and a .or thicket at every query. Use one ordered enum, which is exhaustive and exclusive by construction because the single column holds one of the listed words. Campfire's enum :involvement, %w[ invisible nothing mentions everything ]... (membership.rb:9) makes muted-and-mentions-only impossible to even type.
  • Build your queries by .merge-ing the scopes the enum generates so they read like English — and notice that a disabled state can be implemented by writing no code at all. Campfire's pusher composes relevant_subscriptions.merge(Membership.involved_in_everything) (message_pusher.rb:49), and there is no merge for invisible or nothing, so a muted member is simply never in the set the pusher iterates. The naive version needs where(muted: false) remembered at every call site and still leaks; here muting is the absence of a merge, and code that doesn't exist can't have a bug.
  • When a second caller (a bot, an API) tempts you to write a parallel Api::MessagesController that re-implements auth, room lookup, creation, and broadcasting, don't — that fork drifts the day the human path grows a feature the copy doesn't. Inherit the human controller and super past it, overriding only the genuine seam. Campfire's Messages::ByBotsController < MessagesController overrides just message_params (a bot sends a raw request body, not a form field) and calls super for the rest (by_bots_controller.rb), so the human create and the bot path can't fork — there is one create.
  • When you need to grant a new actor capabilities, look for the capability you can get by subtracting a filter rather than adding a new code path. A bot in Campfire isn't a new model or a new subsystem — it's a User with role: :bot (role.rb:5) that Current.user.memberships, authorship, and broadcast_create all already serve. The only thing it needs is to be let through one door: deny_bots denies bots everywhere by default (authentication.rb:7), and allow_bot_access is literally skip_before_action :deny_bots (authentication.rb:18-20). One filter removed on one action, and the bot's reply round-trips through the identical rails (webhook.rb:60).