SOLID Principles
What Are SOLID Principles?
SOLID is a mnemonic acronym introduced by Robert C. Martin (Uncle Bob) to describe five fundamental principles of object-oriented programming and design:
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
These principles work together to create software that is maintainable, extensible, and robust. They guide developers in creating systems that can evolve with changing requirements without requiring extensive rewrites.
Single Responsibility Principle (SRP)
Single Responsibility Principle
In business terms, SRP means assigning clear, distinct responsibilities to different parts of your system:
- Similar to how organizations separate departments with specific responsibilities
- Reduces risk when changes are needed, as changes affect smaller parts of the system
- Improves team workflows since developers can work on isolated components
- Makes onboarding easier as components have clear, focused purposes
Single Responsibility Principle
A class should have only one reason to change, meaning it should have only one responsibility or job.
- Focuses on cohesion - everything in the class should be related to a single purpose
- Reduces code complexity and improves maintainability
- Makes classes easier to understand, test, and modify
- Enables better code organization and composition
SRP Example
Single Responsibility Principle
class UserManager {
constructor(database) {
this.database = database;
}
// User data management
saveUser(user) {
// Validate user input
if (!user.email || !user.email.includes('@')) {
throw new Error('Invalid email');
}
if (!user.password || user.password.length < 8) {
throw new Error('Password too short');
}
// Hash password
const hashedPassword = this.hashPassword(user.password);
user.password = hashedPassword;
// Save to database
this.database.save('users', user);
// Send welcome email
this.sendWelcomeEmail(user.email);
}
hashPassword(password) {
// Complex password hashing logic
return 'hashed_' + password;
}
sendWelcomeEmail(email) {
// Email server configuration
const emailServer = {
host: 'smtp.example.com',
port: 587,
secure: true
};
// Email sending logic
console.log(`Email sent to ${email} via ${emailServer.host}`);
}
}
// User validation responsibility
class UserValidator {
validate(user) {
if (!user.email || !user.email.includes('@')) {
throw new Error('Invalid email');
}
if (!user.password || user.password.length < 8) {
throw new Error('Password too short');
}
return true;
}
}
// Password security responsibility
class PasswordService {
hashPassword(password) {
// Complex password hashing logic
return 'hashed_' + password;
}
}
// Email service responsibility
class EmailService {
constructor(emailConfig) {
this.emailConfig = emailConfig;
}
sendWelcomeEmail(email) {
// Email sending logic
console.log(`Email sent to ${email} via ${this.emailConfig.host}`);
}
}
// User management responsibility
class UserManager {
constructor(database, validator, passwordService, emailService) {
this.database = database;
this.validator = validator;
this.passwordService = passwordService;
this.emailService = emailService;
}
saveUser(user) {
// Validate
this.validator.validate(user);
// Hash password
user.password = this.passwordService.hashPassword(user.password);
// Save to database
this.database.save('users', user);
// Send welcome email
this.emailService.sendWelcomeEmail(user.email);
}
}
Open-Closed Principle (OCP)
Open-Closed Principle
From a business perspective, OCP enables safer evolution of your software:
- Like how companies add new product lines without disrupting existing ones
- Reduces regression risk when adding new features
- Allows for more predictable release cycles and quality assurance
- Enables incremental feature delivery without destabilizing the core system
Open-Closed Principle
Software entities (classes, modules, functions) should be open for extension but closed for modification.
- Adding new functionality should be done by adding new code, not changing existing code
- Uses abstraction, inheritance, and composition to allow behavior extension
- Reduces risk of introducing bugs in existing functionality
- Promotes use of interfaces and abstract classes
OCP Example
Open-Closed Principle
class PaymentProcessor {
processPayment(payment) {
if (payment.type === 'credit') {
// Process credit card payment
console.log(`Processing credit card payment of $${payment.amount}`);
// Connect to credit card gateway
// Validate credit card
// Charge the card
return true;
}
else if (payment.type === 'paypal') {
// Process PayPal payment
console.log(`Processing PayPal payment of $${payment.amount}`);
// Connect to PayPal API
// Redirect to PayPal
// Verify PayPal transaction
return true;
}
else if (payment.type === 'bitcoin') {
// Process Bitcoin payment
console.log(`Processing Bitcoin payment of $${payment.amount}`);
// Generate Bitcoin address
// Verify blockchain transaction
return true;
}
return false;
}
}
// Abstract payment method interface
class PaymentMethod {
processPayment(payment) {
throw new Error('Method not implemented');
}
}
// Concrete implementation for credit cards
class CreditCardPayment extends PaymentMethod {
processPayment(payment) {
console.log(`Processing credit card payment of $${payment.amount}`);
// Connect to credit card gateway
// Validate credit card
// Charge the card
return true;
}
}
// Concrete implementation for PayPal
class PayPalPayment extends PaymentMethod {
processPayment(payment) {
console.log(`Processing PayPal payment of $${payment.amount}`);
// Connect to PayPal API
// Redirect to PayPal
// Verify PayPal transaction
return true;
}
}
// Concrete implementation for Bitcoin
class BitcoinPayment extends PaymentMethod {
processPayment(payment) {
console.log(`Processing Bitcoin payment of $${payment.amount}`);
// Generate Bitcoin address
// Verify blockchain transaction
return true;
}
}
// Payment processor using the strategy pattern
class PaymentProcessor {
constructor() {
this.paymentMethods = {};
}
registerPaymentMethod(type, paymentMethod) {
this.paymentMethods[type] = paymentMethod;
}
processPayment(payment) {
if (this.paymentMethods[payment.type]) {
return this.paymentMethods[payment.type].processPayment(payment);
}
return false;
}
}
Liskov Substitution Principle (LSP)
Liskov Substitution Principle
In business terms, LSP ensures reliable substitution in your organization's systems:
- Similar to how a manager expects any team member to fulfill their role competently
- Prevents disruption when swapping components or service providers
- Ensures that system upgrades and replacements work as expected
- Provides confidence when extending functionality through inheritance
Liskov Substitution Principle
Objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.
- Subclasses must maintain the behavior expected from their base class
- Ensures that inheritance hierarchies are modeled correctly
- Constrains how contracts are implemented in inheritance hierarchies
- Prevents unexpected behavior when using polymorphism
LSP Example
Liskov Substitution Principle
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;
}
}
// Square is a special rectangle where width = height
class Square extends Rectangle {
constructor(size) {
super(size, size);
}
// Square must enforce width = height
setWidth(width) {
this.width = width;
this.height = width; // Side effect!
}
// Square must enforce width = height
setHeight(height) {
this.width = height; // Side effect!
this.height = height;
}
}
// Client code that uses a rectangle
function increaseRectangleWidth(rectangle) {
// Store the original height
const originalHeight = rectangle.height;
// Increase width by 2x
rectangle.setWidth(rectangle.width * 2);
// Verify the area calculation
console.log(`New area: ${rectangle.getArea()}`);
// Ensure original height is maintained
return rectangle.height === originalHeight;
}
// Base shape interface
class Shape {
getArea() {
throw new Error('Method not implemented');
}
}
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;
}
}
// Square is a separate shape, not a Rectangle subtype
class Square extends Shape {
constructor(size) {
super();
this.size = size;
}
setSize(size) {
this.size = size;
}
getArea() {
return this.size * this.size;
}
}
// Client code that uses shapes
function increaseShapeWidth(shape) {
if (shape instanceof Rectangle) {
const originalHeight = shape.height;
shape.setWidth(shape.width * 2);
return shape.height === originalHeight;
} else if (shape instanceof Square) {
// Handle square differently
const originalArea = shape.getArea();
shape.setSize(Math.sqrt(originalArea * 2));
return true;
}
}
Interface Segregation Principle (ISP)
Interface Segregation Principle
From a business perspective, ISP promotes focused, modular services:
- Similar to offering customers specific service packages rather than all-or-nothing bundles
- Reduces unnecessary dependencies between teams and systems
- Enables more targeted updates and improvements
- Allows for more flexible integration with external systems and partners
Interface Segregation Principle
Clients should not be forced to depend on interfaces they do not use.
- Prefers many specific interfaces over a single general-purpose interface
- Reduces side effects and dependencies when interface changes
- Makes systems more decoupled and easier to refactor, change, and redeploy
- Prevents "fat interfaces" that force clients to implement unnecessary methods
ISP Example
Interface Segregation Principle
// One large interface for all device operations
interface MultiFunctionDevice {
print(document);
scan(document);
fax(document);
copy(document);
staple(document);
bindDocument(document);
emailDocument(document, emailAddress);
authenticateUser(credentials);
}
// Implementation for a high-end office printer
class EnterpriseOfficePrinter implements MultiFunctionDevice {
print(document) {
console.log('Printing document...');
}
scan(document) {
console.log('Scanning document...');
}
fax(document) {
console.log('Faxing document...');
}
copy(document) {
console.log('Copying document...');
}
staple(document) {
console.log('Stapling document...');
}
bindDocument(document) {
console.log('Binding document...');
}
emailDocument(document, emailAddress) {
console.log(`Emailing document to ${emailAddress}...`);
}
authenticateUser(credentials) {
console.log('Authenticating user...');
return true;
}
}
// Implementation for a basic home printer
// Must implement ALL methods, even unsupported ones
class BasicHomePrinter implements MultiFunctionDevice {
print(document) {
console.log('Printing document...');
}
scan(document) {
console.log('Scanning document...');
}
// Methods that are not supported must still be implemented
// with empty or throw implementations
fax(document) {
throw new Error('Fax not supported on this device');
}
copy(document) {
console.log('Copying document...');
}
staple(document) {
throw new Error('Stapling not supported on this device');
}
bindDocument(document) {
throw new Error('Binding not supported on this device');
}
emailDocument(document, emailAddress) {
throw new Error('Email not supported on this device');
}
authenticateUser(credentials) {
throw new Error('Authentication not supported on this device');
}
}
// Segregated interfaces for different device capabilities
interface Printer {
print(document);
}
interface Scanner {
scan(document);
}
interface Copier {
copy(document);
}
interface Fax {
fax(document);
}
interface DocumentFinisher {
staple(document);
bindDocument(document);
}
interface NetworkEnabled {
emailDocument(document, emailAddress);
authenticateUser(credentials);
}
// Implementation for a high-end office printer
// Only implements the interfaces it supports
class EnterpriseOfficePrinter implements
Printer, Scanner, Copier, Fax, DocumentFinisher, NetworkEnabled {
print(document) {
console.log('Printing document...');
}
scan(document) {
console.log('Scanning document...');
}
fax(document) {
console.log('Faxing document...');
}
copy(document) {
console.log('Copying document...');
}
staple(document) {
console.log('Stapling document...');
}
bindDocument(document) {
console.log('Binding document...');
}
emailDocument(document, emailAddress) {
console.log(`Emailing document to ${emailAddress}...`);
}
authenticateUser(credentials) {
console.log('Authenticating user...');
return true;
}
}
// Implementation for a basic home printer
// Only implements the interfaces it actually supports
class BasicHomePrinter implements Printer, Scanner, Copier {
print(document) {
console.log('Printing document...');
}
scan(document) {
console.log('Scanning document...');
}
copy(document) {
console.log('Copying document...');
}
// No need to implement unsupported methods
}
Dependency Inversion Principle (DIP)
Dependency Inversion Principle
In business terms, DIP establishes flexible, interchangeable components:
- Similar to how businesses define standard interfaces for suppliers to implement
- Reduces vendor lock-in and allows changing implementations without disruption
- Simplifies testing and quality assurance processes
- Enables gradual system modernization by swapping components incrementally
Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
- Decouples software modules through abstractions
- Uses dependency injection to provide implementation at runtime
- Makes code more testable through mock implementations
- Enables flexibility to swap implementations without changing clients
DIP Example
Dependency Inversion Principle
// Low-level module
class MySQLDatabase {
constructor() {
// MySQL specific connection setup
this.connection = 'MySQL Connection';
console.log('MySQL Database connected');
}
save(data) {
console.log(`Saving data to MySQL database: ${JSON.stringify(data)}`);
}
delete(id) {
console.log(`Deleting record ${id} from MySQL database`);
}
update(id, data) {
console.log(`Updating record ${id} in MySQL database`);
}
find(id) {
console.log(`Finding record ${id} in MySQL database`);
return { id, name: 'Test Record' };
}
}
// High-level module that directly depends on the low-level module
class UserService {
constructor() {
// Direct dependency on MySQL implementation
this.database = new MySQLDatabase();
}
createUser(user) {
// Some business logic for validation
if (!user.name || !user.email) {
throw new Error('User must have name and email');
}
// Save to MySQL
this.database.save(user);
return true;
}
deleteUser(userId) {
this.database.delete(userId);
return true;
}
updateUser(userId, userData) {
// Some business logic
if (userData.role === 'admin' && !userData.approvedBy) {
throw new Error('Admin users must be approved');
}
this.database.update(userId, userData);
return true;
}
findUser(userId) {
return this.database.find(userId);
}
}
// Abstract database interface (abstraction)
class DatabaseInterface {
save(data) {
throw new Error('Method not implemented');
}
delete(id) {
throw new Error('Method not implemented');
}
update(id, data) {
throw new Error('Method not implemented');
}
find(id) {
throw new Error('Method not implemented');
}
}
// Low-level module implementing the interface
class MySQLDatabase extends DatabaseInterface {
constructor() {
super();
// MySQL specific connection setup
this.connection = 'MySQL Connection';
console.log('MySQL Database connected');
}
save(data) {
console.log(`Saving data to MySQL database: ${JSON.stringify(data)}`);
}
delete(id) {
console.log(`Deleting record ${id} from MySQL database`);
}
update(id, data) {
console.log(`Updating record ${id} in MySQL database`);
}
find(id) {
console.log(`Finding record ${id} in MySQL database`);
return { id, name: 'Test Record' };
}
}
// Another implementation of the same interface
class MongoDBDatabase extends DatabaseInterface {
constructor() {
super();
// MongoDB specific connection setup
this.connection = 'MongoDB Connection';
console.log('MongoDB Database connected');
}
save(data) {
console.log(`Saving document to MongoDB: ${JSON.stringify(data)}`);
}
delete(id) {
console.log(`Deleting document ${id} from MongoDB`);
}
update(id, data) {
console.log(`Updating document ${id} in MongoDB`);
}
find(id) {
console.log(`Finding document ${id} in MongoDB`);
return { id, name: 'Test Record' };
}
}
// High-level module that depends on abstraction
class UserService {
// Dependency injection through constructor
constructor(database) {
// Depends on abstraction, not concrete implementation
this.database = database;
}
createUser(user) {
// Business logic remains the same
if (!user.name || !user.email) {
throw new Error('User must have name and email');
}
// Save using the injected database
this.database.save(user);
return true;
}
deleteUser(userId) {
this.database.delete(userId);
return true;
}
updateUser(userId, userData) {
// Business logic remains the same
if (userData.role === 'admin' && !userData.approvedBy) {
throw new Error('Admin users must be approved');
}
this.database.update(userId, userData);
return true;
}
findUser(userId) {
return this.database.find(userId);
}
}
SOLID Principles in Practice
SOLID Principles Working Together
How the five SOLID principles interact to create more maintainable software
Legend
Components
Connection Types
When to Apply SOLID Principles
SOLID principles are most valuable in the following scenarios:
- Medium to large applications that will be maintained over time
- Team environments where multiple developers work on the same codebase
- Software that will evolve with changing requirements
- Systems requiring high testability for quality assurance
- Reusable libraries or frameworks that will be used by other developers
These principles may add complexity to very small projects or prototypes where simplicity and rapid development are more important than long-term maintainability.
Real-World Benefits
Business Benefits of SOLID
Business outcomes from SOLID-based development:
- Lower maintenance costs over the application lifecycle
- Faster time-to-market for new features and enhancements
- Reduced technical debt and associated business risks
- Improved onboarding experience for new team members
- Higher customer satisfaction through more reliable software
Business Benefits of SOLID
Technical benefits of applying SOLID principles:
- Reduced bug density in complex systems
- Easier isolation and fixing of defects when they occur
- Improved testability through smaller, focused components
- More efficient team collaboration on the same codebase
- Better adaptability to changing requirements and technologies
SOLID Principles in Different Programming Languages
SOLID principles are language-agnostic and can be applied in any object-oriented programming language. However, the implementation details may vary:
Java and C#
- Rich support for interfaces and abstract classes makes implementing ISP and DIP straightforward
- Strong typing helps enforce LSP contracts
- Established dependency injection frameworks like Spring and .NET Core support DIP
JavaScript and TypeScript
- Prototypal inheritance and functional composition provide alternatives to traditional OOP
- TypeScript's interfaces and abstract classes support SOLID directly
- JavaScript can apply SOLID through patterns and conventions
- Modern frameworks like Angular embrace dependency injection and SOLID
Python and Ruby
- Duck typing requires more discipline to ensure LSP compliance
- Dynamic nature allows for flexible composition patterns
- Metaprogramming capabilities offer unique ways to implement OCP
- Interfaces are conventional rather than enforced by the language
Common Anti-Patterns and How to Fix Them
God Class Anti-Pattern (Violates SRP)
Problem: A class that knows or does too much, often with hundreds or thousands of lines of code.
Solution:
- Identify distinct responsibilities
- Extract each responsibility into its own class
- Use composition to reconstruct the original functionality
Rigidity Anti-Pattern (Violates OCP)
Problem: Systems where one change affects many parts of the codebase.
Solution:
- Identify points of frequent change
- Introduce abstractions at these points
- Make concrete implementations depend on these abstractions
Fragile Base Class Anti-Pattern (Violates LSP)
Problem: Base class changes break derived classes in unexpected ways.
Solution:
- Make base classes more abstract with fewer implementation details
- Follow "composition over inheritance" when appropriate
- Document and enforce class invariants and contracts
Interface Bloat Anti-Pattern (Violates ISP)
Problem: Large interfaces forcing clients to implement methods they don't need.
Solution:
- Break large interfaces into smaller, more cohesive ones
- Use composition of interfaces for classes needing multiple capabilities
- Apply the Interface Adapter pattern for backward compatibility
Concrete Dependency Anti-Pattern (Violates DIP)
Problem: High-level modules directly instantiate and depend on low-level modules.
Solution:
- Introduce abstractions that both modules depend on
- Use dependency injection to provide concrete implementations
- Use factories or dependency injection containers to manage object creation