← Back to writing

September 11, 2026

SOLID Design Principles for VibeCoders

A simple, slightly chaotic explanation of SOLID principles.

  • SOLID
  • Software Design
  • Clean Code

Bit of History

Some people find this part of learning boring and skip it, but I believe it does give a bird's eye view and show how things evolve over time. I recommend you go through this section, but if you don't wanna click me.

So early software was relatively small and built for specific purposes like the entire UNIX kernel was about 10,000 lines of code in the 1970s compared to around 40+ Million lines of code in the modern linux kernel.

As computing power increased and software systems grew in scale, their complexity increased drastically. This created problems of its own.

100 lines → 1,000 lines → 10,000 lines → 100,000+ lines

As software systems grew, developers needed better ways to organize this increasing complexity. Object-Oriented Programming became one of the dominant approaches, with languages such as Smalltalk and C++ introducing ideas such as classes, Inheritance and polymorphism.

These ideas made large systems easier to model, but they also created another problem: components became tightly coupled through complex dependencies. Over time, this made software rigid, fragile, and difficult to maintain a problem Uncle Bob referred to as Software Rot.

The problem was no longer simply writing code. The problem was writing code that could continue to change without falling apart. and as the codebase becomes more and more complex it's pratically impossible for a single engineer to know all it's parts, so it's extremely important that the codebase is maintainable and readable.

Developers started noticing the same problems again and again:

  1. Classes were doing too many things.
  2. A small change required modifying existing code in many places.
  3. Classes became tightly coupled to each other.
  4. Inheritance was often misused.
  5. Large interfaces forced classes to implement things they didn't need.

Uncle Bob brought several object-oriented design principles together in his 2000 paper, Design Principles and Design Patterns. These principles were later refined and presented together as a broader set of guidelines for designing maintainable object-oriented software. A few years later, around 2004, Michael Feathers suggested arranging the principles so that their initials formed the memorable acronym SOLID.

SOLID emerged as a set of guidelines for designing software that remains easy to understand, maintain, and change as it grows.

Enough yapping about the History, let's start the actual SOLID Principles

The SOLID Principles

  1. S - Single Responsibility
  2. O - Open/Closed
  3. L - Liskov Substitution
  4. I - Interface Segregation
  5. D - Dependency Inversion

1. Single Responsibility Principle (SRP) :

A class should have only one reason to change:

This doesn't mean that it will only contain one method, it means tha all the methods will together work together to fullfill a single, well-defined purpose

In simple terms, everything in that call will work together to do one thing.

Example: Let's say you are building a dating app

You start with

class User{
  public:   
    void createProfile();
    void updateProfile();
}; // pretty reasonable 

Then you go like I already have User class, I'll just add a few more things here.

class User{
  public:   
    void createProfile();
    void updateProfile();
    
    void sendEmail();
    void saveToDataBase();

    void matchWithPeople();
    void generateAIProfilePicture();

    void makeCoffee();
}

Sir this ain't a class at this point, this is a whole school. ;)

The problem isn't that the class has too many methods.

The problem is that those methods have completely different reasons to change.

Database changes            ──→  User changes
Payment provider changes    ──→  User changes
Email provider changes      ──→  User changes
Matching algorithm changes  ──→  User changes
AI image model changes      ──→  User changes
Coffee machine breaks ☕    ──→  somehow... User changes

That's a violation of SRP.

// Instead, give each responsibility its own class:
class User {
    void createProfile();
    void updateProfile();
};

class EmailService {
    void sendEmail();
};

class PaymentService {
    void chargeCreditCard();
};

class UserRepository {
    void saveToDatabase();
};

class MatchingService {
    void findMatches();
};

class ProfilePictureService {
    void generateAIProfilePicture();
};

so basically Don't build a God class. Your class doesn't need to know how to save a user, charge their card, find them a soulmate, generate their face with AI, and make their coffee.

2. Open & Close Principle (OCP) :

Software entities should be open for extension, but closed for modification.

In simple terms, you should be able to add new behavior / methods but shouldn't have to change already tested code everytime you add a new behavior / method.

Let's continue our dating app from the previous example.
Our app has a User class. Now we need to decide how two users should be matched.

//  So, naturally, the VibeCoder solution is:
class MatchingService {
public:
    bool match(User a, User b) {

        if (a.age == b.age) {
            return true;
        }

        return false;
    }
};

Now your PM says : We should also match peoplr based on interests.

// so you modify 
if (sameAge(a, b)) {
    return true;
}

if (sameInterests(a, b)) {
    return true;
}

then again : Can we match based on location

// so you modify again 
if (sameAge(a, b)) { ... }
else if (sameInterests(a, b)) { ... }
else if (sameLocation(a, b)) { ... }

then your AI team comes and says : We trained an AI model that decides compatibility based on personality.

