Writing SOLID Go code

October 4, 2020

Why?

We want to design Go programs that are well engineered, decoupled, reusable, and responsive to changing requirements.

We want to avoid writing bad code or bad design, which has these characteristics: rigidity, fragility, immobility, complexity, verbosity.

In 2003, Robert C. Martin wrote a book titled Agile Software Development - Principles, Patterns, and Practices,
in which he described the 5 properties of reusable software under the SOLID mnemonic.

What does SOLID stand for?

  1. S: Single Responsibility Principle (SRP)
    Structure functions, types and methods into packages that exhibit natural cohesion.
    The types belong together.
    Functions serve a single purpose.

  2. O: Open / Closed Principle (OCP)
    Compose types with embedding rather than extend them through inheritance.
    Simple types should be embedded into more complex types.

  3. L: Liskov Substitution Principle (LSP)
    Express the dependencies between packages in terms of interfaces, not concrete types.
    By defining small interfaces we can be more confident that implementations will satisfy that contract.

  4. I: Interface Segregation Principle (ISP)
    Define functions and methods that depend only on the behaviour that they need.

  5. D: Dependency Inversion Principle (DIP)
    Refactor dependencies from compile time to run time.

1. Single Responsibility Principle

A class should have one, and only one, reason to change. (Robert C. Martin)

Why is it important that a piece of code should have only one reason to change?
If the code that our code depends on changes in ways that break its usage, we will need to update our code every time our dependencies change.

If the code has a single responsibility it has the fewest reasons to change,
therefore our code doesn’t need to change for every change in code it depends on.

The terms that are used to describe how easy or difficult it is to change a piece of code are coupling and cohesion.

Coupling describes two things that change together. A change in one induces a change in the other.

Cohesion describes pieces of code that naturally belong together.

Types

Structs should represent an abstraction for a single concept or entity.

Functions and methods

Functions and methods should be responsible for accomplishing a single task. (unit of work)

Packages

In Go, all code lives inside a package and a well designed package starts with its name.

The package name should be both a description of its purpose and a namespace prefix.

  • net/http: HTTP clients and servers
  • os/exec: running external commands
  • encoding/json: encoding and decoding of JSON documents

When using other packages in a package with the import declaration, we establish a source level coupling between the two packages.

A Go package should embody the spirit of the UNIX philosophy: small sharp tools which combine to solve larger tasks:
Each Go package is itself a small program, a single unit of change with a single responsibility.

Example with shapes and areas, where each shape is responsible for calculating its area
and the ShapeFormatter struct is responsible for formatting the area for display as text or JSON.

// shapes.go
package shapes

import "math"

type Shape interface {
	Name() string
	Area() float64
}

type Square struct {
	Length float64
}

func (s Square) Name() string {
	return "Square"
}

func (s Square) Area() float64 {
	return s.Length * s.Length
}

type Circle struct {
	Radius float64
}

func (c Circle) Name() string {
	return "Circle"
}

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

// --------------------------------------------

// shapeformatter.go
package shapeformatter

import (
	"../shapes"
	"encoding/json"
	"fmt"
	"log"
)

type ShapeFormatter struct{}

func (p ShapeFormatter) Text(shape shapes.Shape) string {
	return fmt.Sprintf("Shape{Name: %s, Area: %f}", shape.Name(), shape.Area())
}

func (p ShapeFormatter) JSON(shape shapes.Shape) string {
	jsonStructure := struct {
		Name string  `json:"name"`
		Area float64 `json:"area"`
	}{
		Name: shape.Name(),
		Area: shape.Area(),
	}

	jsonData, err := json.Marshal(jsonStructure)
	if err != nil {
		log.Println("Cannot marshal to json: %v", jsonStructure)
		return ""
	}

	return string(jsonData)
}

// --------------------------------------------

// main.go
package main

import (
	"./shapeformatter"
	"./shapes"
	"fmt"
)

func main() {
	square := shapes.Square{Length: 2}
	circle := shapes.Circle{Radius: 3}
	formatter := shapeformatter.ShapeFormatter{}
	fmt.Println(formatter.Text(square))
	fmt.Println(formatter.Text(circle))
	fmt.Println(formatter.JSON(square))
	fmt.Println(formatter.JSON(circle))
}

