Sitemap
Stackademic

Stackademic is a learning hub for programmers, devs, coders, and engineers. Our goal is to democratize free coding education for the world.

When “Is-A” Lies: A Practical Deep Dive into LSP in Modern .NET

18 min readSep 3, 2026

--

In this article, we’ll go beyond the textbook definitions and perform a tactical deep dive into LSP using C# and .NET examples.

Press enter or click to view image in full size

Introduction

Software developers often learn the Liskov Substitution Principle (LSP) through simple examples involving birds, penguins, rectangles, and squares. While these examples are useful for introducing the concept, they rarely explain why LSP violations become expensive architectural problems in real-world .NET applications.

The Liskov Substitution Principle, introduced by Barbara Liskov in 1987, states that derived types must be substitutable for their base types without altering the correctness of a program. In practice, this means that inheritance is not merely about sharing code. A derived class must preserve the behavioral expectations established by its parent.

In this article, we’ll go beyond the textbook definitions and perform a tactical deep dive into LSP using C# and .NET examples. We’ll examine classic violations such as the famous Penguin problem, explore more realistic business scenarios like electric charging docks, identify warning signs in production code, and discuss practical alternatives such as composition, interfaces, and domain-driven design.

By the end, you’ll be able to recognize subtle LSP violations before they evolve into fragile inheritance hierarchies and difficult-to-maintain systems.

Why Another Article About LSP?

If you have spent any time preparing for software engineering interviews, reading clean code books, or discussing object-oriented design, you have probably encountered the SOLID principles. Among them, the Liskov Substitution Principle, often shortened to LSP, is one of the most frequently repeated and least deeply understood principles.

Most explanations start with the same familiar examples: birds that cannot fly, squares that do not behave like rectangles, or animals that refuse to perform some expected action. These examples are useful as introductions, but they often leave developers with a shallow understanding of the principle.

Press enter or click to view image in full size
In real-world .NET applications, LSP violations rarely appear as obvious as a Penguin throwing an exception from a Fly() method

In real-world .NET applications, LSP violations rarely appear as obvious as a Penguin throwing an exception from a Fly() method. They usually appear in more subtle forms:

public override void Save()
{
throw new NotSupportedException();
}

Or:

if (paymentProvider is LegacyPaymentProvider)
{
// Special handling because this provider behaves differently
}

At first, these decisions may look harmless. A method is required by the base class, so the derived class overrides it. A special case is needed, so we add an if statement. A behavior does not apply, so we throw a NotSupportedException.

But over time, these small compromises accumulate. The codebase becomes fragile. New implementations become risky. Unit tests start depending on concrete types instead of abstractions. Developers lose confidence in polymorphism because the base type no longer tells the truth about what its derived types can actually do. That is where LSP becomes much more than an interview topic. It becomes a practical design tool.

LSP Is Not Just a SOLID Checkbox

A common mistake is treating the Liskov Substitution Principle as a theoretical rule that only matters when discussing object-oriented design in abstract terms. Many developers can recite a simplified version of the principle:

A derived class should be substitutable for its base class.

That definition is correct, but it is not enough. The real value of LSP appears when we ask a deeper question:

Can the caller use the base type without knowing which derived type it received?

This question is extremely important in .NET because so much of the ecosystem relies on abstraction and polymorphism. We use interfaces, abstract classes, dependency injection, generic constraints, mocking frameworks, pipelines, middleware, repositories, providers, handlers, and services.

For example, when we write this:

public class OrderService
{
private readonly IPaymentProcessor _paymentProcessor;

public OrderService(IPaymentProcessor paymentProcessor)
{
_paymentProcessor = paymentProcessor;
}

public async Task PlaceOrderAsync(Order order)
{
await _paymentProcessor.ProcessAsync(order.TotalAmount);
}
}

The OrderService trusts the abstraction. It should not care whether the implementation is: StripePaymentProcessor, PayPalPaymentProcessor, BankTransferPaymentProcessor, FakePaymentProcessor or any other type of payment implementation. As long as each implementation honors the contract of IPaymentProcessor, the system remains flexible.

But if one implementation behaves unexpectedly, the abstraction becomes unreliable. For example:

public class BankTransferPaymentProcessor : IPaymentProcessor
{
public Task ProcessAsync(decimal amount)
{
if (amount < 100)
{
throw new InvalidOperationException(
"Bank transfers are only supported for amounts greater than 100.");
}

return Task.CompletedTask;
}
}

At first glance, this may seem reasonable from a business perspective. But from the caller’s perspective, the contract has changed. However, the business rule itself is not the problem. A bank transfer provider may genuinely require a minimum transaction amount. The real design question is whether this rule is part of the expected contract of IPaymentProcessor The interface says:

ProcessAsync(decimal amount)

But the implementation silently adds a stronger precondition:

amount must be greater than or equal to 100

This is where LSP becomes practical. It forces us to ask whether our abstractions are honest. If an implementation cannot behave according to the expectations of the abstraction, then either the abstraction is wrong, the implementation belongs somewhere else, or the design needs to express that behavior more explicitly.

Important Correction

In the example above a bank transfer may genuinely require a minimum amount. The real LSP question is not “should this validation exist?”, but where and how should this constraint be expressed so the abstraction remains honest and predictable?

The following implementation is not automatically bad:

public class BankTransferPaymentProcessor : IPaymentProcessor
{
public Task ProcessAsync(decimal amount)
{
if (amount < 100)
{
throw new InvalidOperationException(
"Bank transfers are only supported for amounts greater than 100.");
}

return Task.CompletedTask;
}
}

The possible design problem is this:

public interface IPaymentProcessor
{
Task ProcessAsync(decimal amount);
}

If the interface promises that every processor can process any positive amount, then BankTransferPaymentProcessor violates that expectation by introducing a stronger precondition.

This interface does not tell the caller that some payment processors may reject certain amounts because of provider-specific rules. So the real issue is not the validation. The issue is that the abstraction hides an important business constraint.

Senior Developer Approach

There are several good solutions depending on the domain.

First Approach: Make Validation Part of the Contract

If every payment processor is allowed to reject unsupported payments, then the interface should say so clearly.

Instead of only this:

public interface IPaymentProcessor
{
Task ProcessAsync(decimal amount);
}

Use a richer request model and expose validation:

public sealed record PaymentRequest(
decimal Amount,
string Currency,
string CustomerId);

public sealed record PaymentValidationResult(
bool IsValid,
string? ErrorMessage)
{
public static PaymentValidationResult Success()
=> new(true, null);

public static PaymentValidationResult Failure(string errorMessage)
=> new(false, errorMessage);
}

public interface IPaymentProcessor
{
PaymentValidationResult Validate(PaymentRequest request);

Task ProcessAsync(PaymentRequest request);
}

Now the BankTransferPaymentProcessor can honestly express its rules:

public class BankTransferPaymentProcessor : IPaymentProcessor
{
public PaymentValidationResult Validate(PaymentRequest request)
{
if (request.Amount < 100)
{
return PaymentValidationResult.Failure(
"Bank transfers require a minimum amount of 100.");
}

return PaymentValidationResult.Success();
}

public Task ProcessAsync(PaymentRequest request)
{
var validationResult = Validate(request);

if (!validationResult.IsValid)
{
throw new InvalidOperationException(validationResult.ErrorMessage);
}

// Execute bank transfer
return Task.CompletedTask;
}
}

The calling code can now handle validation before processing:

public class CheckoutService
{
private readonly IPaymentProcessor _paymentProcessor;

public CheckoutService(IPaymentProcessor paymentProcessor)
{
_paymentProcessor = paymentProcessor;
}

public async Task CheckoutAsync(PaymentRequest request)
{
var validationResult = _paymentProcessor.Validate(request);

if (!validationResult.IsValid)
{
throw new InvalidOperationException(validationResult.ErrorMessage);
}

await _paymentProcessor.ProcessAsync(request);
}
}

This design does not remove the business rule. It makes the business rule discoverable. The caller no longer assumes that every processor accepts every amount. Instead, the abstraction communicates:

A payment processor can validate whether a payment request is supported before processing it. This is much more LSP-friendly.

Second Approach: Use a Result Object Instead of Throwing for Business Validation

Throwing exceptions for expected business rules is often not ideal. A minimum payment amount is not really an exceptional system failure. It is a known business condition.

