SOLID Principles + OOPs Concepts Explained using Python

·

8 min read

Cover Image for SOLID Principles + OOPs Concepts Explained using Python

SOLID Principles and OOPs are a fundamental concept asked in all junior- to mid- level interviews. You need to not only know the definitions of these, but you also should be able to write code which follows these principles, and give real-world examples of these wherever necessary.

This blog is an attempt to do just that.

SOLID stands for

  1. Single Responsibility Principle (SRP)

  2. Open-Closed Principle (OCP)

  3. Liskov Substitution Principle (LSP)

  4. Interface Segregation Principle (ISP)

  5. Dependency Inversion Principle (DIP)

Let us check out these principles one-by-one.

Single Responsibility Principle (SRP)

This principles states that “A class should have only one reason to change.”

This means a class should be responsible for a single, specific piece of functionality.

Take the example of an Authentication Service versus a User Profile Service. Commonly used by almost all companies which serve Software as a Service to users.

SRP Violation (what beginners often do): Put all User Details & Auth in a single class

class UserService:
    def login(self): ...
    def logout(self): ...
    def reset_password(self): ...
    def update_profile(self): ...
    def upload_avatar(self): ...

Problem:
This class changes for multiple reasons:

  • Auth rules change (OAuth, MFA, tokens)

  • Profile rules change (new fields, avatars, preferences)

SRP Applied (real startup architecture): Separate classes based on specific functionality (Authentication vs User Profile Details)

class AuthService:
    def login(self): ...
    def logout(self): ...
    def reset_password(self): ...

class UserProfileService:
    def update_profile(self): ...
    def upload_avatar(self): ...

Open/ Closed Principle (OCP)

This principle states that “A software entity (like a class, module, or function) should be open for extension, but closed for modification.”

You should be able to add new functionality without changing existing code.

Real World Example: Imagine an early-stage product that only supports card payments. Someone writes this:

class PaymentService:
    def pay(self, amount, method):
        if method == "card":
            return self.pay_by_card(amount)
        elif method == "upi":
            return self.pay_by_upi(amount)
        elif method == "wallet":
            return self.pay_by_wallet(amount)

Now when the business expands new payments will start coming. International Payments, Net Banking, Crypto. And because of the current structure, we would need to add a new method. Every single time a new method is added, this class has to be edited. That means touching code that already works, retesting everything, and hoping nothing breaks.

This is exactly the situation companies like Stripe design around, because payment code is too sensitive to keep rewriting. So the design shifts. Instead of modifying the same method again and again, the system becomes extensible:

class PaymentMethod:
    def pay(self, amount):
        raise NotImplementedError


class CardPayment(PaymentMethod):
    def pay(self, amount):
        ...


class UpiPayment(PaymentMethod):
    def pay(self, amount):
        ...


class WalletPayment(PaymentMethod):
    def pay(self, amount):
        ...

And now the Service looks like:

class PaymentService:
    def pay(self, amount, payment_method: PaymentMethod):
        return payment_method.pay(amount)

What’s important here is not inheritance itself. It’s the fact that adding a new payment method no longer requires modifying PaymentService. You extend the system by adding new classes, not by reopening old ones. That’s OCP working in a very real, revenue-protecting way.

Liskov Substitution Principle (LSP)

This principle states that “The objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program”. The use of this principle commonly occurs during file handling, storage, and cloud integrations.

Imagine this base class:

class FileStorage:
    def save(self, filename, data):
        pass

    def delete(self, filename):
        pass

Your code assumes that if a file can be saved, it can also be deleted. That’s a reasonable assumption.

Now someone adds a read-only storage backend, maybe for compliance or audit logs:

class ReadOnlyStorage(FileStorage):
    def save(self, filename, data):
        raise Exception("Storage is read-only")

    def delete(self, filename):
        raise Exception("Storage is read-only")

Again, this compiles fine. But it violates LSP in a very sneaky way.

Any code that works with FileStorage and reasonably expects save and delete to work will now break at runtime. The subclass is saying, “I am a FileStorage”, but behaving like one that can’t actually do what FileStorage promises.

This kind of mistake shows up in systems built on top of platforms like Amazon S3 integrations, where some buckets or backends are intentionally immutable.

The fix is not defensive try/except everywhere. The fix is modeling reality correctly.

You separate the abstraction:

class ReadableStorage:
    def read(self, filename):
        pass


class WritableStorage(ReadableStorage):
    def save(self, filename, data):
        pass

    def delete(self, filename):
        pass

Now a read-only store is a ReadableStorage, not a WritableStorage. Nothing pretends to support behavior it cannot guarantee. Substitution becomes safe again.

Interface Segregation Principle (ISP)

This principle states that “no client should be forced to depend on methods it does not use.”

Let us take an example which is extremely common in internal tools, admin panels, and dashboards.

Imagine an interface for a user in an admin system:

class AdminUserActions:
    def create_user(self):
        pass

    def delete_user(self):
        pass

    def view_reports(self):
        pass

    def export_data(self):
        pass

At first, this makes sense for a super-admin. Then the company hires support staff. They only need to view users and reports. Now you get something like this:

class SupportAgent(AdminUserActions):
    def create_user(self):
        raise Exception("Not allowed")

    def delete_user(self):
        raise Exception("Not allowed")

    def view_reports(self):
        ...

    def export_data(self):
        ...

