Top SOLID Principles with Real Code Examples
Top SOLID Principles with Real Code Examples
Most developers know SOLID principles exist but struggle to apply them in real code. You read definitions about single responsibility and open-closed principles, nod along, then face actual code and don't know what to refactor or why. A class handling user authentication, logging, email sending, and database access feels wrong, but which principle does it violate and how do you fix it without creating dozens of tiny classes?
This guide explains SOLID principles through concrete code examples showing violations and fixes. You'll see real patterns that violate each principle, understand why they cause problems in production, and learn specific refactoring techniques to fix them. These examples come from actual production code that survived initial development but broke down during maintenance and scaling.
We'll cover each SOLID principle with before/after code examples, explain the problems each principle solves, and show when following these principles matters versus when it adds unnecessary complexity.
Why SOLID Principles Matter in Production Code
SOLID principles aren't academic exercises—they're solutions to specific problems that emerge in growing codebases. The code you write today is easy to understand because it's fresh in your mind and small. Six months later, when you need to add features or fix bugs, that same code becomes a maintenance burden if it violates these principles.
The core value: SOLID principles make code maintainable by reducing coupling and increasing cohesion. Low coupling means changing one part of your system doesn't require changing unrelated parts. High cohesion means related functionality stays together. These properties determine whether adding a feature takes two hours or two weeks.
The Real Cost of Violating SOLID
SOLID violations create specific failure modes. A class with multiple responsibilities requires changes in unrelated features. A tightly coupled system breaks in unexpected places when you modify one component. Concrete dependencies make testing require elaborate setup with databases and APIs spinning up.
These problems compound. A single-responsibility violation in a core class means every feature touching that class risks breaking unrelated features. This creates fear of changes—developers avoid refactoring because they can't predict what will break. The codebase ossifies.
Single Responsibility Principle (SRP)
Single Responsibility Principle states that a class should have only one reason to change. If changing business logic requires modifying the same class as changing logging behavior, that class has multiple responsibilities.
Violation: God Class Handling Everything
The classic SRP violation is a class that handles business logic, persistence, validation, logging, and email notifications.
class UserService {
constructor() {
this.db = createDatabaseConnection();
this.emailClient = createEmailClient();
this.logger = createLogger();
}
async registerUser(email, password) {
// Validation responsibility
if (!email.includes('@')) {
throw new Error('Invalid email');
}
if (password.length < 8) {
throw new Error('Password too short');
}
// Business logic responsibility
const hashedPassword = await bcrypt.hash(password, 10);
// Persistence responsibility
const result = await this.db.query(
'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
[email, hashedPassword]
);
// Logging responsibility
this.logger.info(`User registered: ${email}`);
// Email responsibility
await this.emailClient.send({
to: email,
subject: 'Welcome!',
body: 'Thanks for registering'
});
// More business logic
return { id: result.rows[0].id, email };
}
}
This class has at least five responsibilities: validation, business logic, persistence, logging, and email notifications. Every time you change validation rules, logging format, email templates, database schema, or business logic, you modify this same class. Testing requires setting up database, email client, and logger even when testing validation logic.
Fix: Separate Classes with Single Responsibilities
Split responsibilities into focused classes, each with one reason to change.
// Validation responsibility
class UserValidator {
validate(email, password) {
const errors = [];
if (!email.includes('@')) {
errors.push('Invalid email format');
}
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
return {
isValid: errors.length === 0,
errors
};
}
}
// Persistence responsibility
class UserRepository {
constructor(db) {
this.db = db;
}
async create(email, hashedPassword) {
const result = await this.db.query(
'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
[email, hashedPassword]
);
return { id: result.rows[0].id, email: result.rows[0].email };
}
}
// Email responsibility
class WelcomeEmailSender {
constructor(emailClient) {
this.emailClient = emailClient;
}
async send(email) {
await this.emailClient.send({
to: email,
subject: 'Welcome!',
body: 'Thanks for registering'
});
}
}
// Business logic coordination
class UserRegistrationService {
constructor(validator, repository, emailSender, logger) {
this.validator = validator;
this.repository = repository;
this.emailSender = emailSender;
this.logger = logger;
}
async registerUser(email, password) {
// Use validator
const validation = this.validator.validate(email, password);
if (!validation.isValid) {
throw new ValidationError(validation.errors);
}
// Hash password (business logic)
const hashedPassword = await bcrypt.hash(password, 10);
// Use repository for persistence
const user = await this.repository.create(email, hashedPassword);
// Log the event
this.logger.info(`User registered: ${email}`);
// Send welcome email
await this.emailSender.send(email);
return user;
}
}
Now each class has one responsibility and one reason to change. Testing UserValidator doesn't require database or email setup. Changing email templates means modifying only WelcomeEmailSender. Database schema changes affect only UserRepository.
| Class | Single Responsibility | Changes When |
|---|---|---|
| UserValidator | Validate user input | Validation rules change |
| UserRepository | Persist users | Database schema changes |
| WelcomeEmailSender | Send welcome emails | Email template changes |
| UserRegistrationService | Coordinate registration flow | Registration business rules change |
Open-Closed Principle (OCP)
Open-Closed Principle states that classes should be open for extension but closed for modification. You should be able to add new functionality without changing existing code. This prevents ripple effects where adding a feature breaks unrelated features.
Violation: Switch Statements for Type Handling
The classic OCP violation is using switch statements or if-else chains to handle different types, requiring modification every time you add a new type.
class PaymentProcessor {
processPayment(payment) {
if (payment.type === 'credit_card') {
// Credit card processing logic
const result = this.chargeCreditCard(
payment.cardNumber,
payment.cvv,
payment.amount
);
return result;
} else if (payment.type === 'paypal') {
// PayPal processing logic
const result = this.processPayPal(
payment.email,
payment.amount
);
return result;
} else if (payment.type === 'crypto') {
// Cryptocurrency processing logic
const result = this.processCrypto(
payment.walletAddress,
payment.currency,
payment.amount
);
return result;
} else {
throw new Error('Unknown payment type');
}
}
chargeCreditCard(cardNumber, cvv, amount) { }
processPayPal(email, amount) { }
processCrypto(walletAddress, currency, amount) { }
}
Every time you add a payment method, you modify PaymentProcessor by adding another if-else branch. This violates open-closed because the class isn't closed for modification—adding features requires changing existing code.
Fix: Polymorphism with Strategy Pattern
Use polymorphism to make the system open for extension without modifying existing code. Define an interface for payment methods, then implement different strategies.
// Payment strategy interface
class PaymentMethod {
process(amount) {
throw new Error('Subclass must implement process()');
}
}
// Concrete implementations
class CreditCardPayment extends PaymentMethod {
constructor(cardNumber, cvv) {
super();
this.cardNumber = cardNumber;
this.cvv = cvv;
}
async process(amount) {
// Credit card processing logic
const gateway = new CreditCardGateway();
return gateway.charge(this.cardNumber, this.cvv, amount);
}
}
class PayPalPayment extends PaymentMethod {
constructor(email) {
super();
this.email = email;
}
async process(amount) {
// PayPal processing logic
const gateway = new PayPalGateway();
return gateway.charge(this.email, amount);
}
}
class CryptoPayment extends PaymentMethod {
constructor(walletAddress, currency) {
super();
this.walletAddress = walletAddress;
this.currency = currency;
}
async process(amount) {
// Crypto processing logic
const gateway = new CryptoGateway();
return gateway.charge(this.walletAddress, this.currency, amount);
}
}
// Payment processor is now closed for modification
class PaymentProcessor {
async processPayment(paymentMethod, amount) {
// Works with any PaymentMethod implementation
return paymentMethod.process(amount);
}
}
// Usage: adding new payment methods requires no changes to PaymentProcessor
const processor = new PaymentProcessor();
const creditCardPayment = new CreditCardPayment('4111111111111111', '123');
await processor.processPayment(creditCardPayment, 99.99);
const paypalPayment = new PayPalPayment('[email protected]');
await processor.processPayment(paypalPayment, 99.99);
// Add new payment method without touching PaymentProcessor
class BankTransferPayment extends PaymentMethod {
constructor(accountNumber, routingNumber) {
super();
this.accountNumber = accountNumber;
this.routingNumber = routingNumber;
}
async process(amount) {
const gateway = new BankTransferGateway();
return gateway.charge(this.accountNumber, this.routingNumber, amount);
}
}
Now PaymentProcessor is closed for modification—adding new payment methods doesn't require changing it. The system is open for extension—create new PaymentMethod subclasses without touching existing code.
Liskov Substitution Principle (LSP)
Liskov Substitution Principle states that objects of a subclass should be substitutable for objects of the superclass without breaking functionality. If code expects a base class, passing a subclass should work correctly without special handling.
Violation: Subclass Changes Expected Behavior
LSP violations occur when subclasses override methods in ways that break the contract established by the base class.
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(size) {
super(size, size);
}
// Square overrides to maintain square property
setWidth(width) {
this.width = width;
this.height = width; // Violates LSP
}
setHeight(height) {
this.width = height; // Violates LSP
this.height = height;
}
}
// This function expects Rectangle behavior
function resizeRectangle(rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(4);
return rectangle.getArea(); // Expects 20 (5 * 4)
}
const rect = new Rectangle(0, 0);
console.log(resizeRectangle(rect)); // 20 - works as expected
const square = new Square(0);
console.log(resizeRectangle(square)); // 16 (4 * 4), not 20 - breaks!
Square violates LSP because substituting it for Rectangle breaks code that expects independent width and height. The function expects setWidth and setHeight to work independently, but Square couples them.
Fix: Composition Over Inheritance
Fix LSP violations by using composition instead of inheritance when subclass behavior fundamentally differs from superclass behavior.
// Define shape interface without inheritance hierarchy
class Shape {
getArea() {
throw new Error('Subclass must implement getArea()');
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
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 Shape {
constructor(size) {
super();
this.size = size;
}
setSize(size) {
this.size = size;
}
getArea() {
return this.size * this.size;
}
}
// Functions work with shapes based on their actual interface
function resizeRectangle(rectangle) {
rectangle.setWidth(5);
rectangle.setHeight(4);
return rectangle.getArea();
}
function resizeSquare(square) {
square.setSize(5);
return square.getArea();
}
Now Square and Rectangle are separate shape types with their own interfaces. No inheritance means no LSP violations. Code that works with rectangles doesn't accidentally receive squares.
Interface Segregation Principle (ISP)
Interface Segregation Principle states that clients shouldn't depend on interfaces they don't use. Large interfaces with many methods force implementations to provide methods they don't need, creating confusion and maintenance burden.
Violation: Fat Interface
A fat interface requires implementations to provide methods that don't make sense for all implementations.
// Fat interface with too many responsibilities
class Worker {
work() { }
eat() { }
sleep() { }
getPaid() { }
attendMeetings() { }
}
class HumanWorker extends Worker {
work() { console.log('Human working'); }
eat() { console.log('Human eating'); }
sleep() { console.log('Human sleeping'); }
getPaid() { console.log('Human getting paid'); }
attendMeetings() { console.log('Human attending meetings'); }
}
class RobotWorker extends Worker {
work() { console.log('Robot working'); }
// Robots don't eat or sleep - but forced to implement
eat() { throw new Error('Robots do not eat'); }
sleep() { throw new Error('Robots do not sleep'); }
getPaid() { throw new Error('Robots do not get paid'); }
attendMeetings() { throw new Error('Robots do not attend meetings'); }
}
RobotWorker is forced to implement methods that don't make sense for robots. Code using Worker interface can't know which methods actually work, leading to runtime errors.
Fix: Segregate Interfaces by Use Case
Split the fat interface into smaller, focused interfaces that clients can implement as needed.
// Focused interfaces
class Workable {
work() {
throw new Error('Implement work()');
}
}
class Feedable {
eat() {
throw new Error('Implement eat()');
}
}
class Restable {
sleep() {
throw new Error('Implement sleep()');
}
}
class Payable {
getPaid() {
throw new Error('Implement getPaid()');
}
}
class MeetingParticipant {
attendMeetings() {
throw new Error('Implement attendMeetings()');
}
}
// Implementations choose which interfaces they need
class HumanWorker extends Workable {
constructor() {
super();
this.feedable = new HumanFeedable();
this.restable = new HumanRestable();
this.payable = new HumanPayable();
this.meetingParticipant = new HumanMeetingParticipant();
}
work() { console.log('Human working'); }
}
class RobotWorker extends Workable {
work() { console.log('Robot working'); }
// Only implements Workable - no need to implement eating, sleeping, etc.
}
// Code depends only on interfaces it actually uses
function manageWorkforce(workables) {
workables.forEach(worker => worker.work());
}
function processPay roll(payables) {
payables.forEach(employee => employee.getPaid());
}
Now each interface represents one cohesive capability. Implementations provide only the methods they actually support. Code depends on focused interfaces instead of fat interfaces.
Dependency Inversion Principle (DIP)
Dependency Inversion Principle states that high-level modules shouldn't depend on low-level modules—both should depend on abstractions. This decouples business logic from implementation details like databases and external services.
Violation: Direct Dependency on Concrete Implementation
Business logic directly instantiating and depending on concrete classes creates tight coupling.
class EmailService {
sendEmail(to, subject, body) {
// Directly uses SendGrid API
const sendgrid = require('@sendgrid/mail');
sendgrid.setApiKey(process.env.SENDGRID_API_KEY);
return sendgrid.send({ to, subject, html: body });
}
}
class UserRegistrationService {
constructor() {
// Directly instantiates concrete EmailService
this.emailService = new EmailService();
this.userRepository = new UserRepository();
}
async register(email, password) {
const user = await this.userRepository.create(email, password);
// Depends on concrete EmailService implementation
await this.emailService.sendEmail(
email,
'Welcome',
'Thanks for registering'
);
return user;
}
}
UserRegistrationService depends directly on EmailService and UserRepository concrete classes. Testing requires actual SendGrid API calls. Switching email providers means modifying EmailService and potentially UserRegistrationService.
Fix: Depend on Abstractions via Dependency Injection
Define interfaces (abstractions) and inject implementations. Business logic depends on interfaces, not concrete classes.
// Abstraction: what email sending looks like
class IEmailSender {
sendEmail(to, subject, body) {
throw new Error('Implement sendEmail()');
}
}
// Concrete implementation depends on abstraction
class SendGridEmailSender extends IEmailSender {
constructor(apiKey) {
super();
this.client = require('@sendgrid/mail');
this.client.setApiKey(apiKey);
}
async sendEmail(to, subject, body) {
return this.client.send({ to, subject, html: body });
}
}
// Alternative implementation
class ConsoleEmailSender extends IEmailSender {
async sendEmail(to, subject, body) {
console.log(`Email to ${to}: ${subject}`);
return { success: true };
}
}
// High-level module depends on abstraction
class UserRegistrationService {
constructor(emailSender, userRepository) {
// Inject dependencies - depends on interfaces
this.emailSender = emailSender;
this.userRepository = userRepository;
}
async register(email, password) {
const user = await this.userRepository.create(email, password);
// Uses injected email sender (works with any IEmailSender)
await this.emailSender.sendEmail(
email,
'Welcome',
'Thanks for registering'
);
return user;
}
}
// Production: inject real implementations
const emailSender = new SendGridEmailSender(process.env.SENDGRID_API_KEY);
const userRepository = new PostgresUserRepository(db);
const registrationService = new UserRegistrationService(
emailSender,
userRepository
);
// Testing: inject test doubles
const testEmailSender = new ConsoleEmailSender();
const testRepository = new InMemoryUserRepository();
const testService = new UserRegistrationService(
testEmailSender,
testRepository
);
Now UserRegistrationService depends on abstractions (IEmailSender interface) not concrete implementations. Testing uses fake implementations without external dependencies. Switching from SendGrid to Mailgun means creating a new MailgunEmailSender—UserRegistrationService doesn't change.
When to Apply SOLID vs Keep It Simple
SOLID principles solve real problems but add complexity. Apply them when they solve actual pain points, not preemptively.
Start Simple, Refactor When Needed
Begin with straightforward implementations. When you identify pain points (difficulty testing, frequent changes to same class, breaking unrelated features), refactor toward SOLID patterns.
// Start simple
class UserService {
async register(email, password) {
const hashed = await bcrypt.hash(password, 10);
const user = await db.query(
'INSERT INTO users (email, password) VALUES ($1, $2) RETURNING *',
[email, hashed]
);
await sendEmail(email, 'Welcome!', 'Thanks for registering');
return user.rows[0];
}
}
// Refactor when pain points emerge:
// - Testing requires database setup
// - Email changes force UserService modification
// - Multiple features need user creation with different email logic
// Then split into SRP-compliant classes as shown earlier
Indicators You Need SOLID Refactoring
| Pain Point | SOLID Principle | Refactoring |
|---|---|---|
| Unrelated features break when modifying a class | Single Responsibility | Split class by responsibility |
| Adding new types requires modifying existing code | Open-Closed | Use polymorphism/strategy pattern |
| Subclass breaks when used in place of superclass | Liskov Substitution | Use composition or separate hierarchies |
| Classes forced to implement unused methods | Interface Segregation | Split interface into focused interfaces |
| Testing requires elaborate setup with external services | Dependency Inversion | Inject dependencies via interfaces |
FAQ
Do I need to follow all SOLID principles from the start?
No. Start with simple, working code. Apply SOLID principles when you encounter specific problems they solve—testing difficulties, frequent changes breaking unrelated features, or adding new functionality requiring extensive modifications. Premature abstraction creates complexity without benefit.
How do I know if a class violates Single Responsibility Principle?
Ask: "How many reasons does this class have to change?" If business logic changes, validation rules change, logging format changes, or database schema changes all require modifying the same class, it has multiple responsibilities. Split it when changes to one concern force you to risk breaking another concern.
Doesn't following SOLID create too many small classes?
Yes, if applied dogmatically. Balance is key. Five focused classes are better than one god class, but fifty micro-classes create navigation overhead. Group related functionality together until you have actual reasons to split it. Refactor toward SOLID patterns when maintenance pain justifies the added structure.
When should I use inheritance vs composition?
Use inheritance when subclass truly "is-a" superclass and shares all behavior. Use composition when you need to combine capabilities or when subclass behavior differs from superclass. Composition is more flexible—favor it unless inheritance provides clear benefits. Liskov Substitution violations often indicate you should use composition.
How does dependency injection relate to Dependency Inversion Principle?
Dependency injection is the technique for implementing Dependency Inversion Principle. DIP says depend on abstractions; dependency injection is how you provide concrete implementations that satisfy those abstractions. You can implement DIP through constructor injection, setter injection, or dependency injection frameworks.
Should I create interfaces for everything to follow Dependency Inversion?
Only create interfaces when you need abstraction—when testing requires mocking dependencies, when you might swap implementations, or when multiple implementations exist. Don't create IUserService interface with only one UserService implementation. That's pointless ceremony without benefit.
How do SOLID principles help with testing?
SRP makes unit tests focused (test validation separately from persistence). DIP enables dependency injection of test doubles instead of real databases/APIs. ISP means mocks only need to implement methods actually used. OCP means adding features doesn't break existing tests. LSP ensures test doubles can substitute real implementations.
Can I use SOLID principles with functional programming?
Yes. SRP applies to functions (single purpose). OCP uses higher-order functions instead of inheritance. DIP uses function parameters instead of dependency injection. ISP means focused function signatures. LSP applies to any abstraction mechanism. SOLID principles transcend programming paradigms—they're about managing dependencies and responsibilities.
What's the performance impact of SOLID principles?
Minimal in most applications. The indirection from abstraction layers (interfaces, dependency injection) adds negligible overhead compared to business logic and I/O operations. Don't avoid SOLID for performance reasons unless profiling proves it's a bottleneck, which is extremely rare.
How do I convince my team to adopt SOLID principles?
Don't mandate adoption. Start by refactoring pain points using SOLID principles, demonstrate the benefits (easier testing, safer changes, clearer code structure), and let the improved maintainability speak for itself. Team adoption follows when developers see concrete improvements, not from top-down mandates.
Conclusion
SOLID principles provide guidance for writing maintainable code by managing dependencies and responsibilities. They're solutions to specific problems that emerge in growing codebases—not rules to follow from day one. Start simple, identify pain points, then refactor toward SOLID patterns when maintenance becomes difficult.
Single Responsibility keeps classes focused on one concern. Open-Closed enables adding functionality without modifying existing code. Liskov Substitution ensures subclasses work anywhere superclasses work. Interface Segregation prevents fat interfaces that force implementations to provide unused methods. Dependency Inversion decouples high-level logic from low-level implementation details.
Apply these principles pragmatically based on actual needs. A simple codebase serving a single purpose doesn't need elaborate SOLID architecture. A complex system with frequent changes and multiple developers benefits greatly from SOLID structure. Let the code tell you when refactoring toward SOLID patterns would improve maintainability.