Practice · SOLID · ISP · Card 1
What's the cost of including this big concern everywhere?
A concern that mixes five different capabilities. It got included in every model that needed one of them. What slowly broke?
The code
A "Publishable" concern, included in Post, Comment, Article, and User. Each one needed one or two pieces of it.
module Publishable
extend ActiveSupport::Concern
included do
scope :published, -> { where.not(published_at: nil) }
scope :draft, -> { where(published_at: nil) }
scope :scheduled, -> { where("published_at > ?", Time.current) }
end
def publish!; update!(published_at: Time.current); end
def unpublish!; update!(published_at: nil); end
def reschedule!(at); update!(published_at: at); end
def published?; published_at.present? && published_at <= Time.current; end
def slug; "#{self.class.name.downcase}-#{id}"; end
end
class Post < ApplicationRecord; include Publishable; end
class Comment < ApplicationRecord; include Publishable; end
class Article < ApplicationRecord; include Publishable; end
class User < ApplicationRecord; include Publishable; end The question
Comments don't get scheduled. Users don't have slugs. What's the actual cost of including this big concern in every model that needed just a piece of it?
Take a moment. The interface-segregation principle says: don't make clients depend on methods they don't use. What goes wrong here over time?
What goes wrong
- Methods that lie.
Comment.scheduledexists but means nothing for comments. A junior developer sees it in autocomplete, calls it, gets unexpected behavior, files a bug. The interface advertised something the model doesn\'t actually support. - Changes ripple too widely. Edit one method in
Publishableand you risk breaking four models. The tests for all four have to run. The PR has to be reviewed by people who own all four. - The slug logic doesn\'t fit User. A user slug shouldn\'t be
user-42; it should be the username. Someone overridesslugin User, and now the concern has special cases. Or the slug stays wrong and someone files a bug six months later. - The concern grows. Each new "thing one of these models needs" gets added, because the concern is "where publishing stuff lives." It becomes the catch-all.
The fix shape
Split the concern by capability, not by domain area. Each smaller concern is one focused interface. Models include only what they actually need.
module Publishable
extend ActiveSupport::Concern
included do
scope :published, -> { where.not(published_at: nil) }
scope :draft, -> { where(published_at: nil) }
end
def publish!; update!(published_at: Time.current); end
def unpublish!; update!(published_at: nil); end
end
module Schedulable
extend ActiveSupport::Concern
included do
scope :scheduled, -> { where("published_at > ?", Time.current) }
end
def reschedule!(at); update!(published_at: at); end
end
module Sluggable
def slug; raise NotImplementedError, "define in including class"; end
end
class Post < ApplicationRecord
include Publishable, Schedulable, Sluggable
def slug; title.parameterize; end
end
class Comment < ApplicationRecord
include Publishable # no scheduling, no slug
end
class User < ApplicationRecord
include Sluggable
def slug; username; end
end Each model now declares exactly what it needs. Comment.scheduled no longer exists. User.publish! doesn\'t exist either. The interface for each model matches what it actually does.
You don\'t always start here. You get here when the fat concern starts producing bugs and special cases. The senior move is noticing that "we keep adding special cases to Publishable" is the signal to split.
Theory
For the full walkthrough, read SOLID · ISP · Focused Concerns and ISP · Role Interfaces.