SOLID Principles Overview

Published on September 08, 2026

7 min read

#clean code

Not something new for me, I already know SOLID since 2019 back in college, but it's good to refresh it once in a while. Especially now with a lot of AI coding assistant around, more and more "vibecoder" popping up, people who can ship a working app fast by just prompting AI, but often skip understanding why the code is structured a certain way.

AI can generate code fast, but it won't stop you from ending up with a messy architecture if you don't know what a good structure actually look like. So even if AI write most of the code now, understanding a basic principle like SOLID is still important to be able to review and guide the AI output properly.

In this article I will try to simply explain what I know so far, with example in Go for each principle. Few topics that will be discussed in this article such as :

  1. Single Responsibility Principle
  2. Open/Closed Principle
  3. Liskov Substitution Principle
  4. Interface Segregation Principle
  5. Dependency Inversion Principle

What is SOLID

Source: Eazymock.net

SOLID is a set of five design principle for writing code that is easier to change without breaking other part of the system. It usually applied in Object Oriented Programming but the idea also works fine in Go even though Go does not have classes.

The main problem SOLID trying to solve is this : code that works fine today but become very hard to touch later, because one small change in one place can break something else that you don't expect.

Single Responsibility Principle (SRP)

This principle states that a class or a function should only have one reason to change. In practice, this means one function or struct should only handle one job.

For example we have a User struct that also handle sending email, this is not ideal since when we need to change how we send email, we also need to touch the User struct.

// Before: User does two jobs
type User struct {
	Name  string
	Email string
}

func (u *User) SendWelcomeEmail() error {
	body := fmt.Sprintf("Hi %s, welcome!", u.Name)
	return smtp.SendMail(smtpAddr, auth, from, []string{u.Email}, []byte(body))
}

To fix this, we can separate the email logic into its own struct, so User only holds data.

// After: User just holds data, EmailService handles email
type User struct {
	Name  string
	Email string
}

type EmailService struct {
	smtpAddr string
	auth     smtp.Auth
}

func (s *EmailService) SendWelcome(u User) error {
	body := fmt.Sprintf("Hi %s, welcome!", u.Name)
	return smtp.SendMail(s.smtpAddr, s.auth, from, []string{u.Email}, []byte(body))
}

With this separation, changing the email logic will not affect the User struct at all.

Open/Closed Principle (OCP)

This principle states that a code should be open for extension, but closed for modification. In simple term, we should be able to add a new feature without changing the existing code that already works.

A common example is a payment processing function, when we need to add a new payment method we usually just add a new if statement to an existing function.

// Before: adding BankTransfer means editing this function again
func ProcessPayment(paymentType string, amount float64) error {
	if paymentType == "credit_card" {
		return chargeCard(amount)
	}
	// every new method = another branch here
	return errors.New("unsupported payment type")
}

This will keep growing every time we add a new payment method, and it also risk breaking the existing method since we are editing the same function. A better approach is to use interface, so a new payment method is just a new implementation.

// After: new methods plug in, nothing existing gets touched
type PaymentMethod interface {
	Process(amount float64) error
}

type CreditCard struct{}

func (c CreditCard) Process(amount float64) error {
	return chargeCard(amount)
}

type BankTransfer struct{}

func (b BankTransfer) Process(amount float64) error {
	return initiateBankTransfer(amount)
}

func Pay(m PaymentMethod, amount float64) error {
	return m.Process(amount)
}

Now, adding a new payment method (e.g QRIS, E-Wallet) will not require any change to Pay function or the existing CreditCard/BankTransfer implementation.

Liskov Substitution Principle (LSP)

This principle states that if two type implement the same interface, they should be able to replace each other without breaking the program. In other words, the behavior should be consistent for the same contract.

For example we have a Storage interface implemented by S3Storage and LocalStorage, but each implementation behave differently when the file already exist.

type Storage interface {
	Save(key string, data []byte) error
}

// Violates LSP: same interface, different behavior on conflict
type S3Storage struct{}

