Practice · SOLID · ISP · Card 9
Why does this method take a whole User?
A function that asks for a User. It only ever reads one attribute. The argument is fatter than the dependency. What does that cost?
The code
A class that sends a one-off email. It takes a User.
class NotifyAdmin
def call(user, subject, body)
AdminMailer.with(
to: user.email,
subject: subject,
body: body
).admin_notice.deliver_later
end
end
# Callers:
NotifyAdmin.new.call(current_user, "New signup", "...")
NotifyAdmin.new.call(Admin.find(1), "Backup ran", "...")
NotifyAdmin.new.call(User.system_bot, "Cleanup", "...") The question
NotifyAdmin only ever calls user.email. What's the cost of asking for a whole User object — and what should the parameter actually be?
Take a moment. A method's signature is a promise about what it needs. Asking for more than you need couples your caller to types they shouldn't have to provide. What's the smallest "interface" this method actually depends on?
The cost
- You can't call it without a User. A test that wants to verify behavior has to set up a User. Sending to a literal email string (a CI-only admin alias) requires inventing or stubbing a fake user with that email.
- Refactoring User affects this code. Rename the column, remove the model, split into Profile + Account — and now
NotifyAdminis in the blast radius even though it only reads one field. - The signature lies. "Takes a User" tells the reader the method might use any User attribute. They have to read the body to learn it only touches
email.
Depend on the smallest interface
The interface this method needs is "something with an email." That can be expressed two ways:
- Take just the email string. Push the
user.emailextraction out to the caller. Now the method has no User dependency at all. - Take any object that responds to
email. A "role interface" — define it implicitly by what methods you call. Now User, AdminProfile, and SystemBot can all be passed in, without any of them inheriting from a base class.
# Option 1: just the email
class NotifyAdmin
def call(email, subject, body)
AdminMailer.with(to: email, subject: subject, body: body)
.admin_notice.deliver_later
end
end
# Callers:
NotifyAdmin.new.call(current_user.email, "...", "...")
NotifyAdmin.new.call("oncall@example.com", "...", "...") # literal works now
# Option 2: role interface (anything with #email)
class NotifyAdmin
def call(recipient, subject, body)
AdminMailer.with(to: recipient.email, subject: subject, body: body)
.admin_notice.deliver_later
end
end
# Now User, SystemBot, AdminProfile, OpenStruct(email: "...") all work. This is the role-interface idea: say what role the argument has to play, not what class it has to be. Ruby's duck typing makes it ergonomic. The principle is the same idea Java reaches for with smaller interfaces (Readable, Writeable, Closeable) — depend on the smallest contract that does the job.
Theory
For the full walkthrough on role interfaces, read SOLID · ISP · Role Interfaces.