SOLID Principle · LSP

Liskov Substitution Principle

Subtypes must be fully substitutable for their base types.

Liskov Substitution Principle (LSP): A Complete Guide with Examples in 11 Programming Languages

The Liskov Substitution Principle states that if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program. In plain terms: a subclass must be fully substitutable for its parent class. Any code that works with the base type must work equally correctly with any derived type — without knowing which one it has.

In this comprehensive guide, we'll explore LSP with real-world examples in Python, TypeScript, Java, JavaScript, C#, PHP, Go, Rust, Dart, Swift, and Kotlin — complete with language-specific best practices, common violations, and clear guidance on when and how to apply this fundamental principle.

Table of Contents

What is the Liskov Substitution Principle?

LSP is the third of the five SOLID principles, introduced by Barbara Liskov in 1987 and formalized as: if φ(x) is a property provable about objects x of type T, then φ(y) should be true for objects y of type S where S is a subtype of T.

The classic illustration is the Rectangle/Square problem. Mathematically, a square is a rectangle. So it seems natural to make Square extend Rectangle. But a rectangle has independently settable width and height. When you set a square's width, its height must also change (to remain square). This breaks code that expects a rectangle to hold its height when its width changes: a function that sets width to 5 and height to 3 and expects an area of 15 will get 9 when given a square. The square is not substitutable for the rectangle — LSP is violated.

The violation surfaces not as a compilation error but as a runtime surprise: behavior that was correct for the parent is wrong for the child. LSP violations are particularly insidious because they are often hidden — the code compiles, type checks pass, but correctness silently breaks when a subtype is substituted.

LSP is fundamentally a contract: the subtype must honor every behavioral guarantee that the supertype's callers rely on.

Why Follow LSP?

LSP provides several critical benefits:

  1. Predictability: Code that depends on an abstraction behaves correctly regardless of which concrete implementation it receives — no surprises.
  2. Safe Polymorphism: Collections of base types can be iterated and used without instanceof checks or type-specific handling — every element behaves as expected.
  3. Reliable Inheritance: Subclasses extend behavior without breaking the contracts the parent type established.
  4. Honest Abstraction: An inheritance or interface relationship that satisfies LSP is one that genuinely models an "is-a" relationship — not a "kind-of" or "similar-to."
  5. Reduced Defensive Code: When LSP holds, callers don't need to check types or guard against unexpected behaviors — the contract is guaranteed.
  6. Test Reusability: Tests written against the base type pass for all subtypes — a natural test for LSP compliance.

LSP Comparison: Violation vs. Compliance

DimensionLSP ViolationLSP Compliant
SubstitutionSubtype breaks code that worked with base typeAny subtype can replace the base type transparently
Method behaviorSubtype throws unexpected exceptions or returns wrong valuesSubtype honors all postconditions of the base type
PreconditionsSubtype demands stricter inputs than the base typeSubtype accepts the same or weaker inputs
PostconditionsSubtype provides weaker guarantees than the base typeSubtype provides the same or stronger guarantees
instanceof checksRequired to handle type-specific behaviorNever needed — polymorphism handles it
Test reliabilityTests written for base type fail for some subtypesTests for base type pass for all subtypes

LSP vs. Related Concepts:

  • LSP vs. Inheritance: Inheritance is the mechanism; LSP is the contract that makes inheritance safe. Inheritance without LSP is inheritance that surprises — it looks like "is-a" but behaves like "kind-of."
  • LSP vs. OCP: OCP says existing code should not need modification for new subtypes. LSP is what makes OCP possible — OCP relies on the promise that new subtypes are correctly substitutable.
  • LSP vs. Interface Implementation: LSP applies equally to interface implementation. An interface defines a contract; every implementor must honor it fully.

LSP Explained

The Behavioral Contract

LSP is about behavioral compatibility, not just structural compatibility. A subtype can compile fine and still violate LSP if it:

  1. Strengthens preconditions: The base method accepts any positive number; the subtype rejects numbers below 10. Code passing 5 worked with the base type but fails with the subtype.
  2. Weakens postconditions: The base method guarantees a non-null return value; the subtype may return null. Code that dereferences the return value worked with the base type but crashes with the subtype.
  3. Throws unexpected exceptions: The base method never throws; the subtype throws NotSupportedException. Code that calls the method without a try/catch worked with the base type but crashes with the subtype.
  4. Changes invariants: The base type guarantees width * height == area; the subtype breaks this invariant. Callers relying on this invariant get wrong answers.