So instead of this:

throw new InvalidOperationException("Bank transfers require a minimum amount of 100.");

You can return a result:

public sealed record PaymentResult(
bool IsSuccess,
string? ErrorMessage)
{
public static PaymentResult Success()
=> new(true, null);

public static PaymentResult Failure(string errorMessage)
=> new(false, errorMessage);
}

Then define the interface like this:

public interface IPaymentProcessor
{
Task<PaymentResult> ProcessAsync(PaymentRequest request);
}

Implementation:

public class BankTransferPaymentProcessor : IPaymentProcessor
{
public Task<PaymentResult> ProcessAsync(PaymentRequest request)
{
if (request.Amount < 100)
{
return Task.FromResult(
PaymentResult.Failure(
"Bank transfers require a minimum amount of 100."));
}

// Execute bank transfer
return Task.FromResult(PaymentResult.Success());
}
}

Caller:

public class CheckoutService
{
private readonly IPaymentProcessor _paymentProcessor;

public CheckoutService(IPaymentProcessor paymentProcessor)
{
_paymentProcessor = paymentProcessor;
}

public async Task CheckoutAsync(PaymentRequest request)
{
var result = await _paymentProcessor.ProcessAsync(request);

if (!result.IsSuccess)
{
Console.WriteLine(result.ErrorMessage);
return;
}

Console.WriteLine("Payment completed successfully.");
}
}

Now every implementation follows the same contract:

Task<PaymentResult> ProcessAsync(PaymentRequest request);

The contract does not say:

Every payment will always succeed.

It says:

Every processor will attempt to process the payment and return a result.

That is a much more honest abstraction.

Third Approach: Use Domain Rules Outside the Processor

Sometimes the payment processor should only execute payment, while domain validation belongs somewhere else.

For example:

public interface IPaymentRule
{
PaymentValidationResult Validate(PaymentRequest request);
}

Bank transfer rule:

public class BankTransferMinimumAmountRule : IPaymentRule
{
public PaymentValidationResult Validate(PaymentRequest request)
{
if (request.Amount < 100)
{
return PaymentValidationResult.Failure(
"Bank transfers require a minimum amount of 100.");
}

return PaymentValidationResult.Success();
}
}

Processor:

public class BankTransferPaymentProcessor : IPaymentProcessor
{
public Task<PaymentResult> ProcessAsync(PaymentRequest request)
{
// Execute payment only
return Task.FromResult(PaymentResult.Success());
}
}

Application service:

public class PaymentService
{
private readonly IEnumerable<IPaymentRule> _rules;
private readonly IPaymentProcessor _processor;

public PaymentService(
IEnumerable<IPaymentRule> rules,
IPaymentProcessor processor)
{
_rules = rules;
_processor = processor;
}

public async Task<PaymentResult> PayAsync(PaymentRequest request)
{
foreach (var rule in _rules)
{
var validationResult = rule.Validate(request);

if (!validationResult.IsValid)
{
return PaymentResult.Failure(validationResult.ErrorMessage!);
}
}

return await _processor.ProcessAsync(request);
}
}

This approach is useful when validation rules are complex, configurable, or shared across multiple flows. It also keeps the processor focused on execution rather than decision-making.

Was the Original Example Really an LSP Violation?

The honest answer is:

It depends on the contract

If IPaymentProcessor means:

Process any positive payment amount successfully.

Then this implementation violates LSP:

if (amount < 100)
{
throw new InvalidOperationException();
}

Because the derived implementation introduces a stronger precondition. But if IPaymentProcessor means:

Attempt to process a payment request and return or throw a meaningful 
result when the request is not supported.

Then the implementation is acceptable. The problem is that the original interface is too vague.

public interface IPaymentProcessor
{
Task ProcessAsync(decimal amount);
}

It hides too much. A better abstraction should communicate what can happen:

public interface IPaymentProcessor
{
Task<PaymentResult> ProcessAsync(PaymentRequest request);
}

Or:

public interface IPaymentMethod
{
bool CanProcess(PaymentRequest request);

Task<PaymentResult> ProcessAsync(PaymentRequest request);
}

The real senior-level lesson is:

