Spot the Tax · Card 9 of 20
Depend on the smallest interface that does the job
Why a service that demands a full User can't be reused with a Lead or a Contact.
The code
What will this cost you in six months?
class ReportPdfService
def initialize(user)
@user = user
end
def generate
pdf.text "Report for #{@user.full_name} (#{@user.email})"
pdf.text "Generated #{Time.current}"
end
end
# Two months later, marketing asks:
# "Can we generate this report for a Lead before they sign up?"
# Lead has full_name and email. But it isn't a User. The problem
The service signature says it takes a User, but if you actually look at what it uses, it only ever calls full_name and email. Nothing else. So when marketing comes back two months later and asks if you can run the same report for a Lead — which has those exact two attributes — you can't, even though it would technically work fine. The service is coupled to the User class even though it never needed any of the things that make a User a User.
Take a moment. Before revealing, ask yourself what the service actually needs from the user it's passed. Could you express that more narrowly?
The solution
Take only what the service actually needs. Either accept the values directly as keyword arguments, or accept any object that responds to those two methods. Either way, the service is no longer tied to the User class, and any other type with the same two attributes can pass through it without anyone having to fake a User.
- Any source of name + email works (User, Lead, Contact)
- Tests don't need to build a full User factory
- The service describes what it actually depends on
class ReportPdfService
def initialize(full_name:, email:)
@full_name = full_name
@email = email
end
def generate
pdf.text "Report for #{@full_name} (#{@email})"
pdf.text "Generated #{Time.current}"
end
end
# Now any source works:
ReportPdfService.new(full_name: user.full_name, email: user.email).generate
ReportPdfService.new(full_name: lead.full_name, email: lead.email).generate The principle at play — Interface segregation
The Interface Segregation Principle says that a class shouldn't depend on more of an interface than it actually uses. If your service only needs two methods on the object you're passing in, then it should accept anything that has those two methods, not demand a specific class with twenty.
The cost of demanding more than you need is coupling. As soon as the service signature says "I need a User", every caller has to either pass a real User or do something awkward to fake one. Test setup gets heavier than it needs to be, and reusing the service with related-but-different types (Lead, Contact, Subscriber) becomes effectively impossible. The service has accidentally locked itself to a class that wasn't actually relevant to what it does.
Shrinking the dependency makes the relationship between the service and its caller more honest. The service says exactly what it needs, the caller provides exactly that, and any new type that fits the same minimal shape can plug in without the service ever having to know.