Let’s try the SOLID Principle in Golang

Ari Nurcahya
11 min readAug 22, 2023
solid principle

Writing code can be challenging, especially dealing with the principle of the code. cause there are a lot of principles that we should use. One of the principles I want to share is the SOLID principle.

What is the SOLID principle?

The SOLID principle stands for:

S: Single Responsibility Principle

O: Open/ Closed Principle

L: Liskov Substitution Principle

I: Interface Segregation Principle

D: Dependency Inversion Principle

By implementing the SOLID Principle on your code, you can achieve easy to extends and easy to maintain

Single Responsibility

“A module should be responsible to one, and only one, actor”

The Single Responsibility Principle relies on the responsibility of the class. every class has its responsibility. and one reason to change

Responsibility means if you have a class, but its function is not related to the class itself you have to separate it based on its responsibility. in the other hand, the class should have a single purpose of its job.

Example

we have a file manager

package main

type FileManager interface {
AddHeader()
AddFooter()
GeneratePDF()
GenerateTXT()
GenerateCSV()
ReadPDF()
ReadTXT()
ReadCSV()
SavePDF()
SaveTXT()
SaveCSV()
}

type FileManage struct {
Name string
Path string
}

func NewFileManage(name, path string) FileManager {
return &FileManage{
Name: name,
Path: path,
}
}

func (f *FileManage) AddHeader()
func (f *FileManage) AddFooter()
func (f *FileManage) GeneratePDF()
func (f *FileManage) GenerateTXT()
func (f *FileManage) GenerateCSV()
func (f *FileManage) ReadPDF()
func (f *FileManage) ReadTXT()
func (f *FileManage) ReadCSV()
func (f *FileManage) SavePDF()
func (f *FileManage) SaveTXT()
func (f *FileManage) SaveCSV()

func main() {
fileManage := NewFileManage("", "")
fileManage.GenerateCSV()
fileManage.SaveCSV()
}

the code above isn’t obey the Single Responsibility Principle. because the file manager has a lot of responsibility, in software development having a single class with a bunch of responsibilities is a nightmare. it’s difficult to maintain and add new features without breaking existing features. let’s update it and implement a solid principle

here is the updated and abides by the Single Responsibility Principle

package main

type PDFManager interface {
AddHeader()
AddFooter()
GeneratePDF() []byte
ReadPDF(data []byte)
}

type PDF struct {
Name string
}

func NewPDF(name string) PDFManager {
return &PDF{
Name: name,
}
}

func (f *PDF) AddHeader()
func (f *PDF) AddFooter()
func (f *PDF) GeneratePDF() []byte
func (f *PDF) ReadPDF(data []byte)

type TXTManager interface {
AddHeader()
AddFooter()
GenerateTXT() []byte
ReadTXT(data []byte)
}

type TXT struct {
Name string
}

func NewTXT(name string) TXTManager {
return &TXT{
Name: name,
}
}

func (f *TXT) AddHeader()
func (f *TXT) AddFooter()
func (f *TXT) GenerateTXT() []byte
func (f *TXT) ReadTXT(data []byte)

type CSVManager interface {
AddHeader()
AddFooter()
GenerateCSV() []byte
ReadCSV(data []byte)
}

type CSV struct {
Name string
}

func NewCSV(name string) CSVManager {
return &CSV{
Name: name,
}
}

func (f *CSV) AddHeader()
func (f *CSV) AddFooter()
func (f *CSV) GenerateCSV() []byte
func (f *CSV) ReadCSV(data []byte)

type StorageManager interface {
Save(data []byte)
Read() []byte
}

type StorageManage struct {
Path string
}

func NewStorage(path string) StorageManager {
return &StorageManage{
Path: path,
}
}

func (f *StorageManage) Save(data []byte)
func (f *StorageManage) Read() []byte

func main() {
storage := NewStorage("")
data := storage.Read()

pdf := NewPDF("")
pdf.ReadPDF(data)
pdfData := pdf.GeneratePDF()

storage.Save(pdfData)

}

the code above compliance with SRP because every class has its job

Open-Closed Principle