LSP is not about ignoring valid business constraints. It is about designing abstractions that make those constraints explicit, predictable, and safe for callers.

A bad abstraction hides restrictions. A good abstraction models them.

A provider-specific validation rule is not automatically an LSP violation. The potential LSP issue appears when the abstraction does not communicate that such restrictions exist.

If IPaymentProcessor.ProcessAsync(decimal amount) implies that every processor can process every positive amount, then BankTransferPaymentProcessor breaks the caller’s expectations by introducing a stronger precondition.

A better design is to make the contract explicit. The abstraction can expose a validation method, return a PaymentResult, or provide a CanProcess method so the caller can discover whether a payment request is supported before attempting execution. LSP does not tell us to remove business rules. It tells us to avoid hiding business rules behind abstractions that promise more than their implementations can safely deliver.

LSP Directly Impacts Maintainability

Maintainability is not only about writing clean methods, meaningful names, or small classes. It is also about whether developers can safely extend the system without breaking existing behavior.

LSP violations damage maintainability because they break trust.

When a base class or interface says, “Every implementation can do this,” but some implementations cannot, developers are forced to defend against the abstraction, as shown in the following code example:

if (processor is BankTransferPaymentProcessor)
{
// Special case
}
else
{
await processor.ProcessAsync(amount);
}

This is a warning sign. The moment client code needs to inspect the concrete type, polymorphism has started to fail. Over time, this leads to several problems.

First, the code becomes harder to reason about. Instead of understanding the behavior through the abstraction, developers must know the quirks of every implementation.

Second, new implementations become dangerous. Every new subclass or interface implementation may require changes in multiple places. The Open/Closed Principle also starts to suffer because the system is no longer open for extension without modification.

Third, testing becomes more complicated. If different implementations of the same abstraction behave inconsistently, test suites must include special cases for each implementation. Instead of testing the contract once, developers are forced to test exceptions to the contract repeatedly.

Fourth, bugs become harder to detect. LSP violations often compile successfully. The compiler sees a valid override or interface implementation. The runtime behavior is where the problem appears.

That is what makes LSP violations dangerous. They are usually not syntax errors. They are design errors.

Press enter or click to view image in full size
Visual explanation of how Liskov Substitution Principle (LSP) violations break trust in software architecture. It illustrates four main consequences: making code harder to reason about, introducing dangerous new implementations, complicating testing, and causing hidden runtime bugs.

The Problem Usually Starts with “Is-A”

Many LSP violations begin with a seemingly innocent modeling decision:

A penguin is a bird.

That statement is true in the real world. But software design is not biology. In software, inheritance is not just about classification. It is about behavior.

Get Aman Toumaj’s stories in your inbox

Join Medium for free to get updates from this writer.

If the base class Bird defines a Fly() method, then every Bird must be safely flyable from the caller’s perspective.

public abstract class Bird
{
public abstract void Fly();
}

This works for an eagle:

public class Eagle : Bird
{
public override void Fly()
{
Console.WriteLine("The eagle is flying.");
}
}

But it fails for a penguin:

public class Penguin : Bird
{
public override void Fly()
{
throw new NotSupportedException("Penguins cannot fly.");
}
}

The issue is not that the code fails to compile. The issue is that the model lies. The base class promises that all birds can fly. The derived class breaks that promise.

Press enter or click to view image in full size
Visual explanation of how a derived class can break the promises made by its base class, using the classic “Flying Penguin” scenario to illustrate a violation of the Liskov Substitution Principle. The model creates a false expectation that all birds can fly, which fails when a penguin is introduced.

A better model separates identity from capability:

public abstract class Bird
{
public abstract void Move();
}

public interface IFlyable
{
void Fly();
}

public class Eagle : Bird, IFlyable
{
public override void Move()
{
Console.WriteLine("The eagle moves.");
}

public void Fly()
{
Console.WriteLine("The eagle is flying.");
}
}

public class Penguin : Bird
{
public override void Move()
{
Console.WriteLine("The penguin waddles.");
}
}

Now the design is more honest. A penguin is still a bird, but it is not forced into a behavior it cannot support. The system no longer needs fake implementations, runtime exceptions, or defensive type checks.

This is the core lesson:

Inheritance should model reliable behavioral substitution, not just real-world classification.

Real-World .NET Systems Have the Same Problem

The bird and penguin example is simple, but the same design problem appears regularly in enterprise .NET applications. It is often hidden behind abstractions that initially look reasonable and work perfectly for the first implementation.

Consider a document storage abstraction:

public abstract class DocumentStorage
{
public abstract Task SaveAsync(Document document);
public abstract Task<Document> ReadAsync(Guid documentId);
public abstract Task DeleteAsync(Guid documentId);
}

At first glance, this base class appears well designed. It identifies three common document operations:

  • Saving a document
  • Reading a document
  • Deleting a document

The first implementation may be backed by a relational database:

public class SqlDocumentStorage : DocumentStorage
{
public override Task SaveAsync(Document document)
{
// Save document to SQL database
return Task.CompletedTask;
}

public override Task<Document> ReadAsync(Guid documentId)
{
// Read document from SQL database
return Task.FromResult(new Document());
}

public override Task DeleteAsync(Guid documentId)
{
// Delete document from SQL database
return Task.CompletedTask;
}
}

SqlDocumentStorage supports all operations defined by the base class, so everything appears correct. A service can depend on DocumentStorage and use it without knowing that SQL is the underlying technology:

public sealed class DocumentService
{
private readonly DocumentStorage _storage;

public DocumentService(DocumentStorage storage)
{
_storage = storage;
}

public async Task ReplaceAsync(
Guid existingDocumentId,
Document replacement)
{
await _storage.DeleteAsync(existingDocumentId);
await _storage.SaveAsync(replacement);
}
}

The service trusts the abstraction. It assumes that every DocumentStorage can delete an existing document and save its replacement.

As long as SqlDocumentStorage is the only implementation, that assumption remains invisible. The weakness in the abstraction becomes apparent only when the system grows.

Introducing Immutable Audit Storage

Suppose the application must now store documents in an immutable audit archive. The archive is append-only because audit records must not be modified or deleted after they have been written. A developer might attempt to reuse the existing hierarchy:

public sealed class AuditDocumentStorage : DocumentStorage
{
public override Task SaveAsync(Document document)
{
// Append the document to the immutable audit archive.
return Task.CompletedTask;
}

public override Task<Document> ReadAsync(Guid documentId)
{
throw new NotSupportedException(
"Audit storage does not support reading documents.");
}

public override Task DeleteAsync(Guid documentId)
{
throw new NotSupportedException(
"Audit documents cannot be deleted.");
}
}

This code compiles. AuditDocumentStorage is technically a valid subclass of DocumentStorage, and the dependency injection container can inject it wherever DocumentStorage is required. Behaviorally, however, it is not a valid substitute.

The base class communicates that every document storage implementation supports saving, reading, and deleting. AuditDocumentStorage supports only saving. Its inherited type says one thing while its runtime behavior says another.

Passing it to DocumentService would break the service:

DocumentStorage storage = new AuditDocumentStorage();
var service = new DocumentService(storage);

await service.ReplaceAsync(documentId, replacement);

The caller works entirely with the base type. It does not access implementation-specific functionality or perform an invalid cast. Nevertheless, the operation fails because the derived class cannot honor the behavioral expectations established by the base class. This is a direct violation of the Liskov Substitution Principle.

The Business Rule Is Not the Problem

It is important to distinguish between an invalid business rule and an invalid abstraction.

Preventing the deletion of audit records may be a legitimate and essential business requirement. Removing that restriction merely to make the subclass compatible would be incorrect and potentially dangerous.

The problem is not this rule:

Audit records cannot be deleted.

The problem is that AuditDocumentStorage inherits from an abstraction that promises deletion. The same reasoning applies to read access. An append-only destination may legitimately accept documents without exposing them through the application. That capability limitation should be represented explicitly rather than hidden behind an inherited method that always fails.

LSP does not require every component to support every possible operation. It requires a type to honor the complete contract of the abstraction it claims to implement.

If audit storage cannot support the DeleteAsync contract, it should not be presented as a fully deletable DocumentStorage.

Why NotSupportedException Is a Warning Sign

Throwing NotSupportedException is not always wrong. The .NET platform itself contains APIs where an operation may not be supported, particularly when the capability is intentionally defined as conditional.

However, an unconditional NotSupportedException inside an override deserves immediate attention:

public override Task DeleteAsync(Guid documentId)
{
throw new NotSupportedException();
}

The method exists because the base type promises the operation. The derived implementation then announces that the operation can never be performed.

This often indicates one of the following design problems:

The last problem is particularly common in enterprise systems. A base class is created around the capabilities of SQL storage. Later, cloud storage, archive storage, temporary storage, or write-only event storage must be forced into the same model.

What looked like a general abstraction was actually a SQL-shaped abstraction.

Defensive Type Checks Do Not Solve the Problem

One possible workaround is to make the caller identify the problematic implementation:

public async Task ReplaceAsync(
Guid existingDocumentId,
Document replacement)
{
if (_storage is AuditDocumentStorage)
{
await _storage.SaveAsync(replacement);
return;
}

await _storage.DeleteAsync(existingDocumentId);
await _storage.SaveAsync(replacement);
}

This avoids the exception, but it does not solve the underlying design problem.

The service now knows about a concrete storage implementation. Every new storage type with different capabilities may require another condition:

if (_storage is AuditDocumentStorage)
{
// Special behavior
}
else if (_storage is ReadOnlyDocumentStorage)
{
// Different special behavior
}
else if (_storage is ExternalArchiveStorage)
{
// Another special case
}

The abstraction is no longer protecting the caller from implementation details. Polymorphism has effectively been replaced by a growing list of type checks.

This also creates a maintenance problem. Adding a new storage implementation may require modifying every service that depends on DocumentStorage. That undermines both LSP and the Open/Closed Principle.

Refactoring Toward Capability-Based Interfaces

A better design is to separate storage capabilities into focused interfaces:

public interface IDocumentWriter
{
Task SaveAsync(
Document document,
CancellationToken cancellationToken = default);
}

public interface IDocumentReader
{
Task<Document?> ReadAsync(
Guid documentId,
CancellationToken cancellationToken = default);
}

public interface IDocumentDeleter
{
Task DeleteAsync(
Guid documentId,
CancellationToken cancellationToken = default);
}

Each interface represents a distinct behavioral contract:

  • IDocumentWriter promises that documents can be saved.
  • IDocumentReader promises that documents can be retrieved.
  • IDocumentDeleter promises that documents can be deleted.

Implementations now declare only the capabilities they genuinely support.

A SQL-backed storage provider can implement all three:

public sealed class SqlDocumentStorage :
IDocumentWriter,
IDocumentReader,
IDocumentDeleter
{
public Task SaveAsync(
Document document,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(document);

// Save the document to a SQL database.
return Task.CompletedTask;
}

public Task<Document?> ReadAsync(
Guid documentId,
CancellationToken cancellationToken = default)
{
// Read the document from a SQL database.
Document? document = new Document();

return Task.FromResult(document);
}

public Task DeleteAsync(
Guid documentId,
CancellationToken cancellationToken = default)
{
// Delete the document from a SQL database.
return Task.CompletedTask;
}
}

The immutable audit implementation exposes only its actual capability:

public sealed class AuditDocumentStorage : IDocumentWriter
{
public Task SaveAsync(
Document document,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(document);

// Append the document to the immutable audit archive.
return Task.CompletedTask;
}
}

There are no fake operations, empty methods, misleading overrides, or unconditional NotSupportedException statements. The type system now prevents invalid usage.

Depending on the Required Capability

Client services should request only the capabilities they actually need.

A service that archives documents needs only a writer:

public sealed class AuditService
{
private readonly IDocumentWriter _writer;

public AuditService(IDocumentWriter writer)
{
_writer = writer;
}

public Task ArchiveAsync(
Document document,
CancellationToken cancellationToken = default)
{
return _writer.SaveAsync(document, cancellationToken);
}
}

Both SQL storage and audit storage can safely satisfy this dependency because both genuinely support writing:

IDocumentWriter sqlWriter = new SqlDocumentStorage();
IDocumentWriter auditWriter = new AuditDocumentStorage();

How This Improves LSP

After the refactoring, every implementation can fulfill the complete contract of every interface it implements.

