SOLID Principles and Their Role in Software Development
SOLID is an acronym for five object-oriented programming principles for creating understandable, scalable, and maintainable code.
S: Single Responsibility Principle.
O:Open/Closed Principle.
L: Liskov Substitution Principle.
I: Interface Segregation Principle.
D: Dependency Inversion Principle.
In this article, we will understand what SOLID is and what each of its five principles states.
All shown code examples were executed by Python interpreter version 3.10.12 on a Hostman cloud server running Ubuntu 22.04 operating system.
Single Responsibility Principle (SRP)
SRP (Single Responsibility Principle) is the single responsibility principle, which states that each individual class should specialize in solving only one narrow task. In other words, a class is responsible for only one application component, implementing its logic.
Essentially, this is a form of "division of labor" at the program code level. In house construction, a foreman manages the team, a lumberjack cuts trees, a loader carries logs, a painter paints walls, a plumber lays pipes, a designer creates the interior, etc. Everyone is busy with their own work and works only within their competencies.
In SRP, everything is exactly the same. For example, RequestHandler processes HTTP requests, FileStorage manages local files, Logger records information, and AuthManager checks access rights.
As they say, "flies separately, cutlets separately." If a class has several responsibilities, they need to be separated.
Naturally, SRP directly affects code cohesion and coupling. Both properties are similar in sound but differ in meaning:
Cohesion: A positive characteristic meaning logical integrity of classes relative to each other. The higher the cohesion, the narrower the class functionality.
Coupling: A negative characteristic meaning logical dependency of classes on each other. The higher the coupling, the more strongly the functionality of one class is intertwined with the functionality of another class.
SRP strives to increase cohesion but decrease coupling of classes. Each class solves its narrow task, remaining as independent as possible from the external environment (other classes). However, all classes can (and should) still interact with each other through interfaces.
Example of SRP Violation
An object of a class capable of performing many diverse functions is sometimes called a god object, i.e., an instance of a class that takes on too many responsibilities, performing many logically unrelated functions, for example, business logic management, data storage, database work, sending notifications, etc.
Example code in Python where SRP is violated:
# implementation of god object class
class DataProcessorGod:
# data loading method
def load(self, file_path):
with open(file_path, 'r') as file:
return file.readlines()
# data processing method
def transform(self, data):
return [line.strip().upper() for line in data]
# data saving method
def save(self, file_path, data):
with open(file_path, 'w') as file:
file.writelines("\n".join(data))
# creating a god object
justGod = DataProcessorGod()
# data processing
data = justGod.load("input.txt")
processed_data = justGod.transform(data)
justGod.save("output.txt", processed_data)
The functionality of the program from this example can be divided into two types:
File operations
Data transformation
Accordingly, to create a more optimal level of abstractions that allows easy scaling of the program in the future, it is necessary to allocate each functionality its own separate class.
Example of SRP Application
The shown program is best represented as two specialized classes that don't know about each other:
DataManager: For file operations.
DataTransformer: For data transformation.
Example code in Python where SRP is used:
class DataManager:
def load(self, file_path):
with open(file_path, 'r') as file:
return file.readlines()
def save(self, file_path, data):
with open(file_path, 'w') as file:
file.writelines("\n".join(data))
class DataTransformer:
def transform(self, data):
return [line.strip().upper() for line in data.text]
# creating specialized objects
manager = DataManager()
transformer = DataTransformer()
# data processing
data = manager.load("input.txt")
processed_data = transformer.transform(data)
manager.save("output.txt", processed_data)
In this case, DataManager and DataTransformer interact with each other using strings that are passed as arguments to their methods.
In a more complex implementation, there could exist an additional Data class used for transferring data between different program components:
class Data:
def __init__(self):
self.text = ""
class DataManager:
def load(self, file_path, data):
with open(file_path, 'r') as file:
data.text = file.readlines()
def save(self, file_path, data):
with open(file_path, 'w') as file:
file.writelines("\n".join(data.text))
class DataTransformer:
def transform(self, data):
data.text = [line.strip().upper() for line in data.text]
# creating specialized objects
manager = DataManager()
transformer = DataTransformer()
# data processing
data = Data()
manager.load("input.txt", data)
transformer.transform(data)
manager.save("output.txt", data)
In this case, low-level data operations are wrapped in user classes. Such an implementation is easy to scale.
For example, you can add many methods for working with files (DataManager) and data (DataTransformer), as well as complicate the internal representation of stored information (Data).
SRP Advantages
Undoubtedly, SRP simplifies application maintenance, makes code readable, and reduces dependency between program parts:
Increased scalability: Adding new functions to the program doesn't confuse its logic. A class solving only one task is easier to change without risk of breaking other parts of the system.
Reusability: Logically coherent components implementing program logic can be reused to create new behavior.
Testing simplification: Classes with one responsibility are easier to cover with unit tests, as they don't contain unnecessary logic inside.
Improved readability: Logically related functions wrapped in one class look more understandable. They are easier to understand, make changes to, and find errors in.
Collaborative development: Logically separated code can be written by several programmers at once. In this case, each works on a separate component.
In other words, a class should be responsible for only one task. If several responsibilities are concentrated in a class, it's more difficult to maintain without side effects for the entire program.
Open/Closed Principle (OCP)
OCP (Open/Closed Principle) is the open/closed principle, which states that code should be open for extension but closed for modification. In other words, program behavior modification is carried out only by adding new components. New functionality is layered on top of the old.
In practice, OCP is implemented through inheritance, interfaces, abstractions, and polymorphism. Instead of changing existing code, new classes and functions are added.
For example, instead of implementing a single class that processes all HTTP requests (RequestHandler), you can create one connection manager class (HTTPManager) and several classes for processing different HTTP request methods: RequestGet, RequestPost, RequestDelete. At the same time, request processing classes inherit from the base handler class, Request.
Accordingly, implementing new request processing methods will require not modifying already existing classes, but adding new ones. For example, RequestHead, RequestPut, RequestConnect, RequestOptions, RequestTrace, RequestPatch.
Example of OCP Violation
Without OCP, any change in program operation logic (its behavior) will require modification of its components.
Example code in Python where OCP is violated:
# single request processing class
class RequestHandler:
def handle_request(self, method):
if method == "GET":
return "Processing GET request"
elif method == "POST":
return "Processing POST request"
elif method == "DELETE":
return "Processing DELETE request"
elif method == "PUT":
return "Processing PUT request"
else:
return "Method not supported"
# request processing
handler = RequestHandler()
print(handler.handle_request("GET")) # Processing GET request
print(handler.handle_request("POST")) # Processing POST request
print(handler.handle_request("PATCH")) # Method not supported
Such implementation violates OCP. When adding new methods, you'll have to modify the RequestHandler class, adding new elif processing conditions. The more complex a program with such architecture becomes, the harder it will be to maintain and scale.
Example of OCP Application
The request handler from the example above can be divided into several classes in such a way that subsequent program behavior changes don't require modification of already created classes.
Abstract example code in Python where OCP is used:
from abc import ABC, abstractmethod
# base request handler class
class Request(ABC):
@abstractmethod
def handle(self):
pass
# classes for processing different HTTP methods
class RequestGet(Request):
def handle(self):
return "Processing GET request"
class RequestPost(Request):
def handle(self):
return "Processing POST request"
class RequestDelete(Request):
def handle(self):
return "Processing DELETE request"
class RequestHead(Request):
def handle(self):
return "Processing HEAD request"
class RequestPut(Request):
def handle(self):
return "Processing PUT request"
class RequestConnect(Request):
def handle(self):
return "Processing CONNECT request"
class RequestOptions(Request):
def handle(self):
return "Processing OPTIONS request"
class RequestTrace(Request):
def handle(self):
return "Processing TRACE request"
class RequestPatch(Request):
def handle(self):
return "Processing PATCH request"
# connection manager class
class HTTPManager:
def __init__(self):
self.handlers = {}
def register_handler(self, method: str, handler: Request):
self.handlers[method.upper()] = handler
def handle_request(self, method: str):
handler = self.handlers.get(method.upper())
if handler:
return handler.handle()
return "Method not supported"
# registering handlers in the manager
http_manager = HTTPManager()
http_manager.register_handler("GET", RequestGet())
http_manager.register_handler("POST", RequestPost())
http_manager.register_handler("DELETE", RequestDelete())
http_manager.register_handler("PUT", RequestPut())
# request processing
print(http_manager.handle_request("GET"))
print(http_manager.handle_request("POST"))
print(http_manager.handle_request("PUT"))
print(http_manager.handle_request("TRACE"))
In this case, the base Request class is implemented using ABC and @abstractmethod:
ABC (Abstract Base Class): This is a base class in Python from which you cannot create an instance directly. It is needed exclusively for defining subclasses.
@abstractmethod: A decorator designating a method as abstract. That is, each subclass must implement this method, otherwise creating its instance will be impossible.
Despite the fact that the program code became longer and more complex, its maintenance was significantly simplified. The handler implementation now looks more structured and understandable.
OCP Advantages
Following OCP endows the application development process with some advantages:
Clear extensibility: Program logic can be easily supplemented with new functionality. At the same time, already implemented components remain unchanged.
Error reduction: Adding new components is safer than changing already existing ones. The risk of breaking an already working program is small, and errors after additions probably come from new components.
Actually, OCP can be compared with SRP in terms of ability to isolate the implementation of individual classes from each other. The difference is only that SRP works horizontally, and OCP vertically.
For example, in the case of SRP, the Request class is logically separated from the Handler class horizontally. This is SRP. At the same time, the RequestGet and RequestPost classes, which specify the request method, are logically separated from the Request class vertically, although they are its inheritors. This is OCP.
All three classes (Request, RequestGet, RequestPost) are fully subjective and autonomous; they can be used separately. Just like Handler. Although, of course, this is a matter of theoretical interpretations.
Thus, thanks to OCP, you can create new program components based on old ones, leaving both completely independent entities.
Liskov Substitution Principle (LSP)
LSP (Liskov Substitution Principle) is the Liskov substitution principle, which states that objects in a program should be replaceable by their inheritors without changing program correctness. In other words, inheritor classes should completely preserve the behavior of their parents.
Barbara Liskov is an American computer scientist specializing in data abstractions.
For example, there is a Vehicle class. Car and Helicopter classes inherit from it. Tesla inherits from Car, and Apache from Helicopter. Thus, each subsequent class (inheritor) adds new properties to the previous one (parent).
Vehicles can start and turn off engines. Cars are capable of driving. Helicopters, flying. At the same time, the Tesla car model is capable of using autopilot, and Apache, radio broadcasting.
This creates a kind of hierarchy of abilities:
Vehicles start and turn off engines.
Cars start and turn off engines, and, as a consequence, drive.
Tesla starts and turns off the engine, drives, and uses autopilot.
Helicopters start and turn off engines, and, as a consequence, fly.
Apache starts and turns off engine, flies, and radio broadcasts.
The more specific the vehicle class, the more abilities it possesses. But basic abilities are also preserved.
Example of LSP Violation
Example code in Python where LSP is violated:
class Vehicle:
def __init__(self):
self.x = 0
self.y = 0
self.z = 0
self.engine = False
def on(self):
if not self.engine:
self.engine = True
return "Engine started"
else:
return "Engine already started"
def off(self):
if self.engine:
self.engine = False
return "Engine turned off"
else:
return "Engine already turned off"
def move(self):
if self.engine:
self.x += 10
self.y += 10
self.z += 10
return "Vehicle moved"
else:
return "Engine not started"
# various vehicle classes
class Car(Vehicle):
def move(self):
if self.engine:
self.x += 1
self.y += 1
return "Car drove"
else:
return "Engine not started"
class Helicopter(Vehicle):
def move(self):
if self.engine:
self.x += 1
self.y += 1
self.z += 1
return "Helicopter flew"
else:
return "Engine not started"
def radio(self):
return "Buzz...buzz...buzz..."
In this case, the parent Vehicle class has a move() method denoting vehicle movement. Inheriting classes override the basic Vehicle behavior, setting their own movement method.
Example of LSP Application
Following LSP, it's logical to assume that Car and Helicopter should preserve movement ability, adding unique types of movement on their own: driving and flying.
Example code in Python where LSP is used:
# base vehicle class
class Vehicle:
def __init__(self):
self.x = 0
self.y = 0
self.z = 0
self.engine = False
def on(self):
if not self.engine:
self.engine = True
return "Engine started"
else:
return "Engine already started"
def off(self):
if self.engine:
self.engine = False
return "Engine turned off"
else:
return "Engine already turned off"
def move(self):
if self.engine:
self.x += 10
self.y += 10
self.z += 10
return "Vehicle moved"
else:
return "Engine not started"
# various vehicle classes
class Car(Vehicle):
def ride(self):
if self.engine:
self.x += 1
self.y += 1
return "Car drove"
else:
return "Engine not started"
class Helicopter(Vehicle):
def fly(self):
if self.engine:
self.x += 1
self.y += 1
self.z += 1
return "Helicopter flew"
else:
return "Engine not started"
def radio(self):
return "Buzz...buzz...buzz..."
class Tesla(Car):
def __init__(self):
super().__init__()
self.autopilot = False
def switch(self):
if self.autopilot:
self.autopilot = False
return "Autopilot turned off"
else:
self.autopilot = True
return "Autopilot turned on"
class Apache(Helicopter):
def __init__(self):
super().__init__()
self.frequency = 103.4
def radio(self):
if self.frequency != 0:
return "Buzz...buzz...Copy, how do you hear? [" + str(self.frequency) + " GHz]"
else:
return "Seems like the radio isn't working..."
In this case, Car and Helicopter, just like Tesla and Apache derived from them, will preserve the original Vehicle behavior. Each inheritor adds new behavior to the parent class but preserves its own.
LSP Advantages
Code following LSP works with parent classes the same way as with their inheritors. This way you can implement interfaces capable of interacting with objects of different types but with common properties.
Interface Segregation Principle (ISP)
ISP (Interface Segregation Principle) is the interface segregation principle, which states that program classes should not depend on methods they don't use.
This means that each class should contain only the methods it needs. It should not "drag" unnecessary "baggage" with it. Therefore, instead of one large interface, it's better to create several small specialized interfaces.
In many ways, ISP has features of SRP and LSP, but differs from them.
Example of ISP Violation
Example code in Python that ignores ISP:
# base vehicle
class Vehicle:
def __init__(self):
self.hp = 100
self.power = 0
self.wheels = 0
self.frequency = 103.4
def ride(self):
if self.power > 0 and self.wheels > 0:
return "Driving"
else:
return "Standing"
# vehicles
class Car(Vehicle):
def __init__(self):
super().__init__()
self.hp = 80
self.power = 250
self.wheels = 4
class Bike(Vehicle):
def __init__(self):
super().__init__()
self.hp = 60
self.power = 150
self.wheels = 2
class Helicopter(Vehicle):
def __init__(self):
super().__init__()
self.hp = 120
self.power = 800
def fly(self):
if self.power > 0 and self.propellers > 0:
return "Flying"
else:
return "Standing"
def radio(self):
if self.frequency != 0:
return "Buzz...buzz...Copy, how do you hear? [" + str(self.frequency) + " GHz]"
else:
return "Seems like the radio isn't working..."
# creating vehicles
bmw = Car()
ducati = Bike()
apache = Helicopter()
# operating vehicles
print(bmw.ride()) # OUTPUT: Driving
print(ducati.ride()) # OUTPUT: Driving
print(apache.ride()) # OUTPUT: Standing (redundant method)
print(apache.radio()) # OUTPUT: Buzz...buzz...Copy, how do you hear? [103.4 GHz]
In this case, the base vehicle class implements properties and methods that are redundant for some of its inheritors.
Example of ISP Application
Example code in Python that follows ISP:
# simple vehicle components
class Body:
def __init__(self):
self.hp = 100
class Engine:
def __init__(self):
self.power = 0
class Radio:
def __init__(self):
self.frequency = 103.4
def communicate(self):
if self.frequency != 0:
return "Buzz...buzz...Copy, how do you hear? [" + str(self.frequency) + " GHz]"
else:
return "Seems like the radio isn't working..."
# complex vehicle components
class Suspension(Engine):
def __init__(self):
super().__init__()
self.wheels = 0
def ride(self):
if self.power > 0 and self.wheels > 0:
return "Driving"
else:
return "Standing"
class Frame(Engine):
def __init__(self):
super().__init__()
self.propellers = 0
def fly(self):
if self.power > 0 and self.propellers > 0:
return "Flying"
else:
return "Standing"
# vehicles
class Car(Body, Suspension):
def __init__(self):
super().__init__()
self.hp = 80
self.power = 250
self.wheels = 4
class Bike(Body, Suspension):
def __init__(self):
super().__init__()
self.hp = 60
self.power = 150
self.wheels = 2
class Helicopter(Body, Frame, Radio):
def __init__(self):
super().__init__()
self.hp = 120
self.power = 800
self.propellers = 2
self.frequency = 107.6
class Plane(Body, Frame):
def __init__(self):
super().__init__()
self.hp = 200
self.power = 1200
self.propellers = 4
# creating vehicles
bmw = Car()
ducati = Bike()
apache = Helicopter()
boeing = Plane()
# operating vehicles
print(bmw.ride()) # OUTPUT: Driving
print(ducati.ride()) # OUTPUT: Driving
print(apache.fly()) # OUTPUT: Flying
print(apache.communicate()) # OUTPUT: Buzz...buzz...Copy, how do you hear? [107.6 GHz]
print(boeing.fly()) # OUTPUT: Flying
Thus, all vehicles represent a set of components with their own properties and methods. No finished vehicle class carries an unnecessary element or capability "on board."
ISP Advantages
Thanks to ISP, classes contain only the necessary variables and methods. Moreover, dividing large interfaces into small ones allows specializing logic in the spirit of SRP.
This way interfaces are built from small blocks, like a constructor, each of which implements only its zone of responsibility.
Dependency Inversion Principle (DIP)
DIP (Dependency Inversion Principle) is the dependency inversion principle, which states that upper-level components should not depend on lower-level components.
In other words, abstractions should not depend on details. Details should depend on abstractions.
Such architecture is achieved through common interfaces that hide the implementation of underlying objects.
Example of DIP Violation
Example code in Python that doesn't follow DIP:
# projector
class Light():
def __init__(self, wavelength):
self.wavelength = wavelength
def use(self):
return "Lighting [" + str(self.wavelength) + " nm]"
# helicopter
class Helicopter:
def __init__(self, color="white"):
if color == "white":
self.light = Light(600)
elif color == "blue":
self.light = Light(450)
elif color == "red":
self.light = Light(650)
def project(self):
return self.light.use()
# creating vehicles
helicopterWhite = Helicopter("white")
helicopterRed = Helicopter("red")
# operating vehicles
print(helicopterWhite.project()) # OUTPUT: Lighting [600 nm]
print(helicopterRed.project()) # OUTPUT: Lighting [650 nm]
In this case, the Helicopter implementation depends on the Light implementation. The helicopter must consider the projector configuration principle, passing certain parameters to its object.
Moreover, the script similarly configures the Helicopter using a boolean variable. If the projector or helicopter implementation changes, the configuration parameters may stop working, which will require modification of upper-level object classes.
Example of DIP Application
The projector implementation should be completely isolated from the helicopter implementation. Vertical interaction between both entities should be performed through a special interface.
Example code in Python that considers DIP:
from abc import ABC, abstractmethod
# base projector class
class Light(ABC):
@abstractmethod
def use(self):
pass
# white projector
class NormalLight(Light):
def use(self):
return "Lighting with bright white light"
# red projector
class SpecialLight(Light):
def use(self):
return "Lighting with dim red light"
# helicopter
class Helicopter:
def __init__(self, light):
self.light = light
def project(self):
return self.light.use()
# creating vehicles
helicopterWhite = Helicopter(NormalLight())
helicopterRed = Helicopter(SpecialLight())
# operating vehicles
print(helicopterWhite.project()) # OUTPUT: Lighting with bright white light
print(helicopterRed.project()) # OUTPUT: Lighting with dim red light
In such architecture, the implementation of a specific projector, whether NormalLight or SpecialLight, doesn't affect the Helicopter device. On the contrary, the Helicopter class sets requirements for the presence of certain methods in the Light class and its inheritors.
DIP Advantages
Following DIP reduces program coupling: upper-level code doesn't depend on implementation details, which simplifies component modification or replacement.
Thanks to active use of interfaces, new implementations (inherited from base classes) can be added to the program, which can be used with existing components. In this, DIP overlaps with LSP.
In addition to this, during testing, instead of real lower-level dependencies, empty stubs can be substituted that simulate the functions of real components.
For example, instead of making a request to a remote server, you can simulate delay using a function like time.sleep().
And in general, DIP significantly increases program modularity, vertically encapsulating component logic.
Practical Application of SOLID
SOLID principles help write flexible, maintainable, and scalable code. They are especially relevant when developing backends for high-load applications, working with microservice architecture, and using object-oriented programming.
Essentially, SOLID is aimed at localization (increasing cohesion) and encapsulation (decreasing coupling) of application component logic both horizontally and vertically.
Whatever syntactic constructions a language possesses (perhaps it weakly supports OOP), it allows following SOLID principles to one degree or another.
How SOLID Helps in Real Projects
As a rule, each iteration of a software product either adds new behavior or changes existing behavior, thereby increasing system complexity.
However, complexity growth often leads to disorder. Therefore, SOLID principles set certain architectural frameworks within which a project remains understandable and structured. SOLID doesn't allow chaos to grow.
In real projects, SOLID performs several important functions:
Facilitates making changes
Divides complex systems into simple subsystems
Reduces component dependency on each other
Facilitates testing
Reduces errors and makes code predictable
Essentially, SOLID is a generalized set of rules based on which software abstractions and interactions between different application components are formed.
SOLID and Architectural Patterns
SOLID principles and architectural patterns are two different but interconnected levels of software design.
SOLID principles exist at a lower implementation level, while architectural patterns exist at a higher level.
That is, SOLID can be applied within any architectural pattern, whether MVC, MVVM, Layered Architecture, Hexagonal Architecture.
For example, in a web application built on MVC, one controller can be responsible for processing HTTP requests, and another for executing business logic. Thus, the implementation will follow SRP.
Moreover, within MVC, all dependencies can be passed through interfaces rather than created inside classes. This, in turn, will be following DIP.
SOLID and Code Testability
The main advantage of SOLID is increasing code modularity. Modularity is an extremely useful property for unit testing. After all, classes performing only one task are easier to test than classes consisting of logical "hodgepodge."
To some extent, testing itself begins to follow SRP, performing multiple small and specialized tests instead of one scattered test.
Moreover, thanks to OCP, adding new functionality doesn't break existing tests, but leaves them still relevant, despite the fact that the overall program behavior may have changed.
Actually, tests can be considered a kind of program snapshot. Exclusively in the sense that they frame application logic and test its implementation. Therefore, there's nothing surprising in the fact that tests follow the same principles and architectural patterns as the application itself.
Criticism and Limitations of SOLID
Excessive adherence to SOLID can lead to fragmented code with many small classes and interfaces. In small projects, strict separations may be excessive.
When SOLID May Be Excessive
SOLID principles are relevant in any project. Following them is good practice.
However, complex SOLID abstractions and interfaces may be excessive for simple projects. On the contrary, in complex projects, SOLID can simplify code understanding and help scale implementation.
In other words, if a project is small, fragmenting code into many classes and interfaces is unnecessary. For example, dividing logic into many classes in a simple Telegram bot will only complicate maintenance.
The same applies to code for one-time use (for example, one-time task automation). Strict adherence to SOLID in this case will be a waste of time.
It must be understood that SOLID is not a dogma, but a tool. It should be applied where it's necessary to improve code quality, not complicate it unnecessarily.
Sometimes it's easier to write simple and monolithic code than fragmented and overcomplicated code.
Alternative Design Approaches
Besides SOLID, there are other principles, approaches, and software design patterns that can be used both separately and as a supplement to SOLID:
GRASP (General Responsibility Assignment Software Patterns): A set of responsibility distribution patterns describing class interactions with each other.
YAGNI (You Ain't Gonna Need It): The principle of refusing excessive functionality that is not immediately needed.
KISS (Keep It Simple, Stupid): A programming principle declaring simplicity as the main value of software.
DRY (Don't Repeat Yourself): A software development principle minimizing code duplication.
CQS (Command-Query Separation): A design pattern dividing operations into two categories: commands that change system state and queries that get data from the system.
DDD (Domain-Driven Design): A software development approach structuring code around the enterprise domain.
Nevertheless, no matter how many approaches there are, the main thing is to apply them thoughtfully, not blindly follow them. SOLID is a useful tool, but it needs to be applied consciously.
29 September 2025 · 25 min to read