Object Oriented Class Design Principles: S.O.L.I.D.

Jorge Huang
Sep 1 · 7 min read

SOLID principles with examples in Go

We’ll be using an application that retrieves list of players and equipment for sports as an example.

/*
* main.go
*/
package main

import "fmt"

func main() {
sportName := "ping pong"

var equipmentList []string
if sportName == "basketball" {
equipmentList = []string{
"ball",
"hoop",
}
} else if sportName == "ping pong" {
equipmentList = []string{
"ball",
"paddle",
}
} else if sportName == "soccer" {
equipmentList = []string{
"ball",
"cleats",
}
}

var players []string
if sportName == "basketball" {
players = []string{
"Michael Jordan",
"Shaquille O'Neal",
}
} else if sportName == "ping pong" {
players = []string{
"Ma Long",
"Hugo Calderano",
}
} else if sportName == "soccer" {
players = []string{
"Cristiano Ronaldo",
"Lionel Messi",
}
}

fmt.Println("equipmentList", equipmentList)
fmt.Println("players", players)
}

Let’s refactor the code above as we learn each principle.

Single Responsibility Principle

An object should only do one thing and all its responsibilities should be encapsulated under one class. The outcomes of SRP are flexible design, few if/switch statements, and software entities with strongly related responsibilities (high cohesion) with minimal dependency on other components (low coupling).

Example

The original code has too many things going on in the main method, so let’s create a sport struct to encapsulate the provision of players and equipment.

/*
* main.go
*/
package main

import "fmt"

func main() {
sportName := "ping pong"
sport := new(sport)

equipmentList := sport.getListOfEquipment(sportName)
players := sport.getListOfPlayers(sportName)

fmt.Println("equipmentList", equipmentList)
fmt.Println("players", players)
}

/*
* sport.go
*/
package main

type sport struct {}

func (s *sport) getListOfEquipment(sportName string) []string {
if sportName == "basketball" {
return []string{
"ball",
"hoop",
}
} else if sportName == "ping pong" {
return []string{
"ball",
"paddle",
}
} else if sportName == "soccer" {
return []string{
"ball",
"cleats",
}
} else {
return nil
}
}

func (s *sport) getListOfPlayers(sportName string) []string {
if sportName == "basketball" {
return []string{
"Michael Jordan",
"Shaquille O'Neal",
}
} else if sportName == "ping pong" {
return []string{
"Ma Long",
"Hugo Calderano",
}
} else if sportName == "soccer" {
return []string{
"Cristiano Ronaldo",
"Lionel Messi",
}
} else {
return nil
}
}

Open/Closed Principle

Software should be open for extension but closed for modification. In other words, existing code should remain unchanged when a new behavior is introduced. The strategy design pattern can be used to achieve OCP. Start simple with if/else statements to avoid unnecessary complexity that abstractions might bring, then refactor as necessary.

Example

After refactoring the original code with the SRP in mind, we have a cleaner design but the code is still not very flexible because when requirements change, we’ll have to modify the if statement which can cause issues. Given we follow the OCP, each different sport will be broken down into their own structs and their behavior will implement the sport interface.

/*
* main.go
*/
package main

import (
"fmt"
"go-srp/sports"
)

type sport interface {
IsMatch(sportName string) bool
GetListOfEquipment() []string
GetListOfPlayers() []string
}

func main() {
sportList := []sport{
&sports.Soccer{},
&sports.PingPong{},
&sports.Basketball{},
}

sportName := "ping pong"
var equipmentList []string
var players []string
for _, spt := range sportList {
if spt.IsMatch(sportName) {
equipmentList = spt.GetListOfEquipment(sportName)
players = spt.GetListOfPlayers(sportName)
}
}

fmt.Println("equipmentList", equipmentList)
fmt.Println("players", players)
}

/*
* sports/soccer.go
*/
package sports

type Soccer struct {}

func (sc *Soccer) IsMatch(sportName string) bool {
return sportName == "soccer"
}

func (sc *Soccer) GetListOfEquipment() []string {
return []string{
"ball",
"cleats",
}
}

