SOLID Design Principles: Hands-On Examples
Key Takeaways
- SOLID principles help you write code that is easier to maintain, test, and extend as your project grows.
- Applying SOLID leads to better separation of concerns, making it simpler to add features or fix bugs without breaking existing functionality.
- Real-world examples show how following SOLID principles makes your codebase more robust, adaptable, and ready for future changes.
When you start building an app, things might feel straightforward at first. The code works, features get added, and progress seems fine. But as the project’s scope grows, the complexity of the code increases as well, and making changes becomes harder. Eventually, even small changes can introduce bugs you didn’t expect at all.
This is the moment many developers realize they need more than just working code. You need code that can survive growth. That’s where design principles like SOLID start to prove useful. They give developers a clear way to structure their code, so it stays flexible and easier to maintain as the project scales.
In this article, we’ll look at what SOLID is all about and how it helps developers build better object-oriented software.
What does SOLID design principle mean?
SOLID is a famous term used to describe 5 key design principles that help software developers write better object-oriented code. These principles were introduced by Robert C. Martin back in 2000. Since that time, these principles have become a standard in software design.
SOLID stands for:
- S ingle-Responsibility Principle
- O pen/Closed Principle
- L iskov Substitution Principle
- I nterface Segregation Principle
- D ependency Inversion Principle
SOLID principles are not tied to any one language or framework. You can use it with any programming language.
Why use SOLID principles?
Applying the SOLID principles to your codebase offers several important benefits that contribute to long-term software quality and maintainability:
- Code is easier to change without breaking things, making ongoing development smoother and less risky.
- New features are easier to add, allowing your system to evolve and adapt as requirements change.
- Code is easier to test, which leads to more reliable software and simpler quality assurance processes.
- Bugs are easier to fix without causing new ones, reducing the likelihood of introducing regressions.
- The system is easier to grow over time, supporting scalability and ongoing project success.
Let’s look at each SOLID principle with simple code examples to see how it works in practice.
Principle 1: Single-responsibility principle
The idea behind single responsibility is simple. Don’t make a class handle more than it should. A class should handle one specific task and have only one reason to change.
For example, in a library system, a book class should deal only with book data. Saving files or printing details should be handled by separate classes.
A class that fails SRP
class Book {
title
author
constructor(title, author) {
this.title = title
this.author = author
}
save() {
// Save book data to a file
writeToFile(title + " by " + author)
}
printDetails() {
print("Title: " + title + ", Author: " + author)
}
}
In this example, Book handles data, saving, and printing. That’s too much responsibility for one class.
Applying SRP
We can break the above code into smaller, unique classes.
class Book {
title
author
constructor(title, author) {
this.title = title
this.author = author
}
}
class BookSaver {
saveToFile(book: Book) {
writeToFile(book.title + " by " + book.author)
}
}
class BookPrinter {
printDetails(book: Book) {
print("Title: " + book.title + ", Author: " + book.author)
}
}
Usage
book = new Book("1984", "George Orwell")
saver = new BookSaver()
saver.saveToFile(book)
printer = new BookPrinter()
printer.printDetails(book)
Now, each class has a single purpose:
- Book holds data
- BookSaver handles saving
- BookPrinter handles output
Why is this better? Each class does one thing. Changes to saving or printing won’t affect the core Book class, and the code is easier to test and maintain.
Principle 2: Open/closed principle
The open/closed principle means that software components like classes and functions should be
- Open for extension: You should be able to add new functionality to your code.
- Closed for modification: You should not have to change the existing code to add that new functionality.
Let’s see how this applies in a simple notification system.
A design that fails OCP
class Notifier {
send(message, type) {
if (type == "email") {
print("Sending email: " + message)
} else if (type == "sms") {
print("Sending SMS: " + message)
}
}
}
The problem here is that every time you want to support a new type of notification (like push notifications), you must modify Notifier. This violates OCP.
Applying OCP with extension
We can create a base interface and let each notification type handle its own logic.
interface Notification {
send(message)
}
class EmailNotification implements Notification {
send(message) {
print("Sending email: " + message)
}
}
class SMSNotification implements Notification {
send(message) {
print("Sending SMS: " + message)
}
}
Now Notifier works with any Notification.
class Notifier {
notify(notification: Notification, message) {
notification.send(message)
}
}
Usage
notifier = new Notifier()
email = new EmailNotification()
sms = new SMSNotification()
notifier.notify(email, "Welcome to our service!")
notifier.notify(sms, "Your code is 1234")
If you later need push notifications, create a new class.
class PushNotification implements Notification {
send(message) {
print("Sending push notification: " + message)
}
}
No change to Notifier is needed. The design is open for extension but closed for modification, as OCP recommends.
Principle 3: Liskov substitution principle
The Liskov Substitution Principle suggests a subclass should work anywhere its parent class works, without causing problems. That keeps systems reliable and predictable when extended.
Let’s see an example where violating LSP can create problems.
A class that fails LSP
class Bird {
fly() {
print("This bird is flying.")
}
}
class Penguin extends Bird {
fly() {
// Penguins cannot fly, but are forced to override this method
throw Error("Penguins cannot fly.")
}
}
This code doesn’t follow LSP because a Penguin can’t be passed as a Bird to every function that expects a Bird. Look at the example below.
function letBirdFly(bird: Bird) {
bird.fly()
}
penguin = new Penguin()
letBirdFly(penguin) //Throws exception & violates LSP
The issue is that not all birds can fly, but our design assumes they can because fly() is part of Bird. Penguins don’t truly fit as a Bird that can fly.
Applying LSP
The solution is to move abilities like fly() out of Bird and into their own interface. That way, only birds that can actually fly implement it.
abstract class Bird {
// Shared bird logic
}
interface Flyer {
fly()
}
class Sparrow extends Bird implements Flyer {
fly() {
print("Sparrow is flying.")
}
}
class Penguin extends Bird {
swim() {
print("Penguin is swimming.")
}
}
Now, functions that expect flying birds only work with birds that can actually fly.
function letFly(flyer: Flyer) {
flyer.fly()
}
Usage
sparrow = new Sparrow()
letFly(sparrow)
penguin = new Penguin()
penguin.swim()
This design avoids forcing classes to implement methods that don’t make sense for them.
Principle 4: Interface segregation principle
This principle means a class should not be forced to depend on methods it does not need. Instead of creating one large interface that tries to cover everything, it is better to break it down into smaller, focused interfaces.
A design that fails ISP
interface Worker {
work()
eat()
}
Now imagine a RobotWorker that can work but doesn’t eat.
class RobotWorker implements Worker {
work() {
print("Robot is working.")
}
eat() {
// Robots have no need to eat, but are forced to implement this method
throw Error("Robot does not eat.")
}
}
Here, RobotWorker is forced to depend on a method that makes no sense for it. This violates the Interface Segregation Principle.
Applying ISP
We can split the interface into smaller, unique pieces.
interface Workable {
work()
}
interface Eatable {
eat()
}
Now, classes can implement only what they need.
class HumanWorker implements Workable, Eatable {
work() {
print("Human is working.")
}
eat() {
print("Human is eating.")
}
}
class RobotWorker implements Workable {
work() {
print("Robot is working.")
}
}
Usage
human = new HumanWorker()
robot = new RobotWorker()
human.work()
human.eat()
robot.work()
No class is forced to depend on methods it doesn’t need. The design is cleaner and follows the Interface Segregation Principle.
Principle 5. Dependency inversion principle
This principle explains that high-level components of the code should not rely on low-level components. Both should rely on abstractions instead. It helps decouple components of a system so they can change independently without causing damage to each other.
Let’s go check out an example.
A design that fails DIP
// Low-level class
class FileLogger {
log(message) {
print("Logging to file: " + message)
}
}
// High-level class
class UserService {
constructor(logger: FileLogger) {
this.logger = logger
}
register(username) {
// registration logic
this.logger.log("User '" + username + "' registered.")
}
}
Here, UserService depends directly on FileLogger. If you want to add database logging or switch to another logging method, you will have to modify UserService itself. This tight coupling violates the Dependency Inversion Principle.
Applying DIP
We can introduce a LoggerInterface abstraction.
interface Logger {
log(message)
}
Then FileLogger, DatabaseLogger, or any other logger can implement this interface.
class FileLogger implements Logger {
log(message) {
print("Logging to file: " + message)
}
}
class DatabaseLogger implements Logger {
log(message) {
print("Logging to database: " + message)
}
}
Now UserService depends on the abstraction created by the interface.
class UserService {
constructor(logger: Logger) {
this.logger = logger
}
register(username) {
// registration logic
this.logger.log("User '" + username + "' registered.")
}
}
Usage
fileLogger = new FileLogger()
dbLogger = new DatabaseLogger()
userService1 = new UserService(fileLogger)
userService1.register("Alice")
userService2 = new UserService(dbLogger)
userService2.register("Bob")
Now UserService works with any logger without needing changes. Both the high-level service and the low-level logger depend only on the abstraction.
How would a developer apply SOLID principles?
Let’s move beyond theory. Here’s what using SOLID principles looks like in real life.
Take user management. It’s tempting to let one class handle everything — storing data, sending emails, logging actions. But you know where that leads: a class that’s impossible to maintain. Instead, you apply the Single Responsibility Principle. One class handles the data. Another takes care of emails. A third deals with logging. Now, when you need to change one piece, you don’t risk breaking the rest.
Or think about payments. Maybe you start with credit cards. Down the line, your team wants PayPal, maybe crypto. Suppose you’ve designed with Open/Closed in mind, no problem. You don’t touch the working code. You add new classes for the new payment types and move on. The existing system stays stable.
And then there’s the pain of swapping services. Maybe you started with a file logger and later want to move to a cloud service. If your code is tightly coupled, that switch is painful. With Dependency Inversion, it’s not. You depend on abstractions. You swap services without digging into your core logic.
These are the kind of small design choices that make a big difference. A wise developer writes code that’s ready for what’s next.
FAQs about SOLID Design Principles
Related Articles

How to Use LLMs for Log File Analysis: Examples, Workflows, and Best Practices

Beyond Deepfakes: Why Digital Provenance is Critical Now

The Best IT/Tech Conferences & Events of 2026

The Best Artificial Intelligence Conferences & Events of 2026

The Best Blockchain & Crypto Conferences in 2026

Log Analytics: How To Turn Log Data into Actionable Insights

The Best Security Conferences & Events 2026

Top Ransomware Attack Types in 2026 and How to Defend