func (s S3Storage) Save(key string, data []byte) error {
	return s3Client.PutObject(key, data) // silently overwrites
}

type LocalStorage struct{}

func (l LocalStorage) Save(key string, data []byte) error {
	if _, err := os.Stat(key); err == nil {
		return errors.New("file already exists") // breaks the contract
	}
	return os.WriteFile(key, data, 0644)
}

Code that swap between S3Storage and LocalStorage (for example switching between production and local development) will behave unexpectedly because one silently overwrite and the other one return an error. To fix this we need to make the contract explicit, so both implementation follow the same rule.

// Fix: make both honor the same contract explicitly
type Storage interface {
	Save(key string, data []byte, overwrite bool) error
}

Interface Segregation Principle (ISP)

This principle states that a client should not be forced to depend on method it does not use. In practice, this means we should split a big interface into a smaller, more specific interface.

For example we have a Worker interface with three method, but not every implementation need all of it.

// Before: one bloated interface
type Worker interface {
	Code() error
	Deploy() error
	WriteDocs() error
}

// TechnicalWriter is forced to fake two methods it doesn't need
type TechnicalWriter struct{}

func (t TechnicalWriter) Code() error   { return nil } // unused stub
func (t TechnicalWriter) Deploy() error { return nil } // unused stub
func (t TechnicalWriter) WriteDocs() error {
	return generateDocs()
}

A TechnicalWriter type is forced to implement Code() and Deploy() even though it never use it. This can be avoided by splitting Worker into smaller interface.

// After: smaller interfaces, implement only what applies
type Coder interface {
	Code() error
}

type Deployer interface {
	Deploy() error
}

type DocWriter interface {
	WriteDocs() error
}

type TechnicalWriter struct{}

func (t TechnicalWriter) WriteDocs() error {
	return generateDocs()
}

Go actually make this principle easy to apply since interface in Go is implicit, we don't need to explicitly declare that a struct implement certain interface.

Dependency Inversion Principle (DIP)

This principle states that a high level module should not depend directly on a low level module, both should depend on an abstraction (interface).

A common case for this is a service that call database directly.

// Before: service is welded to Postgres
type OrderService struct {
	db *sql.DB
}

func (s *OrderService) GetOrder(id string) (*Order, error) {
	row := s.db.QueryRow("SELECT * FROM orders WHERE id = $1", id)
	// ...
	return nil, nil
}

The problem with this approach is that OrderService is now tightly coupled with Postgres, this will make testing harder since we need a real database connection just to test the service logic. Instead, we can define a Repository interface and let OrderService depend on it.

// After: service depends on an abstraction, not a concrete client
type OrderRepository interface {
	GetOrder(id string) (*Order, error)
}

type OrderService struct {
	repo OrderRepository
}

func (s *OrderService) GetOrder(id string) (*Order, error) {
	return s.repo.GetOrder(id)
}

// Real implementation for production
type PostgresOrderRepo struct{ db *sql.DB }

func (r *PostgresOrderRepo) GetOrder(id string) (*Order, error) {
	row := r.db.QueryRow("SELECT * FROM orders WHERE id = $1", id)
	// ...
	return nil, nil
}

// Fake implementation for tests, no database needed
type FakeOrderRepo struct{ orders map[string]*Order }

func (r *FakeOrderRepo) GetOrder(id string) (*Order, error) {
	return r.orders[id], nil
}

With this, we can easily swap PostgresOrderRepo with FakeOrderRepo when writing test, and the test can run fast without needing to spin up a real database.

Conclusion

That's it! From this article I hope you get a grasp of what SOLID principle is and how to apply it in Go. Do note that this is not a rule that need to be followed strictly on every single file, over applying SOLID (especially ISP and DIP) on a small project can make the codebase harder to navigate instead. Use it when it actually solve a problem you have, not just because the principle said so.

As usual, thanks for reading and have a great day.

Source

  1. Robert C. Martin, Clean Architecture
  2. https://en.wikipedia.org/wiki/SOLID