Title: Support Clicked “Refund.” The Server Threw an Exception.
We added gift cards as a payment method. Quick job: create a GiftCardPayment class, inherit from our PaymentMethod base class, done.
Two weeks later, a support agent clicked Refund on an order paid with a gift card, and the page returned a 500 error.
The code compiled. The tests passed. It still crashed in production, because the subclass didn’t keep the promises its parent made.
What Liskov Substitution really means
The Liskov Substitution Principle says that a subclass must be usable anywhere its base class is used, without the program breaking or behaving strangely.
Think of inheritance as a promise. The base class tells every caller, “anything of this type can do these things.” If a subclass can’t keep that promise, every caller that trusted it is now in danger.
Here is what we wrote:
public abstract class PaymentMethod
{
public abstract void Charge(decimal amount);
public abstract void Refund(decimal amount);
}
public class CreditCardPayment : PaymentMethod
{
public override void Charge(decimal amount) { /* charge the card */ }
public override void Refund(decimal amount) { /* refund to the card */ }
}
public class GiftCardPayment : PaymentMethod
{
public override void Charge(decimal amount) { /* deduct from balance */ }
public override void Refund(decimal amount)
=> throw new NotSupportedException("Gift cards cannot be refunded.");
}
And the code that used it:
public class RefundService
{
public void RefundOrder(Order order)
{
order.PaymentMethod.Refund(order.Total); // crashes for gift cards
}
}
RefundService did nothing wrong. It trusted that any PaymentMethod can refund, because the base class said so. The gift card class broke that promise.
How to spot a Liskov violation
- An override that throws NotImplementedException or NotSupportedException
- An override that silently does nothing
- Callers that check “is” or “as” before using a method, because they don’t trust the type
- A subclass that rejects inputs the base class accepts
Fix 1: make the contract something every type can honor
Ask what the business actually wants. For us, refunding a gift card payment means giving the customer store credit. That is valid behavior, so we can make the contract say so:
public enum RefundOutcome
{
RefundedToOriginalMethod,
StoreCreditIssued
}
public abstract class PaymentMethod
{
public abstract void Charge(decimal amount);
public abstract RefundOutcome Refund(decimal amount);
}
public class GiftCardPayment : PaymentMethod
{
public override void Charge(decimal amount) { /* deduct from balance */ }
public override RefundOutcome Refund(decimal amount)
{
// add the amount back as store credit
return RefundOutcome.StoreCreditIssued;
}
}
Now RefundService works for every payment type, and the UI can show a different message depending on the outcome.
Fix 2: if a type truly can’t do it, don’t give it the method
Sometimes there is no sensible behavior. Then the operation shouldn’t be on the shared type at all:
public interface IPaymentMethod
{
void Charge(decimal amount);
}
public interface IRefundablePayment : IPaymentMethod
{
void Refund(decimal amount);
}
public class CreditCardPayment : IRefundablePayment { /* ... */ }
public class GiftCardPayment : IPaymentMethod { /* Charge only */ }
A method that needs refunds now asks for an IRefundablePayment. The compiler stops anyone from passing a gift card to it, so the mistake becomes a build error and not a production error.
The lesson
“Is-a” in code means “behaves-as,” not “sounds like it in English.” A Square inheriting from Rectangle sounds right, until someone sets width and height separately. Before you inherit, ask one question: can every caller of the base type safely use my subclass without knowing which one they got? If the honest answer is no, the design needs to change, not the caller.
Leave a Reply