Software Design Principles in Go: Building Robust and Maintainable Code

Ashish Dhakal
readytowork, Inc.
Published in
6 min readAug 29, 2023

In the world of software development, creating code that is not only functional but also maintainable and scalable is crucial for long-term success. To achieve this, software engineers rely on a set of fundamental principles known as SOLID, DRY, YAGNI, and KISS. In this article, we’ll delve into these principles and explore how they can be applied using the Go programming language.

1. SOLID Principles

SOLID is an acronym that represents five design principles aimed at making software more understandable, flexible, and maintainable. Let’s break down each principle and provide a code example in Go:

Single Responsibility Principle (SRP): A class or module should have only one reason to change. This principle encourages breaking down complex functionalities into smaller, focused components.

package main

import "fmt"

type Product struct {
Name string
Price float64
Quantity int
}
type Order struct {
UserEmail string
Products []Product
}

func (o *Order) CalculateTotalPrice() float64 {
// Calculate total price logic
var total float64
for _, product := range o.Products {
total += product.Price * float64(product.Quantity)
}
return total

}

func (o *Order) CreateOrder() {
// Save Order to db
var db DB
var object createOrderDTO
obj.Order = o
obj.TotalAmout = o.CalculateTotalPrice()
db.Create(&obj)

}

func (o *Order) NotifyCustomer() {
// Sending email to UserEmail
SendMail(o.UserEmail, "Dear customer, your order has been placed successfully.")
}

func main()
product1 := Product{Name: "Shirt", Price: 29.99, Quantity: 2}
product2 := Product{Name: "Jeans", Price: 49.99, Quantity: 1}
order := Order{
UserEmail: "johndoe@mailinator.com",
Products: []Product{product1, product2}
}
order.CreateOrder()
order.NotifyCustomer()
}

To place a single order, it can have many logics to implement. Following single responsibility principle, we’re separating each logic separately. CalculateTotalPrice() has its responsibility to calculate the total price only, it does nothing more than that and CreateOrder() has the responsibility to save the order to the database while NotifyCustomer() notify customer by sending email.

Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification. This principle encourages using interfaces and abstract classes to allow easy addition of new features without altering existing code.

package main

import (
"fmt"
"math"
)

// Shape interface represents different shapes.
type Shape interface {
Area() float64
}

// Rectangle implements the Shape interface for rectangles.
type Rectangle struct {
Width float64
Height float64
}

func (r Rectangle) Area() float64 {
return r.Width * r.Height
}

// Circle implements the Shape interface for circles.
type Circle struct {
Radius float64
}

func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}



// CalculateTotalArea calculates the total area of a list of shapes.
func CalculateTotalArea(shapes []Shape) float64 {
totalArea := 0.0
for _, shape := range shapes {
totalArea += shape.Area()
}
return totalArea
}

func main() {
shapes := []Shape{
Rectangle{Width: 5, Height: 3},
Circle{Radius: 2},
}

totalArea := CalculateTotalArea(shapes)
fmt.Printf("Total Area: %.2f\n", totalArea)
}

Here, with the help of CalculateTotalArea() method we can calculate the total area of any shape that implements the Shape interface without changing CalculateTotalArea() method. This promotes maintainability and allows for easy addition of new shapes in the future.

Liskov Substitution Principle (LSP): Subtypes must be substitutable for their base types without affecting program correctness. This principle ensures that derived classes maintain the behavior expected from the base class.

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("Area: %.2f\n", s.Area())
}

func main() {
rectangle := &Rectangle{Width: 5, Height: 3}
circle := &Circle{Radius: 2}

PrintArea(rectangle)
PrintArea(circle)
}

Above code adheres to the Liskov Substitution Principle by allowing objects of different subclasses (Rectangle and Circle) to be used interchangeably through their shared superclass interface (Shape). This promotes code reusability and modularity, making it easy to extend program with new shapes in the future without breaking existing functionality.

Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. This principle emphasizes creating smaller, specific interfaces rather than a single large one.

package main

import "fmt"

type Printer interface {
Print()
}

type Scanner interface {
Scan()
}

type MultiFunctionDevice interface {
Printer
Scanner
}

type SimplePrinter struct{}

func (p *SimplePrinter) Print() {
fmt.Println("Printing...")
}

type SimpleScanner struct{}

func (s *SimpleScanner) Scan() {
fmt.Println("Scanning...")
}

func main() {
printer := &SimplePrinter{}
scanner := &SimpleScanner{}

printer.Print()
scanner.Scan()
}