// then you modify the class again 
if (sameAge(a, b)) { ... }
else if (sameInterests(a, b)) { ... }
else if (sameLocation(a, b)) { ... }
else if (aiThinksTheyAreCompatible(a, b)) { ... }

after few months MatchingService.cpp has become a 700 line if-else cemetry Every time you introduce a new matching strategy, you have to modify the same class.

That's exactly what OCP tries to avoid.

A Better Approach

Instead of making MatchingService know every possible matching strategy, define an interface for a matching strategy:

class MatchingStrategy {
public:
    virtual bool match(User a, User b) = 0;
};

Now each strategy can implement its own rules:

class InterestMatching : public MatchingStrategy {
public:
    bool match(User a, User b) override {
        return sameInterests(a, b);
    }
};
class LocationMatching : public MatchingStrategy {
public:
    bool match(User a, User b) override {
        return sameLocation(a, b);
    }
};

etc etc....

Our main service doesn't need to change every time a new strategy appears:

class MatchingService {
    MatchingStrategy* strategy;

public:
    MatchingService(MatchingStrategy* strategy)
        : strategy(strategy) {}

    bool match(User a, User b) {
        return strategy->match(a, b);
    }
};

Now if your PM says: We should match people based on how many hours they spend arguing about tabs vs spaces. You don't modify MatchingService. You just create another strategy:

class TabsVsSpacesMatching : public MatchingStrategy {
public:
    bool match(User a, User b) override {
        return a.usesTabs == b.usesTabs;
    }
};

Remember open of extension closed for modification

Don't keep modifying the same class every time the requirements grow. Design it so new behavior can be added alongside the existing behavior.

phewwww.... so that's OCP for you.

3. Liskov Substitution Principle (LSP) :

Objects of a subclass should be replaceable with objects of its superclass without breaking the correctness of the program.

That sounds much more complicated than it actually is.

If B is a child of A, then wherever the program expects an A, you should be able to give it a B without everything catching fire.

Let's continue our dating app. The Problem
Our app supports different types of users.

//So someone creates:
class User {
public:
    virtual void swipeRight() {
        // Like someone
    }
};

Then we create a special type of user:

class PremiumUser : public User {
public:
    void swipeRight() override {
        // Like someone
    }
};

Makes sense.

A PremiumUser is a User, so we should be able to use it anywhere a User is expected according to LSP.

void processSwipe(User& user) {
    user.swipeRight();
}
User normalUser;
PremiumUser premiumUser;

processSwipe(normalUser);
processSwipe(premiumUser);
// both should work

No problem.

But then our PM has another brilliant idea: Let's add a BannedUser class.

//So someone writes:
class BannedUser : public User {
public:
    void swipeRight() override {
        throw "BANNED USERS CANNOT SWIPE";
    }
};

Now

BannedUser user;
processSwipe(user);

The function expects a User, and technically it is a User.

But the program breaks because BannedUser doesn't behave like the User that the function expected.

That's the problem LSP is pointing at. If replacing the parent object with the child causes unexpected behavior, your inheritance relationship is probably wrong.

So instead of forcing everything into the User hierarchy, we might model the behavior separately:

class User {
    // Common user data
};

class Swipeable {
public:
    virtual void swipeRight() = 0;
};

class NormalUser : public User, public Swipeable {
public:
    void swipeRight() override { /* Like someone */ }
};

class PremiumUser : public User, public Swipeable {
public:
    void swipeRight() override { /* Like someone */ }
};

class BannedUser : public User { /*No swipe capability*/ };

Now we're not pretending that every User can swipe.

If your PremiumUser works everywhere a User works, great. If your BannedUser walks into the User party and immediately throws an exception, maybe it shouldn't have been invited through inheritance.

Think of inheritance as making a promise: "I am a specialized version of you, but I still obey the rules you promised."

4. Interface Segregation Principle (ISP) :

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

In simple terms: Don't make someone implement a giant interface when they only need a small part of it.

The Problem :
Our app has different kinds of users. So someone decides to create one interface for everything a user might possibly do:

class UserActions {
public:
    virtual void swipeRight() = 0;
    virtual void sendMessage() = 0;
    virtual void videoCall() = 0;
    virtual void buyPremium() = 0;
    virtual void generateProfilePicture() = 0;
};

Looks convenient. One interface. All the features.

Then we create a FreeUser:

class FreeUser : public UserActions {
public:
    void swipeRight() override { /* Swipe */ }

    void sendMessage() override {/* Message */  }

    void videoCall() override { /* ... */ }

    void buyPremium() override { /* ... */ }

    void generateProfilePicture() override { /* ... */ }
};

But our free user doesn't support video calls, doesn't generate AI profile pictures, and obviously shouldn't have to implement premium functionality.

So now we're writing nonsense:

void videoCall() override {
    throw "Upgrade to Premium";
}
// and 
void generateProfilePicture() override {
    throw "AI credits exhausted";
}
// and maybe 
void buyPremium() override {
    // A FreeUser buying Premium?
    // We have created a paradox.
}

