"SOLID principles are the compass that navigates developers through the complexities of software architecture"
- Definition
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (
DIP
) - Summarise
- Let's Refactor!
- Quiz
- Homework
SOLID principles are a set of design principles in software engineering that, when followed properly, make the software more understandable, flexible, and maintainable. The acronym SOLID stands for five design principles:
- S - Single Responsibility Principle (SRP)
- O - Open/Closed Principle (OCP)
- L - Liskov Substitution Principle (LSP)
- I - Interface Segregation Principle (ISP)
- D - Dependency Inversion Principle (DIP)
A class should have one, and only one, reason to change. This means a class should only have one job or responsibility.
Consider a Report
class that generates a report and then prints it. According to SRP, these two actions should be separated into different classes.
# Violating SRP
class Report:
def generate_report(self, data):
# Code to generate the report
pass
def print_report(self, report):
# Code to print the report
pass
# Adhering to SRP
class ReportGenerator:
def generate_report(self, data):
# Code to generate the report
pass
class ReportPrinter:
def print_report(self, report):
# Code to print the report
pass
Again, each class should have its own purpose, so the best to write down functionality/architecture of the project on the paper and stick to that in advance.
Consider a UserManagement
class that handles user-related operations such as
- Adding a user
- Sending a welcome email
- Save (logging) the activity of the user
class UserManagement:
def add_user(self, user):
# Code to add the user to the system
pass
def send_welcome_email(self, user):
# Code to send a welcome email to the user
pass
def log_activity(self, activity):
# Code to log user activity
pass
Think about how we can refactor this, does this class serve its purpose? Is it correct that it handles lots of different operations? Can we refactor it to improve maintainability/readability and scalability?
The answer is that each method is not really related to the UserManagment
activity, it has 3 different purposes which could be separated into other classes:
class UserRegistry:
def add_user(self, user):
# Code to add the user to the system
pass
class EmailService:
def send_welcome_email(self, user):
# Code to send a welcome email to the user
pass
class ActivityLogger:
def log_activity(self, activity):
# Code to log user activity
pass
The code above ensures that each class has a single reason to change. Which means that we applied SRP
successfully.
Software entities (like classes
, modules
, functions
) should be open for extension but closed for modification.
This means that the behavior of a module/class/function can be extended without modifying its source code.
Let's consider an example of a TaxCalculator
class that calculates tax based on the type of product. Initially, the class is not following OCP
, because every time a new tax type is introduced, the class has to be modified.
class TaxCalculator:
def calculate_tax(self, product_type, price):
if product_type == "book":
return price * 0.05 # 5% tax for books
elif product_type == "food":
return price * 0.10 # 10% tax for food
# More conditions for other product types
To follow the OCP
, we can define a generic TaxCalculator
class and then extend it for each specific tax type.
class TaxCalculator:
def calculate_tax(self, price):
pass
class BookTaxCalculator(TaxCalculator):
def calculate_tax(self, price):
return price * 0.05
class FoodTaxCalculator(TaxCalculator):
def calculate_tax(self, price):
return price * 0.10
# More classes for other product types
...
Let's consider a reporting system where reports can be generated in different formats (e.g., PDF, CSV). Initially, the system is not following OCP
because adding a new report format requires modifying existing code.
class ReportGenerator:
def generate_report(self, data, format_type):
if format_type == "PDF":
# Generate PDF report
pass
elif format_type == "CSV":
# Generate CSV report
pass
# More conditions for other formats
Here, we define a generic ReportGenerator
class and then extend it for each specific report format.
class ReportGenerator:
def generate_report(self, data):
pass
class PDFReportGenerator(ReportGenerator):
def generate_report(self, data):
# Generate PDF report logic
pass
class CSVReportGenerator(ReportGenerator):
def generate_report(self, data):
# Generate CSV report logic
pass
# More classes for other formats
Sometimes people tend to over-engineer the solution by introducing too many layers of abstraction or making the system too generic.
Yes, SOLID
principles tend to be a great foundation but please, don't overthink the design of your application, the majority of code can easily be refactored and updated throughout your journey, JUST CODE!
Objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.
Consider a system with different types of shapes where each shape can calculate its area.
A violation of LSP
would occur if a subclass of Shape does not correctly implement the area()
method.
class Shape:
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
# Incorrect implementation or not implemented
pass
It can lead to incorrect behavior when a Circle
is used in place of a Shape
.
class Shape:
def area(self):
pass
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius * self.radius
Using this principle can be a challenge in the begging of your application design, but if you use it within a correct approach it can be very predictable and robust for further development
Let's consider a system with different types of birds. Initially, each bird has a fly()
method.
A violation of LSP
would occur if a subclass of Bird
(like Penguin
) cannot correctly implement the fly()
method.
class Bird:
def fly(self):
# Default fly behavior
pass
class Sparrow(Bird):
def fly(self):
# Implementation for flying
pass
class Penguin(Bird):
def fly(self):
# Penguins can't fly!
raise Exception("Can't fly")
In complex systems fly()
would lead to incorrect behavior or runtime errors which potentially could be hard to debug.
This can be refactored by creating separate interfaces for FlyingBird
and NonFlyingBird
to ensure consistency throughout the code and correct implementation of overriden methods.
class Bird:
# Common bird behavior (if any)
pass
class FlyingBird(Bird):
def fly(self):
# Implementation for flying
pass
class NonFlyingBird(Bird):
# Other behaviors specific to non-flying birds
pass
class Sparrow(FlyingBird):
def fly(self):
# Sparrow-specific flying behavior
pass
class Penguin(NonFlyingBird):
# Penguin-specific behaviors
pass
Penguin
objects are not expected to fly, and thus, the system does not assume that capability, adhering to LSP
.
There are lots of rabbit holes for LSP
but generally some static linters as mypy
handle all of SOLID
this for developers, but don't forget to turn on the brain while designing your application ;)
The Interface Segregation Principle (ISP
) states that no client should be forced to depend on methods it does not use.
This principle aims to split larger interfaces into smaller, more specific ones so that clients only need to know about the methods that are interesting to them.
Let's consider a printing system where the initial design forces the Printer
class to implement functions that are not essential to all types of printers, such as faxing or scanning.
class AllInOnePrinter:
def print_document(self, document):
# Print the document
pass
def scan_document(self, document):
# Scan the document
pass
def fax_document(self, document):
# Fax the document
pass
In this example, even simple printers without scanning or faxing capability have to implement the scan_document
and fax_document
methods, which violates ISP
.
We refactor the example by creating separate interfaces for each responsibility.
class Printer:
def print_document(self, document):
pass
class Scanner:
def scan_document(self, document):
pass
class FaxMachine:
def fax_document(self, document):
pass
# Yes! That's where multiple inheritacne comes in use
class AllInOneMachine(Printer, Scanner, FaxMachine):
def print_document(self, document):
# Print the document
pass
def scan_document(self, document):
# Scan the document
pass
def fax_document(self, document):
# Fax the document
pass
# Create instances
simple_printer = Printer()
scanner = Scanner()
fax_machine = FaxMachine()
# Using individual component for each class
document = "This is a document."
simple_printer.print_document(document) # ``Printer``: Printing document
scanner.scan_document(document) # ``Scanner``: Scanning document
fax_machine.fax_document(document) # ``FaxMachine``: Faxing document
# All in one can handle three different operations in case we need to use all of them
all_in_one = AllInOneMachine()
all_in_one.print_document(document)
all_in_one.scan_document(document)
all_in_one.fax_document(document)
It might be hard to understand but the simple explanation will be the following:
Printer
,Scanner
, andFaxMachine
are interfaces (or abstract classes) that define specific functionalities. (That is a great example ofSRP
as well)AllInOneMachine
implements all these interfaces, providing the functionality for printing, scanning, and faxing.
Note: Clients that only need a printer can depend on the Printer
interface without being forced to know about scanning or faxing and same applies to Scanner
and FaxMachine
.
If you need to print, you use Printer
, to fax FaxMachine
, and to scan Scanner
is the way to go!
Consider a vehicle control system where the initial design forces all vehicle types to implement functionalities that are not essential for all of them, such as launchMissiles
for military vehicles or playMusic
for civilian vehicles.
We don't want to have the ability to launch missiles in our car we use day to day for commuting to work, do we?
class VehicleControl:
def steerLeft(self):
# Steer the vehicle left
pass
def steerRight(self):
# Steer the vehicle right
pass
def launchMissiles(self):
# Launch missiles (mainly for military vehicles)
pass
def playMusic(self):
# Play music (mainly for civilian vehicles)
pass
We refactor the example above by creating separate interfaces for each category of functionalities.
# Parental Inerfaces (Base classes)
class BasicVehicleOperations:
def steer(self):
# Steer the vehicle
pass
class MilitaryOperations:
def launchMissiles(self):
# Launch missiles
pass
class EntertainmentOperations:
def playMusic(self):
# Play music
pass
class CivilianVehicle(BasicVehicleOperations, EntertainmentOperations):
def steer(self):
# Implementation for steering
pass
def playMusic(self):
# Implementation to play music
pass
class MilitaryVehicle(BasicVehicleOperations, MilitaryOperations):
def steer(self):
# Implementation for steering
pass
def launchMissiles(self):
# Implementation to launch missiles
pass
Sometimes applying ISP
can might result in a large number of interfaces, each with only a few methods. And this can become a headache for developers, but on the other hand, smaller and more specific interfaces lead to more modular and understandable code.
The Dependency Inversion Principle (DIP
) suggests that high-level
modules should not depend on low-level
modules.
Abstractions should not depend on details, but details should depend on abstractions.
Imagine you have a news reporting system where a NewsReporter
class is responsible for reporting news. Initially, it directly uses a RadioChannel
class to broadcast news. We want to adhere to DIP to make our NewsReporter
more flexible and not tightly coupled to the RadioChannel
.
class RadioChannel:
def broadcast_news(self, news):
print(f"Broadcasting news on the radio: {news}")
class NewsReporter:
def __init__(self):
self.radio_channel = RadioChannel()
def report_news(self, news):
self.radio_channel.broadcast_news(news)
In this example, NewsReporter
is directly dependent on RadioChannel
, meaning if we want to broadcast news on a different medium, like TV, we'd have to modify the NewsReporter
class, violating DIP.
Let's introduce an abstraction (interface) named BroadcastMedium
and make NewsReporter
dependent on this interface rather than a concrete class.
class BroadcastMedium:
def broadcast_news(self, news):
pass
class RadioChannel(BroadcastMedium):
def broadcast_news(self, news):
print(f"Broadcasting news on the radio: {news}")
class TVChannel(BroadcastMedium):
def broadcast_news(self, news):
print(f"Broadcasting news on TV: {news}")
class NewsReporter:
def __init__(self, broadcast_medium: BroadcastMedium):
self.broadcast_medium = broadcast_medium
def report_news(self, news):
self.broadcast_medium.broadcast_news(news)
Now, NewsReporter
relies on the BroadcastMedium
interface. We can easily broadcast news on the radio, TV, or any other medium that implements the BroadcastMedium
interface without changing the NewsReporter
class.
Since the main idea is that both high
and low
level modules should depend on abstractions. It is a great when example we don't care of the implementation details of broadcast_news
in BroadcastMedium
class, we just call it.
Let's say we have a book reading app where a BookReader
class is responsible for reading books. Initially, it's directly using a PDFReader
class. We'll apply DIP
to make BookReader
flexible and not dependent on the PDFReader
.
class PDFReader:
def read_book(self, book):
print(f"Reading {book} in PDF format.")
class BookReader:
def __init__(self):
self.reader = PDFReader()
def read_book(self, book):
self.reader.read_book(book)
In this example, BookReader
is directly dependent on PDFReader
. If we want to read books in a different format, we'd need to change BookReader
, violating DIP
.
Introduce an abstraction (interface) named BookFormatReader
and make BookReader
dependent on this interface.
class BookFormatReader:
def read_book(self, book):
pass
class PDFReader(BookFormatReader):
def read_book(self, book):
print(f"Reading {book} in PDF format.")
class EpubReader(BookFormatReader):
def read_book(self, book):
print(f"Reading {book} in EPUB format.")
class BookReader:
def __init__(self, format_reader: BookFormatReader):
self.format_reader = format_reader
def read_book(self, book):
self.format_reader.read_book(book) # Use the specific format of the reader
Now, BookReader
depends on the BookFormatReader
interface. We can easily read books in PDF, EPUB, or any other format that implements the BookFormatReader
interface without changing the BookReader
class.
I want to share a table with you to which you could refer to during designing your application. It might be not extremly readable, but helpful anyway to bear in mind summarising the key principles of SOLID
and all rabit holes you can encounter.
Principle | Purpose | Advantages | Potential Disadvantages |
---|---|---|---|
Single Responsibility Principle (SRP) | Ensure a class has only one reason to change. | - Easier maintenance - Increased modularity - Improved readability |
- May lead to many small, tightly coupled classes |
Open/Closed Principle (OCP) | Allow entities to be open for extension but closed for modification. | - Flexibility in extension - Protection against changes - Reduced risk of bugs |
- May introduce abstract layers - Can lead to over-engineering |
Liskov Substitution Principle (LSP) | Subtypes must be substitutable for their base types. | - Enhanced reliability - Promotes consistency - Better code reusability |
- Restricts how inheritance is used - Can make hierarchy design complex |
Interface Segregation Principle (ISP) | No client should be forced to depend on methods it doesn't use. | - Decoupled system - Increased cohesion - Easier to understand interfaces |
- May increase the number of interfaces - Potential for duplicate methods |
Dependency Inversion Principle (DIP) | High-level modules should not depend on low-level modules. | - Decoupled architecture - Easier to refactor and test - Promotes flexible system |
- Increased complexity - Indirect relations between components |
Suppose we want to create an app that manages and displays messages in various formats.
class MessageManager:
def __init__(self, content):
self.content = content
def format_message(self, format_type):
if format_type == "JSON":
return f"{{'message': '{self.content}'}}"
elif format_type == "XML":
return f"<message>{self.content}</message>"
def display_message(self, format_type):
formatted_message = self.format_message(format_type)
print(formatted_message)
# Usage
message_manager = MessageManager("Hello, SOLID!")
message_manager.display_message("JSON")
In order to refactor our application we should use the following steps:
Step 1: Go throughout the code and identify which principle is violated?
class MessageManager:
def __init__(self, content):
self.content = content
# SRP Violation: This class handles multiple responsibilities
def format_message(self, format_type):
if format_type == "JSON":
return f"{{'message': '{self.content}'}}"
elif format_type == "XML":
return f"<message>{self.content}</message>"
# OCP Violation: Modifying the class to add a new format
# LSP Violation: Subclasses would find it hard to support all format types
# ISP Violation: Clients that need only message formatting are forced to depend on display functionality too
def display_message(self, format_type):
formatted_message = self.format_message(format_type)
print(formatted_message)
message_manager = MessageManager("Hello, SOLID!")
message_manager.display_message("JSON")
- Single Responsibility Principle (SRP) Violation: The
MessageManager
class handles multiple responsibilities, including formatting and displaying messages. - Open/Closed Principle (OCP) Violation: To support a new message format, the
format_message
method needs to be modified, violating the principle of open for extension, closed for modification. - Interface Segregation Principle (ISP) Violation: Clients that only want message formatting are forced to depend on the display functionality.
- Dependency Inversion Principle (DIP) Violation: The
MessageManager
class has a concrete dependency on message formatting and display, making it inflexible.
Step 2: Address each issue separately
A) Separate Responsibilities (SRP)
class Message:
def __init__(self, content):
self.content = content
SRP
: Single responsibility for managing message content.
B) Create Formatter Interface and Implementations (OCP, LSP, ISP)
from abc import ABC, abstractmethod
class MessageFormatter(ABC):
@abstractmethod
def format(self, message):
pass
class JSONFormatter(MessageFormatter):
def format(self, message):
return f"{{'message': '{message.content}'}}"
class XMLFormatter(MessageFormatter):
def format(self, message):
return f"<message>{message.content}</message>"
OCP
: Open for extension, closed for modificationLSP
: Subtypes can be substituted without altering the correctness of the programISP
: Clients can choose specific formatters they need without depending on unused methods
C) Implement Display Functionality
class MessageDisplayer:
def __init__(self, formatter: MessageFormatter):
self.formatter = formatter
def display(self, message: Message):
formatted_message = self.formatter.format(message)
print(formatted_message)
DIP
: High-level MessageDisplayer
depends on abstraction MessageFormatter
, not on concrete implementations!
Step 3: Put the code altogether
from abc import ABC, abstractmethod
class Message:
def __init__(self, content):
self.content = content
class MessageFormatter(ABC):
@abstractmethod
def format(self, message):
pass
class JSONFormatter(MessageFormatter):
def format(self, message):
return f"{{'message': '{message.content}'}}"
class XMLFormatter(MessageFormatter):
def format(self, message):
return f"<message>{message.content}</message>"
class MessageDisplayer:
def __init__(self, formatter: MessageFormatter):
self.formatter = formatter
def display(self, message: Message):
formatted_message = self.formatter.format(message)
print(formatted_message)
message = Message("Hello, SOLID!")
json_formatter = JSONFormatter()
message_displayer = MessageDisplayer(json_formatter)
message_displayer.display(message)
xml_formatter = XMLFormatter()
message_displayer = MessageDisplayer(xml_formatter)
message_displayer.display(message)
{'message': 'Hello, SOLID!'}
<message>Hello, SOLID!</message>
-
Single Responsibility Principle (SRP): Separated the concerns into different classes:
Message
for managing message content,MessageFormatter
for formatting messages, andMessageDisplayer
for displaying messages. -
Open/Closed Principle (OCP): Introduced the
MessageFormatter
interface. New formatters can be added without modifying existing code, adhering to OCP. -
Liskov Substitution Principle (LSP): Clients can use instances of derived classes (
JSONFormatter
,XMLFormatter
) through theMessageFormatter
interface without affecting the correctness of the program. -
Interface Segregation Principle (ISP): Clients that only want to format messages can depend on
MessageFormatter
without being forced to depend on the display functionality. -
Dependency Inversion Principle (DIP):
MessageDisplayer
depends on theMessageFormatter
abstraction, not the concrete implementations. It inverts the traditional dependency from high-level modules to low-level modules.
This refactoring makes the application more maintainable, extensible, and robust by adhering to the SOLID principles.
Suppose we are developing a system for a bookstore that handles different types of book transactions such as selling, renting, and exchanging books.
class BookstoreManager:
def __init__(self, books):
self.books = books
def process_transaction(self, book_id, transaction_type):
if transaction_type == "SELL":
# process selling transaction
print(f"Selling book with ID: {book_id}")
elif transaction_type == "RENT":
# process renting transaction
print(f"Renting book with ID: {book_id}")
elif transaction_type == "EXCHANGE":
# process exchange transaction
print(f"Exchanging book with ID: {book_id}")
# Usage
books = {"001": "Book 1", "002": "Book 2"}
bookstore_manager = BookstoreManager(books)
bookstore_manager.process_transaction("001", "SELL")
The main skill a software engineer should have is thinking and ability to solve real-world
problems using programming. Try to reproduce steps described above and understand which concepts were used in order to refactor our bookstore system.
from abc import ABC, abstractmethod
class Book:
def __init__(self, book_id, title):
self.book_id = book_id
self.title = title
class Transaction(ABC):
@abstractmethod
def execute(self, book: Book):
pass
class SellTransaction(Transaction):
def execute(self, book: Book):
print(f"Selling book with ID: {book.book_id}")
class RentTransaction(Transaction):
def execute(self, book: Book):
print(f"Renting book with ID: {book.book_id}")
class ExchangeTransaction(Transaction):
def execute(self, book: Book):
print(f"Exchanging book with ID: {book.book_id}")
class BookstoreManager:
def __init__(self, books):
self.books = books
def process_transaction(self, book_id, transaction: Transaction):
if book_id in self.books:
book = self.books[book_id]
transaction.execute(book)
else:
print(f"Book with ID: {book_id} not found.")
# Usage
books = {"001": Book("001", "Book 1"), "002": Book("002", "Book 2")}
bookstore_manager = BookstoreManager(books)
sell_transaction = SellTransaction()
bookstore_manager.process_transaction("001", sell_transaction)
Which SOLID principle is violated in the
Report
class?
class Report:
def generate_pdf_report(self, data):
pass
def generate_csv_report(self, data):
pass
def print_report(self, report):
pass
A) Single Responsibility Principle (SRP)
B) Open/Closed Principle (OCP)
C) Liskov Substitution Principle (LSP)
D) Interface Segregation Principle (ISP)
Which SOLID principle is violated in the
Vehicle
class?
class Vehicle:
def start_engine(self):
pass
def stop_engine(self):
pass
def fly(self):
# Hint: Logic for flying (applicable only for certain vehicles)
pass
A) Single Responsibility Principle (SRP)
B) Open/Closed Principle (OCP)
C) Liskov Substitution Principle (LSP)
D) Interface Segregation Principle (ISP)
Which SOLID principle is violated in the
NewsPublisher
class?
class NewsPublisher:
def publish_news(self, news):
if self.platform == "Facebook":
# Publish news to Facebook
pass
elif self.platform == "Twitter":
# Publish news to Twitter
pass
A) Single Responsibility Principle (SRP)
B) Open/Closed Principle (OCP)
C) Liskov Substitution Principle (LSP)
D) Dependency Inversion Principle (DIP)
Which SOLID principle is violated in the code above?
class Rectangle:
def set_dimensions(self, width, height):
self.width = width
self.height = height
class Square(Rectangle):
def set_dimensions(self, side):
self.width = side
self.height = side
A) Single Responsibility Principle (SRP)
B) Open/Closed Principle (OCP)
C) Liskov Substitution Principle (LSP)
D) Dependency Inversion Principle (DIP)
Which SOLID principle is most likely to be violated if the
EmailSender
class is used directly in high-level modules?
class EmailSender:
def send_email(self, content, smtp_server):
# Logic to send email using ``SMTP`` server
pass
A) Single Responsibility Principle (SRP)
B) Open/Closed Principle (OCP)
C) Interface Segregation Principle (ISP)
D) Dependency Inversion Principle (DIP)
Which SOLID principle is violated in the
UserInterface
class?
class UserInterface:
def display_text(self, text):
pass
def play_audio(self, audio):
# Play audio (not needed for text-based interfaces)
pass
A) Single Responsibility Principle (SRP)
B) Liskov Substitution Principle (LSP)
C) Interface Segregation Principle (ISP)
D) Dependency Inversion Principle (DIP)
Which SOLID principle is violated in the
MediaPlayer
class?
class MediaPlayer:
def play_media(self, file):
if file.type == "audio":
# Play audio
pass
elif file.type == "video":
# Play video
pass
A) Single Responsibility Principle (SRP)
B) Open/Closed Principle (OCP)
C) Liskov Substitution Principle (LSP)
D) Dependency Inversion Principle (DIP)
Objective: Update an application below to manage a variety of recipes that allow users to add new recipes, store them, and display them in an organized manner. The application should be adaptable to different types of recipes and dietary requirements.
- Users should be able to create new recipes and specify if they are for a special diet.
- The application should be able to save recipes to a file or database.
- Users should be able to retrieve a list of all recipes or search for recipes by various criteria.
- Users should be able to update and delete recipes.
class RecipeOrganizer:
def __init__(self):
self.recipes = []
def add_recipe(self, title, ingredients, instructions):
self.recipes.append({
'title': title,
'ingredients': ingredients,
'instructions': instructions
})
def display_recipes(self):
for recipe in self.recipes:
print(recipe['title'])
print('Ingredients:', recipe['ingredients'])
print('Instructions:', recipe['instructions'])
def save_to_file(self):
with open('recipes.txt', 'w') as file:
for recipe in self.recipes:
file.write(f"{recipe['title']}\n")
file.write(f"{recipe['ingredients']}\n")
file.write(f"{recipe['instructions']}\n\n")
# Usage
organizer = RecipeOrganizer()
organizer.add_recipe('Pasta', ['Pasta', 'Tomato'], 'Boil pasta, add tomato sauce')
organizer.display_recipes()
organizer.save_to_file()
Try to identify all violated SOLID principles and re-write this application.
Rewrite your apps you have created already to match SOLID. Don't forget to split logic into different modules as well.