ISP Series · 1 of 2
Focused Concerns Over Fat Mixins
Where the Interface Segregation Principle comes from, why fat shared modules force classes to pay for behavior they do not use, and how Mastodon keeps its concerns small and focused.
Where this rule comes from
The Interface Segregation Principle is the I in SOLID. Robert Martin coined the rule in the late 1990s while consulting at Xerox on a printer driver project that had become unmaintainable. The original problem was a single "Printer" interface so wide that every concrete printer driver had to implement dozens of methods, even when most of them did not apply. Adding a fax machine that could not staple still required a staple method on the fax driver — usually a no-op or a raise.
Martin's rule, in its one-line form: clients should not be forced to depend on interfaces they do not use. If a class only needs to print, it should not have to know about stapling. If it only needs to scan, it should not have to know about printing. The fix is to split a fat interface into several small, focused ones, and let each client depend only on what it actually uses.
Ruby does not have formal interfaces, but it has concerns — modules that are mixed into classes via include. A concern is the Ruby analog of an interface, and ISP applies directly. When a concern grows to 200 lines covering authentication, search, soft-delete, and audit logging, every class that includes it pays for behavior it does not use. The methods clutter the class's public surface; the database columns the concern assumes exist become required on every including model; the callbacks fire on every save.
The senior move in Rails, anchored by ISP, is to keep concerns small and focused on a single capability. One concern per capability. A class that needs three capabilities includes three small concerns, not one big one. The cost is more files; the payoff is that classes pay only for what they use.
The anti-pattern
Picture a Rails app where the team noticed several models needed "soft delete, audit logging, search indexing, and full-text snippets." Someone wrote a single AuditableSearchable concern and included it in five models:
module AuditableSearchable
extend ActiveSupport::Concern
included do
# Soft delete
scope :active, -> { where(deleted_at: nil) }
scope :archived, -> { where.not(deleted_at: nil) }
# Audit logging
has_many :audit_logs, as: :auditable, dependent: :destroy
after_create :log_creation
after_update :log_update
after_destroy :log_destruction
# Search
after_commit :reindex
after_destroy :remove_from_index
# Snippet generation
before_save :generate_snippet, if: :content_changed?
end
def soft_delete!
update!(deleted_at: Time.current)
end
def restore!
update!(deleted_at: nil)
end
def deleted?
deleted_at.present?
end
# ... 20 more methods covering search, audit retrieval, snippet rendering
private
def log_creation; end
def log_update; end
# ... and so on
end
class Article < ApplicationRecord
include AuditableSearchable
end
class Comment < ApplicationRecord
include AuditableSearchable
end
class User < ApplicationRecord
include AuditableSearchable
end
class Tag < ApplicationRecord
include AuditableSearchable
end Tag does not need a search index. Comments do not need snippets. User definitely should not have a soft-delete column with audit logs, because users have their own delete-account flow. Every class that includes AuditableSearchable gets all four capabilities whether it needs them or not.
The hidden costs accumulate quietly:
- Database columns become implicit requirements. Every model that includes the concern needs
deleted_at, even if it never gets soft-deleted. Migrations get noisier; schemas get sparser. - Callbacks fire even when meaningless. Tag triggers reindex on every save, even though there is no search index for tags.
- The public surface bloats. An Article responds to
soft_delete!,audit_logs,snippet, andreindex, plus another 15 methods. Reading the Article's interface in a console (article.public_methods) returns a wall of noise. - Test setup gets heavier. Every test that creates an Article triggers the audit-log machinery, the search reindex, the snippet generation. The team adds stubs and skips to make tests fast, accumulating coupling between tests and the concern.
- Adding a fifth capability is an ugly choice. Either grow the concern further, or split it now and update every including class.
How Mastodon does this well
Mastodon has dozens of concerns under app/models/concerns/, and most of them are tiny. Each one captures exactly one capability. The names announce the boundary: Expireable, Reviewable, Lockable, RateLimitable, Paginable, DomainMaterializable. Every model includes exactly the concerns it needs, and no more.
The Expireable concern we looked at in the SRP series is 20 lines and depends on exactly two columns (expires_at, created_at). RateLimitable depends on a different column and a different lifecycle. A model that needs both includes both; a model that needs only one includes only one.
Here is RateLimitable in full — same shape, smaller surface:
# mastodon/app/models/concerns/rate_limitable.rb · @bdad4f78
# License: AGPL-3.0
module RateLimitable
extend ActiveSupport::Concern
included do
attribute :rate_limit, :boolean, default: false
end
def rate_limiter(by, options = {})
return @rate_limiter if defined?(@rate_limiter)
@rate_limiter = RateLimiter.new(by, options)
end
class_methods do
def rate_limit(options = {})
after_create do
by = public_send(options[:by])
if rate_limit? && by&.local?
rate_limiter(by, options).record!
end
end
end
end
end Twenty-five lines, one capability, opt-in via an explicit DSL call. A model that wants rate limiting includes RateLimitable and calls rate_limit(by: :account). A model that does not is unaffected. The cost of including the concern is paid only by classes that actually use it.
The senior move: split until each concern names one capability
The fat AuditableSearchable concern is four concerns in a trench coat. The ISP-correct version splits it:
module SoftDeletable
extend ActiveSupport::Concern
included do
scope :active, -> { where(deleted_at: nil) }
scope :archived, -> { where.not(deleted_at: nil) }
end
def soft_delete!; update!(deleted_at: Time.current); end
def restore!; update!(deleted_at: nil); end
def deleted?; deleted_at.present?; end
end
module Auditable
extend ActiveSupport::Concern
included do
has_many :audit_logs, as: :auditable, dependent: :destroy
after_create :log_creation
after_update :log_update
after_destroy :log_destruction
end
# ... audit-only methods
end
module Searchable
extend ActiveSupport::Concern
included do
after_commit :reindex
after_destroy :remove_from_index
end
# ... search-only methods
end
module Snippetable
extend ActiveSupport::Concern
included do
before_save :generate_snippet, if: :content_changed?
end
# ... snippet-only methods
end
# Now each model declares exactly its needs:
class Article < ApplicationRecord
include SoftDeletable, Auditable, Searchable, Snippetable
end
class Comment < ApplicationRecord
include SoftDeletable, Auditable # no search, no snippet
end
class Tag < ApplicationRecord
include Searchable # tags are not soft-deleted; no audit log
end
class User < ApplicationRecord
include Auditable # users have their own archive flow; no soft delete here
end Each include line is a declaration of intent. Reading the Comment model tells you the team thought about audit and soft-delete and decided yes, and thought about search and snippets and decided no. The model's public surface narrows to exactly what it actually uses.
Why this design holds up
Four wins come out of the split, each one solving a fat-concern failure mode.
Classes pay only for what they use. Tag's interface is small. The User's lifecycle is not muddled with a soft-delete column it should not have. Adding a new capability to one concern does not bloat unrelated classes.
Each concern can be tested in isolation. A SoftDeletable test creates a fake model with one column and calls soft_delete!. No audit log machinery, no search index, no snippet rendering. The test scopes to one capability.
The include declarations are documentation. Reading a model's top section tells you what capabilities the team consciously added. The list of concerns is a short, scannable summary of the model's responsibilities, where a single fat concern was a black box.
Adding a fifth capability is a fifth file. No existing concerns change. No existing models change unless they want to opt in. The system is open to extension and closed to modification — the OCP series's framing applied to capabilities rather than implementations.
When NOT to split
Splitting can go too far. Two capabilities that are always used together, with no class ever needing one without the other, do not benefit from being separate. The cost of an extra file is real (an extra navigation hop, an extra include line, an extra concept). Only split when at least one including class genuinely uses one capability without the other.
A simple test: look at every including class. If every one of them includes the same combination of concerns, those concerns could be merged. If at least one class includes one but not the other, the split is paying for itself.
The ISP-equivalent test for concerns: for each method in the concern, ask "would every including class call this method, or even need to?" If the answer is no, the concern is wider than its callers need. Either split it, or move the unused method out.
The principle at play
The Interface Segregation Principle is a statement about where coupling lives. A class that includes a fat module is coupled to every method that module provides, even if it uses only one. The coupling is invisible until something forces the issue — a refactor, a migration, a name conflict — at which point every including class is in the blast radius of a change to the shared module.
The deeper move is captured by the rule: a class should not be forced to know about things it does not need. The "things" here are methods, callbacks, scopes, database columns. Each one is a small coupling. A fat concern multiplies the coupling across every class that includes it.
Concerns carry their weight in Rails because Ruby's module system makes them quick to write. The discipline ISP imposes is not to make them so wide that the power becomes a tax. Each concern names one capability; each class includes exactly the capabilities it uses. The list of include lines at the top of the model becomes a description of what the model does, instead of a single opaque mixin that does five things at once.
Practice exercise
- List the concerns in your app:
ls app/models/concerns/ app/controllers/concerns/. - Open the largest concern. Count the distinct capabilities it provides. Hint: each capability tends to come with its own scopes, callbacks, methods, and assumed columns.
- If the count is more than one, the concern is fat. Sketch the split: which methods, callbacks, and scopes go into each new focused concern?
- Grep for
include FatConcernName. For each including class, ask: does this class use every capability the concern provides, or only some? Every "only some" is a signal that the split would help. - Bonus: when you split, also check the database. Some concerns assume specific columns. If a class includes the concern but does not have the column, the include is lying. Either the class needs the column or it should not include the concern.