The Go programming language lends itself well to the SOLID programming principles. They go so well together that it should be a prerequisite that those new to Go also learn SOLID as part of their introduction to the language.
This is how the SOLID Principles are applied in Go:
Single Responsibility Principle
A class should only have a single responsibility.
Go doesn’t have classes
, but it does have types
. Thetype
holds the data, and the methods attached to it act on that data (and only that data).
package main
import "fmt"
type Foo int
func (f * Foo) increment(){
*f++
}
func main(){
var i Foo
i.increment()
fmt.Println(i)
}
Open Closed Principle
Software entities should be open for extension, but closed for modification.
In Go a type
or interface
can be embedded within another type
or interface
. A Go developer is composing a new type, rather than building through inheritance.
This has the effect of adding the features of the embedded type
to the new type
, allowing you to extend the functionality, without ever modifying the original type.
package main
import "fmt"
type Foo int
func (f * Foo) increment(){
*f++
}
type Bar struct{
Foo // embedded type
}
func (b *Bar) decrement(){
*b.Foo-- // Note we need to tell Go which field in Bar we are operating on
}
func main(){
var b Bar
b.increment() // method from Foo
fmt.Println(b)
b.decrement() // new method, unavailable to Foo users
fmt.Println(b)
}
Liskov substitution principle
Objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.
Go allows underlying types to be used interchangeably, although the type needs to be converted.
package main
import (
"fmt"
)
type Foo int32
type Bar int32
func main() {
b := Bar(17)
Counter(Foo(b))
}
func Counter(f Foo) {
fmt.Println(f)
}
Interface segregation principle
Many client-specific interfaces are better than one general-purpose interface.
There’s no Go specific code here, it’s a judgement call on the size of your interface. The more you add to an interface, the more work involved in satisfying that interface.
The fact that you can build interfaces via composition allows developers to create monsters, so be aware.
Dependency inversion principle
One should “depend upon abstractions, [not] concretions”.
This is probably my favourite principle, and Go makes it incredibly easy to implement.
The reason it’s my favourite? It both protects key components from being coupled hard to services, and allows applications to be distributed that are able to use a plethora of instances of those services as they suit a given user or use case.
If a developer arranges their code such that they have some business logic, and that business logic depends on concrete implementations of interacting with something like a database, or REST API, then your business logic is coupled to that implementation. Change is hard, both for the business logic, and the service that’s being interacted with.
If, on the other hand, the business logic depends on interfaces, then the business logic is blissfully ignorant of the concrete implementation. That frees the developers, even the users, up, they can, for example, chop and change RDBMS as required, all that is needed is for a ‘driver’ to be written that provides the concrete implementation of the interface.
What this means in real life is the business logic interface specifies the methods it is going to call, what data it is going to supply, and what it expects to be returned. The developer writes a ‘driver’ that implements that interface for a target, say, RDBMS, that allows the business logic to interact with the RBDMS to its heart’s content.
In Go interfaces are implicitly implemented, so a Go developer creating a concrete implementation of a given interface isn’t locked into only one possible interface, their type can implement multiple interfaces at once.
package main
import (
"fmt"
"log"
)
// Interface that defines creation and saving of data
type Baz interface {
Create(one, two int) error
Read(id int64) (Foo, error)
}
// Data type that allows the producer and
// consumer to share information
type Foo struct {
// ... // stuff
}
// One implementation
type SQLImplementation struct{}
func (s *SQLImplementation) Create(one, two int) error {
// Create a row in an imaginary SQL DB, like Postgres
// convert the ints to int64 for fun
one64, two64 := int64(one), int64(two)
fmt.Printf("I am using one as %d and two as %d\n", one64, two64)
// ....
return nil
}
func (s *SQLImplementation) Read(id int64) (Foo, error) {
// Read a row from an imaginary SQL DB, like Postgres
return Foo{}, nil
}
// A second implementation
// The underlying type may make no sense, but it's to show that
// the interface is independent of the underlying type
// and that your type that interacts with the data store
// doesn't *have* to be a struct
type NoSQLImplementation int32
func (n *NoSQLImplementation) Create(one, two int) error {
// Create a row in an imaginary NoSQL DB, like Redis
return nil
}
func (n *NoSQLImplementation) Read(id int64) (Foo, error) {
// Read a row from an imaginary NoSQL DB, like Redis
return Foo{}, nil
}
func main() {
// Ordinarily we'd only have on data store but two demonstrates the flexibility
// Note, also, that the pointer types satisfy the interfaces, not the value types
var postgres *SQLImplementation
var redis *NoSQLImplementation
one := 1
two := 2
// Save some data to the SQL
save(postgres, one, two)
// Save some data to the NoSQL
save(redis, one, two)
}
func save(b Baz, one, two int) {
if err := b.Create(one, two); err != nil {
log.Printf("Creation failed with: %v", err)
}
}
Using this technique also makes unit testing trivial. The unit test only has to create its own concrete implementation of Baz
in order to unit test save(b Baz, one, two int)
In conclusion, SOLID principles are highly accessible in Go. It should go without saying that these principles make your code easier to write, easier to use, and easier to maintain. If you’re not using SOLID principles in your code, you have to wonder what have you been doing with your life ;-)