Trevor Hailey

SOLID Principles

August 19, 2021

This post was heavily inspired by the book Dive Into Design Patterns by Alexander Shvets, found here. There's a lot of interesting stuff explained in small, manageable pieces that I found enjoyable.

Single Responsibility Principle

A class should have only one reason to change

class MultiPurposeEmployee():
  name: str
 
  def __init__(self, name: str):
    self.name = name
 
  def printName(self):
    print('employee name: ' + self.name)
 
  def printTimesheet(self):
    print('Calculate employee timesheet')

The MultiPurposeEmployee class keeps track of an employee and everything related to one. But printTimesheet is out of scope: timesheets are really a separate entity that uses an employee, not something an employee should carry around. Payroll rules changing shouldn't be a reason for the Employee class to change.

class Employee():
  name: str
 
  def __init__(self, name: str):
    self.name = name
 
  def printName(self):
    print('employee name: ' + self.name)
 
class Timesheet():
 
  def printTimesheet(self, employee: Employee):
    print('Calculate employee timesheet')

We have separated the concerns of the employee and the timesheet. Just remember that increasing the number of classes can introduce extra complexity in the codebase — split when there are genuinely separate reasons to change, not reflexively.

Open/Closed Principle

Classes should be open for extension but closed for modification

class Performer():
 
  def doSomething(self, action: str) -> str:
    if action == 'A':
      return 'Do action A'
    elif action == 'B':
      return 'Do action B'
    # ... a new elif every time a new action shows up

This is a simple example, but it illustrates the point. Every new action means reopening Performer and editing the conditional — modifying tested, working code over and over, which is exactly what "closed for modification" warns against. Instead, we can abstract over the actions.

class BaseAction():
 
  def doSomething(self) -> str:
    pass
 
class ActionA(BaseAction):
 
  def doSomething(self) -> str:
    return 'Do action A'
 
class ActionB(BaseAction):
 
  def doSomething(self) -> str:
    return 'Do action B'
 
class Performer():
  action: BaseAction
 
  def __init__(self, action: BaseAction):
    self.action = action
 
  def performAction(self):
    return self.action.doSomething()

Now adding an action means adding a new subclass of BaseAction — extension — while Performer and the existing actions never need to be touched again.

Liskov Substitution Principle

Objects of a subclass should be able to be substituted in for objects of the parent class, in any code that uses the parent class, without breaking anything

One way to break this is for a subclass to narrow the parameter types it accepts:

class Shape():
 
  def __init__(self, length, width):
    self.length = length
    self.width = width
 
class Rectangle(Shape):
 
  def area(self):
    return self.length * self.width
 
class Calculator():
  def calculateArea(self, shape: Shape):
    pass
 
class RectangleCalculator(Calculator):
  def calculateArea(self, rectangle: Rectangle):
    pass

RectangleCalculator extends Calculator but can't be used as a drop-in replacement: code written against Calculator is allowed to pass in any Shape, and RectangleCalculator only knows how to handle rectangles. The subclass accepts something less general than its parent promised.

The mirror image is a subclass that widens its return type:

class BaseVendor():
  def sell(self) -> Rectangle:
    return Rectangle(2, 3)
 
class VendorA(BaseVendor):
  def sell(self) -> Shape:
    return Shape(2, 3)

BaseVendor promises callers a Rectangle, so callers are entitled to call .area() on whatever comes back. VendorA returns a plain Shape — more general than what was promised — and any code expecting BaseVendor can break.

Interface Segregation Principle

Classes should not be forced to depend on interfaces they don't need

class Business():
  def sell(self, items):
    pass
 
  def invest(self, money):
    pass
 
  def marketing(self):
    pass
 
  def prepareFood(self):
    pass
 
  def pay(self, people):
    pass
 
class Restaurant(Business):
  def sell(self, items):
    return items
 
  def invest(self, money):
    pass
 
  def marketing(self):
    pass
 
  def prepareFood(self):
    return 'coming right up'
 
  def pay(self, people):
    return people

The Restaurant is forced to carry invest and marketing methods it has no use for, stubbed out just to satisfy the base class. Forcing classes to inherit actions they don't need creates smelly code and makes it harder to maintain over time. Try breaking interfaces down so unused methods don't bulk up your classes.

class Business():
  def sell(self, items):
    pass
 
  def pay(self, people):
    pass
 
class Restaurant(Business):
  def sell(self, food):
    return food
 
  def pay(self, cook):
    return '$$'
 
  def prepareFood(self):
    return 'Coming right up'
 
class AdAgency(Business):
  def sell(self, ad):
    return ad
 
  def pay(self, salesman):
    return '$$$'
 
  def marketing(self):
    return 'Buy our product'
 
class InvestmentFirm(Business):
  def sell(self, trades):
    return trades
 
  def pay(self, banker):
    return '$$$$'
 
  def invest(self, money):
    return '$$$$$'

The fat has been trimmed from the base class down to what every business actually shares, and each subclass adds only what it needs. Narrower scope, fewer workarounds, less code smell.

Dependency Inversion Principle

High-level classes shouldn't depend on low-level classes; both should depend on abstractions. Abstractions shouldn't depend on details; details should depend on abstractions

class Cognito():
  def generatePasswordHash(self):
    return 'password-hash'
 
  def invalidateToken(self):
    return 'successfully invalidated token'
 
  def deleteSession(self):
    return 'session removed'
 
class AuthSolution():
  authProvider: Cognito
 
  def __init__(self):
    self.authProvider = Cognito()
 
  def login(self):
    return self.authProvider.generatePasswordHash()
 
  def logout(self):
    self.authProvider.invalidateToken()
    return self.authProvider.deleteSession()

AuthSolution — the high-level policy — is tightly coupled to Cognito, one specific low-level provider, right down to knowing which three methods to call and in what order. Switching providers later means rewriting AuthSolution.

class AuthProvider():
  def login(self):
    pass
 
  def logout(self):
    pass
 
class Cognito(AuthProvider):
  def login(self):
    return 'password-hash'
 
  def logout(self):
    return 'successfully logged out'
 
class AuthSolution():
  authProvider: AuthProvider
 
  def __init__(self, authProvider: AuthProvider):
    self.authProvider = authProvider
 
  def login(self):
    return self.authProvider.login()
 
  def logout(self):
    return self.authProvider.logout()

Now AuthSolution depends only on the AuthProvider abstraction, and Cognito is just one implementation of it hidden behind that interface. Should the need arise, we can swap in a new provider subclass — and AuthSolution never has to know.