Skip to main content

Keep Your Promises: The Liskov Substitution Principle Made Simple

If you're learning clean code, you'll keep running into the "SOLID" principles. The L stands for the Liskov Substitution Principle (LSP) — a scary name for a genuinely simple idea. This guide starts from zero, so no prior knowledge is needed. What it is The Liskov Substitution Principle says: If something is supposed to work like a certain type, then any "version" of that type must be able to take its place without causing problems. That's the whole thing. If you call something a Doctor , it has to actually behave like a doctor everywhere a doctor is expected. If you call something an ApiClient , it has to behave like one everywhere. Break that, and your program fails — usually later, in a confusing way. The principle is named after Barbara Liskov, a computer scientist who described it back in 1987. But you don't need the academic version to use it well. First, what does "substitute" mean? This is the word that makes the whole pr...

Keep Your Promises: The Liskov Substitution Principle Made Simple

Liskov Substitution Principle cover

If you're learning clean code, you'll keep running into the "SOLID" principles. The L stands for the Liskov Substitution Principle (LSP) — a scary name for a genuinely simple idea. This guide starts from zero, so no prior knowledge is needed.

What it is

The Liskov Substitution Principle says:

If something is supposed to work like a certain type, then any "version" of that type must be able to take its place without causing problems.

That's the whole thing. If you call something a Doctor, it has to actually behave like a doctor everywhere a doctor is expected. If you call something an ApiClient, it has to behave like one everywhere. Break that, and your program fails — usually later, in a confusing way.

The principle is named after Barbara Liskov, a computer scientist who described it back in 1987. But you don't need the academic version to use it well.

First, what does "substitute" mean?

This is the word that makes the whole principle click, so let's be clear about it.

A substitute is a stand-in — something you swap in for another thing, expecting it to do the same job.

Think of a substitute teacher. When your regular teacher is away, a substitute steps in. You still expect the class to run: attendance is taken, the lesson happens, the bell rings on time. A good substitute does the teacher's job without the students even noticing a difference.

Now imagine a "substitute" who shows up, sits down, and refuses to teach. Technically a person is standing at the front of the room — but they've failed at being a substitute.

That's exactly what LSP is about. A "child" type is a substitute for its "parent" type. It must be able to step in and do the parent's job. If it can't, it's a bad substitute — and shouldn't have claimed to be one.

The idea as a diagram

Doctor substitution flowchart

Both a Surgeon and a Receptionist claim to be a kind of Doctor. But only the surgeon can actually stand in for one. The receptionist is the bad substitute — and LSP is the rule that tells you to never build one.

The simple example

In code, we have a general type (the "parent") and specific versions of it (the "children"). Any doctor should be able to treat a patient:

doctor.dart
class Doctor {
  String treatPatient() => "treating the patient";
}

class Surgeon extends Doctor {
  @override
  String treatPatient() => "performing surgery";
}

A Surgeon is a proper substitute for a Doctor. Ask it to treat a patient and it works.

Where it fails

Now someone makes a mistake. A receptionist works at the hospital, so they think, "let's make Receptionist a Doctor too":

receptionist.dart
class Receptionist extends Doctor {
  @override
  String treatPatient() => throw Exception("I only book appointments!");
}

Here's the trap: this code runs fine when you write it. The computer doesn't complain. It only blows up when the code is actually used:

main.dart
void sendToPatient(Doctor doctor) {
  print(doctor.treatPatient());
}

sendToPatient(Surgeon());       // works
sendToPatient(Receptionist());  // crashes!

This is the key lesson: the computer only checks that things look right — correct method names, correct spelling. It does not check that they actually behave right. That's your job. A receptionist is not a doctor, so making it one was the bug.

Failures like this show up in a few common shapes:

  • Refusing to do the job. The receptionist that throws an error instead of treating a patient.
  • Being pickier than promised. If a parent says "you can give me any name, even an empty one," a child is not allowed to secretly demand a non-empty name. Code that trusted the parent's rule will break.
  • Giving back something different. If a parent promises to return a sorted list, a child that returns an unsorted one breaks anyone who counted on the order — even though both "return a list."

A production example: where you'll really use this

The doctor story is easy to picture, but here's where LSP earns its keep in an actual app.

Almost every app talks to a server to fetch data. You don't want your app glued to one specific way of doing that — and during testing, you want a fake version that returns pretend data instead of hitting a real server. LSP is what makes this swap safe.

Step 1 — Define the job description (the parent). This is a promise: anything calling itself an ApiClient can get and post.

api_client.dart
abstract class ApiClient {
  Future<Map<String, dynamic>> get(String path);
  Future<Map<String, dynamic>> post(String path, Map<String, dynamic> body);
}

Step 2 — Make two substitutes that both keep the promise.

clients.dart
// The REAL one - talks to a live server.
class HttpApiClient implements ApiClient {
  final String baseUrl;
  HttpApiClient(this.baseUrl);

  @override
  Future<Map<String, dynamic>> get(String path) async {
    // ...makes a real network request and returns the data
    return {'data': 'real response from server'};
  }

  @override
  Future<Map<String, dynamic>> post(String path, Map<String, dynamic> body) async {
    return {'status': 'ok'};
  }
}

// The FAKE one - for tests. Same job, pretend data, no server needed.
class MockApiClient implements ApiClient {
  @override
  Future<Map<String, dynamic>> get(String path) async {
    return {'data': 'fake response for testing'};
  }

  @override
  Future<Map<String, dynamic>> post(String path, Map<String, dynamic> body) async {
    return {'status': 'ok'};
  }
}

Both are good substitutes: same inputs, same kind of result, no surprise crashes. Either one can stand in for an ApiClient.

Step 3 — The rest of your app only knows the job description, never which substitute it got.

user_repository.dart
class UserRepository {
  final ApiClient client; // depends on the PROMISE, not a specific class
  UserRepository(this.client);

  Future<String> fetchUserName() async {
    final data = await client.get('/user');
    return data['data'].toString();
  }
}

Step 4 — Swap freely. This is the payoff.

main.dart
// In the real app:
final repo = UserRepository(HttpApiClient('https://api.example.com'));

// In your tests - same repository code, zero changes:
final testRepo = UserRepository(MockApiClient());

UserRepository has no idea whether it's talking to a real server or a fake one. It doesn't need to. Because both clients are honest substitutes for ApiClient, you can switch between them freely — real data in production, fake data in tests — and nothing else in your app changes.

That's LSP quietly doing its job. And notice the danger: if MockApiClient secretly threw an error or returned the wrong shape of data, this swap would still look fine but break at runtime — the exact same receptionist mistake, just hidden inside a real feature.

A real payment example: bKash and Nagad

Here's a violation you could easily write by accident. Say you're building payments and you make a parent for every payment service. You figure all of them can send money, make payments — and handle subscription payments. So you put all three in the parent:

payment_gateway.dart
abstract class PaymentGateway {
  void sendMoney(double amount);
  void makePayment(double amount);
  void subscriptionPayment(double amount); // assumes EVERY gateway can do this
}

Now bKash is happy — it really does support subscriptions:

bkash.dart
class Bkash extends PaymentGateway {
  @override
  void sendMoney(double amount) => print("bKash: sent $amount");

  @override
  void makePayment(double amount) => print("bKash: paid $amount");

  @override
  void subscriptionPayment(double amount) => print("bKash: subscription $amount");
}

But Nagad doesn't offer subscription payments. Yet because the parent promised the method, Nagad is forced to have one — and the only thing it can honestly do is fail:

nagad.dart
class Nagad extends PaymentGateway {
  @override
  void sendMoney(double amount) => print("Nagad: sent $amount");

  @override
  void makePayment(double amount) => print("Nagad: paid $amount");

  @override
  void subscriptionPayment(double amount) =>
      throw Exception("Nagad doesn't support subscriptions!"); // the violation
}

Now any code written for the parent gets a nasty surprise:

main.dart
void chargeSubscription(PaymentGateway gateway) {
  gateway.subscriptionPayment(500);
}

chargeSubscription(Bkash());  // works
chargeSubscription(Nagad());  // crashes

This is the receptionist mistake again, just wearing a different outfit. The parent promised something (subscriptionPayment) that not every child can actually deliver. Nagad is a bad substitute — not because Nagad is bad, but because we put a promise in the parent that wasn't true for everyone.

The fix: don't promise what everyone can't keep

The problem isn't Nagad — it's the parent. Move the shared abilities into the base, and pull subscriptionPayment out into a separate contract that only the gateways that truly support it sign up for:

bKash and Nagad correct inheritance design
payment_fixed.dart
// Everyone can do these.
abstract class PaymentGateway {
  void sendMoney(double amount);
  void makePayment(double amount);
}

// A separate promise - only for gateways that support subscriptions.
abstract class SubscriptionPayment {
  void subscriptionPayment(double amount);
}

// bKash keeps BOTH promises.
class Bkash extends PaymentGateway implements SubscriptionPayment {
  @override
  void sendMoney(double amount) => print("bKash: sent $amount");
  @override
  void makePayment(double amount) => print("bKash: paid $amount");
  @override
  void subscriptionPayment(double amount) => print("bKash: subscription $amount");
}

// Nagad only promises what it can actually do. No fake method, no crash.
class Nagad extends PaymentGateway {
  @override
  void sendMoney(double amount) => print("Nagad: sent $amount");
  @override
  void makePayment(double amount) => print("Nagad: paid $amount");
}

Now subscription code asks for the right promise, and it's impossible to accidentally hand it something that can't deliver:

main.dart
void chargeSubscription(SubscriptionPayment gateway) {
  gateway.subscriptionPayment(500);
}

chargeSubscription(Bkash());  // works
// chargeSubscription(Nagad()); // won't even compile - Nagad isn't a SubscriptionPayment

Notice how much better this is: the mistake is now caught while you're writing the code, not when a real user tries to subscribe. The best fix for an LSP problem is usually to stop promising something in the parent that not every child can keep.

A simple warning sign

If you ever catch yourself writing this:

smell.dart
if (gateway is Bkash) {
  // only bKash can do subscriptions, so check first...
}

…that's a red flag. If you have to check which specific version you're holding to stop things from breaking, your substitute isn't a real substitute. A proper one just works — you shouldn't have to babysit it.

An easy checklist

Before you make one thing a "child" of another, ask:

  1. Can it do everything the parent can do? (bKash can do subscriptions ✓. Nagad can't ✗ — so subscriptions don't belong in the shared parent.)
  2. Does it give back what the parent promised? (Same kind of result, same shape.)
  3. Will it avoid crashing where the parent wouldn't? (No surprise errors.)
  4. Can people use it without knowing which exact version it is?

Four "yes" answers means you've got a proper substitute. Any "no" means it probably shouldn't be a child of that type.

The takeaway

The Liskov Substitution Principle is really just about keeping promises.

When you say something is a certain type, you're promising how it behaves. Break that promise and the computer won't warn you — but your program will fail later, often in confusing ways. Keep it, and everything "just works," whether it's a surgeon standing in for a doctor, a fake client standing in for a real one, or bKash and Nagad each only promising what they can truly deliver.

So next time you make one class extend another, pause and ask: "Is this a real substitute — or am I asking Nagad to run a subscription?"

Comments

Popular posts from this blog

Understanding BuildContext in Flutter!

As a Flutter developer, you might have encountered the term BuildContext early in your journey. Despite its importance, many developers struggle to grasp its full significance. Today, we’re going to dive into what BuildContext is, what it does, and why it’s crucial for running your Flutter app. What is BuildContext In both stateless and stateful widgets, when you override the build method, it takes BuildContext context as a parameter. This context is provided by the Flutter framework. Here’s a simple example to illustrate: Widget build(BuildContext context) { return OutlinedButton( onPressed: () async { await Future< void >.delayed( const Duration(seconds: 1 )); if (context.mounted) { Navigator.of(context).pop(); } }, child: const Text( 'Delayed pop' ), ); } BuildContext do a simple task, track all of your widget location. In flutter, it all about widgets. Everything is build on a collection of widgets. Some widgets are parent w...

Impeller: Elevating Flutter Performance with Predictable Rendering

In the world of Flutter, Impeller is a buzzword that has caught the attention of developers and enthusiasts alike. But what exactly is Impeller, and why is it crucial for Flutter apps? Let’s dive into the details. What is Impeller? Impeller is a new rendering engine for flutter. Now, many of you can think. What is a rendering engine. Rendering engine is nothing but a piece of software responsible for converting input instructions or data into visual or audible output. Basically , it helps us to draw UI of an app according to the instruction you give it. Flutter engine is written is C/C++language. Which is the part of dart UI. Rendering engine in Flutter orchestrates the transformation from widget tree to pixels on the screen. Before Impeller flutter used skia as a default graphics engine. But, there was some issues on it. Challenges with Skia: Skia has been the graphic engine for Flutter since its inception. Skia is also a powerful render engine, which powers various platforms, inc...