Skip to main content

Command Palette

Search for a command to run...

SOLID Principles đŸ’Ș

Published
‱6 min read‱View as Markdown
SOLID Principles đŸ’Ș
A

Let's see how this goes.......

The significance of SOLID Principles is that it helps us to write code that doesn’t collapse under its own weight as the project grows.

Golang code examples are attached to explain each principle.

1.) Single Responsibility Principle

SRP states that Class should have only one reason to change.

Let’s understand this better with the example below

// Bad Design đŸ€ź
type UserService struct{}

func (u UserService) SaveUser() {}
func (u UserService) SendEmail() {}

// Good Design 😉
// for user related operations
type UserService struct{}

func (u UserService)SaveUser() {}

type EmailService struct{}

func (e EmailService) SendEmail() {}

In the snippet above, first let’s talk about the Bad Design, here UserService is doing 2 different things**:**

1.) Managing the User data(saving user)

2.) Handling Email communications(sending emails)

This violates SRP because the UserService struct now has 2 reasons to change, if in the future we want to change the manner in which the users are saved, you’d have to modify UserService too which would affect SendEmail and vice-versa. This creates tight coupling and makes the code harder to maintain and test.

Now let’s talk about Good Design, where the responsibilities are separated.

1.) UserService focuses only on user-related operations (saving users)

2.) EmailService focuses only on email-related operations.

Now each struct has a single responsibility and only one reason to change.

2.) Open/Closed Principle

OCP states that Objects or entities should be open for extension but closed for modification

Let’s understand this example better

// Creating an abstraction
type DiscountStrategy interface{
    Discount(amount float64) float64
}

type RegularDiscount struct{}
func (r RegularDiscount) Discount(a float64) float64 {
    return a * 0.05
}

type PremiumDiscount struct{}
func (p PremiumDiscount) Discount(a float64) float64 {
    return a * 0.10
}

type VIPDiscount struct{}
func (v VIPDiscount) Discount(a float64) float64 {
    return a * 0.20
}

// Using Polymorphism
type BillingService struct{
    discount DiscountStrategy 
}
func NewBillingService(d DiscountStrategy) BillingService {
    return BillingService{discount: d}
}

func (b BillingService) FinalAmount(amount float64) float64 {
    return amount - b.discount.Discount(amount)
}

In the snippet above

Abstraction: DiscountStrategy interface defines a contract that any discount type must follow. This is the foundation that allows extension without modification.

Open for Extension: When we need to add a new discount type (like a seasonal discount or student discount), we simply create a new struct that implements the DiscountStrategy interface:

type SeasonalDiscount struct{}
func (s SeasonalDiscount) Discount(a float64) float64 {
    return a * 0.15
}

Notice how we didn’t modify any existing code just add the new one.

BillingService doesn’t need to know about specific discount types. It works with DiscountStrategy interface through polymorphism. FinalAmount method will work correctly with any discount strategy without needing any changes.

If we were not following OCP, our code would have looked like something below, every time a new discount type is added, we’d have to modify the code too.

func (b BillingService) FinalAmount(amount float64, discountType string) float64 {
    if discountType == "regular" {
        return amount - (amount * 0.05)
    } else if discountType == "premium" {
        return amount - (amount * 0.10)
    } else if discountType == "vip" {
        return amount - (amount * 0.20)
    }
}

3.) Liskow Substitution Principle

LSP states that If S is a subtype of T, then objects of type T should be replaceable with objects of type S without breaking correctness.

In simple words it means if your code works with a parent type, it must work with any child type.

Let’s understand better with the example below

// Bad Design đŸ€ź
type Bird interface {
    Fly()
}
type Penguin struct{}
func (p Penguin) Fly() { panic("can't fly") }


// Good Design 😏
type Bird interface {
    Move()
}
type Sparrow struct{}

func (s Sparrow) Move() { println("fly") }

type Penguin struct{}
func (p Penguin) Move() { println("walk") }

In the Bad Design, this violates LSP because:

  1. If you have code that expects any Bird to fly, passing a Penguin will cause a panic

  2. Penguin cannot be safely substituted wherever Bird is expected

  3. The interface makes a promise (birds can fly) that not all implementations can keep

Let’s talk about the Good Design:

In this version we are using a more general Move() method that all birds can genuinely implement.

  1. Sparrow moves by flying

  2. Penguin moves by walking

4.) Interface Segregation Principle

ISP states that Clients should not be forced to depend on interfaces they don’t use.

In very simple words it means it is better to have multiple small, specific interfaces than one large purpose interface.

Let’s understand with the example below

// Bad Design đŸ€ź
type Worker interface {
    Work()
    Eat()
    Sleep()
}

//Good Design 😉
type Work interface{
    Work()
} 
type Eatable interface{
    Eat()
}

In the Bad Design, Worker interface forces every implementation to provide all 3 methods.

type Robot struct{}
func (r Robot) Work() { println("working") }
func (r Robot) Eat() { /* robot doesnt eat! */ }
func (r Robot) Sleep() { /* robot doesnt sleep! */ }

This violates ISP because Robot is forced to depend on interface methods it doesn’t depend on.

Let’s discuss the Good Design, we have split the larger interface into smaller focused ones.

type Robot struct{}
func (r Robot) Work() { println("working") } // Only implements Work

type Human struct{}
func (h Human) Work() { println("working") }
func (h Human) Eat() { println("eating") } // Implements both Work and Eatable

Each type only implements the interface relevant to it.

Everyone’s happy because no one is forced to implement methods they don’t need, making the code more flexible and maintainable.

5.) Dependency Inversion Principle

DIP states that

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

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

In simple words it means that our business logic should not care how things are done, it should care only about what needs to be done.

Let’s understand this better with the example below

// Bad Design đŸ€ź
type MySQLDatabase struct{}
func (m MySQLDatabase) Save(data string) {
    println("Saving to MySQL:", data)
}

type UserRepository struct {
    db MySQLDatabase // Depends on concrete implementation
}
func (u UserRepository) SaveUser(user string) {
    u.db.Save(user)
}

// Good Design 😉
type Database interface {
    Save(data string)
}
type MySQLDatabase struct{}

func (m MySQLDatabase) Save(data string) {
    println("Saving to MySQL:", data)
}

type PostgresDatabase struct{}
func (p PostgresDatabase) Save(data string) {
    println("Saving to Postgres:", data)
}

type UserRepository struct {
    db Database // Depends on abstraction, not concrete implementation
}
func NewUserRepository(db Database) UserRepository {
    return UserRepository{db: db}
}
func (u UserRepository) SaveUser(user string) {
    u.db.Save(user)
}

In the Bad Design snippet UserRepository (high level module) directly depends on MySQLDatabase.

Problem here is:

  1. UserRepository is tightly coupled to MySQL - it cannot work with any other database

  2. If you want to switch to PostgreSQL, MongoDB, or any other database, you must modify UserRepository

  3. Testing is difficult because you can't easily mock the database

  4. The dependency flows downward: high-level → low-level (violates DIP)

Let’s talk about the Good Design now

  1. UserRepository doesn't know or care about the specific database implementation

  2. We can inject any database that implements the Database interface.

  3. The dependency is inverted: both high-level (UserRepository) and low-level (MySQLDatabase, PostgresDatabase) depend on the abstraction (Database).

  4. Easy to test by injecting a mock DB.

  5. We can switch databases without changing UserRepository at all.

And that’s a wrap on SOLID Principles đŸ€©
Hopefully you learned something through this