Liskov Substitution: fix a subtype that breaks callers
Make every subtype work wherever its base type already works.
Watch the narrated video
Every step
Problem
Steps 1–5Refactor
Steps 6–9Payoff
Steps 10–11Recap
Steps 12–13The code: before and after
Before
abstract class PaymentMethod
{
public abstract void Charge(int amount);
public abstract void Refund(int amount);
}
class Card : PaymentMethod
{
public override void Charge(int amount) =>
Console.WriteLine($"card charged {amount}");
public override void Refund(int amount) =>
Console.WriteLine($"card refunded {amount}");
}
class Checkout
{
public void Pay(PaymentMethod method, int amount) =>
method.Charge(amount);
}
class Returns
{
public void GiveBack(PaymentMethod method, int amount) =>
method.Refund(amount);
}
class Shop
{
public static void Main()
{
var card = new Card();
new Checkout().Pay(card, 40);
new Returns().GiveBack(card, 40);
}
}After
interface IRefundable
{
void Refund(int amount);
}
abstract class PaymentMethod
{
public abstract void Charge(int amount);
}
class Card : PaymentMethod, IRefundable
{
public override void Charge(int amount) =>
Console.WriteLine($"card charged {amount}");
public void Refund(int amount) =>
Console.WriteLine($"card refunded {amount}");
}
class GiftCard : PaymentMethod
{
public override void Charge(int amount) =>
Console.WriteLine($"gift card charged {amount}");
}
class Checkout
{
public void Pay(PaymentMethod method, int amount) =>
method.Charge(amount);
}
class Returns
{
public void GiveBack(IRefundable method, int amount) =>
method.Refund(amount);
}
class Shop
{
public static void Main()
{
var card = new Card();
new Checkout().Pay(card, 40);
new Returns().GiveBack(card, 40);
var gift = new GiftCard();
new Checkout().Pay(gift, 25);
// new Returns().GiveBack(gift, 25); // does not compile
}
}Transcript, step by step
Problem
Step 1: A shop that takes cards Before
Payment Method is an abstract class, and every way to pay extends it. It promises its callers a charge method and a refund method, and that promise is its contract. Card is the one way the shop has to pay, and it overrides both methods. Checkout and Returns are the callers, and they only know that base type. Shop pays forty dollars with the card and gives forty back, so it prints card charged forty and card refunded forty. Every payment so far could be charged and refunded, so putting both methods in the base type made sense.
Step 2: Finance adds gift cards Code smell
Finance adds gift cards, and a gift card can pay, but its money is never refunded. The quick way adds Gift Card as a subclass of Payment Method, and its charge prints gift card charged. The base type forces it to have a refund too, so this one throws Not Supported Exception. Shop pays twenty five with a gift card, then gives it back. Everything compiles, so what happens when Shop runs this last line? It crashes, because give back calls refund, and refund throws.
Step 3: Returns checks the class Code smell
To stop the crash, the developer puts a type check at the top of give back. If the method is a Gift Card, it prints gift card no refund and returns early. So the program runs to the end again, with two gift card lines in its output.
Step 4: A subtype that breaks its contract Code smell
Gift Card is a subtype that can't keep its contract, so a caller crashed, until Returns added a type check. The contract includes refund here, the gift card breaks it here, and Returns now has to check the exact class here. But the next payment that can't refund needs another check, and every other caller of refund still crashes.
Step 5: Liskov Substitution Principle
So code that uses a base type must work with any subtype, and that rule is the Liskov Substitution Principle. Gift Card breaks it, and the type check in Returns is the symptom.
Refactor
Step 6: Refactor first Back it out
So let's take the gift card and the type check out again. We'll refactor first, so the contract fits every payment, and then add gift cards back.
Step 7: Name the refund Extract Interface
First we give refund a type of its own, an interface called I Refundable with that one method. Card implements it as well, and its public refund method already matches, so nothing inside Card changes. Payment Method still declares refund, and the next two moves fix that.
Step 8: Returns asks for IRefundable Change Function Declaration
Next, give back takes an I Refundable instead of a Payment Method, because refund is the only thing it calls. Shop still passes the card, and a card is an I Refundable, so it prints the same two lines.
Step 9: Refund leaves the base type Push Down Method
Now no caller needs refund from Payment Method, so it moves down out of the base type. Card's refund drops the override keyword, because the base type has no refund left to override. The contract of Payment Method is charge only, and refund lives in I Refundable, which Card implements.
Payoff
Step 10: Gift cards, again Payoff
So now gift cards can come back in. Gift Card extends Payment Method with charge only, so there's no refund method and nothing to throw. Shop pays twenty five with it, and the program prints gift card charged twenty five. The give back line from before would not compile here, because Gift Card isn't an I Refundable.
Step 11: Every subtype keeps the contract Payoff
Card, Checkout and Returns did not change to add gift cards. The contract has no refund here, the gift card has nothing to break here, and Returns has no type check here. Gift Card can stand in for Payment Method everywhere, because the contract holds only what every payment can do. But the split has a cost, because code holding only a Payment Method now needs a cast to I Refundable to refund. That's the Liskov Substitution Principle, kept by every subtype of Payment Method.
Recap
Step 12: Where we started, where we ended Before / after
On the left, Payment Method promised refund to every payment it would ever have. On the right, Payment Method promises charge only, and refund has its own interface. Gift Card keeps the whole contract it inherits, and Returns asks only for something it can refund.
Step 13: When to reach for it Takeaways
Reach for it when a subclass overrides a method only to throw, or when its callers need a special case for it. Skip it when the subtype can keep the contract, like gift cards that pay back in store credit. You'll spot it by a caller that checks the exact class, or an override that throws Not Supported Exception. Dot net splits lists this way, so code that only reads asks for I Read Only List, which has no add method.