Loading content...
So far, we've discussed behavioral contracts from the perspective of the class designer. But there's another perspective that's equally important—perhaps more important: the client perspective.
In software engineering, clients are the pieces of code that use your classes. They might be other classes in the same codebase, code in different modules, third-party code that imports your library, or even code that doesn't exist yet but will be written in the future.
Each client forms expectations about how your class behaves. These expectations become implicit contracts. When you create a subclass, you inherit not just the parent's code, but also the expectations of every client that uses the parent.
This is the profound insight of LSP: Substitutability is defined by client expectations, not by class implementations.
By the end of this page, you will understand how to think about class design from the client's perspective, identify the expectations clients form, and design type hierarchies that honor these expectations. You'll learn to see your classes the way their users see them.
When a developer writes code that uses your class, they build a mental model of how that class behaves. This mental model includes:
What the class represents — Is it a collection? A service? A data transfer object? The category affects expectations.
What its methods do — Method names, documentation, and prior experience create expectations about behavior.
What can go wrong — Which methods might fail? What exceptions are possible? What edge cases need handling?
What invariants hold — Is the list always sorted? Is the balance always non-negative? Are connections always valid?
How it interacts with other components — Does it own its resources? Does it share state? Is it thread-safe?
This mental model is the client's expectation set. LSP requires that subclasses fit within the mental model clients have for the parent class.
1234567891011121314151617181920212223242526272829303132333435
// CLIENT'S MENTAL MODEL FOR List<T>// A developer using List has these expectations://// 1. REPRESENTATION: An ordered collection of elements// 2. BEHAVIOR: add() appends, get(i) retrieves at index, remove() deletes// 3. FAILURE MODES: IndexOutOfBounds for invalid indices// 4. INVARIANTS: size() == number of elements, indices are 0 to size-1// 5. INTERACTIONS: Can iterate with for-each, can pass to methods expecting List void processItems(List<Item> items) { // Client code based on mental model: // Expectation: I can check if empty if (items.isEmpty()) { return; } // Expectation: I can iterate in order for (Item item : items) { process(item); } // Expectation: I can add and it goes to the end items.add(new Item("new")); // Expectation: get(0) is first item Item first = items.get(0); // Expectation: size reflects actual count int count = items.size();} // This client code should work identically for:// - ArrayList, LinkedList, Vector, CopyOnWriteArrayList, etc.// Any subtype that breaks these expectations breaks client code.The client's mental model isn't about what's documented—it's about what developers reasonably believe. If 1,000 developers would expect add() to actually add an element (not silently fail), then that's the contract, regardless of what any documentation says.
understanding how expectations form helps you predict what clients will assume—and avoid violating those assumptions. Clients form expectations through multiple channels:
List<User> users, the developer expects all List behaviors. The declared type sets the expectation baseline.User getUser(long id) suggests a User will be returned, not null.findById, save, validate, process carry semantic weight. save() should persist something.Queue should behave like queues they've used before.The Declaration Effect:
When client code declares a variable using the parent type, expectations are set by that type:
// Expectation: all Bird behaviors
Bird myBird = getBird();
myBird.fly(); // Expected to work
The client doesn't know—and shouldn't need to know—what subtype getBird() returns. They programmed against Bird, so they expect Bird behavior.
The Experience Effect:
Developers bring assumptions from prior experience:
// "I've used Lists for 10 years"
// "Lists always allow adding"
// "size() is always accurate"
List items = factory.getList();
items.add(x); // Expected to work
If factory.getList() returns an immutable list, the developer's expectations—formed over years—are violated.
Clients don't form expectations from a single source. They combine type declarations + naming + experience + documentation + examples into a composite expectation set. Violating any one source can break the client's mental model.
Client expectations exist on a spectrum from fully explicit (documented, specified, tested) to completely implicit (assumed, presumed, conventional). Both kinds are real expectations that subclasses must honor.
| Aspect | Explicit Expectations | Implicit Expectations |
|---|---|---|
| Source | Documentation, specifications, contracts | Conventions, naming, common sense |
| Visibility | Written down somewhere | Assumed by developers |
| Enforcement | Can be tested against | Often discovered only when violated |
| Examples | Javadoc, API specs, interface contracts | sort() sorts, save() saves, isEmpty() is O(1) |
| Responsibility | Author documents | Community establishes |
| When violated | Clear bug, deviation from spec | Surprise, confusion, subtle bugs |
12345678910111213141516171819202122232425262728293031323334353637383940
// EXPLICIT EXPECTATIONS (documented)/** * Returns the user with the given ID. * * @param id The user ID * @return The user object * @throws UserNotFoundException if no user with this ID exists * @throws IllegalArgumentException if id is null */public User getUser(String id);// Explicit: throws UserNotFoundException, not returns null// Explicit: throws IllegalArgumentException for null // IMPLICIT EXPECTATIONS (not documented, but expected)public interface Collection<E> { boolean add(E element); // Implicit: actually adds element (not silently fails) // Implicit: modifies this collection // Implicit: runs in reasonable time // Implicit: element is retrievable afterward // Implicit: size() increases by 1} // Client code relies on BOTH explicit and implicit:void addToCollection(Collection<Item> items, Item item) { if (items.add(item)) { // Explicit: returns boolean // Implicit: item is now IN the collection assert items.contains(item); // Would fail with broken implicit expectation }} // VIOLATION: Honors explicit but breaks implicitclass BrokenCollection<E> implements Collection<E> { @Override public boolean add(E element) { // Returns true (explicit contract honored) // But doesn't actually add (implicit contract violated) return true; // Compiles, but is semantically wrong }}You cannot excuse a violation by saying 'it wasn't documented.' If the name, type, or common usage pattern creates an expectation, that expectation is part of the contract. Implicit contracts are real contracts.
The Principle of Least Astonishment (POLA), also known as the Principle of Least Surprise, directly connects client expectations to design quality:
A component should behave in a way that most users will expect it to behave. Behavior should not astonish or surprise users.
This principle is the client-facing formulation of LSP. If a subclass astonishes users who expected parent-class behavior, the subclass violates LSP.
Astonishment indicates expectation violation. If a developer says "that's surprising" or "I didn't expect that," you've likely violated their mental model.
save() that doesn't persist, close() that doesn't release resources, validate() that doesn't validate.RuntimeException when clients expect IOException, or never throwing when throwing is expected.1234567891011121314151617181920212223242526272829303132333435363738394041424344
// ASTONISHING: getName() modifies stateclass User { private String name; private int accessCount = 0; public String getName() { accessCount++; // SURPRISE! Getter has side effect return name; } // Clients expect getters to be pure queries // Side effects in getters violate POLA} // ASTONISHING: Calculator that logsclass LoggingCalculator extends Calculator { @Override public int add(int a, int b) { // SURPRISE! Pure calculation sends HTTP request analyticsService.logOperation("add", a, b); return a + b; } // Clients expect add() to just add // Network I/O in arithmetic is astonishing} // ASTONISHING: Comparator affects compared objectsclass ModifyingComparator implements Comparator<Item> { @Override public int compare(Item a, Item b) { a.incrementCompareCount(); // SURPRISE! Compare modifies items b.incrementCompareCount(); return a.getValue() - b.getValue(); } // Collections.sort() using this corrupts the items // Comparison should be read-only} // NON-ASTONISHING: Behavior matches expectationsclass StandardCalculator extends Calculator { @Override public int add(int a, int b) { return a + b; // Just adds—no surprises }}Before finalizing a subclass implementation, ask: 'If I described this behavior to a developer who knows the parent class, would they be surprised?' If yes, reconsider the design. Surprise is a design smell.
A foundational principle of object-oriented design is: program to interfaces, not implementations. This practice has profound implications for LSP.
When clients program to interfaces (or abstract parent classes), they intentionally don't know what concrete class they're using. The whole point is that any implementation should work:
void processPayment(PaymentProcessor processor) {
processor.process(payment); // Any PaymentProcessor should work
}
This design pattern works because clients trust that all implementations of PaymentProcessor behave according to the interface's contract. LSP violations break this trust, undermining the entire pattern.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// THE PATTERN: Client programs to interfaceinterface DataStore { void save(Entity entity); Entity load(String id);} class BusinessLogic { private final DataStore store; // Depends on interface, not implementation public BusinessLogic(DataStore store) { this.store = store; // Could be any DataStore } public void process(Entity entity) { // Trust: any DataStore implementation honors the contract store.save(entity); Entity loaded = store.load(entity.getId()); // Expectation: loaded == entity (or equivalent) }} // VALID IMPLEMENTATIONS: Honor the contractclass PostgresDataStore implements DataStore { public void save(Entity entity) { /* Persists to Postgres */ } public Entity load(String id) { return /* from Postgres */; }} class RedisDataStore implements DataStore { public void save(Entity entity) { /* Persists to Redis */ } public Entity load(String id) { return /* from Redis */; }} // VIOLATION: Breaks the contractclass CachingDataStore implements DataStore { public void save(Entity entity) { cache.put(entity.getId(), entity); // Does NOT persist to durable storage! } public Entity load(String id) { // Returns null if cache is cleared return cache.get(id); } // BusinessLogic expects durable save // This violates client expectations} // RESULT: Client code breaks unpredictably// "Programming to interfaces" relies on LSP complianceOne of the most challenging aspects of LSP compliance is designing for future clients—developers who will use your class months or years from now, possibly in ways you never anticipated.
You cannot interview future clients or review their code. Yet you must honor their expectations. How?
The answer is to design according to reasonable expectations. Ask: What would a competent developer, familiar with the parent class but unaware of your subclass, reasonably expect?
getX() should get X, not compute it freshly with side effects.Imagine a developer five years from now, inheriting a codebase that uses your class. They've never read your documentation (developers often don't). They just see the parent type and write code. Will their code work? If not, you've failed future clients.
Case study: Java's Collections Framework
Java's Collections.unmodifiableList() returns a List that throws UnsupportedOperationException on mutation. This is arguably an LSP violation—clients expect List.add() to add, not throw.
The Java designers chose this approach for practical reasons, but it causes real problems:
void processItems(List<Item> items) {
items.add(newItem()); // Might throw! Depends on which List
}
Future clients of this method cannot know if they're allowed to add. The type system says yes; the runtime says maybe not. This is the cost of LSP violations: uncertainty propagating through codebases, requiring defensive coding everywhere.
In a typical type hierarchy, expectations flow downward:
Interface (e.g., List)
↓ establishes contract
Abstract Class (e.g., AbstractList)
↓ inherits + may refine contract
Concrete Class (e.g., ArrayList)
↓ inherits entire contract
Subclass (e.g., CustomArrayList)
↓ must honor entire contract stack
Each level inherits all contracts from above. A subclass at the bottom inherits expectations from every level in the hierarchy.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// LEVEL 1: Interface establishes core contractinterface Repository<T> { T save(T entity); // Contract: persists entity, returns saved version T findById(Long id); // Contract: returns entity or throws void delete(Long id); // Contract: removes entity} // LEVEL 2: Abstract class adds implementation detailsabstract class AbstractRepository<T> implements Repository<T> { // Inherits Repository contract // Adds: save() validates before persisting @Override public T save(T entity) { validate(entity); // Added behavior return doPersist(entity); // Template method } protected abstract void validate(T entity); protected abstract T doPersist(T entity);} // LEVEL 3: Concrete class provides implementationclass JpaRepository<T> extends AbstractRepository<T> { // Inherits: Repository contract + AbstractRepository behavior // Contract: save validates, then persists via JPA @Override protected void validate(T entity) { /* JPA validation */ } @Override protected T doPersist(T entity) { return entityManager.merge(entity); }} // LEVEL 4: Subclass must honor ENTIRE hierarchyclass CachingJpaRepository<T> extends JpaRepository<T> { // Must honor: // 1. Repository contract (save persists, findById returns or throws) // 2. AbstractRepository contract (save validates first) // 3. JpaRepository contract (persists via JPA) @Override public T findById(Long id) { // Enhancement: check cache first (valid) T cached = cache.get(id); if (cached != null) return cached; // Must: call parent to honor full contract T entity = super.findById(id); cache.put(id, entity); return entity; // Contract honored }}Every level of inheritance adds to the contract your subclass must honor. Deep hierarchies mean extensive contract obligations. This is one reason 'favor composition over inheritance' is sound advice—delegation doesn't inherit contracts the same way.
Before creating a subclass, perform a systematic client analysis to identify expectations you must preserve:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// STEP 1-2: Search usages and categorize call sites// Found usages of PaymentProcessor: // Call site A: CheckoutServiceclass CheckoutService { void completeCheckout(PaymentProcessor processor, Payment payment) { Result result = processor.process(payment); if (result.isSuccess()) { orderService.confirm(order); } else { // EXPECTATION: process() returns Result, never throws for declined notifyUser(result.getFailureReason()); } }} // Call site B: SubscriptionServiceclass SubscriptionService { void renewSubscription(PaymentProcessor processor, Subscription sub) { // EXPECTATION: process() is synchronous Result result = processor.process(sub.getPayment()); // EXPECTATION: if successful, money has been charged if (result.isSuccess()) { sub.extend(30); // Extend because payment already happened } }} // Call site C: Test suite@Testvoid testPaymentProcessing() { PaymentProcessor processor = new TestProcessor(); Result result = processor.process(validPayment); // EXPECTATION: valid payment succeeds assertTrue(result.isSuccess()); // EXPECTATION: amount is charged assertEquals(100.00, testAccount.getChargedAmount());} // COMPILED EXPECTATIONS for PaymentProcessor:// 1. process() returns Result (never throws for business failures)// 2. process() is synchronous (result is final on return)// 3. If success, money has been charged// 4. Valid payments produce success// 5. Result.getFailureReason() is meaningful for failures // Any subclass MUST honor all five expectationsWhat's Next:
We've now explored behavioral subtyping from multiple angles: syntax vs. semantics, preserving expected behavior, and understanding client expectations. In the final page of this module, we'll bring it all together with contracts in inheritance—the formal framework for specifying and verifying behavioral compatibility using preconditions, postconditions, and invariants.
You now understand LSP from the client's perspective. The key insight: substitutability isn't defined by class implementations—it's defined by client expectations. A subclass that compiles, passes internal tests, and seems correct is still an LSP violation if it surprises clients who expected parent-class behavior.