This post was heavily inspired from 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

Classes should only change or perform actions for a single reason

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

Here you can see the MultiPurposeEmployee class keeps track of an employee and the functions thereof. However, something that might be out of scope could include the printTimesheet function as timesheets could be separate entities from employees and could utilize the employee

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 timesheet. Just remember that increasing the number of classes can introduce extra complexity in the codebase.

Open/Closed Principle

Classses should be open for extension but closed for modification

class Performer():
  def doSomething(self) -> str:     if (case1):       return 'Do action A'     elif (case2):       return 'Do action B'     # ... possibly more conditions

This is a rather simple example however it could illustrate the point I believe. Each action is likely to perform functionality that could grow as the project needs grow. We could easily end up modifying the original class over and over, violating the idea of the closed (for modification) principle. Instead, we could abstract over the actions.

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

Here, we extend the use of our BaseAction into subclasses adhering to the open (for extension) principle and prevent constant modification of the BaseAction every time a new action needs to be included.

Liskov Substitution Principle

Objects of a subclass should be able to be substituted-in for objects of a parent class in code that utilizes the parent class

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

Here, the RectangleCalculator class extends the Calculator class and could not be used as a drop in replacement in code that uses the Calculator class. The calculateArea function in RectangleCalculator accepts a parameter that is less general than that of its superclass.

class BaseVendor():   def sell(self):     return Rectangle()
class VendorA(BaseVendor):   def sell(self):     return Shape()

The idea in this example is, again, the Liskov Substitution Principle is violated since the subclass returns a more general entity than that of its superclass. Thus, any code expecting BaseVendor could break.

Interface Segregation Principle

Classes should not be forced to use 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

Forcing classes to inherit or use actions they don't need creates smelly code and makes it harder to maintain over time. Try breaking down interfaces further to prevent unused code from bulking up your clases.

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 super class. We'll maintain a class that has been narrowed in scope and won't require as many workarounds or contribute to 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 AuthSolution():   authProvider: Cognito
  def login(self):     authProvider.generatePasswordHash()
  def logout(self):     authProvider.invalidateToken()     authProvider.deleteSession()
class Cognito():   def generatePasswordHash(self):     return 'password-hash'
  def invalidateAccessToken(self):     return 'successfully invalidated token'
  def deleteSession(self):     return 'session removed'

There is a sort of tight coupling here between the AuthSolution we're providing and the provider that we're utilizing. This could make future updates or changes in providers difficult.

class AuthSolution():   authProvider: AuthProvider
  def login(self):     authProvider.login()
  def logout(self):     authProvider.logout()
class AuthProvider():   def login(self):     pass
  def login(self):     pass
class Cognito(AuthProvider):   def login(self):     return generatePasswordHash()
  def logout(self):     invalidateToken()     deleteSession()     return 'successfully logged out'

Now our auth solution isn't tied directly with a specific provider and should the need arise, we could simply change or add the new provider subclass hidden below our superclass AuthProvider.