The Square/Rectangle Problem

This is the canonical LSP violation:

Rectangle has: setWidth(w), setHeight(h), area() = width * height
Square extends Rectangle — but must keep width == height at all times

client code:
    shape.setWidth(5)
    shape.setHeight(3)
    assert shape.area() == 15  // Passes for Rectangle, FAILS for Square (gets 9)

Square is not substitutable for Rectangle despite the mathematical "is-a" relationship. The correct design: both Square and Rectangle implement a Shape interface. They don't inherit from each other.

How to Detect LSP Violations

Three practical tests:

  1. The Substitution Test: Replace every instance of the base type in your code with the subtype. Does all existing behavior still hold? If not, the subtype violates LSP.
  2. The instanceof Test: If your code contains if (obj instanceof SubType) to handle a subtype specially, the subtype is not truly substitutable — an LSP violation.
  3. The Base Type Test Suite Test: Run the test suite you wrote for the base type against each subtype. All tests should pass. Any failure signals an LSP violation.

Class Diagram

Here's the UML class diagram showing a shape hierarchy that correctly follows LSP:

Beginner-Friendly Example

Let's illustrate LSP with the classic Rectangle/Square problem — first showing the violation, then the correct design.

from __future__ import annotations
import math
from abc import ABC, abstractmethod


# ── ❌ LSP VIOLATION: Square extends Rectangle ─────────────────────
class Rectangle:
    def __init__(self, width: float, height: float) -> None:
        self._width  = width
        self._height = height

    def set_width(self, width: float) -> None:
        self._width = width

    def set_height(self, height: float) -> None:
        self._height = height

    def area(self) -> float:
        return self._width * self._height

    def __repr__(self) -> str:
        return f"Rectangle({self._width}×{self._height})"


class Square(Rectangle):
    """Violates LSP: overrides set_width/set_height to keep sides equal,
    breaking the Rectangle contract that width and height are independent."""

    def set_width(self, width: float) -> None:
        self._width  = width
        self._height = width  # Side effect: changes height too!

    def set_height(self, height: float) -> None:
        self._width  = height  # Side effect: changes width too!
        self._height = height


def test_area(shape: Rectangle) -> None:
    """Code written for Rectangle — should work for any 'Rectangle'."""
    shape.set_width(5)
    shape.set_height(3)
    expected = 15
    actual = shape.area()
    status = "✅" if actual == expected else "❌ LSP VIOLATED"
    print(f"  {shape.__class__.__name__}: area={actual} (expected={expected}) {status}")


print("=== LSP Violation ===\n")
test_area(Rectangle(2, 2))   # ✅ 15
test_area(Square(2))          # ❌ 9 — LSP violated!


# ── ✅ LSP COMPLIANT: Both implement the same interface ────────────
class Shape(ABC):
    """Base contract — all shapes fulfill these guarantees."""

    @abstractmethod
    def area(self) -> float:
        """Returns the area. Always ≥ 0."""

    @abstractmethod
    def perimeter(self) -> float:
        """Returns the perimeter. Always ≥ 0."""

    @abstractmethod
    def describe(self) -> str:
        """Human-readable description of the shape."""


class LSPRectangle(Shape):
    def __init__(self, width: float, height: float) -> None:
        assert width > 0 and height > 0, "Dimensions must be positive"
        self._width  = width
        self._height = height

    def area(self)      -> float: return self._width * self._height
    def perimeter(self) -> float: return 2 * (self._width + self._height)
    def describe(self)  -> str:   return f"Rectangle {self._width}×{self._height}"


class LSPSquare(Shape):
    def __init__(self, side: float) -> None:
        assert side > 0, "Side must be positive"
        self._side = side

    def area(self)      -> float: return self._side ** 2
    def perimeter(self) -> float: return 4 * self._side
    def describe(self)  -> str:   return f"Square {self._side}×{self._side}"


class LSPCircle(Shape):
    def __init__(self, radius: float) -> None:
        assert radius > 0, "Radius must be positive"
        self._radius = radius

    def area(self)      -> float: return math.pi * self._radius ** 2
    def perimeter(self) -> float: return 2 * math.pi * self._radius
    def describe(self)  -> str:   return f"Circle r={self._radius}"


def print_shape_info(shape: Shape) -> None:
    """Works correctly for ANY Shape — LSP guarantees this."""
    print(f"  {shape.describe()}: area={shape.area():.2f}, perimeter={shape.perimeter():.2f}")


