SOLID Principles đȘ

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:
If you have code that expects any Bird to fly, passing a Penguin will cause a panic
Penguin cannot be safely substituted wherever Bird is expected
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.
Sparrow moves by flying
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
High-level modules should not depend on low-level modules. Both should depend on abstractions.
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:
UserRepository is tightly coupled to MySQL - it cannot work with any other database
If you want to switch to PostgreSQL, MongoDB, or any other database, you must modify UserRepository
Testing is difficult because you can't easily mock the database
The dependency flows downward: high-level â low-level (violates DIP)
Letâs talk about the Good Design now
UserRepository doesn't know or care about the specific database implementation
We can inject any database that implements the Database interface.
The dependency is inverted: both high-level (UserRepository) and low-level (MySQLDatabase, PostgresDatabase) depend on the abstraction (Database).
Easy to test by injecting a mock DB.
We can switch databases without changing UserRepository at all.
And thatâs a wrap on SOLID Principles đ€©
Hopefully you learned something through this