func (sc *Soccer) GetListOfPlayers() []string {
return []string{
"Cristiano Ronaldo",
"Lionel Messi",
}
}

/*
* sports/pingpong.go
*/
package sports

type PingPong struct {}

func (pp *PingPong) IsMatch(sportName string) bool {
return sportName == "ping pong"
}

func (pp *PingPong) GetListOfEquipment() []string {
return []string{
"ball",
"paddle",
}
}

func (pp *PingPong) GetListOfPlayers() []string {
return []string{
"Ma Long",
"Hugo Calderano",
}
}

/*
* sports/basketball.go
*/
package sports

type Basketball struct {}

func (bb *Basketball) IsMatch(sportName string) bool {
return sportName == "basketball"
}

func (bb *Basketball) GetListOfEquipment() []string {
return []string{
"ball",
"hoop",
}
}

func (bb *Basketball) GetListOfPlayers() []string {
return []string{
"Michael Jordan",
"Shaquille O'Neal",
}
}

Liskov Substitution Principle

Base classes can be replaced by derived classes. It relies heavily on polymorphism, and the child should not change the behavior or integrity of its base class. Hence, derived classes should expect no more and provide no less. Think in terms of “is substitutable for” as opposed to “is a” relationships.

Example

In addition to traditional sports, we’ll be adding e-sports! So let’s create a higher level interface called activities, and have the sport interface implement it. In order to demonstrate the LSP, eSports interface will derive from sport.

The prettyPrintPlayers function takes an activity as a parameter, and since eSport is substitutable for sport, it is able to pretty print both without any issues.

/*
* main.go
*/
package main

import (
"fmt"
e_sports "go-srp/e-sports"
"go-srp/sports"
)

type activity interface {
IsMatch(activityName string) bool
GetListOfEquipment() []string
GetListOfPlayers() []string
}

type sport interface {
activity
}

type eSport interface {
sport
}

func main() {
var myFavSport sport
myFavSport = &sports.PingPong{}
prettyPrintPlayers(myFavSport)

var myFavESport eSport
myFavESport = &e_sports.LeagueOfLegends{}
prettyPrintPlayers(myFavESport)
}

func prettyPrintPlayers(act activity) {
players := act.GetListOfPlayers()
fmt.Println("..:: List of Players ::..")
for i, p := range players {
fmt.Printf("%d. %s\n", i+1, p)
}
}

/*
* e-sports/dota.go
*/
package e_sports

type Dota struct {}

func (dt *Dota) IsMatch(activityName string) bool {
return activityName == "dota"
}

func (dt *Dota) GetListOfEquipment(activityName string) []string {
return []string{
"computer",
"chair",
}
}

func (dt *Dota) GetListOfPlayers(activityName string) []string {
return []string{
"Mandy",
"Brax",
}
}

/*
* e-sports/league.go
*/
package e_sports

type LeagueOfLegends struct {}

func (ll *LeagueOfLegends) IsMatch(activityName string) bool {
return activityName == "league of legends"
}

func (ll *LeagueOfLegends) GetListOfEquipment(activityName string) []string {
return []string{
"computer",
"chair",
}
}

func (ll *LeagueOfLegends) GetListOfPlayers(activityName string) []string {
return []string{
"Sneaky",
"Licorice",
}
}

Interface Segregation Principle

A client should not depend upon interfaces with irrelevant methods. Prioritize small and cohesive interfaces instead of fat interfaces so that code can be more maintainable with less dependencies. Façades and adapters can be used to solve the fat interface problem.

Example for owned dependency

Now we have a service that provides a list of all equipments and players for sports and e-sports. However, we are not interested in the lists involving sports. We can use a façade to expose only the methods we need.

/*
* main.go
*/
package main

import (
"fmt"
"go-srp/activities"
)

type eSportService interface {
GetESportEquipment() []string
GetESportPlayers() []string
}

func main() {
var service eSportService = new(activities.Service)
eSportEquip := service.GetESportEquipment()
eSportPlayers := service.GetESportPlayers()

fmt.Println(eSportEquip)
fmt.Println(eSportPlayers)
}