The Open-Closed Principle states that software entities (such as classes, modules, functions, etc) should be open for extension but closed for modification

“A software artifact should be open for extension but closed for modification”

from this statement, we know that we can add a new feature or specification to our system, but when we modify our system we don’t have to change existing specification.

if you add a new specification but your existing features are broken meaning that you don’t follow the Open-Closed Principle

let’s take a look at the code below

package main

type PDFManager interface {
AddHeader()
AddFooter()
GeneratePDF() []byte
ReadPDF(data []byte)
}

type PDF struct {
Name string
}

func NewPDF(name string) PDFManager {
return &PDF{
Name: name,
}
}

func (f *PDF) AddHeader()
func (f *PDF) AddFooter()
func (f *PDF) GeneratePDF() []byte
func (f *PDF) ReadPDF(data []byte)

type TXTManager interface {
AddHeader()
AddFooter()
GenerateTXT() []byte
ReadTXT(data []byte)
}

type TXT struct {
Name string
}

func NewTXT(name string) TXTManager {
return &TXT{
Name: name,
}
}

func (f *TXT) AddHeader()
func (f *TXT) AddFooter()
func (f *TXT) GenerateTXT() []byte
func (f *TXT) ReadTXT(data []byte)

type CSVManager interface {
AddHeader()
AddFooter()
GenerateCSV() []byte
ReadCSV(data []byte)
}

type CSV struct {
Name string
}

func NewCSV(name string) CSVManager {
return &CSV{
Name: name,
}
}

func (f *CSV) AddHeader()
func (f *CSV) AddFooter()
func (f *CSV) GenerateCSV() []byte
func (f *CSV) ReadCSV(data []byte)

type StorageManager interface {
Save(data []byte)
Read() []byte
}

type StorageManage struct {
Path string
}

func NewStorage(path string) StorageManager {
return &StorageManage{
Path: path,
}
}

func (f *StorageManage) Save(data []byte)
func (f *StorageManage) Read() []byte

func main() {
storage := NewStorage("")
data := storage.Read()

pdf := NewPDF("")
pdf.ReadPDF(data)
pdfData := pdf.GeneratePDF()

storage.Save(pdfData)

}

as you can see the PDF, TXT, and CSV are tight on the same interface’s function. we can use the same interface with the different implementations

package main

type FileManipulator interface {
AddHeader()
AddFooter()
Generate() []byte
AttachTemplate(data []byte)
AttachProps(props ...any)
}

type PDF struct {
Name string
}

func NewPDF(name string) FileManipulator {
return &PDF{
Name: name,
}
}

func (f *PDF) AddHeader()
func (f *PDF) AddFooter()
func (f *PDF) Generate() []byte
func (f *PDF) AttachTemplate(data []byte)
func (f *PDF) AttachProps(props ...any)

type TXT struct {
Name string
}

func NewTXT(name string) FileManipulator {
return &TXT{
Name: name,
}
}

func (f *TXT) AddHeader()
func (f *TXT) AddFooter()
func (f *TXT) Generate() []byte
func (f *TXT) AttachTemplate(data []byte)
func (f *TXT) AttachProps(props ...any)

type CSV struct {
Name string
}

func NewCSV(name string) FileManipulator {
return &CSV{
Name: name,
}
}

func (f *CSV) AddHeader()
func (f *CSV) AddFooter()
func (f *CSV) Generate() []byte
func (f *CSV) AttachTemplate(data []byte)
func (f *CSV) AttachProps(props ...any)

type StorageManager interface {
Save(data []byte)
Read() []byte
}

type StorageManage struct {
Path string
}

func NewStorage(path string) StorageManager {
return &StorageManage{
Path: path,
}
}

func (f *StorageManage) Save(data []byte)
func (f *StorageManage) Read() []byte

func main() {
storage := NewStorage("")
data := storage.Read()

pdf := NewPDF("")
pdf.AttachTemplate(data)
pdfData := pdf.Generate()

storage.Save(pdfData)

}

and we can add a new implementation like the example is a spreadsheet

package main

type FileManipulator interface {
AddHeader()
AddFooter()
Generate() []byte
AttachTemplate(data []byte)
AttachProps(props ...any)
}

type SpreadSheet struct {
Name string
}

func NewSpreadSheet(name string) FileManipulator {
return &CSV{
Name: name,
}
}

func (f *SpreadSheet) AddHeader()
func (f *SpreadSheet) AddFooter()
func (f *SpreadSheet) Generate() []byte
func (f *SpreadSheet) AttachTemplate(data []byte)
func (f *SpreadSheet) AttachProps(props ...any)

type StorageManager interface {
Save(data []byte)
Read() []byte
}

type StorageManage struct {
Path string
}

func NewStorage(path string) StorageManager {
return &StorageManage{
Path: path,
}
}

func (f *StorageManage) Save(data []byte)
func (f *StorageManage) Read() []byte

func main() {
storage := NewStorage("")
data := storage.Read()

pdf := NewPDF("")
pdf.AttachTemplate(data)
pdfData := pdf.Generate()

storage.Save(pdfData)

}

No matter if we add another feature, our existing code will not break. because, when we do something, we touch the interface A.K.A contract. not the actual class

Liskov Substitution

The Liskov Substitution Principle states that an object from a derived class must be substitutable from its parent class. it means the derived class must be implemented the characteristics of its parent, and the things that can be handled by the parent class should be handled by the derived class too. if the parent class can calculate the length of the rectangle means the child class must be able to calculate the length of the rectangle too. In the real world, we can see that Tiger, Tiger can lurk with their prey, and they eat meals. so the child’s Tiger can eat meals and lurk with its prey too. So the Liskov Substitution Principle talks about the delegation of responsibility.

we can see our code below

package main

type FileManipulator interface {
AddHeader()
AddFooter()
Generate() []byte
AttachTemplate(data []byte)
AttachProps(props ...any)
}

type PDF struct {
Name string
}

func NewPDF(name string) FileManipulator {
return &PDF{
Name: name,
}
}

func (f *PDF) AddHeader()
func (f *PDF) AddFooter()
func (f *PDF) Generate() []byte
func (f *PDF) AttachTemplate(data []byte)
func (f *PDF) AttachProps(props ...any)

type TXT struct {
Name string
}

func NewTXT(name string) FileManipulator {
return &TXT{
Name: name,
}
}

func (f *TXT) AddHeader()
func (f *TXT) AddFooter()
func (f *TXT) Generate() []byte
func (f *TXT) AttachTemplate(data []byte)
func (f *TXT) AttachProps(props ...any)

type CSV struct {
Name string
}

func NewCSV(name string) FileManipulator {
return &CSV{
Name: name,
}
}

func (f *CSV) AddHeader()
func (f *CSV) AddFooter()
func (f *CSV) Generate() []byte
func (f *CSV) AttachTemplate(data []byte)
func (f *CSV) AttachProps(props ...any)

type SpreadSheet struct {
Name string
}

func NewSpreadSheet(name string) FileManipulator {
return &SpreadSheet{
Name: name,
}
}

func (f *SpreadSheet) AddHeader()
func (f *SpreadSheet) AddFooter()
func (f *SpreadSheet) Generate() []byte
func (f *SpreadSheet) AttachTemplate(data []byte)
func (f *SpreadSheet) AttachProps(props ...any)

type StorageManager interface {
Save(data []byte)
Read() []byte
}

type StorageManage struct {
Path string
}

func NewStorage(path string) StorageManager {
return &StorageManage{
Path: path,
}
}

func (f *StorageManage) Save(data []byte)
func (f *StorageManage) Read() []byte

type Generate struct {
File FileManipulator
Name string
}

func (g *Generate) Generate(template []byte) []byte {
g.File.AddFooter()
g.File.AddHeader()
g.File.AttachTemplate(template)
g.File.AttachProps(nil)
return g.File.Generate()
}

func main() {
storage := NewStorage("")
template := storage.Read()

pdf := NewPDF("")
txt := NewTXT("")
csv := NewCSV("")

generates := []Generate{
{
Name: "",
File: pdf,
},
{
Name: "",
File: txt,
},
{
Name: "",
File: csv,
},
}

files := make([][]byte, 0)
for _, g := range generates {
files = append(files, g.Generate(template))
}

for _, file := range files {
storage.Save(file)
}
}

I made a new struct called generate. it has one function to generate the file whether it’s a PDF, CSV, or TXT. All of the files have the same responsibility and traits which’s generate a file. hence, no matter what is implementation you took as the parameters the result is still the same, the function can be called

Interface Segregation

“Clients should not be forced to depend upon interfaces that they do not use.
(Robert Cecil Martin)

Interface Segregation told us about the implementation of the interface, every object that implements the interface must implement all of its functions. In another world, the class should not be forced to the unused interface. take a look, we have a product interface

type Product interface {
GetName() string
GetExpiredDate() time.Time
}

and we have 2 implementations

type VegetableProduct struct{
}

func (f *VegetableProduct) GetName() string

func (f *VegetableProduct) GetExpiredDate() time.Time

type ElectronicDeviceProduct struct{
}

func (f *ElectronicDeviceProduct) GetName() string

func (f *ElectronicDeviceProduct) GetExpiredDate() time.Time

what do you think, Have the electronic device an expiration time? I think not, we still can use our old-school electronic devices even if it has 2000 years old and we won’t die if they are being used. So we can implement the interface like this

type Product interface {
GetName() string
}

type FoodProduct interface{
Product
GetExpiredDate() time.Time
}

type VegetableProduct struct{
}

func (f *VegetableProduct) GetName() string

func (f *VegetableProduct) GetExpiredDate() time.Time

type ElectronicDeviceProduct struct{
}

func (f *ElectronicDeviceProduct) GetName() string

Okay, back to our project, The FileManipulation’s interface has AddHeader and AddFooter functions. but do the CSV and SpreadSheet need to handle the header and footer? No, I don’t think so. because the header and footer suit text and pdf instead of CSV and SpreadSheet file

Because the header and footer are tight to PDF and TXT only, so I make a new interface that would be implemented on the PDF and TXT generator. in the other hand the CSV and SpreadSheet files don’t know about AddHeader and AddFooter

package main

type FileManipulator interface {
Generate() []byte
AttachTemplate(data []byte)
AttachProps(props ...any)
}

type DocumentManipulator interface {
AddHeader()
AddFooter()
FileManipulator
}

type PDF struct {
Name string
}

func NewPDF(name string) DocumentManipulator {
return &PDF{
Name: name,
}
}

func (f *PDF) AddHeader()
func (f *PDF) AddFooter()
func (f *PDF) Generate() []byte
func (f *PDF) AttachTemplate(data []byte)
func (f *PDF) AttachProps(props ...any)

type TXT struct {
Name string
}

func NewTXT(name string) DocumentManipulator {
return &TXT{
Name: name,
}
}

func (f *TXT) AddHeader()
func (f *TXT) AddFooter()
func (f *TXT) Generate() []byte
func (f *TXT) AttachTemplate(data []byte)
func (f *TXT) AttachProps(props ...any)

type CSV struct {
Name string
}

func NewCSV(name string) FileManipulator {
return &CSV{
Name: name,
}
}

func (f *CSV) Generate() []byte
func (f *CSV) AttachTemplate(data []byte)
func (f *CSV) AttachProps(props ...any)

type SpreadSheet struct {
Name string
}

func NewSpreadSheet(name string) FileManipulator {
return &SpreadSheet{
Name: name,
}
}

func (f *SpreadSheet) Generate() []byte
func (f *SpreadSheet) AttachTemplate(data []byte)
func (f *SpreadSheet) AttachProps(props ...any)

type StorageManager interface {
Save(data []byte)
Read() []byte
}

type StorageManage struct {
Path string
}

func NewStorage(path string) StorageManager {
return &StorageManage{
Path: path,
}
}

func (f *StorageManage) Save(data []byte)
func (f *StorageManage) Read() []byte

type GenerateDocument struct {
File DocumentManipulator
Name string
}

