SOLID Principles in Real Life
Published on November 24, 2025
SOLID Principles in Real Life
SOLID principles are one of those concepts every developer has heard of, but few truly understand in practice. They aren’t abstract rules for theoretical debates; they’re practical principles that, when applied correctly, can make the difference between a maintainable project and one that collapses under its own weight.
I’ve worked on projects that violated all SOLID principles, and every change was a nightmare. I’ve worked on projects that followed them, and changes were simple and safe. The difference isn’t academic; it’s real and affects your daily productivity.
In this article, I’ll explain each SOLID principle with practical real-world examples, showing what happens when you violate them and how to apply them correctly.
What is SOLID?
SOLID is an acronym for five software design principles:
- S - Single Responsibility Principle
- O - Open/Closed Principle
- L - Liskov Substitution Principle
- I - Interface Segregation Principle
- D - Dependency Inversion Principle
Each principle solves a specific problem you’ll encounter in real projects.
S: Single Responsibility Principle (SRP)
A class should have only one reason to change.
This is probably the most important principle and the most violated. A class with multiple responsibilities is hard to understand, test, and maintain.
❌ Violation: Class with multiple responsibilities
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
// Responsibility 1: Validation
validate() {
if (!this.email.includes('@')) {
throw new Error('Invalid email');
}
}
// Responsibility 2: Persistence
save() {
database.save(this);
}
// Responsibility 3: Sending emails
sendWelcomeEmail() {
emailService.send(this.email, 'Welcome!');
}
// Responsibility 4: Report generation
generateReport() {
return reportService.generate(this);
}
}
Problems:
- If validation logic changes, you have to touch
User - If how data is saved to the database changes, you have to touch
User - If the email service changes, you have to touch
User - If how reports are generated changes, you have to touch
User
Every change affects the same class, increasing the risk of breaking something.
✅ Solution: Separate responsibilities
// Single responsibility: Represent a user
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
}
// Single responsibility: Validate users
class UserValidator {
validate(user) {
if (!user.email || !user.email.includes('@')) {
throw new ValidationError('Invalid email');
}
if (!user.name || user.name.length < 2) {
throw new ValidationError('Invalid name');
}
}
}
// Single responsibility: User persistence
class UserRepository {
save(user) {
database.save(user);
}
findById(id) {
return database.findById(id);
}
}
// Single responsibility: Sending emails
class EmailService {
sendWelcomeEmail(user) {
emailService.send(user.email, 'Welcome!');
}
}
// Single responsibility: Report generation
class ReportService {
generateUserReport(user) {
return reportService.generate(user);
}
}
Now each class has only one reason to change. If validation changes, you only touch UserValidator. If persistence changes, you only touch UserRepository.
Practical example: Course system
In a course management system, you can separate responsibilities like this:
// ❌ Before: Everything in one class
class Course {
validate() { /* ... */ }
save() { /* ... */ }
sendNotification() { /* ... */ }
calculatePrice() { /* ... */ }
generateCertificate() { /* ... */ }
}
// ✅ After: Responsibilities separated
class Course { /* Data only */ }
class CourseValidator { /* Validation */ }
class CourseRepository { /* Persistence */ }
class NotificationService { /* Notifications */ }
class PricingService { /* Price calculation */ }
class CertificateService { /* Certificate generation */ }
Every change is now localized and safe.
O: Open/Closed Principle (OCP)
Software entities should be open for extension, but closed for modification.
Instead of modifying existing code (which can break things), you should be able to extend it.
❌ Violation: Modifying existing code
class PaymentProcessor {
processPayment(amount, method) {
if (method === 'credit_card') {
// Credit card logic
return creditCardProcessor.charge(amount);
} else if (method === 'paypal') {
// PayPal logic
return paypalProcessor.charge(amount);
} else if (method === 'crypto') {
// Crypto logic
return cryptoProcessor.charge(amount);
}
// Each new method requires modifying this class
}
}
Problem: Every time you add a new payment method, you have to modify PaymentProcessor, which can break existing code.
✅ Solution: Extension without modification
// Common interface
class PaymentMethod {
process(amount) {
throw new Error('Must implement process method');
}
}
// Specific implementations
class CreditCardPayment extends PaymentMethod {
process(amount) {
return creditCardProcessor.charge(amount);
}
}
class PayPalPayment extends PaymentMethod {
process(amount) {
return paypalProcessor.charge(amount);
}
}
class CryptoPayment extends PaymentMethod {
process(amount) {
return cryptoProcessor.charge(amount);
}
}
// Processor that doesn't need modification
class PaymentProcessor {
processPayment(amount, paymentMethod) {
return paymentMethod.process(amount);
}
}
Now you can add new payment methods without modifying PaymentProcessor:
class BankTransferPayment extends PaymentMethod {
process(amount) {
return bankTransferProcessor.charge(amount);
}
}
// You don't need to modify PaymentProcessor
const processor = new PaymentProcessor();
processor.processPayment(100, new BankTransferPayment());
Practical example: Notification system
In a notification system, you can use this principle for different types:
class NotificationService {
send(notification) {
return notification.send();
}
}
class EmailNotification {
send() { /* ... */ }
}
class SMSNotification {
send() { /* ... */ }
}
class PushNotification {
send() { /* ... */ }
}
// Adding Discord notifications doesn't require modifying NotificationService
class DiscordNotification {
send() { /* ... */ }
}
L: Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable by objects of its subclasses without breaking the application.
If you have a base class and a derived class, you should be able to use the derived class anywhere you use the base class.
❌ Violation: Subclass that breaks the contract
class Rectangle {
constructor(width, height) {
this.width = width;
this.height = height;
}
setWidth(width) {
this.width = width;
}
setHeight(height) {
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Rectangle {
constructor(side) {
super(side, side);
}
setWidth(width) {
this.width = width;
this.height = width; // Breaks expected behavior
}
setHeight(height) {
this.width = height;
this.height = height; // Breaks expected behavior
}
}
// Code that expects a Rectangle
function testRectangle(rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(4);
console.log(rectangle.getArea()); // Expects 20
// With Square, you get 16, not 20 - unexpected behavior
}
Problem: Square cannot replace Rectangle without breaking expected behavior.
✅ Solution: Design that respects the contract
class Shape {
getArea() {
throw new Error('Must implement getArea');
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
getArea() {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(side) {
super();
this.side = side;
}
getArea() {
return this.side * this.side;
}
}
// Now both can be used where a Shape is expected
function calculateTotalArea(shapes) {
return shapes.reduce((total, shape) => total + shape.getArea(), 0);
}
const shapes = [
new Rectangle(5, 4),
new Square(3)
];
console.log(calculateTotalArea(shapes)); // Works correctly
I: Interface Segregation Principle (ISP)
Clients should not depend on interfaces they don’t use.
Instead of one large interface, use specific, small interfaces.
❌ Violation: Large interface
class Worker {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
}
class Human extends Worker {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
}
class Robot extends Worker {
work() { /* ... */ }
eat() {
throw new Error('Robots don\'t eat');
}
sleep() {
throw new Error('Robots don\'t sleep');
}
}
Problem: Robot is forced to implement methods it doesn’t need.
✅ Solution: Segregated interfaces
class Workable {
work() {
throw new Error('Must implement work');
}
}
class Eatable {
eat() {
throw new Error('Must implement eat');
}
}
class Sleepable {
sleep() {
throw new Error('Must implement sleep');
}
}
class Human extends Workable, Eatable, Sleepable {
work() { /* ... */ }
eat() { /* ... */ }
sleep() { /* ... */ }
}
class Robot extends Workable {
work() { /* ... */ }
// Doesn't need to implement eat() or sleep()
}
Practical example: Transaction system
In a transaction system, you can separate interfaces for different types:
// ❌ Before: Large interface
class Transaction {
process() { /* ... */ }
validate() { /* ... */ }
sendEmail() { /* ... */ }
generateReceipt() { /* ... */ }
refund() { /* ... */ }
}
// ✅ After: Segregated interfaces
class Processable {
process() { /* ... */ }
}
class Validatable {
validate() { /* ... */ }
}
class Refundable {
refund() { /* ... */ }
}
// A simple transaction only implements what it needs
class SimpleTransaction extends Processable, Validatable {
process() { /* ... */ }
validate() { /* ... */ }
}
// A full transaction implements everything
class FullTransaction extends Processable, Validatable, Refundable {
process() { /* ... */ }
validate() { /* ... */ }
refund() { /* ... */ }
}
D: Dependency Inversion Principle (DIP)
Depend on abstractions, not on concretions.
High-level modules should not depend on low-level modules. Both should depend on abstractions.
❌ Violation: Direct dependency on concretions
class UserService {
constructor() {
// Direct dependency on MySQLDatabase
this.database = new MySQLDatabase();
this.emailService = new SendGridEmailService();
}
createUser(userData) {
this.database.save(userData);
this.emailService.send(userData.email, 'Welcome!');
}
}
Problem: UserService is coupled to specific implementations. If you want to switch from MySQL to PostgreSQL, or from SendGrid to another email service, you have to modify UserService.
✅ Solution: Depend on abstractions
// Abstractions (interfaces)
class UserRepository {
save(user) {
throw new Error('Must implement save');
}
}
class EmailService {
send(to, subject, body) {
throw new Error('Must implement send');
}
}
// Concrete implementations
class MySQLUserRepository extends UserRepository {
save(user) {
// MySQL-specific implementation
}
}
class SendGridEmailService extends EmailService {
send(to, subject, body) {
// SendGrid-specific implementation
}
}
// High-level service depends on abstractions
class UserService {
constructor(userRepository, emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
createUser(userData) {
this.userRepository.save(userData);
this.emailService.send(userData.email, 'Welcome!', '...');
}
}
// Dependency injection
const userService = new UserService(
new MySQLUserRepository(),
new SendGridEmailService()
);
Now you can change implementations without modifying UserService:
// Switch to PostgreSQL without modifying UserService
class PostgreSQLUserRepository extends UserRepository {
save(user) {
// PostgreSQL implementation
}
}
// Switch to another email service without modifying UserService
class MailgunEmailService extends EmailService {
send(to, subject, body) {
// Mailgun implementation
}
}
const userService = new UserService(
new PostgreSQLUserRepository(),
new MailgunEmailService()
);
Real example: All my projects
In modern projects, dependency injection is fundamental:
class CourseService {
constructor(
courseRepository,
notificationService,
pricingService
) {
this.courseRepository = courseRepository;
this.notificationService = notificationService;
this.pricingService = pricingService;
}
// Business logic that doesn't depend on specific implementations
}
This allows:
- Easy testing: You can inject mocks
- Flexibility: Change implementations without modifying business logic
- Decoupling: Services don’t know implementation details
Applying SOLID in practice
SOLID principles aren’t rigid rules. They’re guidelines that help you write more maintainable code. You don’t need to apply them perfectly from the start, but understanding them helps you make better decisions.
When to apply SOLID
- Projects that will grow: If you know the code will change, SOLID helps you prepare for it
- Large teams: SOLID makes it easier for multiple developers to work without conflicts
- Critical code: If the code is critical to the business, SOLID reduces risks
When not to obsess
- Prototypes: For code that will be discarded, you don’t need perfect SOLID
- Simple scripts: A one-off script doesn’t need complex architecture
- Code that won’t change: If the code will never change, over-engineering is unnecessary
My practical experience
I’ve seen projects that violated all SOLID principles. Every change required modifying multiple classes, every bug was hard to find, and every new developer took weeks to become productive.
I’ve seen projects that followed SOLID. Changes were localized, bugs were easy to find, and new developers were productive in days.
The difference isn’t theoretical; it’s practical and affects your daily work.
Applying SOLID constantly isn’t about perfection, it’s about making the code a little better each time. Every time you see a class with multiple responsibilities, separate it. Every time you see direct dependencies, invert them.
My personal perspective
SOLID principles aren’t abstract concepts for theoretical debates. They’re practical principles that, when applied correctly, make your code more maintainable, testable, and extensible.
I’ve seen projects that violated all SOLID principles. Every change required modifying multiple classes, every bug was hard to find, and every new developer took weeks to become productive.
I’ve seen projects that followed SOLID. Changes were localized, bugs were easy to find, and new developers were productive in days.
The difference isn’t theoretical; it’s practical and affects your daily work.
Applying SOLID constantly isn’t about perfection, it’s about making the code a little better each time. Every time you see a class with multiple responsibilities, separate it. Every time you see direct dependencies, invert them.
You don’t need to apply them perfectly from the start. But understanding them helps you recognize problems before they become nightmares, and to make better architectural decisions.
Because at the end of the day, the code you write today will be maintained by someone (possibly yourself) tomorrow. And SOLID principles are one of the best ways to ensure that maintenance is possible.