/*
* activities/service.go
*/
package activities

import (
e_sports "go-srp/e-sports"
"go-srp/sports"
)

type activity interface {
IsMatch(activityName string) bool
GetListOfEquipment() []string
GetListOfPlayers() []string
}

var (
sportList = []activity{
&sports.PingPong{},
&sports.Basketball{},
&sports.Soccer{},
}

eSportList = []activity{
&e_sports.LeagueOfLegends{},
&e_sports.Dota{},
}
)

type Service struct{}

func (s *Service) GetSportPlayers() []string {
return getPlayers(sportList)
}

func (s *Service) GetSportEquipment() []string {
return getEquip(sportList)
}

func (s *Service) GetESportPlayers() []string {
return getPlayers(eSportList)
}

func (s *Service) GetESportEquipment() []string {
return getEquip(eSportList)
}

func getEquip(activities []activity) []string {
var equip []string
for _, sport := range activities {
list := sport.GetListOfEquipment()
equip = append(equip, list...)
}

return equip
}

func getPlayers(activities []activity) []string {
var players []string
for _, sport := range activities {
list := sport.GetListOfPlayers()
players = append(players, list...)
}

return players
}

Example for 3rd party dependency

We can user adapters when we don’t control dependencies. Find out why a factory is used in newEsportService in the Dependency Inversion Principle section.

/*
* main.go
*/
package main

import (
"fmt"
"go-srp/activities"
)

func main() {
service := newEsportService()
eSportEquip := service.GetESportEquipment()
eSportPlayers := service.GetESportPlayers()

fmt.Println(eSportEquip)
fmt.Println(eSportPlayers)
}

func newEsportService() *activities.ESportService {
externalDependency := activities.Service{}
return &activities.ESportService{
AllActivitiesService: externalDependency,
}
}

/*
* activities/esport.go
*/
package activities

type ESportService struct {
AllActivitiesService Service
}

func (es *ESportService) GetESportEquipment() []string {
return es.AllActivitiesService.GetESportEquipment()
}

func (es *ESportService) GetESportPlayers() []string {
return es.AllActivitiesService.GetESportPlayers()
}

Dependency Inversion Principle

High level classes should not depend on low level classes. Their interaction should rely on abstractions. Classes expose dependencies through interfaces, and clients decide on which implementations to use. When dependencies are hidden deep inside of classes, it becomes extremely difficult to maintain and test code. Not to mention that tightly coupling dependencies, makes it impossible to swap implementations without intrusive changes. DIP can be accomplished with dependency injection in constructors, properties, or parameters.

Example

We have a service that retrieves a list of all players and equipment for sports, and it depended on a list of activities that is injected to the property with a factory.

/*
* main.go
*/
package main

import (
"fmt"
"go-srp/activities"
"go-srp/sports"
)

func main() {
service := newSportService()
eSportEquip := service.GetSportEquipment()
eSportPlayers := service.GetSportPlayers()

fmt.Println(eSportEquip)
fmt.Println(eSportPlayers)
}

func newSportService() *activities.SportService {
sportList := []activities.Activity{
&sports.PingPong{},
&sports.Basketball{},
&sports.Soccer{},
}

return &activities.SportService{
SportList: sportList,
}
}

/*
* activities/service.go
*/
package activities

type Activity interface {
IsMatch(activityName string) bool
GetListOfEquipment() []string
GetListOfPlayers() []string
}

type SportService struct{
SportList []Activity
}

func (s *SportService) GetSportPlayers() []string {
return getPlayers(s.SportList)
}

func (s *SportService) GetSportEquipment() []string {
return getEquip(s.SportList)
}

func getEquip(activities []Activity) []string {
var equip []string
for _, sport := range activities {
list := sport.GetListOfEquipment()
equip = append(equip, list...)
}

return equip
}

func getPlayers(activities []Activity) []string {
var players []string
for _, sport := range activities {
list := sport.GetListOfPlayers()
players = append(players, list...)
}

return players
}

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade