SOLID Principles in GO

SOLID Principles in GO

They are foundation for writing clean, scalable, and maintainable code, in real-world backend development.

Mostly in LLD rounds, we are not directly asked about SOLID principles. But we are expected to demonstrate and explain them in theoretically while designing a system.



🔷 S — Single Responsibility Principle (SRP)

"One thing should do only one job, only reason to change" The single responsibility says , a function, class, module or a struct should be designed to have a single responsibility just one single purpose.

Why is this important?

if a piece of a code have multiple responsibility, then

  • It's hard to understand.
  • It becomes hard to test and reuse
  • A small change in one part can accidentally break something unrelate

Real-Life Example:

Think of a journal notebook:

  • Writing notes → user job
  • Printing the notes → printer’s job

// SRP compliant design
type Report struct {
  Title string
  Content string
}
func (r Report) Generate(){
   // Responsible only for report generation
} 

type FileSaver struct{}

func (fs FileSaver) Save(report Report, filename string) {
    // Responsible only for saving
}        

🔶 O — Open/Closed Principle (OCP)

"Your code should be open for extension & closed for modification".

  • Open for extension : We should be able to add new behavior without changing old code.
  • Closed for modification → We should not touch existing code just to add something new.

Real-World Analogy

Think of power socket at home:

  • We dont change socket every time we get a new device.
  • We just plug new device in it.
  • The socket is closed for modification (we don’t touch it), but open for extension (we can plug in more devices).

// creating a payment gateway interface for multiple payment modes
type PaymentProcessor interface {
   Pay()
}
type CreditCard struct{}
func (c CreditCard ) Pay() {
    // credit card logic
}
type Paytm struct{}
func (p Paytm ) Pay() {
   // paytm logic
}

func ProcessPayment(p PaymentProcessor) {
    p.Pay()
}        

Now, suppose if we have to add UPI.

  • No need to change existing ProcessPayment
  • Just create a new type that implements the interface
  • Follows Open/Closed Principle!


🔷 L — Liskov Substitution Principle

This is the one where people bit struggles to understand it. In simple words it says that, if we have a piece of code that works with base type (interface or parent ) it should also works with its subtypes ( implementation ) without breaking its behavior.

for eg. In natural language and mathematics, we say:

"A square is a rectangle"

...feels intuitive and correct. But in code, "is-a" should really mean "behaves like" — and this is where we get it wrong. We cant inherit the rectangle in square due to difference in behavior.

When we say:

"Type B inherits from Type A" : ...we're telling the compiler and the reader:

"You can use B wherever you expect A — it behaves like A" : And that is the core of Liskov Substitution Principle.

# Violation of Liskov substitution principle
Rectangle:
    SetWidth(w)  → width = w
    SetHeight(h) → height = h

Square:
    SetWidth(w)  → width = w, height = w  ←  side effect!
    SetHeight(h) → height = h, width = h  ←  breaks expectations!        

creating a function that calculates area, considering the fact a square is a rectangle

func ResizeAndCheckArea(r *Rectangle) {
    r.SetWidth(5)
    r.SetHeight(10)
    fmt.Println("Expected Area: 50, Got:", r.Area())
}
// but it will fails with the square, if we tried to use the square as rectangle because height and width are not independent.        
Why we use Liskov's Substitution principle ?

  • Preserves Correctness of Polymorphism : LSP is what makes polymorphism safe. It ensures that all implementations of the base type (interface) same as expected so that base logic dont break.
  • Strengthens Abstraction Boundaries : It forces us think carefully about the interfaces and base types. So that we can avoid wrong inheritance. If behaviors dont match then we have to use composition
  • Makes Unit Testing Easier and Safer : If test our basic logic once ( processPayment), we can plug in CreditCard, UPI, PayPal — and rely on them working. Otherwise , we'll have to modify old tests. Which is hard to maintain.
  • Real-World Relevance (e.g., APIs, Plugins, Handlers) : LSP insures that when we swap implementation, system behavior remains consistent.


🔶 I — Interface Segregation Principle (ISP)

It's says, Clients (types that implements interface) should not be forced to dependent upon interface they dont use. Or in even simpler terms, keep interfaces small and focused do not make them fat.

Go has a powerful advantage here, Interfaces in Go are implicitly satisfied. So it enforce us to avoid bloated interfaces naturally and define the methods we actually need.

// interface violating ISP
type Machine interface {
    Print()
    Scan()
    Fax()
}

// Correct ISP
type Printer interface {
    Print()
}
type Scanner interface {
    Scan()
}
type Faxer interface {
    Fax()
}        

🔷 L — Dependency Inversion Principle (DIP)

Let's understand the simple definition first before explaining it through the example. Dependency inversion is a strategy of depending upon interfaces or abstract function or classes rather then upon concrete functions and classes.

In other words, our higher level modules should not depend upon the low level modules. Both should depend upon abstraction.

Let’s simplify it more :

  1. High-level modules = what system does, eg proecessing payments etc.
  2. Low-level modules = how system does eg. API, file I/O etc.

Traditionally, high-level modules directly use low-level modules. That causes tight coupling and fragile code.

DIP inverts this dependency — instead of high-level modules depending directly on concrete details, both should depend on interfaces (abstractions).

Now understand it with real life analogy ( Power Outlet and Home Appliances):

At our homes, we use different types of appliances like A.C., fan etc. They all need electricity to work. To operate that appliance we plug them into wall socket or maybe powerbank or even a generator. But the appliance really does'nt care where the electricity come from - as long as it follows standard plug and voltage.

Here our appliance depends upon the general source of electricity not a specific wall socket.

Why Do We Need the Dependency Inversion Principle ?

  • Loose Coupling : if high-level logic depends on specific implementations, any change in the system forces changes across the system.
  • Easier Testing / Mocking : Without DIP its hard to test in isolation because our logic depends on concrete implementations. But with DIP , we can inject mock objects or fake implementations.
  • Maintenance and Scalability : Without DIP, a small change in low-level code can break or ripple into our core logic.

How We Achieve It ?

  1. First we need to create abstractions(interfaces) for behaviors we need.
  2. Let high level and low level module depends upon the abstraction.
  3. Use Dependency injection to provide concrete implementations to high level module at run time.

High-Level Module (OrderService)
     ↕
Interface (OrderRepository)
     ↕
Low-Level Module (MySQLRepository or MongoRepository)        

let's try to implement DIP through an example : Suppose a userService(high level module) sends a message using messager (low level module). We apply DIP by introducing an interface Messenger, and inject a concrete implementation. at run time.

package main

import "fmt"

// Abstraction
type Messenger interface {
	SendMessage(to string, message string)
}

// Low-level module
type EmailMessenger struct{}

func (e EmailMessenger) SendMessage(to string, message string) {
	fmt.Println("Email sent to", to, "with message:", message)
}

// High-level module
type UserService struct {
	messenger Messenger // depends on abstraction
}

// Dependency Injection via constructor
func NewUserService(m Messenger) *UserService {
	return &UserService{messenger: m}
}

func (u *UserService) NotifyUser(email string) {
	u.messenger.SendMessage(email, "Welcome to our platform!")
}

func main() {
	emailMessenger := EmailMessenger{}  // low-level concrete
	userService := NewUserService(emailMessenger) // inject it into high-level module
	userService.NotifyUser("user@example.com")             // works via abstraction
}        

  • Now we can swap EmailMessenger with SmsMessenger, SlackMessenger, etc., without changing UserService.
  • Easily test UserService with a mock Messenger.
  • This follows DIP and achieves loose coupling.



To view or add a comment, sign in

Others also viewed

Explore topics