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.
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.
O: Open / Closed Principle (OCP)
Compose types with embedding rather than extend them through inheritance.
Simple types should be embedded into more complex types.
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.
I: Interface Segregation Principle (ISP)
Define functions and methods that depend only on the behaviour that they need.
D: Dependency Inversion Principle (DIP)
Refactor dependencies from compile time to run time.
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.
Structs should represent an abstraction for a single concept or entity.
Functions and methods should be responsible for accomplishing a single task. (unit of work)
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 serversos/exec
: running external commandsencoding/json
: encoding and decoding of JSON documentsWhen 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))
}
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
}
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.
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:
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)
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.