Single Responsibility Principle
The Unix motto is “Do one thing, and do it very well.” This is quite similar to the single responsibility principle, which suggests that any single class should not be responsible for multiple things. In other words, we want to keep the scope of a class small and simple. So it is in line with the KISS principle. The principle of cohesion is also relevant to this.
High Cohesion
Code is cohesive when its functions are all closely related and aligned to a common purpose or objective. It is similar to the single responsibility principle. It helps you keep the scope of classes small, so they are rarely updated (in line with the open/closed principle), and far less likely to have bugs. This pattern also means your team is less likely to have merge conflicts in your code.
Example
Let’s say that an engineer wrote this class:
import (
"errors"
"fmt"
"os"
)
type Service struct {
admin string
factor int
logTo string
units string
}
func NewService(admin string, factor int, logTo string, units string) *Service {
return &Service{
admin: admin,
factor: factor,
logTo: logTo,
}
}
func (s *Service) Execute(user string, input int) (string, error) {
if user != s.admin {
return "", errors.New("unauthorized")
}
f, _ := os.Open(s.logTo)
f.WriteString(fmt.Sprintf("executing %d", input))
v := s.factor * input
out := fmt.Sprintf("The product is %d %s", v, s.units)
return out, nil
}
Unfortunately, the code above has a single Service that is mixing many concerns or responsibilities:
- It has logic to decide if the request is authorized.
- It has logic to control how the request event was logged.
- It holds the logic for a computation.
- It has a built-in, hard coded template for the output text.
Obviously, the code above is going to be very difficult to maintain:
- What if the administrator changes? What if there are now multiple administrators?
- What if the authorization logic changes to use a different method? Like maybe ldap groups or entitlements will be used instead.
- What if we want the log files to rotate? Or be shipped to some external service?
- What if we want to use a big integer for the input and output?
- What if we want to reject the request if the input is less than one?
- What if we want to change the template for the output sentence?
We can re-factor the above code to consider the the above problems, and follow the single responsibility principle. The example below is shown in one file, but in reality they would be separate files, which lets separate developers work on them while reducing the risk of merge conflicts.
import (
"errors"
"fmt"
"os"
)
// in file authorizer.go
type Authorizer interface {
ConfirmAuthorized(user string) error
}
type authorizer struct {
admin string
}
func NewAuthorizer(admin string) Authorizer {
return &authorizer{admin}
}
func (a *authorizer) ConfirmAuthorized(user string) error {
if user != a.admin {
return errors.New("unauthorized")
}
return nil
}
// in file calculation.go
type Calculation interface {
Calculate(input int) int
}
type Multiplier struct {
factor int
}
func NewMultiplier(x int) Calculation {
return &Multiplier{factor: x}
}
func (m *Multiplier) Calculate(input int) int {
return m.factor * input
}
type Logger interface {
Log(s string)
}
type logger struct {
}
func (l *logger) Log(s string) {
fmt.Fprint(os.Stderr, s)
}
type OutputFormatter interface {
Format(value int) string
}
type outputFormatter struct {
theFormat string
units string
}
func NewOutputFormatter(template string, units string) OutputFormatter {
if template == "" {
template = "The product is %d %s"
}
if units == "" {
units = "units"
}
return &outputFormatter{
theFormat: template,
units: units,
}
}
func (f *outputFormatter) Format(value int) string {
return fmt.Sprintf(f.theFormat, value, f.units)
}
type Service struct {
authorizer Authorizer
calculation Calculation
logger Logger
formatter OutputFormatter
}
func NewService(authorizer Authorizer, calculation Calculation, logger Logger, formatter OutputFormatter) *Service {
return &Service{
authorizer: authorizer,
calculation: calculation,
logger: logger,
formatter: formatter,
}
}
func (s *Service) Execute(user string, input int) (string, error) {
if e := s.authorizer.ConfirmAuthorized(user); e != nil {
return "", e
}
s.logger.Log(fmt.Sprintf("executing %d", input))
v := s.calculation.Calculate(input)
out := s.formatter.Format(v)
return out, nil
}
func RunService() {
auth := NewAuthorizer("pat")
calc := NewMultiplier(3)
log := &logger{}
formatter := NewOutputFormatter("", "things")
svc := NewService(auth, calc, log, formatter)
out, _ := svc.Execute("pat", 8)
fmt.Println(out)
}
The re-factoring above might seem very verbose to new engineers, but it will be far easier to maintain in the long term.
- If we need to change the logic for authorizations, calculations, logging, or output formatting, the service class does not need to be altered itself. That means it is more stable. It is in line with the open/closed principle in the next section, because the service class does not need to be updated, but it can behave different ways by replacing the services it depends upon.
- If the structs are in separate files, it reduces the chances of merge conflicts.
- We can also write special implementations of the interfaces that wrap existing ones.
Some people might say, why use an interface here? There is only one implementation. You might think it is over-complicating things and going against the KISS principle. More often than you first expect, new implementations come around in the future. It is better to de-couple the service from specific implementations, so that it does not need any updates when the implementation changes. The interface segregation principle recommends this approach.
Yes, you should frequently have interfaces defined with only one implementation.