Spot the Tax · Card 8 of 20
A subclass that breaks the parent's promises isn't really a subclass
Why GuestUser inheriting from User causes random NoMethodErrors throughout the app.
The code
What will this cost you in six months?
class User < ApplicationRecord
validates :email, presence: true
end
class GuestUser < User
def save
raise "Guests cannot be saved"
end
def email
nil
end
end
# In a service that takes any User:
def send_welcome(user)
WelcomeMailer.welcome(user.email.downcase).deliver_later
user.save
end The problem
GuestUser inherits from User, so as far as the type system (or anyone reading the code) is concerned, you can pass a GuestUser anywhere a User is expected. The trouble is that it doesn't actually behave like one. Its email returns nil instead of a string, and its save raises instead of saving. So a method like send_welcome, which assumes the user it's been given has a real email and can be saved, blows up the moment a GuestUser flows through it — usually deep inside a service object that's painful to trace back to the source.
Take a moment. Before revealing, ask yourself: should GuestUser inherit from User at all? If not, what should it be instead?
The solution
Stop pretending GuestUser is a User. If something can't honor the contract that User defines, then it isn't really a subclass — it's a different concept that happens to share some attributes. Make Guest its own class, and have any code that needs to handle guests check for them explicitly.
- Code that handles users can rely on the User contract
- Guest-handling code is intentional, not accidental
- Bugs from passing a Guest where a User is expected become impossible
class Guest
def initialize(session_id:)
@session_id = session_id
end
# Guest has its own surface. It doesn't pretend to be a User.
end The principle at play — Liskov substitution
The Liskov Substitution Principle says that if class B is a subclass of A, then anywhere your code expects an A, it should be safe to pass a B. The subclass has to honor the parent's contract: the same methods, the same expectations about what they return, the same conditions under which they succeed.
Inheritance creates an implicit promise. Anyone reading the code sees that GuestUser < User and assumes that everything they know about User still applies. If the subclass quietly changes that contract, like returning nil where a string was promised or raising where a method was supposed to succeed, then all the code that interacts with it through the User type can break in ways that are very hard to predict.
The fix is usually not to override the methods more carefully. It's to recognize that the relationship isn't really "is-a" in the first place. A guest isn't a special kind of user, it's a different concept that happens to overlap. Modeling it as its own type makes that explicit and forces every consumer to handle each case intentionally.