Liskov Substitution Principle


The Trust Problem in Software#

Every time you write code that depends on an interface or base class, you’re making a trust assumption. You trust that any implementation will behave as expected. The Liskov Substitution Principle (LSP) is what makes that trust justified, or reveals when it’s misplaced.

As Bob Martin puts it in “Clean Code”: “The LSP is all about expected behavior of objects: objects of a subclass should behave as objects of the base class are expected to behave.” This isn’t just academic theory; it’s the foundation of reliable polymorphic systems.

Understanding Behavioral Contracts#

Barbara Liskov introduced this principle in 1987, stating that objects of a subtype should be substitutable for objects of the base type without altering the correctness of the program. But what does “correctness” really mean here?

It means honoring three key aspects of behavioral contracts. First, preconditions define what must be true when a method is called, and these cannot be strengthened by implementations. Second, postconditions specify what must be true when a method returns, and these cannot be weakened. Finally, invariants describe what must remain true throughout an object’s lifetime.

// Interface with clear behavioral contract
interface PaymentProcessor {
  // Precondition: amount > 0
  // Postcondition: returns PaymentResult (never null)
  // Postcondition: throws PaymentException on failure
  process(amount: number): PaymentResult;
}

// LSP-compliant implementation
class CreditCardProcessor implements PaymentProcessor {
  process(amount: number): PaymentResult {
    if (amount <= 0) {
      throw new Error('Amount must be positive');
    }
    // Process and always return a result
    return new PaymentResult(true, 'Processed');
  }
}

// LSP violation: Strengthens preconditions
class RestrictedProcessor implements PaymentProcessor {
  process(amount: number): PaymentResult {
    // VIOLATION: Adds restriction not in contract
    if (amount > 10000) {
      throw new Error('Amount exceeds limit');
    }
    return new PaymentResult(true, 'Processed');
  }
}

The RestrictedProcessor breaks LSP because client code expecting to process $15,000 payments will fail unexpectedly. This is what Martin Fowler calls “refusing the bequest,” when a subclass doesn’t fully honor its inheritance.

Real-World LSP Violations and Their Consequences#

The classic Square/Rectangle problem illustrates how intuitive inheritance can violate LSP. While mathematically a square IS-A rectangle, in software they have different behavioral contracts:

class Rectangle {
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
}

class Square extends Rectangle {
  // LSP violation: Changes expected behavior
  setWidth(w: number) {
    this.width = w;
    this.height = w; // Unexpected side effect!
  }
}

// Client code breaks with Square
function calculateArea(rect: Rectangle) {
  rect.setWidth(5);
  rect.setHeight(10);
  // Expects 50, but Square gives 100
  return rect.width * rect.height;
}

This violation forces defensive programming throughout your codebase. As Kent Beck notes in “Implementation Patterns,” when you can’t trust your abstractions, you end up with code littered with type checks and special cases. Every piece of client code must now wonder whether it’s dealing with a Rectangle or a Square, defeating the entire purpose of polymorphism.

Testing for LSP Compliance#

Following the advice from “Growing Object-Oriented Software, Guided by Tests” by Freeman and Pryce, we should write contract tests that all implementations must pass. These tests become the executable specification of our behavioral contracts:

// Contract test suite for all implementations
describe('PaymentProcessor Contract', () => {
  const implementations = [
    new CreditCardProcessor(),
    new PayPalProcessor(),
    new CryptoProcessor()
  ];

  implementations.forEach(processor => {
    describe(processor.constructor.name, () => {
      it('accepts positive amounts', () => {
        const result = processor.process(100);
        expect(result).toBeDefined();
        expect(result.success).toBeDefined();
      });

      it('rejects invalid amounts', () => {
        expect(() => processor.process(-1)).toThrow();
        expect(() => processor.process(0)).toThrow();
      });
    });
  });
});

Michael Feathers emphasizes in “Working Effectively with Legacy Code” that these contract tests are your safety net when refactoring. They ensure behavioral compatibility across all implementations. When a new implementation fails these tests, you know immediately that it violates LSP and will cause problems in production.

LSP in System Architecture#

At the architectural level, LSP violations create cascading problems across your application. Consider different implementations of a service interface used throughout your system:

interface UserService {
  // Contract: Returns User or throws UserNotFoundException
  // Never returns null or undefined
  getUser(id: string): Promise<User>;
}

// LSP-compliant: Database implementation
class DatabaseUserService implements UserService {
  async getUser(id: string): Promise<User> {
    const user = await this.db.findById(id);
    if (!user) throw new UserNotFoundException();
    return user;
  }
}

// LSP-compliant: Cached implementation
class CachedUserService implements UserService {
  async getUser(id: string): Promise<User> {
    const cached = await this.cache.get(id);
    if (cached) return cached;

    const user = await this.fallback.getUser(id);
    await this.cache.set(id, user);
    return user; // Never null, maintains contract
  }
}

// LSP VIOLATION: Returns null instead of throwing
class LegacyUserService implements UserService {
  async getUser(id: string): Promise<User> {
    const user = await this.oldSystem.lookup(id);
    // VIOLATION: Returns null when user not found
    return user as User; // Type assertion hides the violation!
  }
}

// Client code that breaks with the violation
class UserController {
  constructor(private userService: UserService) {}

  async getProfile(userId: string) {
    try {
      const user = await this.userService.getUser(userId);
      // Trusts contract: user is never null
      return {
        name: user.name, // Runtime error with LegacyUserService!
        email: user.email
      };
    } catch (e) {
      if (e instanceof UserNotFoundException) {
        return null; // Only handles expected exception
      }
      throw e;
    }
  }
}

As Eric Evans discusses in “Domain-Driven Design,” maintaining these behavioral contracts across bounded contexts is crucial for system reliability. The LegacyUserService violation causes runtime errors because client code trusts the contract that getUser never returns null. This forces developers to add defensive checks everywhere, defeating the purpose of the abstraction.

Practical Guidelines for LSP Compliance#

Drawing from “Clean Architecture” by Bob Martin, the key to maintaining LSP starts with designing by contract first. Document behavioral expectations before implementation, using tools like JSDoc or TypeScript to make contracts explicit. When you define an interface, you’re not just defining method signatures; you’re defining promises about how those methods behave.

interface Repository<T> {
  /**
   * @throws {ValidationError} if entity is invalid
   * @returns {string} the ID of saved entity
   * @invariant Entity count increases by 1
   */
  save(entity: T): Promise<string>;
}

The Gang of Four famously stated in “Design Patterns” that we should prefer composition over inheritance. Composition gives you more flexibility without LSP violations because you’re not claiming an IS-A relationship; you’re simply using capabilities:

// Instead of inheritance hierarchies
class EnhancedProcessor {
  constructor(private base: PaymentProcessor) {}

  process(amount: number): PaymentResult {
    // Add behavior without violating contract
    this.logAttempt(amount);
    return this.base.process(amount);
  }
}

Martin Fowler identifies several code smells in “Refactoring” that indicate LSP violations. When you find yourself using instanceof checks in client code, you’re probably dealing with an LSP violation. Similarly, if you’re throwing NotImplementedException or creating empty method implementations that do nothing, you’re violating the behavioral contract. Methods that unexpectedly return null are another red flag; they break the trust that client code has in the interface.

The Business Impact of LSP#

Dave Thomas and Andy Hunt emphasize in “The Pragmatic Programmer” that LSP violations create technical debt that compounds over time. When teams can’t trust their abstractions, testing becomes exponentially harder because you need to test every possible combination of implementations and client code. This uncertainty slows down onboarding as new developers can’t trust their intuitive assumptions about how interfaces behave.

The bugs that result from LSP violations are particularly insidious because they often only appear in production when specific implementations interact in unexpected ways. Refactoring becomes risky because you can’t safely substitute one implementation for another without extensive testing. What should be a simple dependency injection configuration change becomes a multi-day investigation into subtle behavioral differences.

As Grady Booch notes, “Clean code reads like well-written prose.” LSP-compliant code tells a consistent story where components behave predictably, making the system’s behavior clear to anyone reading it. When every implementation honors its contract, the code becomes self-documenting; the interface tells you everything you need to know about how to use any implementation.

Conclusion: Building Systems You Can Trust#

The Liskov Substitution Principle isn’t just about inheritance; it’s about building systems where trust is justified. When every implementation honors its behavioral contract, you can compose complex systems from simple, reliable parts.

As Bob Martin emphasizes in “Agile Software Development,” the SOLID principles work together synergistically. LSP enables the Open/Closed Principle by ensuring new implementations don’t break existing code. It supports Interface Segregation by encouraging focused, cohesive contracts. Most importantly, it creates codebases where polymorphism is a tool for flexibility rather than a source of bugs.

Remember Barbara Liskov’s key insight: substitutability is about behavior, not structure. Focus on what your code does, not just what it is. When you design with behavioral contracts in mind, you create systems that are genuinely extensible, testable, and maintainable. These are systems that other developers (including your future self) can work with confidently.

The next time you write an interface or create a subclass, ask yourself: “Can this be dropped in anywhere the abstraction is used without surprises?” If the answer is yes, you’re building software that stands the test of time. If not, you’re creating tomorrow’s legacy code problem that someone will curse while debugging at 3 AM.