print("\n=== LSP Compliant ===\n")
shapes: list[Shape] = [
    LSPRectangle(5, 3),
    LSPSquare(4),
    LSPCircle(3),
]
for shape in shapes:
    print_shape_info(shape)  # Same code, correct results for all subtypes

Production-Ready Example

Now let's build a document storage and processing system — a production scenario where LSP violations appear subtly. We'll define a DocumentStore abstraction and show how different storage backends (in-memory, file system, cloud) must honor the same behavioral contract.

🐍 Python

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
import os


# ── Domain Model ───────────────────────────────────────────────────
@dataclass
class Document:
    id:         str
    title:      str
    content:    str
    created_at: datetime = field(default_factory=datetime.now)
    version:    int       = 1


# ── Behavioral Contract ────────────────────────────────────────────
class DocumentStore(ABC):
    """All implementations MUST honor these behavioral guarantees:
    - save(doc) always succeeds or raises DocumentStoreError
    - find(id) returns the exact document last saved with that id, or None
    - delete(id) ensures find(id) returns None afterwards
    - list_all() returns every document currently in the store
    """

    @abstractmethod
    def save(self, document: Document) -> None:
        """Save or update a document. Must not fail silently."""

    @abstractmethod
    def find(self, doc_id: str) -> Optional[Document]:
        """Return the document with the given id, or None if not found."""

    @abstractmethod
    def delete(self, doc_id: str) -> bool:
        """Delete document. Return True if deleted, False if not found."""

    @abstractmethod
    def list_all(self) -> list[Document]:
        """Return all stored documents. Never return None; return [] if empty."""

    @abstractmethod
    def count(self) -> int:
        """Return the number of stored documents."""


class DocumentStoreError(Exception):
    pass


# ── Compliant Implementation 1: In-Memory Store ────────────────────
class InMemoryDocumentStore(DocumentStore):
    """Backed by a dictionary. Suitable for tests and lightweight usage."""

    def __init__(self) -> None:
        self._store: dict[str, Document] = {}

    def save(self, document: Document) -> None:
        self._store[document.id] = document
        print(f"  [Memory] Saved: {document.id} '{document.title}'")

    def find(self, doc_id: str) -> Optional[Document]:
        return self._store.get(doc_id)

    def delete(self, doc_id: str) -> bool:
        if doc_id in self._store:
            del self._store[doc_id]
            return True
        return False

    def list_all(self) -> list[Document]:
        return list(self._store.values())

    def count(self) -> int:
        return len(self._store)


# ── Compliant Implementation 2: File System Store ──────────────────
class FileSystemDocumentStore(DocumentStore):
    """Persists documents as text files. Suitable for local persistence."""

    def __init__(self, base_dir: str) -> None:
        self._base_dir = base_dir
        os.makedirs(base_dir, exist_ok=True)

    def _path(self, doc_id: str) -> str:
        return os.path.join(self._base_dir, f"{doc_id}.txt")

    def save(self, document: Document) -> None:
        try:
            with open(self._path(document.id), 'w') as f:
                f.write(f"{document.title}\n{document.content}")
            print(f"  [FS] Saved: {document.id}{self._path(document.id)}")
        except OSError as e:
            raise DocumentStoreError(f"Failed to save {document.id}: {e}") from e

    def find(self, doc_id: str) -> Optional[Document]:
        path = self._path(doc_id)
        if not os.path.exists(path):
            return None
        try:
            with open(path) as f:
                lines = f.readlines()
            return Document(id=doc_id, title=lines[0].strip(), content="".join(lines[1:]).strip())
        except OSError as e:
            raise DocumentStoreError(f"Failed to read {doc_id}: {e}") from e

    def delete(self, doc_id: str) -> bool:
        path = self._path(doc_id)
        if os.path.exists(path):
            os.remove(path)
            return True
        return False

    def list_all(self) -> list[Document]:
        docs = []
        for filename in os.listdir(self._base_dir):
            if filename.endswith(".txt"):
                doc_id = filename[:-4]
                doc = self.find(doc_id)
                if doc:
                    docs.append(doc)
        return docs

    def count(self) -> int:
        return len([f for f in os.listdir(self._base_dir) if f.endswith(".txt")])


# ── Consumer — closed, depends only on DocumentStore contract ──────
class DocumentService:
    """Uses any DocumentStore without knowing its implementation.
    Correct for InMemory, FileSystem, and any future store."""

    def __init__(self, store: DocumentStore) -> None:
        self._store = store

    def create_document(self, title: str, content: str) -> Document:
        import uuid
        doc = Document(id=str(uuid.uuid4())[:8], title=title, content=content)
        self._store.save(doc)
        return doc

    def get_document(self, doc_id: str) -> Optional[Document]:
        return self._store.find(doc_id)

    def remove_document(self, doc_id: str) -> bool:
        return self._store.delete(doc_id)

    def list_documents(self) -> list[Document]:
        docs = self._store.list_all()
        print(f"  Found {len(docs)} document(s):")
        for doc in docs:
            print(f"    - [{doc.id}] '{doc.title}'")
        return docs

    def document_count(self) -> int:
        return self._store.count()


# ── LSP Test: Same tests must pass for ALL implementations ─────────
def run_store_tests(store: DocumentStore, label: str) -> None:
    print(f"\n=== Testing {label} ===\n")
    service = DocumentService(store)

    # Test 1: Create and find
    doc = service.create_document("Meeting Notes", "Agenda: budget review")
    found = service.get_document(doc.id)
    assert found is not None, "LSP: find() must return saved document"
    assert found.title == "Meeting Notes", "LSP: find() must return exact document"
    print(f"  ✅ Create + Find: '{found.title}'")

    # Test 2: List
    service.create_document("Project Plan", "Phase 1: research")
    docs = service.list_documents()
    assert len(docs) >= 2, "LSP: list_all() must include all saved documents"
    print(f"  ✅ List: {len(docs)} documents")

    # Test 3: Count
    count = service.document_count()
    assert count >= 2, "LSP: count() must match actual stored documents"
    print(f"  ✅ Count: {count}")

    # Test 4: Delete
    deleted = service.remove_document(doc.id)
    assert deleted, "LSP: delete() must return True for existing document"
    gone = service.get_document(doc.id)
    assert gone is None, "LSP: find() must return None after delete()"
    print(f"  ✅ Delete: document gone after removal")

    # Test 5: Delete non-existent
    deleted_again = service.remove_document("nonexistent-id")
    assert not deleted_again, "LSP: delete() must return False for missing document"
    print(f"  ✅ Delete non-existent: returned False correctly")


# ── Client ─────────────────────────────────────────────────────────
if __name__ == "__main__":
    import tempfile
    import shutil

    # Same tests, different stores — all pass because LSP is honored
    run_store_tests(InMemoryDocumentStore(), "In-Memory Store")

    tmp_dir = tempfile.mkdtemp()
    try:
        run_store_tests(FileSystemDocumentStore(tmp_dir), "File System Store")
    finally:
        shutil.rmtree(tmp_dir)

    print("\n✅ All stores passed the LSP behavioral contract tests.")

Real-World Use Cases

LSP violations appear in surprising places in production systems. Understanding where they commonly arise helps you recognize and prevent them:

Read-Only Repository Subclass: A UserRepository base class defines find(), save(), update(), and delete(). A ReadOnlyUserRepository subclass throws NotImplementedException on save(), update(), and delete(). Code that calls these methods on the base type breaks when given the read-only subtype — an LSP violation. Correct design: define ReadableRepository and WritableRepository as separate interfaces.

Throwing Base Class Methods: A Logger interface has log(level, message). An AsyncLogger subclass throws NotYetFlushed exceptions if flush() wasn't called first. Code using Logger has no idea it needs to call flush() — the contract is broken. Correct design: the async logger's log() method always succeeds (buffering internally), and flush() is an additional method.

Mutable vs. Immutable Collections: A List<T> base type allows add() and remove(). An ImmutableList<T> subclass throws UnsupportedOperationException on mutations. Code that takes a List<T> and calls add() breaks — LSP is violated. Java's Collections.unmodifiableList() is a famous example of this. Correct design: ImmutableList implements ReadableList, not List.

Narrowed Return Types That Break Callers: A parse(input: string): any method returns flexible data. A subclass overrides it to return only string, throwing if the result would be a number. Callers expecting numeric results from the base type get exceptions from the subtype. LSP requires that postconditions be honored or strengthened, never weakened.


Language-Specific Mistakes and Anti-Patterns

❌ Mistake #1: Raising NotImplementedError in Subclass Methods

# BAD: ReadOnlyCache violates the Cache contract by throwing on write methods
class Cache:
    def get(self, key: str) -> str | None: ...
    def set(self, key: str, value: str) -> None: ...
    def delete(self, key: str) -> bool: ...