The problem isn't the FreeUser.
The problem is the interface.

We've created one enormous interface containing every possible action and forced every user type to depend on it.

A Better Approach
Break the giant interface into smaller, focused interfaces:

class Swipeable {
public:
    virtual void swipeRight() = 0;
};

class Messageable {
public:
    virtual void sendMessage() = 0;
};

class VideoCallable {
public:
    virtual void videoCall() = 0;
};

class PremiumPurchasable {
public:
    virtual void buyPremium() = 0;
};

Now a free user can implement only what it actually supports:

class FreeUser : public Swipeable, public Messageable {
public:
    void swipeRight() override { /* Swipe */ }

    void sendMessage() override { /* Message */ }
};

While a premium user can have additional capabilities:

class PremiumUser
    : public Swipeable,
      public Messageable,
      public VideoCallable {

public:
    void swipeRight() override { /* Swipe */ }

    void sendMessage() override { /* Message */ }

    void videoCall() override { /* Video call */ }
};

Now each interface represents a small, specific capability.

That's Interface Segregation

Instead of this:

                         UserActions
                             │
                ┌────────────┼────────────┐
                ↓            ↓            ↓
             Swipe        Message       Video
                ↓            ↓            ↓
          EVERY USER MUST IMPLEMENT EVERYTHING

we have

Swipeable       → users who can swipe
Messageable     → users who can message
VideoCallable   → users who can video call
Premium         → users who have premium capabilities

An interface should be as small and focused as the clients that use it.

Don't make a free user promise to video call, generate AI photos, and buy Premium just because your interface had commitment issues.

5. Dependency Inversion Principle (DIP) :

High-level modules should not depend on low-level modules. Both should depend on abstractions.

In simple terms : Don't make your important business logic depend directly on specific tools or implementations. Depend on what those tools are supposed to do.

Abstractions should not depend on details. Details should depend on abstractions.

The Problem
Our dating app needs to send notifications.

So we write:

class NotificationService {
public:
    void notify(User& user) {
        Gmail gmail;
        gmail.send(user.email, "You got a new match!");
    }
};

Seems fine.

Until the product manager says: "We're moving from Gmail to SendGrid."

So we change the class:

SendGrid sendGrid;
sendGrid.send(...);

Then six months later: "We're moving to Amazon SES."

Change it again.

Then: "Can we also send WhatsApp notifications?"

Change it again.
Our important business logic is now tightly coupled to whichever notification provider we're currently using.

And then someone does this:

class NotificationService {
public:
    void notify(User& user) {

        Gmail gmail;
        SendGrid sendGrid;
        Twilio twilio;
        WhatsApp whatsapp;

        // Figure out which one to use...
    }
};

At this point, the NotificationService has become less of a service and more of a group project where nobody knows who's responsible for what.

The real problem is that NotificationService knows too much about the implementation.
It shouldn't care whether the notification is sent through Gmail, SendGrid, WhatsApp, or carrier pigeon.

It only needs to know: "Can you send a notification?"

Depend on an Abstraction

Create an interface:

class NotificationSender {
public:
    virtual void send(string message) = 0;
};

Now different providers can implement it:

class EmailSender : public NotificationSender {
public:
    void send(string message) override { /* Send email */ }
};
class WhatsAppSender : public NotificationSender {
public:
    void send(string message) override { /* Send WhatsApp message */ }
};

Our high-level service now depends on the abstraction:

class NotificationService {
    NotificationSender& sender;

public:
    NotificationService(NotificationSender& sender)
        : sender(sender) {}

    void notify(User& user) {
        sender.send("You got a new match!");
    }
};

Now we can plug in whatever implementation we want:

EmailSender email;
NotificationService service(email);
//   or
WhatsAppSender whatsapp;
NotificationService service(whatsapp);

NotificationService doesn't change.

That's the important part.

What actually changed?

Before:

NotificationService
        ↓
      Gmail

after:

        NotificationService
                ↓
       NotificationSender
          ↙           ↘
      Email        WhatsApp

The high-level code depends on an abstraction, while the concrete implementations depend on that same abstraction.

That's the Dependency Inversion Principle. High-level code should depend on abstractions, not concrete implementations.

Conclusion

The five principles aren't five random rules that you have to memorize before your next coding interview. They all point toward one larger goal of making software easier to understand, change, and extend without breaking everything around it.

Ahh one more thing : SOLID sounds great on paper.

  • Small classes.
  • Small interfaces.
  • Loose coupling.
  • Easy extension.

So should we apply all five principles to everything?

No.

SOLID is a set of design guidelines, not laws of physics.

Every design decision comes with tradeoffs. Applying SOLID too aggressively can turn a simple piece of code into a maze of interfaces, abstractions, and classes that nobody asked for.

The goal isn't to write the most SOLID code possible.

The goal is to write code that is easy to change for the kind of changes you actually expect.

Thank You for reading.