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 upThis 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):
passRectangleCalculator 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 peopleThe 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.