This design is screaming for trouble. Permission checks are scattered everywhere, and “not allowed” becomes a runtime surprise instead of a design guarantee.

Large platforms like Shopify handle this by separating responsibilities at the interface level, not by stuffing permissions into one giant abstraction.

The design moves toward something like:

class UserManagement:
    def create_user(self):
        pass

    def delete_user(self):
        pass


class Reporting:
    def view_reports(self):
        pass

    def export_data(self):
        pass

Now a support agent depends only on Reporting. An admin depends on both. No one implements methods they aren’t supposed to use, and permission logic becomes explicit instead of implicit.

Dependency Inversion Principle (DIP)

This principle states that “High-level modules should not depend on low-level modules.” Both should depend on abstractions.

Imagine an order service in an e-commerce app:

class OrderService:
    def __init__(self):
        self.db = MySQLDatabase()

    def create_order(self, order):
        self.db.save(order)

This looks innocent. It works. Orders get saved.

Then reality hits. The startup grows. Someone wants to move to PostgreSQL. Or to DynamoDB. Or add a cache. Suddenly OrderService is tightly bound to MySQLDatabase. Every infrastructure change forces you to modify core business logic.

Companies like Amazon ran into this problem at massive scale. Their business logic cannot care whether data is stored in MySQL, DynamoDB, or something custom.

So the dependency is inverted:

class OrderRepository:
    def save(self, order):
        pass

Now OrderService depends on this abstraction:

class OrderService:
    def __init__(self, repository: OrderRepository):
        self.repository = repository

    def create_order(self, order):
        self.repository.save(order)

And the concrete database lives at the edge:

class MySQLOrderRepository(OrderRepository):
    def save(self, order):
        ...

What changed here is subtle but powerful. The high-level code no longer knows or cares about databases. Infrastructure details depend on the business abstraction, not the other way around. That inversion is the core of DIP.


Now, let us move on to OOPs concepts.

OOP stands for Object-Oriented Programming. There are 4 concepts in Object-Oriented Programming namely:

  1. Abstraction

  2. Encapsulation

  3. Inheritance

  4. Polymorphism

Let us start with Abstraction.

Abstraction

Abstraction involves hiding complex implementation details and showing only the necessary features of an object. In real codebases, abstraction exists to reduce cognitive load. You don’t want every part of the system to understand every detail.

A very common abstraction shows up in databases.

Imagine writing business logic that directly uses SQL everywhere:

def create_order(order):
    cursor.execute(
        "INSERT INTO orders (id, total) VALUES (?, ?)",
        (order.id, order.total)
    )

This works, but now your business logic knows too much. It knows SQL. It knows table names. It knows schema details. When the database changes, business logic breaks.

So teams introduce an abstraction:

class OrderRepository:
    def save(self, order):
        pass

Now the business code talks only to the abstraction:

def create_order(order, repo: OrderRepository):
    repo.save(order)

The database details disappear behind the abstraction. Whether the data goes to MySQL, PostgreSQL, or DynamoDB becomes irrelevant to the caller. This is abstraction doing its job: shielding the rest of the system from complexity.

Encapsulation

Encapsulation is the bundling of data and the methods that operate on that data within a single unit (class). It restricts direct access to some of an object's components, which is a means of preventing accidental interference and misuse of the methods and data. It exists because uncontrolled access to data always turns into bugs.

Consider a naive bank account class:

class BankAccount:
    def __init__(self):
        self.balance = 0

Anyone can now do this:

account.balance = -100000

Nothing stops it. No rules are enforced. This is how systems quietly rot.

Encapsulation fixes this by controlling access:

class BankAccount:
    def __init__(self):
        self._balance = 0

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Invalid deposit")
        self._balance += amount

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount

Now the state can only change through valid operations. The rules live close to the data they protect. This pattern shows up everywhere in financial systems because money without guardrails is dangerous.

Inheritance

Inheritance allows a class to inherit properties and methods from another class, promoting code reuse and establishing a relationship between parent and child classes.

Inheritance appears in domain modeling. You might have a base Employee:

class Employee:
    def calculate_salary(self):
        pass

Then specializations:

class FullTimeEmployee(Employee):
    def calculate_salary(self):
        return self.base_salary

class Contractor(Employee):
    def calculate_salary(self):
        return self.hours * self.rate

Payroll systems rely on this kind of inheritance because different employee types share identity but differ in behavior.

Polymorphism

Polymorphism allows objects of different types to be treated as objects of a common base class. It enables a single interface to represent different underlying forms (data types).

A very real example is payment handling.

class PaymentMethod:
    def pay(self, amount):
        pass

Different implementations:

class CardPayment(PaymentMethod):
    def pay(self, amount):
        print("Paid by card")

class UPIPayment(PaymentMethod):
    def pay(self, amount):
        print("Paid by UPI")

Here pay method is implemented differently for CardPayment Class and UPIPayment Class though they inherit the same method from the PaymentMethod Class. As you can see, Polymorphism means the same call behaves differently depending on the object. The calling code does not care which one it gets:

def checkout(method: PaymentMethod, amount):
    method.pay(amount)

This is polymorphism in its purest form, and companies like Stripe build entire platforms around this idea.


And that is it, hope you had a clear idea of what SOLID Principles and OOPs concepts are and how they are used in real-world projects and implementations.

If you liked this blog, please write to me on shreyastaware.work@gmail.com. And for anything else, feel free to visit my website: shreyastaware.me

Until next time,

Shreyas