func (g *GenerateDocument) Generate(template []byte) []byte {
g.File.AddHeader()
g.File.AddFooter()
g.File.AttachTemplate(template)
g.File.AttachProps(nil)
return g.File.Generate()
}

type Generate struct {
File FileManipulator
Name string
}

func (g *Generate) Generate(template []byte) []byte {
g.File.AttachTemplate(template)
g.File.AttachProps(nil)
return g.File.Generate()
}

func main() {
storage := NewStorage("")
template := storage.Read()

pdf := NewPDF("")
txt := NewTXT("")
csv := NewCSV("")

generates := []Generate{
{
Name: "",
File: csv,
},
}

generateDocuments := []GenerateDocument{
{
Name: "",
File: pdf,
},
{
Name: "",
File: txt,
},
}

files := make([][]byte, 0)
for _, g := range generates {
files = append(files, g.Generate(template))
}

for _, g := range generateDocuments {
files = append(files, g.Generate(template))
}

for _, file := range files {
storage.Save(file)
}
}

Dependency Inversion

Dependency Inversion Principles state:

High-level modules should not import anything from low-level modules. Both should depend on abstractions (e.g., interfaces).

it means when we create a module and our module needs another module, we don’t have to call it’s concrete object rather we can implement the abstract object (interface).

package main

type FileManipulator interface {
Generate() []byte
AttachTemplate(data []byte)
AttachProps(props ...any)
}

type DocumentManipulator interface {
AddHeader()
AddFooter()
FileManipulator
}

type PDF struct {
Name string
}

func NewPDF(name string) DocumentManipulator {
return &PDF{
Name: name,
}
}

func (f *PDF) AddHeader()
func (f *PDF) AddFooter()
func (f *PDF) Generate() []byte
func (f *PDF) AttachTemplate(data []byte)
func (f *PDF) AttachProps(props ...any)

type TXT struct {
Name string
}

func NewTXT(name string) DocumentManipulator {
return &TXT{
Name: name,
}
}

func (f *TXT) AddHeader()
func (f *TXT) AddFooter()
func (f *TXT) Generate() []byte
func (f *TXT) AttachTemplate(data []byte)
func (f *TXT) AttachProps(props ...any)

type CSV struct {
Name string
}

func NewCSV(name string) FileManipulator {
return &CSV{
Name: name,
}
}

func (f *CSV) Generate() []byte
func (f *CSV) AttachTemplate(data []byte)
func (f *CSV) AttachProps(props ...any)

type SpreadSheet struct {
Name string
}

func NewSpreadSheet(name string) FileManipulator {
return &SpreadSheet{
Name: name,
}
}

func (f *SpreadSheet) Generate() []byte
func (f *SpreadSheet) AttachTemplate(data []byte)
func (f *SpreadSheet) AttachProps(props ...any)

type StorageManager interface {
Save(data []byte)
Read() []byte
}

type StorageManage struct {
Path string
}

func NewStorage(path string) StorageManager {
return &StorageManage{
Path: path,
}
}

func (f *StorageManage) Save(data []byte)
func (f *StorageManage) Read() []byte

type Servicer interface {
Generate(template []byte) []byte
}

type Service struct {
file FileManipulator
}

func NewService(file FileManipulator) Servicer {
return &Service{
file: file,
}
}

func (s *Service) Generate(template []byte) []byte {
s.file.AttachTemplate(template)
s.file.AttachProps(nil)
return s.file.Generate()
}

func main() {
storage := NewStorage("")
template := storage.Read()

csv := NewCSV("")

svc := NewService(csv)
svc.Generate(template)

// you can inject another implementation to the service
// spreadsheet := NewSpreadSheet("")

// svc := NewService(spreadsheet)
// svc.Generate(template)

}

Here I made a service and it needs FileManipulator to generate a file. but rather we create the module on the service’s constructor. we inject it. if we want to change to another file we just need to change the implementation and our code won’t be broken.

Thank you for reading my article about the SOLID principle, this article is how I presented my understanding of the SOLID principle. Hopefully, this article can help you 😁

--

--