go

SOLID and Functional Programming Principles in Go

Posted by Vikash Patel on Sunday, Sep 22, 2024 Reading time: 5 Min

Let’s go through the SOLID principles and functional programming principles that can apply to your Go codebase. I’ll provide simple Go examples along with brief explanations to show how each principle can improve code quality.


SOLID Principles

  1. Single Responsibility Principle (SRP)

    • Description: A class or function should have only one reason to change, meaning it should only have one job or responsibility.

    Example:

    // Good: Separate responsibilities into two functions
    type EmailSender struct{}
    
    
    func (e *EmailSender) SendEmail(to, subject, body string) {
        // logic for sending email
    }
    
    
    func formatEmailBody(template string, data map[string]string) string {
        // logic for formatting the email body
        return ""
    }
    
    
    // Avoid: Single function doing multiple jobs
    func sendFormattedEmail(to, subject, template string, data map[string]string) {
        body := formatEmailBody(template, data)
        // email sending logic mixed with formatting
    }
    

  1. Open/Closed Principle (OCP)

    • Description: Software entities (classes, modules, functions) should be open for extension but closed for modification.

    Example:

    // Good: Extend functionality via interfaces
    type PaymentProcessor interface {
        ProcessPayment(amount float64) error
    }
    
    
    type PayPalProcessor struct{}
    
    
    func (p PayPalProcessor) ProcessPayment(amount float64) error {
        // PayPal payment processing logic
        return nil
    }
    
    
    type CreditCardProcessor struct{}
    
    
    func (c CreditCardProcessor) ProcessPayment(amount float64) error {
        // Credit card processing logic
        return nil
    }
    
    
    // Avoid: Modifying existing functions to add new payment methods
    func processPayment(method string, amount float64) error {
        if method == "paypal" {
            // PayPal logic
        } else if method == "creditcard" {
            // Credit card logic
        }
        return nil
    }
    

  1. Liskov Substitution Principle (LSP)

    • Description: Objects should be replaceable by their subtypes without altering the correctness of the program.

    Example:

    // Good: Subtypes maintain expected behavior
    type Animal interface {
        Speak() string
    }
    
    
    type Dog struct{}
    
    
    func (d Dog) Speak() string {
        return "Woof!"
    }
    
    
    type Cat struct{}
    
    
    func (c Cat) Speak() string {
        return "Meow!"
    }
    
    
    func makeAnimalSpeak(a Animal) {
        fmt.Println(a.Speak())
    }
    
    
    // Avoid: Subtypes behaving unpredictably
    type SilentDog struct{}
    
    
    func (s SilentDog) Speak() string {
        return "" // Violates expectations of the Speak method
    }
    

  1. Interface Segregation Principle (ISP)

    • Description: No client should be forced to depend on methods it doesn’t use. Create small, specific interfaces instead of one large interface.

    Example:

    // Good: Segregated interfaces
    type Reader interface {
        Read() string
    }
    
    
    type Writer interface {
        Write(data string) error
    }
    
    
    // Avoid: Fat interface forcing implementations to use unused methods
    type ReadWriteCloser interface {
        Read() string
        Write(data string) error
        Close() error
    }
    
    
    // If a class only needs reading, it should not be forced to implement Write and Close.
    

  1. Dependency Inversion Principle (DIP)

    • Description: High-level modules should not depend on low-level modules. Both should depend on abstractions (interfaces).

    Example:

    // Good: High-level module depends on abstraction
    type NotificationService struct {
        sender EmailSender
    }
    
    
    func NewNotificationService(sender EmailSender) *NotificationService {
        return &NotificationService{sender: sender}
    }
    
    
    func (n *NotificationService) Notify(message string) {
        n.sender.SendEmail("[email protected]", "Notification", message)
    }
    
    
    // Avoid: High-level module directly depending on a low-level module
    func sendNotification() {
        sender := &EmailSender{} // tight coupling with EmailSender
        sender.SendEmail("[email protected]", "Notification", "Hello!")
    }
    

Functional Programming Principles

  1. First-Class and Higher-Order Functions

    • Description: Functions are treated as first-class citizens and can be passed around as arguments, returned from other functions, or assigned to variables.

    Example:

    // Higher-order function that accepts another function as a parameter
    func applyDiscount(price float64, discountFunc func(float64) float64) float64 {
        return discountFunc(price)
    }
    
    
    func tenPercentDiscount(price float64) float64 {
        return price * 0.9
    }
    
    
    func main() {
        price := 100.0
        discountedPrice := applyDiscount(price, tenPercentDiscount)
        fmt.Println(discountedPrice) // 90.0
    }
    

  1. Pure Functions

    • Description: A pure function’s output is determined only by its input values, without observable side effects.

    Example:

    // Good: Pure function (no side effects)
    func add(a, b int) int {
        return a + b
    }
    
    
    // Avoid: Impure function (with side effects)
    var counter int
    
    
    func addWithSideEffect(a, b int) int {
        counter++ // changes external state
        return a + b
    }
    

  1. Immutability

    • Description: Data should not be modified after it is created. Instead, create a new copy of the data when changes are needed.

    Example:

    // Good: Immutability by creating a new slice
    func addToSlice(slice []int, value int) []int {
        newSlice := append(slice, value)
        return newSlice
    }
    
    
    // Avoid: Modifying the original slice
    func modifySlice(slice []int, value int) {
        slice = append(slice, value)
    }
    

  1. Recursion Over Loops

    • Description: Functional programming often favors recursion over iterative loops, especially when solving problems like list processing.

    Example:

    // Good: Recursion
    func factorial(n int) int {
        if n == 0 {
            return 1
        }
        return n * factorial(n-1)
    }
    
    
    // Avoid: Iterative approach (loops)
    func iterativeFactorial(n int) int {
        result := 1
        for i := n; i > 0; i-- {
            result *= i
        }
        return result
    }
    

  1. Function Composition

    • Description: Combine simple functions to build more complex ones, allowing for clean and reusable logic.

    Example:

    func double(x int) int {
        return x * 2
    }
    
    
    func square(x int) int {
        return x * x
    }
    
    
    func compose(f, g func(int) int) func(int) int {
        return func(x int) int {
            return f(g(x)) // Apply g, then f
        }
    }
    
    
    func main() {
        doubleSquare := compose(double, square)
        fmt.Println(doubleSquare(3)) // Output: 18 (3^2 * 2)
    }
    

These principles can help keep your code clean, modular, and testable while using both object-oriented and functional paradigms.



comments powered by Disqus