Understanding SOLID Principles with Golang
SOLID is an acronym for five principles of object-oriented design that can help developers create more flexible, understandable, and maintainable software. These principles were introduced by Robert C. Martin, also known as Uncle Bob. They are:
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
In this blog, we'll explore each principle with simple examples in Go (Golang).
Single Responsibility Principle (SRP)
The Single Responsibility Principle states that a class (or in Go, a struct) should have only one reason to change, meaning it should only have one job or responsibility.
Example in Go
Consider a simple application that processes orders. Initially, we might be tempted to create a single struct that handles everything:
package main
import "fmt"
type Order struct {
ID int
Items []string
}
func (o *Order) CalculateTotal() float64 {
total := 0.0
for _, item := range o.Items {
total += 10.0 // Simplified: Assume each item costs $10
}
return total
}
func (o *Order) PrintOrder() {
fmt.Printf("Order #%d: %v\n", o.ID, o.Items)
}
func main() {
order := &Order{ID: 1, Items: []string{"item1", "item2"}}
total := order.CalculateTotal()
order.PrintOrder()
fmt.Printf("Total: $%.2f\n", total)
}
Here, the Order
struct has multiple responsibilities: calculating the total amount and printing the order. To adhere to SRP, we should separate these concerns:
package main
import "fmt"
type Order struct {
ID int
Items []string
}
type OrderCalculator struct{}
func (oc *OrderCalculator) CalculateTotal(o *Order) float64 {
total := 0.0
for _, item := range o.Items {
total += 10.0 // Simplified: Assume each item costs $10
}
return total
}
type OrderPrinter struct{}
func (op *OrderPrinter) PrintOrder(o *Order) {
fmt.Printf("Order #%d: %v\n", o.ID, o.Items)
}
func main() {
order := &Order{ID: 1, Items: []string{"item1", "item2"}}
calculator := &OrderCalculator{}
total := calculator.CalculateTotal(order)
printer := &OrderPrinter{}
printer.PrintOrder(order)
fmt.Printf("Total: $%.2f\n", total)
}
Each struct has a single responsibility, making the code easier to maintain and extend.
Open/Closed Principle (OCP)
The Open/Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. This means you should be able to add new functionality without changing existing code.
Example
Let's extend the order processing example. Suppose we want to add a discount feature. Following OCP, we should add new functionality without modifying existing code.
First, define an interface for discount strategies:
package main
type DiscountStrategy interface {
ApplyDiscount(total float64) float64
}
Then, create concrete implementations for different discount strategies:
package main
type NoDiscount struct{}
func (d *NoDiscount) ApplyDiscount(total float64) float64 {
return total
}
type PercentageDiscount struct {
Percentage float64
}
func (d *PercentageDiscount) ApplyDiscount(total float64) float64 {
return total * (1 - d.Percentage/100)
}
Modify the OrderCalculator
to use a discount strategy:
package main
import "fmt"
type Order struct {
ID int
Items []string
}
type DiscountStrategy interface {
ApplyDiscount(total float64) float64
}
type NoDiscount struct{}
func (d *NoDiscount) ApplyDiscount(total float64) float64 {
return total
}
type PercentageDiscount struct {
Percentage float64
}
func (d *PercentageDiscount) ApplyDiscount(total float64) float64 {
return total * (1 - d.Percentage/100)
}
type OrderCalculator struct {
Discount DiscountStrategy
}
func (oc *OrderCalculator) CalculateTotal(o *Order) float64 {
total := 0.0
for _, item := range o.Items {
total += 10.0 // Simplified: Assume each item costs $10
}
return oc.Discount.ApplyDiscount(total)
}
type OrderPrinter struct{}
func (op *OrderPrinter) PrintOrder(o *Order) {
fmt.Printf("Order #%d: %v\n", o.ID, o.Items)
}
func main() {
order := &Order{ID: 1, Items: []string{"item1", "item2"}}
calculator := &OrderCalculator{Discount: &PercentageDiscount{Percentage: 10}}
total := calculator.CalculateTotal(order)
printer := &OrderPrinter{}
printer.PrintOrder(order)
fmt.Printf("Total after discount: $%.2f\n", total)
}
Now, we can easily add new discount strategies without modifying the OrderCalculator
class.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In Go, this translates to ensuring that implementations of an interface can be used interchangeably.
Example
Consider a shape interface:
package main
import "fmt"
type Shape interface {
Area() float64
}
type Rectangle struct {
Width float64
Height float64
}
func (r *Rectangle) Area() float64 {
return r.Width * r.Height
}
type Circle struct {
Radius float64
}
func (c *Circle) Area() float64 {
return 3.14 * c.Radius * c.Radius
}
func PrintArea(s Shape) {
fmt.Printf("The area is %.2f\n", s.Area())
}
func main() {
r := &Rectangle{Width: 10, Height: 5}
c := &Circle{Radius: 7}
PrintArea(r)
PrintArea(c)
}
Both Rectangle
and Circle
implement the Shape
interface, and we can substitute one for the other without any issues, adhering to LSP.
Interface Segregation Principle (ISP)
The Interface Segregation Principle states that a client should not be forced to depend on interfaces it does not use. In Go, this means creating small, specific interfaces rather than a large, general-purpose one.
Example
Suppose we have a printer and scanner interface. Instead of having a single interface for both functionalities, we can split them:
package main
import "fmt"
type Printer interface {
Print(document string)
}
type Scanner interface {
Scan() string
}
type SimplePrinter struct{}
func (p *SimplePrinter) Print(document string) {
fmt.Println("Printing document:", document)
}
type SimpleScanner struct{}
func (s *SimpleScanner) Scan() string {
return "Scanned document content"
}
func main() {
printer := &SimplePrinter{}
scanner := &SimpleScanner{}
printer.Print("My Document")
fmt.Println(scanner.Scan())
}
This way, clients can depend only on the interfaces they need, following the ISP.
Dependency Inversion Principle (DIP)
The Dependency Inversion Principle states that high-level modules should not depend on low-level modules. Both should depend on abstractions. Additionally, abstractions should not depend on details. Details should depend on abstractions.
Example
Let's modify our order processing example to use dependency inversion. We'll create an interface for storage:
package main
import "fmt"
type Order struct {
ID int
Items []string
}
type OrderStorage interface {
Save(order *Order)
}
type DatabaseStorage struct{}
func (ds *DatabaseStorage) Save(order *Order) {
fmt.Printf("Saving order #%d to the database\n", order.ID)
}
type OrderProcessor struct {
Storage OrderStorage
}
func (op *OrderProcessor) Process(order *Order) {
op.Storage.Save(order)
}
func main() {
order := &Order{ID: 1, Items: []string{"item1", "item2"}}
storage := &DatabaseStorage{}
processor := &OrderProcessor{Storage: storage}
processor.Process(order)
}
Depending on the OrderStorage
interface, OrderProcessor
doesn't need to know the details of how orders are saved. This makes it easy to swap out different storage mechanisms if needed.
Conclusion
The SOLID principles are fundamental guidelines for designing robust, maintainable software. By following these principles in Go (Golang), you can create easier code to understand, extend, and maintain. We hope this blog has provided a clear and practical introduction to SOLID principles with examples in Go. Happy coding!