class ReadOnlyCache(Cache):
    def get(self, key: str) -> str | None:
        return self._backend.get(key)

    def set(self, key: str, value: str) -> None:
        raise NotImplementedError("Read-only cache!")  # LSP VIOLATION

    def delete(self, key: str) -> bool:
        raise NotImplementedError("Read-only cache!")  # LSP VIOLATION

# GOOD: Separate interfaces for reading and writing
class ReadableCache(ABC):
    @abstractmethod
    def get(self, key: str) -> str | None: ...

class WritableCache(ReadableCache, ABC):
    @abstractmethod
    def set(self, key: str, value: str) -> None: ...

    @abstractmethod
    def delete(self, key: str) -> bool: ...

class ReadOnlyCache(ReadableCache):
    def get(self, key: str) -> str | None:
        return self._backend.get(key)
# No set() or delete() — the interface doesn't demand them

❌ Mistake #2: Overriding Methods to Tighten Preconditions

# BAD: Subclass accepts narrower input than base class
class FileProcessor:
    def process(self, filepath: str) -> str:
        """Processes any file path."""
        with open(filepath) as f:
            return f.read()

class JsonFileProcessor(FileProcessor):
    def process(self, filepath: str) -> str:
        if not filepath.endswith(".json"):
            raise ValueError("Only .json files accepted!")  # Tightened precondition — LSP VIOLATION
        with open(filepath) as f:
            return f.read()

# GOOD: The subclass accepts the same input range; its specialization is in how it processes
class JsonFileProcessor(FileProcessor):
    def process(self, filepath: str) -> str:
        content = super().process(filepath)
        # Specialization: parse and re-serialize for normalization
        import json
        return json.dumps(json.loads(content), indent=2)

Frequently Asked Questions

Q1: Is the Square/Rectangle problem only about mathematics?

No. It's about behavioral contracts. Mathematically, a square is a rectangle — but in an OOP context, a Square class is not a behavioral substitute for a Rectangle class when the Rectangle class allows independent width and height mutation. The problem isn't the geometry; it's the contract. If Rectangle were immutable — with no setters — then Square could safely inherit from it because the independence invariant doesn't exist. The lesson: inheritance must respect behavioral contracts, not just mathematical relationships.

Q2: How do I know if an inheritance relationship violates LSP?

Run the Substitution Test: imagine replacing every reference to the base type in your codebase with the subtype. Does every piece of code still behave correctly? If you must add an instanceof check somewhere, or if a test that passed for the base type fails for the subtype, LSP is violated. The base-type test suite test is particularly reliable: write tests that capture the behavioral contract of the base type, then run them against every subtype. Failures signal violations.

Q3: Can LSP be violated through interface implementation (not just inheritance)?

Yes. LSP applies equally to interface implementation. An interface defines a behavioral contract: if Serializer.serialize() guarantees non-null output and NullSerializer returns null, the contract is violated even though NullSerializer is "implementing an interface," not "inheriting a class." The mechanism is different, but the violation is the same: callers relying on the guarantee are broken.

Q4: Is it ever acceptable to throw NotSupportedException in a subtype method?

Rarely, and only when the base type's contract explicitly allows it. If the base type's documentation says "some implementations may throw NotSupportedException for optional operations," then a throwing subtype is within the contract. Java's Collection interface does this deliberately for optional mutation methods. But when the base type makes no such allowance, throwing in a subtype silently breaks callers — an LSP violation. If you're considering throwing, it usually signals that the abstraction is too broad and should be split into separate interfaces.

Q5: How does LSP relate to contravariance and covariance?

LSP has formal type-theoretic rules: method parameters may be contravariant (accept broader types), and return types may be covariant (return narrower types). A subtype that accepts only a narrower input type (strengthened precondition) or returns a broader output type (weakened postcondition) violates LSP. In practice: a subtype's methods should accept the same or wider range of inputs than the parent, and return the same or narrower type than the parent. Most languages enforce this statically for typed return types but not for preconditions, which is why behavioral LSP violations can still occur at runtime.

Q6: Should I never use deep inheritance hierarchies?

Deep hierarchies increase LSP risk — each level of inheritance adds another set of contracts that must be honored all the way down. Shallow hierarchies (at most 2-3 levels) are easier to reason about. When hierarchies grow deep, consider using composition instead: an object that holds a base object and delegates to it can extend or restrict behavior at the composition level, where the contracts are more explicit. Go and Rust's absence of inheritance entirely and reliance on interfaces and composition is in part a response to this risk.