It demonstrates the Interface Segregation Principle by defining focused interfaces (Printer and Scanner) and allowing clients to depend only on the specific behaviors they require. The MultifunctionDevice interface combines the behaviors only where needed, avoiding unnecessary dependencies for clients that don't need both printing and scanning. This promotes clean, modular, and easily maintainable code.

Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions. This principle encourages the use of interfaces and the inversion of control containers.

package main

import "fmt"

type MessageSender interface {
SendMessage(message string)
}

type EmailSender struct{}

func (e *EmailSender) SendMessage(message string) {
fmt.Println("Sending email:", message)
}

type SMSsender struct{}

func (s *SMSsender) SendMessage(message string) {
fmt.Println("Sending SMS:", message)
}

type NotificationService struct {
sender MessageSender
}

func (n *NotificationService) SendNotification(message string) {
n.sender.SendMessage(message)
}

func main() {
emailSender := &EmailSender{}
smsSender := &SMSsender{}

emailNotification := &NotificationService{sender: emailSender}
smsNotification := &NotificationService{sender: smsSender}

emailNotification.SendNotification("Hello, this is an email notification!")
smsNotification.SendNotification("Hello, this is an SMS notification!")
}

Here, The MessageSender interface serves as an abstraction that defines the contract for sending messages. It declares the SendMessage() method. Both EmailSender and SMSsender implements this interface, providing concrete implementations for sending emails and SMS messages. The NotificationService is responsible for sending notifications.

Instead of depending directly on concrete implementations of message senders (such as EmailSender or SMSsender), the NotificationService depends on the abstract MessageSender interface. This adheres to the DIP as it depends on an abstraction rather than a concrete implementation.

2.DRY (Do not Repeat Yourself)

The DRY principle suggests that duplication in code should be minimized. By reusing code through functions, classes, or libraries, we can avoid inconsistencies and maintenance headaches.

package main

import "fmt"

func ItemExistsInList(item string, itemList []string) bool {
for _, i := range itemList {
if i == item {
return true
}
}
return false
}

func main() {
var myFruit string = "apple"
var fruits string = []string{"mango", "apple", "banana"}
if exists := ItemExistsInList(myFruit, fruits); exists {
fmt.Printf("%s exists in fruits list", myFruit)
} else {
fmt.Printf("%s does not exists in fruits list", myFruit)
}
}

Above code adheres to the DRY principle by encapsulating the common area calculation logic in a reusable function ItemExistsInList(). This function is used to determine whether a string exists in a slice of strings or not, which helps eliminate duplication of logic for checking existance of item in a slice.

3. YAGNI (You Ain’t Gonna Need It)

The YAGNI principle advises to avoid adding features or functionalities unless they are necessary. Unnecessary additions can lead to increased complexity and potential maintenance issues.

package main

import "fmt"

type UserManager struct {
// User management logic
}

func (u *UserManager) CreateUser(username, password string) {
// Create user logic
}

// No need to add more methods unless required

func main() {
userManager := &UserManager{}
userManager.CreateUser("john_doe", "secure_password")
}

Above code adheres to the YAGNI principle by implementing required method CreateUser() in the UserManager. It avoids adding unnecessary methods or complexity that are not required by the current specifications. We can add other methods for it unless it is required to make it simpler and cleaner.

4. KISS (Keep It Simple, Stupid)

KISS encourages to keep things simple and avoid unnecessary complexities. Simple solutions are easier to understand, maintain, and debug.

package main

import "fmt"

// CalculateAverage calculates the average of a slice of numbers.
func CalculateAverage(numbers []int) float64 {
sum := 0
for _, number := range numbers {
sum += number
}
return float64(sum) / float64(len(numbers))
}

func main() {
numberList := []int{15, 23, 8, 12, 30}

average := CalculateAverage(numberList)

fmt.Printf("Average: %.2f\n", average)
}

Above code snippet has a clear and singular purpose: to calculate the average of a given list of numbers. The CalculateAverage() function employs a straightforward loop to iterate through the list of numbers and calculate their sum and divide it by total length of numbers. Also it uses descriptive variable names (numbers, number, sum, average) that makes the logic self-explanatory.

Conclusion

Applying SOLID, DRY, YAGNI, and KISS principles in your codebase can greatly enhance its quality, maintainability, and scalability. These principles guide developers toward writing cleaner, more efficient, and robust code that stands the test of time. By keeping your code simple, avoiding unnecessary additions

--

--