If a method accepts IDocumentWriter, any registered writer can be substituted safely:

public static Task StoreAsync(
IDocumentWriter writer,
Document document,
CancellationToken cancellationToken = default)
{
return writer.SaveAsync(document, cancellationToken);
}

The method does not need to know whether it received:

  • SqlDocumentStorage
  • AuditDocumentStorage
  • AzureBlobDocumentStorage
  • FileSystemDocumentStorage
  • An in-memory test implementation

The caller depends on a narrow and reliable behavioral promise: the supplied object can save a document. That is substitutability in practice.

The Connection to the Interface Segregation Principle

This refactoring also demonstrates how SOLID principles reinforce one another.

The original DocumentStorage abstraction forces every implementation to depend on all three operations. Splitting it into smaller capability-based interfaces applies the Interface Segregation Principle, or ISP:

Clients should not be forced to depend on methods they do not use.

At the same time, the smaller interfaces make it easier to satisfy LSP because each implementation makes fewer, more precise promises.

This is an important architectural insight:

Broad abstractions are easy to violate. Focused abstractions are easier to implement honestly.

LSP evaluates whether implementations preserve a contract. ISP helps us create contracts that are small enough to preserve reliably.

Testing Becomes More Meaningful

Focused contracts also improve automated testing. A reusable contract test can verify that every IDocumentWriter honors the same basic expectations:

public abstract class DocumentWriterContractTests
{
protected abstract IDocumentWriter CreateWriter();

[Fact]
public async Task SaveAsync_WithValidDocument_CompletesSuccessfully()
{
var writer = CreateWriter();
var document = new Document();

await writer.SaveAsync(document);
}

The same tests can be applied to every writer implementation. These are not merely implementation tests. They verify that each type preserves the behavioral contract of IDocumentWriter.

A Practical Design Lesson

The original DocumentStorage abstraction was not necessarily careless. It may have been entirely adequate when SQL storage was the only requirement. The problem appeared when the system evolved.

That is typical of LSP violations in enterprise software. They often begin with a reasonable abstraction designed around current requirements. As new implementations are introduced, developers discover that the supposedly universal operations were actually capabilities of the original implementation.

The correct response is not to force new implementations into the old hierarchy. It is to revisit the abstraction and express the capabilities more accurately.

The central question should be:

What behavior can every implementation genuinely guarantee?

Only that behavior belongs in the shared contract. Reading, writing, deletion, versioning, archiving, encryption, and search may all relate to document storage, but that does not mean every storage provider supports all of them.

Key Takeaway

The document storage example demonstrates why LSP is more than an academic rule about inheritance. It affects dependency injection, service design, testing, extensibility, and runtime reliability.

An abstraction should not describe everything that one implementation can do. It should describe what every implementation claiming that abstraction can reliably guarantee.

When a subtype must disable inherited operations, return meaningless values, or throw NotSupportedException unconditionally, the model is probably making promises that the subtype cannot keep.

Final Thought

The Liskov Substitution Principle is ultimately about creating abstractions that callers can trust. A subtype should preserve the behavioral promises of its base type without introducing unexpected restrictions, disabling supported operations, or requiring special handling based on its concrete type. As the penguin, payment method, electric vehicle, and document storage examples demonstrate, valid real-world classifications and business rules do not always translate into valid inheritance relationships. Rather than removing legitimate constraints, we should make them explicit through focused interfaces, capability-based modeling, meaningful result types, composition, and clearly defined contracts. In modern .NET development, inheritance should therefore be chosen not simply because one type is a another type, but because every derived implementation can safely and predictably replace its abstraction without surprising the caller.

Thank you for following along with this Article! If you found this helpful, feel free to share it with your fellow developers. Happy coding!

Before you go

  • Please take a moment to like the post and follow the writer!
  • Did you know that over 400,000 developers share what they’re building, learning, and discovering across our platforms every month? Learn how you can contribute here

--

--

Aman Toumaj
Aman Toumaj

Written by Aman Toumaj

Senior C#/.NET Developer | .NET Architect | 14+ Years Experience

Stackademic
Stackademic

Published in Stackademic

Stackademic is a learning hub for programmers, devs, coders, and engineers. Our goal is to democratize free coding education for the world.