SOLID Principles — SOLID принципы
SOLID is set of five object-oriented design principles that help write code easily maintainable, testable and extensible. These principles were formulated by Robert C. Martin, also known as "Uncle Bob".
Single Responsibility Principle (SRP)
Single Responsibility Principle
Each class or module should have single responsibility and, accordingly, single reason to change.
Why?
- Simplifies code understanding, as class/module is responsible for one task.
- If requirements change, corrections are made in strictly defined place.
Example (JS)
// Bad: class does too many things — saves data and logs
class UserService {
saveUser(user) {
// ...code to save to database
Logger.log(`User ${user.name} saved`);
}
}
// Better: extract logging logic into separate class/service
class UserService {
constructor(logger) {
this.logger = logger;
}
saveUser(user) {
// ...code to save to database
this.logger.log(`User ${user.name} saved`);
}
}
class Logger {
log(message) {
console.log(`[LOG]: ${message}`);
}
}
Open-Closed Principle (OCP)
Open-Closed Principle
Classes and modules should be open for extension, but closed for modification.
Simply put — modules should be designed so they need to be changed as rarely as possible, and functionality can be extended by creating new entities and composing them with old ones.
Why?
- Allows adding new functionality without changing already existing code.
- Reduces risk of introducing bugs into stable parts of system.
// Bad: each time we add new condition to code
function getDiscount(user) {
if (user.type === 'regular') {
return 5;
}
if (user.type === 'vip') {
return 10;
}
// ...
}
// Better
class RegularUser {
getDiscount() {
return 5;
}
}
class VipUser {
getDiscount() {
return 10;
}
}
function showDiscount(user) {
// Don't change logic, just call method
return user.getDiscount();
}
Here we can easily add new user by implementing their class with own getDiscount method. At same time showDiscount function won't need to be changed.
Liskov Substitution Principle (LSP)
Liskov Substitution Principle
Child classes should be fully replaceable with their parent classes. If somewhere parent type object is expected, we should be able to substitute child type object without breaking program.
Simply put — child classes shouldn't contradict base class. For example, they can't provide interface narrower than base. Child behavior should be expected for functions that use base class.
Why?
- Ensures correct and predictable behavior when using inheritance.
- Helps avoid errors related to unexpected logic in children.
// Bad: Square breaks Rectangle behavior
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 {
// Violates LSP: when changing width or height
// other dimension should also change, but this breaks parent logic
setWidth(width) {
this.width = width;
this.height = width;
}
setHeight(height) {
this.width = height;
this.height = height;
}
}
In this case, if we expect Rectangle in code and call setWidth and setHeight methods, for Square we'll get behavior that doesn't match what base class assumes (square always has width equal to height).
Better — don't inherit Square from Rectangle, but either use common abstract class (e.g., Shape), or apply composition. This way we avoid breaking base logic with inheritance.
Interface Segregation Principle (ISP)
Interface Segregation Principle
Clients (classes, modules) shouldn't depend on interfaces (or contracts) they don't use. If interface is "fat" and contains too many methods, better split it into several specialized interfaces.
Why?
- Avoids "cluttering" contracts with methods not needed in every place.
- Makes code more flexible and understandable: each part of program depends only on what's really important to it.
// Bad: one class containing all methods
// (e.g., Animal with eat, walk, swim, fly)
class Animal {
eat() {}
walk() {}
swim() {}
fly() {}
}
// Dog class may not swim, and Bird class may not walk on ground
// etc. - we get empty or unused methods.
// Better: separate responsibilities
class Eatable {
eat() {}
}
class Walkable {
walk() {}
}
class Flyable {
fly() {}
}
// Now classes that need to fly simply extend Flyable,
// and those that need to walk — Walkable, and so on
class Dog extends Eatable {
// Additionally, if we want, we can inherit Dog from Walkable
// but not add Flyable and Swimable methods that it doesn't need
}
Dependency Inversion Principle (DIP)
Dependency Inversion Principle
Dependencies should be built at abstraction level, not concrete implementations. High-level modules shouldn't depend on low-level modules directly — both types of modules depend on abstractions.
Why?
- Simplifies replacing implementations (e.g., working with different databases).
- Improves testability: can easily substitute dependencies (e.g., with mocks).
// Bad: class directly depends on concrete implementation
class UserService {
constructor() {
this.db = new MySQLDatabase();
}
getUsers() {
return this.db.query('SELECT * FROM users');
}
}
// Better: through abstraction (interface/contract)
class UserService {
constructor(database) {
// database is abstract contract that should be able to .query()
this.database = database;
}
getUsers() {
return this.database.query('SELECT * FROM users');
}
}
// Now we can substitute concrete implementation
class MySQLDatabase {
query(sql) {
// MySQL query
}
}
class MongoDatabase {
query(sql) {
// Mongo query
}
}
// In different environments/applications
// we can pass needed implementation to UserService constructor
const userService = new UserService(new MySQLDatabase());
Here UserService doesn't know concrete database implementation details, it works through abstraction provided by MySQLDatabase or MongoDatabase classes.
Conclusion
- SRP – One module, one responsibility.
- OCP – Open for extension, closed for modification.
- LSP – Children should be substitutable for parent types without problems.
- ISP – Split "fat" interfaces so dependencies are minimal.
- DIP – Depend on abstractions, not concrete implementations.