SOLID Principles

By the Syncfusion DevTutorials team · Last updated

Learn the five SOLID rules with pictures and runnable examples, then fix QuickBite's god-class OrderService until hidden tests pass and the design smells are gone.

In the previous lesson, you learned how to create classes. Now comes an important question: how do you organize an application into classes so that it remains easy to maintain and update?

Think about a busy restaurant kitchen. One cook prepares dosas, another cooks curries, and a third packs the orders. When the restaurant introduces a new dish, only the relevant cook needs to learn the new process.

Now imagine a kitchen where one person does everything. They cook food, collect payments, pack orders, and answer phone calls. Every new responsibility gets added to the same person. Over time, mistakes increase and the entire kitchen becomes less efficient.

SOLID is a set of five principles that help keep your code organized like the first kitchen instead of the second.

In simple words: SOLID helps you create classes that are easier to understand, maintain, and change.

What You Will Learn

In this lesson, you will learn:

  1. What a "god class" is and why it creates problems.
  2. The five SOLID principles, along with an illustration and an example for each.
  3. How to improve a real QuickBite class in a hands-on lab, using tests to verify your work.

This lesson uses classes, private, interfaces, and inheritance. If any of these concepts are new to you, read the OOP Basics lesson first.

The God Class

A god class is a class that handles too many responsibilities. It is like the one-person kitchen where a single person does every job.

Here is a simplified version of QuickBite's real OrderService. It handles five different responsibilities:

  • calculates the total price;
  • applies coupons;
  • chooses the payment method using a switch;
  • saves the order;
  • sends an SMS notification.

Why It Hurts

When one class handles five different responsibilities, there are five different reasons why it might need to change. Adding a new coupon requires a change. Adding a new payment method requires a change. Switching to a new SMS provider also requires a change.

Each change increases the chance of affecting one of the other responsibilities. It also becomes difficult to test features independently because pricing, payment processing, and messaging are all combined in the same class.

Be careful when you see a switch statement (or a long if/else chain) based on a type value, such as paymentMethod or status. This is one of the most common signs of a god class. Every new type usually requires another update to that same switch statement.

SOLID: Five Rules

A programmer named Robert C. Martin introduced and popularized these five principles. Each letter in SOLID represents one principle.

Each principle helps answer an important question: what problems might occur when this code needs to change?

Easy way to remember SOLID: Single responsibility. Open for extension. Liskov substitution. Interface segregation. Dependency inversion. In simple words: one job, add without changing, keep promises, use small interfaces, and depend on abstractions.

S: Single Responsibility

Rule: a class should have only one responsibility.

Think about the kitchen example again. Each cook is responsible for one station. If the dosa recipe changes, only the dosa cook needs to adapt.

The Example

We move the pricing logic out of OrderService and place it in a separate class, PriceCalculator. Its one method, total(items), adds up the items and the delivery fee of 30. Step through one order of two dosas:

Here is the same idea in code:

Pricing in its own class, testable on its own

type Item = { name: string; price: number; qty: number };

class PriceCalculator {
  private readonly deliveryFee = 30;

  total(items: Item[]): number {
    let subtotal = 0;
    for (const i of items) subtotal += i.price * i.qty;
    return subtotal + this.deliveryFee;
  }
}

const calc = new PriceCalculator();
console.log(calc.total([{ name: "Masala dosa", price: 120, qty: 2 }]));

Expected output:

270

Two dosas priced at 120 each cost 240 in total. Adding the delivery fee of 30 makes the final total 270. A test can check this number without touching payments or SMS.

Without This Rule

If pricing logic and payment logic are placed in the same class, a change in pricing could accidentally affect payments.

O: Open/Closed

Rule: add new features by extending the code, not by changing code that already works correctly.

Think about a power strip. When you want to use a new lamp, you simply plug it in. You do not need to open the power strip and rewire it.

The Example

QuickBite wants a new wallet payment. With the PaymentMethod interface, that is one new class, QuickBiteWallet. No old class changes. Watch the new class plug in:

The code behind the animation:

A new payment method without touching existing classes

interface PaymentMethod {
  pay(amount: number): string;
}
class Upi implements PaymentMethod {
  pay(amount: number): string { return `upi request ${amount}`; }
}
// New this month. Nothing else changed.
class QuickBiteWallet implements PaymentMethod {
  pay(amount: number): string { return `wallet debited ${amount}`; }
}

const byName: Record<string, PaymentMethod> = { upi: new Upi(), wallet: new QuickBiteWallet() };
console.log(byName["wallet"].pay(199));

Expected output:

wallet debited 199

Checkout calls pay(199) on the wallet and gets "wallet debited 199".

Without This Rule

Every time a new payment method is added, the existing switch statement must be modified. Each modification creates a risk of breaking a payment method that was working correctly before.

L: Liskov Substitution

Rule: a subclass should be able to replace its parent class without causing problems.

Imagine renting a car and receiving a toy car instead. It is called a car, but you cannot drive it. As a result, your plans no longer work.

The Example

If code works with a Delivery, it must also work with every subclass of Delivery. Delivery.etaMinutes(km) returns 10 + km × 4. A subclass called PickupOnly returns NaN instead. Step through what the customer sees:

Here is the same idea in code:

A subclass that breaks its parent's promise

class Delivery {
  etaMinutes(distanceKm: number): number {
    return Math.round(10 + distanceKm * 4); // promise: always a number of minutes
  }
}

class PickupOnly extends Delivery {
  etaMinutes(distanceKm: number): number {
    return NaN; // "there is no delivery" — but callers expect minutes
  }
}

const options: Delivery[] = [new Delivery(), new PickupOnly()];
for (const d of options) console.log(`ETA: ${d.etaMinutes(3)} min`);

Expected output:

ETA: 22 min
ETA: NaN min

Delivery promises to return the delivery time in minutes. For 3 km that is "ETA: 22 min". However, PickupOnly returns NaN, which means "Not a Number". As a result, the app displays "ETA: NaN min" to the customer.

The Fix

Do not treat pickup as a delivery type when it is not. Pickup is a different option and should be handled separately.

I: Interface Segregation

Rule: it is better to have several small interfaces than one large interface.

Imagine a single job form used by every QuickBite partner, with two sections: "cooks food" and "delivers orders". A restaurant would have to complete the delivery section even though it does not deliver. A rider would have to complete the cooking section even though they do not cook. Both end up entering information that does not make sense.

The Example

Split the big form into two small ones. Make a Kitchen interface for cooking and a Courier interface for riding. Each class follows only the one it really does. Watch the big interface cause trouble, then the split fix it:

After the split, Restaurant follows Kitchen and says "cooking biryani". Rider follows Courier and says "riding to MG Road". Neither class has a method it cannot do.

After the split: each class follows only the small interface it needs

The code behind the animation:

Small interfaces: each class takes only what it does

interface Kitchen {
  cookFood(dish: string): string;
}
interface Courier {
  rideToCustomer(address: string): string;
}

class Restaurant implements Kitchen {
  cookFood(dish: string): string { return `cooking ${dish}`; }
}
class Rider implements Courier {
  rideToCustomer(address: string): string { return `riding to ${address}`; }
}

console.log(new Restaurant().cookFood("biryani"));
console.log(new Rider().rideToCustomer("MG Road"));

Expected output:

cooking biryani
riding to MG Road

Without This Rule

Classes are forced to implement methods that they do not need. These methods may do nothing or simply throw errors. Because of this, callers cannot confidently rely on every method in the interface.

D: Dependency Inversion

Rule: a class should not create the tools it depends on. Instead, those tools should be provided from outside.

Think about a phone charger. A phone has a standard charging port, so you can connect any compatible charger. The phone is not permanently attached to a single charger.

The Example

In code, the "port" is an interface. The class gets its tool through its constructor.

Here, OrderNotifier needs something that sends messages. It asks for a Sender in its constructor. In a test, we plug in a FakeSender that only remembers the messages.

OrderNotifier depends on the Sender interface, so a real or a fake sender can plug in

Step through one test:

Here is the same idea in code:

Pass the dependency in, so a fake can replace it

interface Sender {
  send(to: string, text: string): void;
}

class OrderNotifier {
  constructor(private readonly sender: Sender) {}
  orderPlaced(phone: string, id: number): void {
    this.sender.send(phone, `Order ${id} placed`);
  }
}

class FakeSender implements Sender {
  readonly sent: string[] = [];
  send(to: string, text: string): void { this.sent.push(`${to}: ${text}`); }
}

const fake = new FakeSender();
new OrderNotifier(fake).orderPlaced("98450 12345", 881);
console.log(fake.sent);

Expected output:

[
  "98450 12345: Order 881 placed"
]

The fake ends up holding one message: "98450 12345: Order 881 placed". OrderNotifier does not create a real SMS sender by itself. Instead, it uses whichever Sender object is provided to it. Because of this, tests can verify the message without actually sending an SMS.

Without This Rule

Every test would send real text messages. If the company switched to a different SMS provider, you would also need to modify OrderNotifier.

SOLID principles are useful guidelines, not strict rules. Apply them where code changes often. If a small class has worked without changes for years, it does not need multiple interfaces or extra complexity.

Lab: Fix the God Class

Now it is your turn to improve QuickBite's OrderService.

How the Lab Checks You

Hidden tests verify the behavior of your code. Pricing, coupons, payments, and order numbers must continue to work exactly the same way. If a test fails, it will indicate which scenario has a problem.

The lab also checks the design of your solution. The payment switch statement must be removed, and no field should be public.

Watch the Diagram

The class diagram on the right is generated from your code. It starts with a single large class. After completing the lab, it should display several smaller classes connected to OrderService.

Common Mistakes

MistakeWrongCorrectWhy it happens
God classOne OrderService handles five different responsibilities.Create one class for each responsibility, and let OrderService only coordinate them.Adding "just one more method" often feels like the fastest solution.
Switch on a typeUsing switch (paymentMethod) in multiple places.Create one class for each type and use an interface.A switch statement is quick to write and may seem clean at first.
Fake subclassesOverriding a method to throw an error or return an invalid value.Create a separate type for the special case.Inheritance is sometimes used only to reuse code instead of representing a true "is a kind of" relationship.
One large interfacePartner contains both cookFood() and rideToCustomer().Split it into smaller interfaces, such as Kitchen and Courier.A single interface often looks easier to create initially.
Creating dependencies inside a classUsing new SmsSender() directly inside a class.Pass a Sender object through the constructor.It is usually the quickest way to get the code working.
Using SOLID everywhereCreating many interfaces for a class that rarely changes.Apply SOLID principles where the code frequently changes.Following rules can sometimes feel safer than using judgment.

Practice

What to Remember

  • A god class has too many responsibilities, so changes become difficult and risky.
  • S: Give each class a single responsibility.
  • O: Add new functionality by adding new code, not by changing working code.
  • L: A subclass should keep the promises made by its parent class.
  • I: Keep interfaces small and focused.
  • D: Pass dependencies into a class instead of creating them inside the class.
  • Make changes step by step and run the tests after each change.

Further Reading

These are the sources used to check this lesson:

Next: UML Diagrams. You will learn to read and draw the diagrams the labs show you.