2. Open / Closed Principle

Software entities should be open for extension, but closed for modification. (Bertrand Meyer)

The open / closed principle is achieved in Go using embedded types
Embedding one struct into another allows access to the embedded type’s fields and methods, thus the embedded type is open for extension.

In the example below, the Vehicle type is embedded inside the ElectricVehicle type.

  • ElectricVehicle can access all Vehicles’s methods, but because ElectricVehicle has its own PrintInfo() method it overrides the one from Vehicle. We can still access the embedded type’s PrintInfo() method using: electricVehicle.Vehicle.PrintInfo()
  • ElectricVehicle can access all Vehicle’s private fields, e.g. manufacturingYear field as if it were defined in ElectricVehicle

However, the embedded type is closed for modification. The Vehicle’s PrintEmissions method is defined with Vehicle as a receiver,
calling it from another type where Vehicle is embedded inside will not alter the method’s definition.

When the PrintEmissions method is called from ElectricVehicle, the value of v.Emissions() will be from Vehicle, because PrintEmissions method is called with Vehicle as receiver.

package main

import "fmt"

type Vehicle struct {
	manufacturingYear int
}

func (v Vehicle) PrintInfo() {
	fmt.Printf("Vehicle's manufacturing year is: %d\n", v.manufacturingYear)
}

func (v Vehicle) Emissions() int {
    return 200
}

func (v Vehicle) PrintEmissions() {
    fmt.Printf("Vehicle's emissions: %d\n", v.Emissions())
}

type ElectricVehicle struct {
	Vehicle
}

func (ev ElectricVehicle) Emissions() int {
    return 0
}

func (ev ElectricVehicle) PrintInfo() {
	fmt.Printf("ElectricVehicle's manufacturing year is: %d\n", ev.manufacturingYear)
}

func main() {
    var vehicle Vehicle
    vehicle.manufacturingYear = 2019

    var electricVehicle ElectricVehicle
    electricVehicle.manufacturingYear = 2020

    // open for extension
    vehicle.PrintInfo() // Vehicle's manufacturing year is: 2019
    electricVehicle.PrintInfo() // ElectricVehicle's manufacturing year is: 2020

    // closed for modification
    fmt.Println(electricVehicle.Emissions()) // prints 0
    electricVehicle.PrintEmissions() // prints Vehicle's emissions: 200, instead of 0
}

3. Liskov Substitution Principle

Two types are substitutable if they exhibit behaviour such that the caller is unable to tell the difference. (Barbara Liskov)

A method declaration in Go is syntactic sugar for a function that has an implicit first parameter, called receiver.

func (v Vehicle) PrintEmissions() {
    fmt.Printf("Vehicle's emissions: %d\n", v.Emissions())
}

// equivalent to:

func PrintEmissions(v Vehicle) {
    fmt.Printf("Vehicle's emissions: %d\n", v.Emissions())
}

Because Go doesn’t support function overloading calling func PrintEmissions(v Vehicle) with an ElectricVehicle as argument yields an error:
cannot use electricVehicle (type ElectricVehicle) as type Vehicle in argument to PrintEmissions

And declaring:

func PrintEmissions(v ElectricVehicle) {
  fmt.Printf("ElectricVehicle's emissions: %d\n", v.Emissions())
}

gives error: PrintEmissions redeclared in this block ... previous declaration at ....

Therefore, we cannot substitute an ElectricVehicle for a Vehicle struct type.

To fix this, we can either implement the func (v ElectricVehicle) PrintEmissions() method in the ElectricVehicle type
or define a new interface and a function that accepts it and does the actual printing for all vehicles that implement the interface.

type Emissioner interface {
    Emissions() int
}

func PrintEmissions(vehicleName string, e Emissioner) {
  fmt.Printf("%s emissions: %d\n", vehicleName, e.Emissions())
}

// called from main.go as:
PrintEmissions("Vehicle", vehicle)
PrintEmissions("ElectricVehicle", electricVehicle)
// Vehicle emissions: 200
// ElectricVehicle emissions: 0 <-- correct output!

In other languages, this principle is implemented with an abstract base class and several concrete subtypes extending it.
However, in Go we don’t have classes, nor inheritance, this means we will implement substitution using interfaces.

Types in Go implement a particular interface simply by having a matching set of methods,
i.e. interfaces are satifised implicitly, rather than explicitly.

It is common for interfaces to have a single method, thus small interfaces lead to simple implementations.
This creates packages composed of simple implementations connected by common behaviour.

For example, the io.Reader interface:

type Reader interface {
    // Read reads up to len(buf) bytes into buf
    Read(buf []byte) (n int, err error)
}

By having multiple types implement this interface we can read data from any of them in the same way.
As a client reading data, we don’t care what each type is doing internally to give us the data.

4. Interface Segregation Principle

Clients should not be forced to depend on methods they do not use. (Robert C. Martin)

This principle can be applied in Go by isolating the behaviour for a function to do its job aka a function should depend only on the behaviour that is uses.

For example, if we have a function that saves the contents of a document to a file on disk:

func Save(f *os.File, doc *Document) error

The os.File type has methods which are not relevant to the implementation of the Save operation.

What if we want to write the file to a network location?
To accomodate this requirement, we would have to change the signature of the function and this will affect all its callers.

What if we want to test this function? To verify that this function works we have to:

  • read from disk the file we just wrote out
  • ensure we write to a temporary test location so that we don’t overwrite important files on disk
  • ensure this test doesn’t conflict with other test runs by cleaning up at the end

If we change the signature of the Save function to be:

// Save writes the contents of doc to the supplied Write
func Save(w io.Writer, doc *Document) error

we can use any type that implements the io.Writer interface, not just os.File, thus broadening the areas where this function might be used.
It also signals to the callers that the only method we are interested in is: Write

We could also have used io.ReadCloser or io.ReadWriteCloser as the interface type, but doing so would have limited the ways in which clients might use the Save function.
For example, Close() might be called internally at undesired times in cases where the client wants to call Save() multiple times without wanting the underlying stream to be closed after each call.

A great rule of thumb for Go is accept interfaces, return structs. (Jack Lindamood)

5. Dependency Inversion Principle

High-level modules should not depend on low-level modules.
Both should depend on abstractions (e.g. interfaces).
Abstractions should not depend on details (concrete implementations).
Details should depend on abstractions. (Robert C. Martin)

In Go, this principle can be applied by depending on interfaces in lower level code (at package boundaries) and by pushing the responsibility of providing concrete implementations to higher level code.

package persistence

import "fmt"

// Storer is the interface responsible for storing data in a storage location.
type Storer interface {
  Store(data interface{}) (int, error)
}

// Persister is the interface used to persist data on behalf of its clients
type Persister interface {
  Persist(data interface{}) error
}

// NewPersister creates a concrete implementation of the Persister interface
func NewPersister(store Storer) Persister {
  return persistence{
    storage: store,
  }
}

type persistence struct {
  storage Storer
}

func (p persistence) Persist(data interface{}) error {
  _, err := p.storage.Store(data)
  if err != nil {
    return fmt.Errorf("Cannot save data to storage: %s\n", err)
  }
  return nil
} 

// -----------------------------------

package storage

// Storage defines the storage medium/location of the saved data
type Storage interface {
  Store(data interface{}) (int, error)
}

// NewStorage returns a concrete implementation of the Storage interface
func NewStorage() Storage {
  return dataStore{}
}

type dataStore struct {
  count int
  data []interface{}
}

func (ds dataStore) Store(data interface{}) (int, error) {
  ds.count++
  ds.data = append(ds.data, data)
  return ds.count, nil
}

// -----------------------------------

package main

import (
  "./storage"
  "./persistence"
)

// main is the high level package that is the only one aware of the two components, 
// thus the lower level abstractions Persister and Storage are said to be decoupled from one another
func main() {
  dataStore := storage.NewStorage()
  persistor := persistence.NewPersister(dataStore)
  persistor.Persist("Go is awesome!")
}

Due to the duck typing property: If it walks like duck and it quacks like a duck, then it is a duck,
the Storage interface from the storage package is treated to be the same as the Storer interface from the persistence package,
that’s why NewPersister constructor function any type that has the same method set.

At compile time there is no dependency between the two packages,
but at runtime we can see that the persistence package depends on the concrete implementation created in the storage package.

References