Practice · SOLID · LSP · Card 8
Two exporters, one method name, different shapes
Both classes implement export. Calling code treats them as interchangeable. They aren't. Why?
The code
A CSV exporter, a JSON exporter, and the caller that picks between them.
class CSVExporter
def export(records)
CSV.generate do |csv|
csv << records.first.attributes.keys
records.each { |r| csv << r.attributes.values }
end
end
end
class JSONExporter
def export(records)
{ count: records.size, data: records.map(&:as_json) }
end
end
# The caller:
def deliver_report(format:, records:)
exporter = format == :csv ? CSVExporter.new : JSONExporter.new
payload = exporter.export(records)
ReportMailer.with(body: payload).deliver_later
end The question
Both implement export. The caller swaps between them by reading a format flag. What's the LSP-shaped trap, and how do you spot it on review?
Take a moment. Duck typing says two objects with the same method are interchangeable from the caller's view. The contract isn't just the method name — it's also what the method returns. Compare the two return values.
What's wrong
CSVExporter#export returns a String. JSONExporter#export returns a Hash. The caller hands the return value to ReportMailer.with(body: ...) as if it didn't care, but the mailer almost certainly does — emails with string bodies vs hash bodies render very differently, and one of the two paths is wrong (or fails silently in production).
Duck typing requires substitutability of behavior, not just method names. Two methods with the same name and incompatible return types fail the substitution contract just as badly as Square's setters did to Rectangle.
The smell on review: anywhere you see polymorphism by class swap (exporter = X ? A.new : B.new), trace the return type of the method through both branches. If they diverge, the abstraction is leaky.
Make the contract explicit
Two healthier shapes:
- Standardize the return. Both
exportmethods return a serialized string (CSV text, JSON text). The caller hands a string to the mailer. No hidden type divergence. - Return a small wrapper. Both return an
Export.new(body:, mime_type:)value object. The mailer asks for body and mime type. The exporters can be different inside but the caller doesn't see it.
class Export
attr_reader :body, :mime_type
def initialize(body:, mime_type:)
@body = body
@mime_type = mime_type
end
end
class CSVExporter
def export(records)
Export.new(body: render_csv(records), mime_type: "text/csv")
end
private
def render_csv(records); end
end
class JSONExporter
def export(records)
Export.new(body: JSON.generate(...), mime_type: "application/json")
end
end
# Caller:
result = exporter.export(records)
ReportMailer.with(body: result.body, mime_type: result.mime_type).deliver_later The contract is now visible at the call site: both exporters return an Export. Substitution works because both implementations honor the same interface, in shape and in type.
Theory
For duck typing as Ruby's LSP and the failure shapes to watch for, read SOLID · LSP · Duck Typing and Contract Breaking.