Q7: How does Go avoid LSP violations?

Go has no inheritance — only interfaces (implemented implicitly) and struct composition. This makes the classic inheritance-based LSP violations structurally impossible. In Go, a Rectangle and a Square would both implement a Shape interface independently, with no subtype relationship between them. The Square/Rectangle problem simply cannot arise. However, LSP can still be violated at the behavioral level: a Go struct implementing an interface can still return wrong values or panic unexpectedly. The principle remains relevant even without inheritance.


Key Takeaways

🎯 Core Concept: The Liskov Substitution Principle states that objects of a supertype should be replaceable with objects of any subtype without breaking the program's correctness. LSP is a behavioral contract: a subtype must honor every guarantee that callers rely on when using the base type. Violations occur not at compile time but at runtime — through unexpected exceptions, wrong return values, broken invariants, or silent no-ops.

🔑 Key Benefits:

  • Safe polymorphism: Code that works with a base type works correctly with any subtype — no instanceof checks, no runtime surprises
  • Predictable inheritance: Subclasses extend behavior without breaking the contracts the parent established
  • Reusable tests: Tests for the base type pass for all subtypes — violations are caught immediately
  • Honest "is-a" relationships: If LSP holds, the inheritance or interface relationship genuinely models substitutability, not just code reuse

🌐 Language-Specific Highlights:

  • Python: Use the abstractmethod decorator to enforce the full contract; avoid NotImplementedError in subclasses — it signals that the abstraction is too broad
  • TypeScript: Use readonly properties on immutable implementations; never widen a return type (from User to User | null) in a subclass without updating the interface
  • Java: The Collections.unmodifiableList() pattern is a canonical LSP violation — prefer separate ReadableList and MutableList interfaces
  • JavaScript: Be explicit about return type consistency — a subclass that turns a synchronous return into a Promise silently breaks all callers
  • C#: Avoid virtual methods with empty base implementations — they invite silent no-op overrides; use interfaces to make capabilities explicit
  • PHP: Use readonly for immutable data; never override a parent method to throw an exception type not in the parent's contract
  • Go: Structural (implicit) interfaces naturally enforce LSP — two structs implementing the same interface are independent, no subtype risks
  • Rust: Trait objects (Box<dyn Trait>) enforce the behavioral contract at the type system level; LSP violations reduce to returning wrong Result variants
  • Dart: Use abstract interface class to define strict behavioral contracts; sealed hierarchies help enforce exhaustive handling
  • Swift: Protocols are pure behavioral contracts; avoid protocol requirements that only some conformers implement meaningfully
  • Kotlin: sealed interface gives exhaustive handling; data class provides value equality consistency; avoid deep open class hierarchies

📋 When LSP Is Violated:

  • A subtype throws an exception that the base type's contract does not allow
  • A subtype requires stricter inputs (narrowed precondition) than the base type
  • A subtype returns weaker guarantees (weakened postcondition) than the base type
  • Code contains instanceof checks to handle a subtype specially
  • Tests written for the base type fail when run against the subtype

⚠️ How to Fix LSP Violations:

  • Split a too-broad interface into smaller, capability-specific interfaces (ReadableList / MutableList)
  • Use composition instead of inheritance when the "is-a" relationship holds mathematically but not behaviorally
  • Make the base type's contract explicit in documentation or by using checked exceptions
  • Prefer flat, interface-based designs over deep inheritance hierarchies

🛠️ Best Practices Across Languages:

  1. Write the contract before writing the subtype: Document the behavioral guarantees of the base type — preconditions, postconditions, invariants — before any subtype is written
  2. Run base-type tests against all subtypes: Test-based LSP checking is the most reliable way to detect violations early
  3. Prefer composition over deep inheritance: A composed object can override behavior at the composition level without subtype contract risks
  4. Use interface segregation: Small, focused interfaces reduce the risk of "empty implementation" LSP violations — ISP and LSP are natural allies
  5. Avoid NotImplemented and silent no-ops: If a subtype can't implement a method meaningfully, the abstraction is too broad — split it

The Liskov Substitution Principle is the principle that makes polymorphism trustworthy. When LSP holds, you can pass any subtype where the base type is expected and the system behaves correctly — no defensive checks, no runtime surprises, and no code paths that work for some types but not others.


Want to dive deeper into SOLID principles? Check out our comprehensive guides on: