Back to Course

LSP Series · 2 of 2

When Inheritance Breaks the Contract

Where Liskov violations show up in Rails, why STI and concerns silently widen contracts they cannot keep, and how to spot a substitutability failure before it ships.

Where this rule comes from

The first lesson framed Liskov as a duck-typing rule: any object that responds to the same methods, with the same return shape, can substitute for the type the caller was thinking about. That framing covers the cases where Liskov works correctly in Rails. This lesson is about the cases where it breaks.

Liskov's original 1987 statement was more specific than the duck-typing summary. The subtype must honor every guarantee the supertype made. That includes the method signatures, the return shapes, the exceptions raised, the side effects, and the invariants the supertype documents (or implies). A subtype is allowed to do more, but it cannot weaken what the supertype promised.

Robert Martin coined the canonical example: a Square class that inherits from Rectangle. Geometrically, a square is a rectangle. As a subtype, it is not, because Rectangle#width= and Rectangle#height= can be called independently; on a Square, setting one without setting the other breaks the invariant that all sides are equal. Code that worked correctly with a Rectangle (set width, set height, compute area) returns wrong answers when you pass it a Square. The subtype broke a guarantee the supertype made, and the caller silently produced a bug.

In Rails, the same violation shape appears in three concrete places: STI subclasses that raise on inherited operations, concerns that change a method's contract, and overridden ActiveRecord methods that silently differ from the parent. Each one is a Liskov violation. Each one ships in plenty of Rails apps.

The anti-pattern (three flavors)

Flavor one: STI subclass that raises on what the parent allows.

class Order < ApplicationRecord
  # Base class. Supports refunds.
  def refund!(amount: total_cents)
    transaction do
      update!(status: "refunded")
      refunds.create!(amount_cents: amount)
    end
  end
end

class GiftCardOrder < Order
  # Subtype. Gift cards cannot be refunded by policy.
  def refund!(*)
    raise NotRefundableError, "Gift card orders are final"
  end
end

This is a Liskov violation. A caller that has an Order and calls order.refund! expects the refund to happen. If the order happens to be a GiftCardOrder, the call raises. The caller did nothing wrong; the subtype broke the parent's contract.

Flavor two: concern that overrides a method with a different contract.

module SoftDeletable
  extend ActiveSupport::Concern

  included do
    scope :default_scope, -> { where(deleted_at: nil) }
  end

  def destroy
    update_column(:deleted_at, Time.current)
  end
end

class Customer < ApplicationRecord
  include SoftDeletable
end

# Caller code expecting normal ActiveRecord:
customer.destroy
customer.destroyed?  # => false  <-- not deleted in the usual sense
Customer.find(customer.id)  # => raises ActiveRecord::RecordNotFound
                            #     (because of default_scope hiding deleted rows)
                            # ...except the row is still there!

The concern silently changed the meaning of destroy. Callers that learned Rails semantics expect a destroyed record to be gone from the table. With this concern, the record is still there, hidden by a default scope. destroyed? returns false because the parent ActiveRecord method was never called. Background jobs holding a destroyed record see a row that "exists" but cannot be re-queried.

Flavor three: overridden method that subtly changes return type.

class Article < ApplicationRecord
  def author
    user
  end
end

class GuestArticle < Article
  def author
    "Guest"  # was a User object; now a String
  end
end

A caller that expects article.author.email works for an Article and crashes with NoMethodError for a GuestArticle. The author method returns a different shape in the subtype. The Liskov violation might be intentional in the model design, but it is unambiguously a contract change, and every caller has to learn about it.

How to detect these in real code

Three specific code smells flag Liskov-breaking shapes:

1. Subclass methods that raise where the parent does not. Grep for raise NotImplementedError and raise.*Error inside methods that override parent methods. Every one is a subtype refusing to honor a parent guarantee. Sometimes the right answer is to redesign the hierarchy (separate Order types into a class that supports refunds and one that does not, neither inheriting from a third). Sometimes the right answer is to weaken the parent's contract so the method can return a Result struct that includes "refused" as a valid outcome.

2. Methods overridden in concerns or subclasses that do not call super. Grep for def destroy, def save, def update inside concerns and subclasses. If the override does not call super, it is replacing the parent's behavior, not extending it. That is exactly the move Liskov forbids unless the new behavior honors the same contract.

3. Methods that return different types in the same class hierarchy. If Article#author returns a User and GuestArticle#author returns a String, the hierarchy lies to callers. Make the method always return the same shape, even if that shape is "a User-like object," potentially via a small adapter class.

How real OSS apps avoid this

The cleanest fix in Rails is the one the OCP series already covered: delegated_type. Instead of inheriting subtle differences via STI, each kind gets its own table and its own model, with no parent-child contract to break. The Entry parent in a delegated_type design does not pretend to support refunding; the refund-supporting kinds expose refund! and the others omit it entirely. A caller that wants to refund knows it has to be looking at a refund-supporting kind, and the type system (such as it is in Ruby) makes the constraint visible.

For the soft-delete concern, real Rails apps that need that feature use one of two shapes:

  • A different method name. customer.archive! instead of customer.destroy. The two operations have different semantics; giving them different names makes the difference obvious to callers.
  • A separate "state as a record" pattern. Instead of a deleted_at column, create a CustomerDeletion record when archiving. The Customer model is unchanged; the archive state is a separate row that callers can query explicitly. (37signals' Fizzy uses this pattern.)

Both shapes keep the original method's contract intact. Neither overloads destroy with semantics it did not have. The Liskov-safe move is to not break the contract; if you need different behavior, name it differently.

The hardest case: contract creep

The trickiest Liskov violations in real apps are not the obvious "subclass raises NotImplementedError" cases. They are contract creep: a subclass that adds a precondition the parent never had, or a postcondition the parent never promised. The new constraint is silent, so callers do not learn about it until something breaks in production.

class Notification < ApplicationRecord
  def deliver
    # the parent: deliver is idempotent
    transaction do
      Mailer.notification(user, content).deliver_later
      update!(delivered_at: Time.current)
    end
  end
end

class SmsNotification < Notification
  def deliver
    # the subtype: also charges the user's account
    raise InsufficientCredits unless user.sms_credits >= 1
    user.deduct_sms_credit!
    super
  end
end

The supertype's contract for deliver was idempotent. Calling it twice was fine — the email got delivered, the timestamp got updated. The subtype broke that: calling it twice deducts two credits, even if the deduct succeeds and the deliver fails. A caller that has a transient error and retries silently double-charges the user.

The fix is not to refuse to deduct credits; it is to keep the supertype's idempotence guarantee by checking whether the deduction has already happened. The subtype is allowed to do more (charge for the SMS), but it has to honor what the parent promised (calling twice does not change the outcome).

When breaking Liskov is the right call

Strict Liskov adherence is sometimes more theoretical than useful. Real Rails apps often have inheritance hierarchies where one subtype is genuinely "almost like the parent except for X." The pragmatic move is to be honest about the deviation:

  • Name the difference in the method. If a GiftCardOrder cannot be refunded, do not call its method refund!. Call it something else, or have the parent's refund! return a Result that includes "not allowed for this kind" as a valid outcome.
  • Document the contract publicly. If callers depend on idempotence, write that down. If the subtype cannot keep it, note exactly which subclasses break it.
  • Test the substitution. A test that calls the parent method on every subclass and checks the contract holds is the best protection against accidental Liskov drift.

The senior heuristic is to treat Liskov as a coupling check, not a religion. When a subclass breaks the parent's contract, every caller of the parent now has to know about the subclass. That coupling is bad and worth avoiding. If you cannot avoid it, at least make it explicit: callers should not learn about subclass-specific behavior in production.

The principle at play

Liskov is the principle that says inheritance is a promise, not a free transformation. When a class inherits from another, it inherits the parent's contract along with its methods. The subclass is bound by what the parent told callers it would do. Breaking that contract in a subclass turns inheritance into a trap, where callers think they are working with the parent's well-known behavior but are actually working with something subtly different.

The deeper move is that "is-a" relationships in code are about behavior, not taxonomy. A Square is geometrically a Rectangle. A GiftCardOrder is conceptually an Order. But if the behavior diverges, the inheritance lies, and the substitution that LSP demands becomes a bug surface.

In Rails, the safer designs avoid the problem by avoiding the inheritance. delegated_type, composition, and concerns with named contracts all let you express "this is another kind of thing" without the implicit promise that it can stand in for some parent. When you reach for STI or a subclass, ask the Liskov question first: can my subtype honor every guarantee the parent made? If the answer is no, the relationship is not subtype — it is something else.

Practice exercise

  1. Find every STI hierarchy in your app: grep -rn "self.inheritance_column\\|< Order\\|< User" app/models (substitute your actual base classes).
  2. For each parent class, list every public method. For each subclass, ask: does this subclass honor every method's contract — same arguments, same return shape, same side effects, same exceptions?
  3. If you find a subclass method that raises where the parent does not, or returns a different shape, that is a Liskov violation. Decide whether the right fix is to rename the method, redesign the hierarchy, or weaken the parent's contract.
  4. Grep for concerns that override common ActiveRecord methods: grep -rn "def destroy\\|def save\\|def update" app/models/concerns/. For each one, check whether it calls super. Overrides that do not call super are silently replacing parent behavior — usually a Liskov violation.
  5. Bonus: write a "Liskov spec" for one of your STI hierarchies. The spec instantiates every subclass and calls every public method of the parent, asserting that the contracts hold. The first time the spec catches a drift, the pattern will pay for itself.