A customer cancels an order. Your refund job calls gateway.refund(...), the same call it has made ten thousand times, and this time the process dies on UnsupportedOperationException: offline gateway cannot refund. Nothing changed in the refund job. Someone added a new PaymentGateway implementation last sprint and wired it up for one region.
The compiler was happy. The types lined up. The code still broke, because a type that compiles is not the same as a type that keeps its promises. That gap is what the Liskov Substitution Principle covers.
This is Part 4 of the series that starts at SOLID Roadmap — read that for the other four letters and the shared Order / PaymentGateway / OrderProcessor domain. Here we only care about one question: when you hand a subtype to code that expects the supertype, does anything have to change?
What LSP actually asks of a subtype
The one-line version from Part 1:
Any subtype must be usable everywhere its supertype is expected, without callers changing how they behave.
“Callers” is the important word. LSP is not a rule about class hierarchies in isolation; it is a rule about the code on the other side of the reference. If a caller holding a PaymentGateway needs to know which implementation it has, substitution already failed.
Bertrand Meyer’s phrasing turns that into two checks you can run on any override: require no more, promise no less. A subtype must accept every input its supertype accepts and deliver every guarantee its supertype makes.
That gives four things a subtype can quietly change, in the order they hurt:
| Contract element | Subtype must | Violation looks like |
|---|---|---|
| Preconditions | Not tighten them | Rejects an amount or currency the parent accepted |
| Postconditions | Not weaken them | Returns null, skips a side effect, returns an unsettled result |
| Exceptions | Not add new failure modes | UnsupportedOperationException on an inherited method |
| Invariants | Preserve them | Breaks idempotency, ordering, or “charged means charged” |
Note: The textbook example is Square extends Rectangle, where setting the width silently changes the height. It is a fine illustration and a terrible motivator, because nobody ships that class. The version you actually ship is a payment gateway, a repository, or a cache that “mostly” behaves like its interface.
The dishonest subtype
Part 1 introduced PaymentGateway with a single charge method. Real payment code needs the other half of the story, so the interface grows:
public interface PaymentGateway {
/** Charges the order for the given amount. Never returns null. */
PaymentResult charge(Order order, BigDecimal amount);
/** Refunds a previously captured transaction. Never returns null. */
PaymentResult refund(String transactionId, BigDecimal amount);
}
StripeGateway implements both against a live API. Then a requirement arrives: some stores take payment at the counter and reconcile later, so the system needs a gateway that records the intent without calling anyone. Inheritance is right there, so:
public class OfflineGateway implements PaymentGateway {
private final PaymentLedger ledger; // injected
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
ledger.recordPending(order.id(), amount);
return PaymentResult.captured("offline-" + order.id(), amount); // it is not captured
}
@Override
public PaymentResult refund(String transactionId, BigDecimal amount) {
throw new UnsupportedOperationException("offline gateway cannot refund");
}
}
Two violations in one class, and the second is worse than the first:
refundadds a failure mode the interface never declared. Every caller that holds aPaymentGatewayis now one code path away from a crash.chargereturnscapturedfor money that has not moved. That is a weakened postcondition, and it is the dangerous kind, because nothing throws — the order ships and the mismatch surfaces in a reconciliation report next month.
The caller does nothing wrong and still breaks:
public class RefundService {
private final PaymentGateway gateway; // injected: any implementation at all
public void cancel(Order order, String transactionId) {
PaymentResult result = gateway.refund(transactionId, order.total());
// reached only when the gateway happens to be a refundable one
auditLog.refunded(order.id(), result);
}
}
RefundService was written against the interface, tested against the interface, and reviewed against the interface. It fails because the interface lied.
The instanceof workaround is the smell
The first fix anyone reaches for is a guard at the call site. It starts as one instanceof check and works exactly once; here is the same method six months and two providers later:
public void cancel(Order order, String transactionId) {
if (gateway instanceof OfflineGateway
|| gateway instanceof VoucherGateway
|| (gateway instanceof SquareGateway sq && !sq.refundsEnabled())) {
manualRefundQueue.submit(order.id(), order.total());
return;
}
auditLog.refunded(order.id(), gateway.refund(transactionId, order.total()));
}
Look at what that condition means. RefundService — a piece of policy that should know nothing about providers — now imports three concrete gateways and one of their configuration flags. Every new provider is an edit to a tested file, which is an Open-Closed failure layered on top of the Liskov one. The abstraction has stopped abstracting; it is a naming convention with a switch behind it.
instanceof in a caller is not always wrong — pattern matching over a sealed hierarchy is a legitimate design, and Sealed Classes and Pattern Switch covers when. The tell is what the check is for. Matching on a closed set of results to decide what to do next is fine; checking which implementation you got so you can avoid calling a method it declares is a substitution failure being paid for at every call site. A try { ... } catch (UnsupportedOperationException e) around the call is the same smell wearing a costume — the same type check, moved to runtime where the compiler cannot help you.
Tightened preconditions: the violation nobody reviews
Missing methods are loud. Narrowed inputs are quiet, and they pass code review because each subclass looks reasonable on its own.
SquareGateway has a per-charge cap and only handles USD, so its implementation defends itself:
public class SquareGateway implements PaymentGateway {
private static final BigDecimal CAP = new BigDecimal("5000.00");
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
if (amount.compareTo(CAP) > 0) {
throw new IllegalArgumentException("amount above per-charge cap");
}
if (!"USD".equals(order.currency())) {
throw new IllegalArgumentException("USD only");
}
return squareClient.capture(order.id(), amount);
}
}
Nothing in PaymentGateway said “any positive amount, any currency” — but nothing said otherwise either, so every caller assumed it. OrderProcessor charges a €7,000 order, the gateway rejects it as a programming error, and the retry logic hammers the same call because IllegalArgumentException is not something a retry policy expects to be permanent.
The sneakiest version of this narrows state rather than range — an if (!connected) throw new IllegalStateException("call connect() first") at the top of an overridden method. The parent contract had no ordering requirement, the subtype invents one, and every caller must now know about a lifecycle it never signed up for.
Fix 1: give capabilities their own type
If OfflineGateway genuinely cannot refund, then it is not a PaymentGateway as that interface is currently written. Split the capability instead of implementing it dishonestly:
public interface PaymentGateway {
PaymentResult charge(Order order, BigDecimal amount);
}
public interface RefundablePaymentGateway extends PaymentGateway {
PaymentResult refund(String transactionId, BigDecimal amount);
}
RefundService then asks for what it actually needs:
public class RefundService {
private final RefundablePaymentGateway gateway; // no guard, no instanceof
public void cancel(Order order, String transactionId) {
auditLog.refunded(order.id(), gateway.refund(transactionId, order.total()));
}
}
OfflineGateway implements PaymentGateway only. It is still perfectly usable for charging; it simply cannot be handed to RefundService. The mistake moved from a 2 a.m. stack trace to a compile error — or, with a DI container, to a startup failure with a clear message. Both are places where the wrong person is not woken up.
This is where LSP hands off to Interface Segregation, and the rule of thumb for splitting is one interface per client conversation, not one per optional method. Two or three capability types is a design; ten is a new problem, which Part 5 unpacks.
Fix 2: make the limit part of the contract, not an exception
Capability splitting does not help SquareGateway, because it can charge — just not everything. Here the fix is to widen the contract so refusal is an expected outcome rather than a surprise: model the result instead of the failure, with a sealed hierarchy of records that says exactly what can come back.
public sealed interface PaymentResult {
record Captured(String transactionId, BigDecimal amount) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
/** The gateway refused to attempt the charge — cap, currency, unsupported method. */
record Unsupported(String reason) implements PaymentResult {}
}
Now the interface can make a promise every implementation can keep:
public interface PaymentGateway {
/**
* Returns a non-null result for every business outcome, including refusal.
* Throws only for infrastructure failure: network, auth, timeout.
*/
PaymentResult charge(Order order, BigDecimal amount);
}
SquareGateway stops throwing and starts answering:
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
if (amount.compareTo(CAP) > 0) {
return new PaymentResult.Unsupported("amount above per-charge cap");
}
if (!"USD".equals(order.currency())) {
return new PaymentResult.Unsupported("USD only");
}
return squareClient.capture(order.id(), amount);
}
And the caller handles all three outcomes once, for every gateway that will ever exist:
switch (gateway.charge(order, payable)) {
case PaymentResult.Captured c -> fulfil(order, c.transactionId());
case PaymentResult.Declined d -> askCustomerForAnotherCard(order, d.reason());
case PaymentResult.Unsupported u -> routeToFallbackGateway(order, u.reason());
}
That is the general move: when a subtype cannot meet the contract, widen the contract to include its answer instead of letting it break the narrow one. Sealed types keep the widening honest, because adding a fourth outcome makes every exhaustive switch fail to compile rather than fall through silently. If records are new to you, Java Records covers the shape.
Note: Widening has a limit. PaymentResult.Unsupported works because “this gateway won’t take it” is a real business outcome the caller can route around. Do not widen a contract into Object or a status-code grab-bag just to make an awkward subtype fit — at that point the type has stopped telling anyone anything.
Fix 3: compose instead of subclassing to remove behavior
The third pattern is inheritance used to subtract. A subclass overrides a method to do nothing, or to disable the parent’s side effect:
// Dishonest: silently drops the charge, and the caller cannot tell
public class ReadOnlyPaymentGateway extends StripeGateway {
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
return PaymentResult.captured("readonly", amount); // no network call, no money
}
}
Overriding to do less is the clearest signal that inheritance is the wrong tool: the subclass wants the parent’s shape, not its behavior, and extends gives it both. Composition asks for one and not the other — wrap the real gateway and add behavior around it, keeping the contract intact:
public class AuditedGateway implements PaymentGateway {
private final PaymentGateway delegate; // injected
private final AuditLog audit;
@Override
public PaymentResult charge(Order order, BigDecimal amount) {
PaymentResult result = delegate.charge(order, amount);
audit.record(order.id(), result);
return result;
}
}
AuditedGateway is substitutable by construction: it accepts everything the delegate accepts and returns exactly what the delegate returns. Retries, metrics, and circuit breakers all fit the same decorator shape. For the read-only case, the honest version is a different type with a different name — PaymentQuery, say, with a lookup method and no charge at all. It was never a PaymentGateway; it was a class that wanted to reuse Stripe’s HTTP client. Reuse of plumbing is a field, not a superclass.
Prove it with one contract test
Substitutability is a property of a whole hierarchy, so test it that way. Write the test once against the interface and make every implementation run it:
abstract class PaymentGatewayContractTest {
protected abstract PaymentGateway gateway();
@Test
void refusesLargeAmountsWithAResultInsteadOfAThrow() {
PaymentResult result = gateway().charge(sampleOrder(), new BigDecimal("999999.00"));
assertNotNull(result);
assertTrue(result instanceof PaymentResult.Captured
|| result instanceof PaymentResult.Unsupported);
}
}
class StripeGatewayTest extends PaymentGatewayContractTest {
@Override protected PaymentGateway gateway() { return new StripeGateway(fakeHttp()); }
}
class OfflineGatewayTest extends PaymentGatewayContractTest {
@Override protected PaymentGateway gateway() { return new OfflineGateway(inMemoryLedger()); }
}
A new implementation now inherits the contract test along with the interface, and any subtype that quietly narrows an input fails on the day it is written. This also flushes out contracts you never wrote down: if you cannot express a guarantee as a test that all implementations pass, it is not a contract, it is a habit that some caller is already relying on.
When a violation is not worth fixing
LSP is easy to turn into ceremony — a capability interface for every optional method, a result type for every edge case. Part 1’s rule applies: the trigger is a real change, not an acronym. Leave it alone when:
- The hierarchy is private and has one caller. A package-private base class used in one file has no surprised callers to protect.
- The subtype is a test fake. A fake that ignores network failures is not lying to production code; it is the point.
- The narrowing is universal. If every gateway caps amounts, that cap belongs in the shared contract as a documented precondition, not in three capability interfaces.
Fix it when a caller holding the supertype has to know which subtype it got. That is the whole test, and the reason instanceof in a caller is worth treating as a defect rather than a style preference.
Cheat sheet
Check any subtype S of supertype T:
preconditions S requires no MORE than T -> don't reject inputs T accepts
postconditions S promises no LESS than T -> no null, no skipped side effect
exceptions S adds no NEW failures -> UnsupportedOperationException = red flag
invariants S keeps T's always-true -> ordering, idempotency, "charged means charged"
Smells: instanceof / catch(UnsupportedOperationException) in callers
no-op overrides; javadoc "not supported by all implementations"
Fixes: capability interface -> the method is gone from the type, not from the object
widen the contract -> refusal becomes a result, not an exception
composition -> wrap the delegate, keep the contract
contract test -> one abstract test, every implementation runs it
Do:
- Write down the contract — nullability, accepted range, side effects — in the interface, not in each implementation.
- Model refusal as a return value (
Unsupported,Declined) so callers handle it on purpose. - Split a capability into its own type when an implementation genuinely cannot provide it.
- Prefer composition when you want a class’s plumbing but not its behavior.
- Run one contract test across every implementation of the interface.
Don’t:
- Throw
UnsupportedOperationExceptionfrom an inherited method and call it documentation. - Tighten what a subtype accepts — caps, currencies, required call order.
- Return
nullor a fake success where the supertype promised a real result, or addinstanceofchecks in callers to route around one implementation. - Build a capability interface for every optional method; that trades one problem for ten.
Wrap-up
Liskov Substitution is the principle that fails silently, because the compiler signs off on every violation. A subtype that throws where the parent returned, rejects what the parent accepted, or returns a hollow success is a landmine planted for code that only ever sees the interface — and the instanceof chains that grow around it are the interest payment.
The repair is almost always to make the type honest rather than to make the caller careful: split the capability out, widen the contract so refusal is a legitimate answer, or compose instead of subclassing to subtract. Then lock it in with one contract test that every implementation has to pass. Next, the same fat PaymentGateway gets looked at from the client’s side, where the question is not “can this subtype keep the promise” but “why does this caller see methods it never calls.”