SOLID principle in GO
“What if there’s a way that is less subjective to talk about the properties of a good and bad code?” — Dave Cheney
Dave Cheney in his awesome post SOLID Go Design proposed how SOLID principle guides us to identify a well designed Go program in a nonsubjective manner.
SOLID stands for:
- S : Single responsibility principle
- O : Open/Closed principle
- L : Liskov substitution principle
- I : Interface segregation principle
- D : Dependency inversion principle
These principles were introduced by Robert C. Martin, in his paper Design Principles and Design Patterns.
According to Robert C. Martin the symptoms of a rotting design or bad code are:
- Rigidity
Code to be difficult to change even if the change is small
- Fragility
Code to break whenever a new change is introduced in the system
- Immobility
Code being not reusable
- Viscosity
Hacking rather than finding a solution that preserve the design when it comes to change
In this article, I will revisit these principles with some examples and diagrams from Golang prospectives.
Single responsibility principle
“Do one thing and do it well” — McIlroy (Unix philosophy)
The single responsibility principle suggests that two separate aspects of a problem need to be handled by a different module. In other word, changes in a module should be originated from only one reason.
In Object-oriented language, if you have more than one responsibilities embedded into a single class, the internal logics become highly coupled, which makes the class less responsive towards changes. Similarly, if you have two separate classes say class A
and class B
, and if the consumer of class A
needs to know about class B
, then A
and B
are considered highly coupled. Single responsibility principle aims to maintain a good level of Coupling that also maintains a good level of Cohesion.
Let’s take an example in Golang. Say we have a module Command
in a command-driven system. The Command
module decode, validate and finally execute the incoming commands.
type Command struct {
commandType string
args []string
}func (c Command) Decode(data []byte) {
// decodes and initialise
}func (c Command) ValidateCommandType() bool {
// validates command type
}func (c Command) ValidateArgs() bool {
// validate provided args as if input
}func (c Command) Execute() {
// Executes seperate types of commands
}
In this case, changes on how a Command
gets decoded and validated and how a command gets executed will directly affect theCommand
module. Hence the module performs multiple responsibilities and highly coupled. As per single responsibility principle the Decode()
and Validate()
is a separate concern than Execute()
, and should be handle in separate modules.
We can introduce the CommandFactory
module that parses, validates and initializes a command, where the CommandExecutor
module executes the command. Now CommandFactory
and CommandExecutor
are loosely coupled via Command
module. Also notice how we separated the validation of command type and input to the corresponding module.
type Command struct {
commandType string
args []string
}type CommandFactory struct {
...
}// Create decode and validate the command
func (cf CommandFactory) Create(data []byte) (*Command, error) {
// decode command
command, err := cf.Decode(data)
if err != nil {
return nil, err
}
// validate type
switch cf.Type {
case Foo:
case Bar:
default:
return nil, InvalidCommandType
}
return command, nil
}type CommandExecutor struct {
}// Execute executes the command
func (ce CommandExecutor) Execute(command *Command) ([]byte, error) {
// validate input and execute
switch command.Type {
case Foo:
if len(args) == 0 || len(args[0]) == 0 {
return nil, InvalidInput
}
return ExecuteFoo(command) case Bar: // Bar doesn't take any input
return ExecuteBar(command)
}
}
Open/Closed principle
“A module should be open for extensions, but closed for modification” — Robert C. Martin
Open Closed is considered one of the most important principles in an object-oriented class-based language. The concept suggests that modules should be written in a way so that we can add new modules or new functionalities in a module without requiring existing modules to be modified.
Let’s assume we have an abstract class S
, which provide a common method F()
for the derived types A
and B
. Class S
would be considered as closed for an extension if the method F()
need to be aware of the existence of the derived classes. This means the addition of a newly derived class say C
would need F()
to be changed, making F()
open for modification.
One of the solutions is to make F()
work on a defined interface rather than handling the subtypes. Say interface I
define the necessary abstract method and need to be implemented by the subtypes A
,B
and C
. The interface I
can have many subtypes, so it’s open for extension. And F()
is implemented separately to work on interface I
, so that it’s closed for modification.
In Golang there is no concept of generalization. Reusability is available as a form of embedding. Although a similar pattern could be seen in practice. Let’s take the example of the CommandExecutor
, which is responsible for executing Commands. The Execute()
and ValidateInput()
methods need to handle each command separately. So every time a new command is added Execute()
implementation needs to change.
Here we can use a Command
interface with Execute()
and ValidateInput()
method.
type Command interface {
Execute() ([]byte, error)
ValidateInput() bool
}type CommandExecutor struct {
}func (c CommandExecutor) Execute(command Command) {
if command.ValidateInput() {
command.Execute()
}
}type FooCommand struct {
args []string // need args
}func (c FooCommand) ValidateInput() {
// validate args
if len(args) >= 1 && len(args[0]) > 0 {
return true
}
return false
}func (c FooCommand) Execute() ([]byte, error) {
...
}type BarCommand struct {
}func (c BarCommand) ValidateInput() {
// does nothing
return false
}func (c BarCommand) Execute() ([]byte, error) {
...
}
Liskov substitution principle
“Derived methods should expect no more and provide no less” — Robert C. Martin
In an object-oriented class-based language, the concept of the Liskov substitution principle is that a user of a base class should be able to function properly of all derived classes.
This means if client C
uses a class A
. And B
is a class derived from class A
. Then functionalities within client C
that depend on the methods of class A
should work as it is with the same method of class B
. Class B
should provide no special method for client C
, neither it should leave any method unimplemented. But in practice, it often happens that we come to a situation where the client needs to handle the base class and subclass separately.
Liskov substitution principle suggests that the client and the derived classes should interact via a Contract that defines the client’s intention.
The functionalities within client C
, that depend on the methods of class A
should be substituted via an abstract base class T
with A
and B
being concrete subtypes. Class T
becomes the Contract, and client C
consumes the Contract methods.
As we discussed earlier, in Golang there is no class-based inheritance. Instead, Golang provides a more powerful approach towards polymorphism via Interfaces and Struct Embedding. Unlike class-based language, Go polymorphism involves creating many different data types that satisfy a common interface.
In the above example client C
need to consume an interface T
(the Contract) so that multiple concrete types A
and B
can be passed. Good thing is that one not need to be aware of all the contracts at the time of defining a type. As in Go, interfaces are satisfied implicitly, rather than explicitly.
Dave Cheney in his SOLID Go Design blog mentioned:
Well designed interfaces are more likely to be small interfaces; the prevailing idiom is an interface contains only a single method. It follows logically that small interfaces lead to simple implementations, because it is hard to do otherwise. Which leads to packages comprised of simple implementations connected by common behaviour.
Designing simple interfaces has been the core fundaments of Golang ecosystem. Such interfaces example includes
> error
These interface designed in a way so that the implementations are substitutable without any special handling as they fulfil the same contract.
In our earlier example, we provided theCommand
interface. Which is fairly simple, but is it good enough?
BarCommand
doesn’t have any input. For the same reason, ValidateInput()
always return False
. Now the client CommandExecutor
will fail as it expects ValidateInput()
to work. Here BarCommand
provides less than what is expected.
Alternatively, we can separate the interface into Command
and CommandWithInput
as
type Command interface {
Execute() ([]byte, error)
}type CommandWithInput interface {
Command
ValidateInput() bool
}
This brings us to the next principle
Interface segregation principle
“Many client specific interfaces are better than one general purpose interface” — Robert C. Martin
In an object-oriented class-based language, it states that if a class provides methods to multiple clients, then rather than having a generic interface loaded with all methods, provide a separate interface for each client and implement all of them in the class.
Say, client C1
uses F1
method, C2
uses F2
method. Interface I
provides F1
and F2
. class A
implements interface I
. The problems with the generalised interface is that:
- Changes in client
C1
‘s methods can cause changes inC2
’s method - A new class
B
implements interfaceI
butB
only get used by the clientC2
. Which promotes unimplemented methods inB
.
The interface segregation principle suggests segregating the interface I
into IC1
and IC2
, so thatIC1
is responsible for the client C1
and IC2
is responsible for the client C2
.
In Golang interfaces are satisfied implicitly, rather than explicitly, which makes it easier to extend a class behaviour by implementing multiple interface based on needs. It also encourages to the design of small and reusable interfaces.
type I1 interface { // consumed by C1
M1()
M2()
M3()
}type I2 interface { // consumed by C2 and C3
M3()
M4()
}
These may lead to several small interfaces and some clients need to use a type that implements a set of interfaces among all. In Golang aggregates of the interface is particularly useful to define an aggregate interface with a set of interfaces. But breaking down interfaces can be tricky.
Robert C. Martin in his Design Principles and Design Patterns paper mentioned:
As with all principles, care must be taken not to overdo it. The specter of a class with hundreds of different interfaces, some segregated by client and other segregated by version, would be frightening indeed.
The rule to follow is that each interface must be defined in a way so that it provides the exact and full set of functionalities needed by at least one of the client. This also means that there is no need to break an interface if consumed by only one client.
type I1 interface { // consumed by C1
M1()
M2()
M3() // also defined in I2
}type I2 interface { // consumed by C2 and C3
M3() // here M3 not in a separate interface as no client
// use an interface with only M3()
M4()
}type I3 interface { // consumed by C4
M5() // similarly M5() only used along with I1 and I2
// thus not needed to have it in a separate interface
I1
I2
}
In our previous example, we separated the Command
interface into two interfaces:
type Command interface {
Execute() ([]byte, error)
}type CommandWithInput interface {
Command
ValidateInput() bool
}
Although we have only one client CommandExecutor
that consumes it. Therefore it might not be the best idea to break it into two. Alternatively, we could have added a method NeedInput()
that returns either true and false. This way we also make the Contract complete.
type Command interface {
Execute() ([]byte, error)
HasInput() bool
ValidateInput() bool
}
and change CommandExecutor
as
func (c CommandExecutor) Execute(command Command) {
if !command.HasInput() || command.ValidateInput() {
command.Execute()
}
}
Dependency inversion principle
“Depend upon Abstractions. Do not depend upon concretions” — Robert C. Martin
In an object-oriented class-based language, it states that every dependency between modules should target an abstract class or an interface. No dependency should target a concrete class.
Say class A
depends on class B
and use it directly. Class B
is a concrete type, which means any changes on class B
, will directly affect class A
. Similarly, changes in class A
may require claas B
to change. If class B
is being used by more than one classes, it will break other dependencies too.
The dependency inversion principle suggests providing an interface I
that provides the methods needed by class A
. And class B
should implement the interface in order to get used by class A
. This way one or many implementations of the interface I
may exist. And class A
can be used by other classes with different interfaces.
In our example, we already have done something similar to theCommand
interface. Let’s see how we can extend it more. Our CommandFactory
takes an encoded string, validates it and creates a concrete Command
. Let’s assume the encoded input comes as a JSON string, which needs to be decoded first and validated. Based on the Single Responsibility Principle we may use a separate JsonDecoder
to handle Decode()
.
type CommandFactory struct {
decoder JsonDecoder // decoder decodes the command
}// Create decode and validate the command
func (cf CommandFactory) Create(encoded String) (Command, error) {
// decode command
command, err := cf.decoder.Decode(data)
if err != nil {
return nil, err
}
...
}
Although in future encoding strategy may change, having fixed Encoder
makes it resilient to changes. Instead, we can change CommandFactory
to use an interface CommandDecoder
.
type CommandDecoder interface {
func Decode(data []byte) (Command, err)
}type CommandFactory struct {
Decoder CommandDecoder
}type JsonDecoder struct {}func (jcd JsonDecoder) Decode(data string) (Command, err){
// json command decode logic
}
We can pass the right CommandDecoder
at the time of initializing the factory. This way the factory depends on abstraction rather than concretion.
// Initialize CommandFactory with required CommandDecoder
factory = CommandFactory{Decoder: JsonCommandDecoder{}}
References:
Design Principles and Design Patterns — Robert C. Martin
SOLID Principles: Explanation and examples — Simon LH
In this article, I presented my understanding while I’m trying to structure my code following SOLID principles. Please suggest changes, correction, and improvement via comments 😇
And if you find this writeup useful, please spare some claps 👏 😃