SOLID — набор правил, которые помогают писать устойчивый, понятный и сопровождаемый объектно-ориентированный код. В этой статье я объясню каждое правило на доступных примерах и покажу, как небольшие изменения архитектуры делают код чище и проще в поддержке. Материал ориентирован на разработчиков, которые хотят применять принципы в реальных проектах, а не только заучивать определения.
Что такое SOLID и зачем это нужно
Аббревиатура SOLID состоит из первых букв пяти принципов: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. Каждый принцип решает определённый класс проблем, возникающих в масштабируемых системах. Вместе они уменьшают связность компонентов и повышают гибкость кода при изменениях.
Нередко люди воспринимают SOLID как набор догм, но это не так. Принципы — инструмент, который помогает принимать решения при проектировании: когда использовать абстракции, а когда проще оставить конкретику. Я делюсь практическими рекомендациями и короткими примерами, чтобы вы могли применять идеи сразу после чтения.
Single Responsibility Principle (SRP)
Принцип единственной ответственности гласит: класс должен иметь одну причину для изменения. Это означает, что каждый класс отвечает ровно за одну сферу поведения. Разделение обязанностей упрощает тестирование и уменьшает вероятность побочных эффектов при доработках.
Ниже пример на C#, где один класс неправильно совмещает логику модели и сохранение в базу. После примера показано, как разделить обязанности.
// Плохо: одна сущность делает и валидацию, и сохранение
class User {
public string Name { get; set; }
public bool IsValid() {
return !string.IsNullOrEmpty(Name);
}
public void Save() {
// код сохранения в базу
}
}
// Лучше: разделяем модель и репозиторий
class User {
public string Name { get; set; }
public bool IsValid() {
return !string.IsNullOrEmpty(Name);
}
}
interface IUserRepository {
void Save(User user);
}
class SqlUserRepository : IUserRepository {
public void Save(User user) {
// конкретный код сохранения
}
}
Отделив репозиторий от модели, мы получили возможность менять способы хранения без правок модели и тестировать валидацию отдельно. Такой подход уменьшает плотность изменений в классах.
Open/Closed Principle (OCP)
Открытость/закрытость означает: сущности должны быть открыты для расширения, но закрыты для модификации. Идея в том, чтобы добавлять новую функциональность без правки уже существующего, проверенного кода. Для этого используют абстракции и полиморфизм.
Простой пример на Java: вместо изменения условия при добавлении нового типа логики, вводим интерфейс и добавляем реализацию.
// Плохо: каждый новый тип требует изменения метода
class DiscountCalculator {
double calculate(String type, double price) {
if (type.equals("season")) return price * 0.9;
if (type.equals("vip")) return price * 0.8;
return price;
}
}
// Лучше: интерфейс и новые реализации добавляются без правки калькулятора
interface Discount {
double apply(double price);
}
class SeasonDiscount implements Discount {
public double apply(double price) { return price * 0.9; }
}
class VipDiscount implements Discount {
public double apply(double price) { return price * 0.8; }
}
class DiscountCalculator {
double calculate(Discount discount, double price) {
return discount.apply(price);
}
}
Такой подход делает систему предсказуемой: новые стратегии добавляются в виде классов, тесты остаются актуальными, а базовый код не ломается.
Liskov Substitution Principle (LSP)
Принцип подстановки Лисков требует, чтобы объекты подклассов могли заменять объекты базового класса без изменения корректности программы. На практике это значит: подкласс не должен усиливать пред- или постусловия и менять ожидаемое поведение.
Типичный антипаттерн — наследование для расширения, которое ломает контракты базового класса. Пример ниже демонстрирует проблему и способ её устранить.
// Плохо: прямое наследование приводит к неожиданному поведению
class Rectangle {
protected int width, height;
public void setWidth(int w) { width = w; }
public void setHeight(int h) { height = h; }
public int area() { return width * height; }
}
class Square extends Rectangle {
public void setWidth(int w) {
width = w; height = w;
}
public void setHeight(int h) {
width = h; height = h;
}
}
// Код, который ожидает Rectangle, может сломаться при работе с Square
// Лучше: отделяем интерфейсы или используем композицию
interface Shape {
int area();
}
class Rectangle2 implements Shape {
private int width, height;
public Rectangle2(int w, int h) { width = w; height = h; }
public int area() { return width * height; }
}
class Square2 implements Shape {
private int side;
public Square2(int s) { side = s; }
public int area() { return side * side; }
}
Я сталкивался с подобной ошибкой при рефакторинге графических элементов: тесты падали из‑за того, что Square изменял состояние Rectangle. Переход на композицию снял ограничения и упростил логику.
Interface Segregation Principle (ISP)
ISP утверждает: лучше иметь несколько специализированных интерфейсов, чем один большой. Клиенты не должны зависеть от методов, которые они не используют. Это уменьшает шум в реализациях и делает контракты понятнее.
Рассмотрим пример на TypeScript, где один интерфейс заставляет классы реализовывать лишние методы, а затем разделим интерфейс на более узкие.
// Плохо: монолитный интерфейс
interface Worker {
work(): void;
eat(): void;
}
class HumanWorker implements Worker {
work() { /* работает */ }
eat() { /* перекус */ }
}
class RobotWorker implements Worker {
work() { /* работает */ }
eat() { throw new Error("Robots don't eat"); }
}
// Лучше: разделяем интерфейсы
interface Workable { work(): void; }
interface Eatable { eat(): void; }
class Human implements Workable, Eatable {
work() { /* */ }
eat() { /* */ }
}
class Robot implements Workable {
work() { /* */ }
}
Разделение убирает необходимость писать заглушки и бросать исключения в местах, где функциональность не применима. Это особенно важно в крупных командах с разными типами компонентов.
Dependency Inversion Principle (DIP)
DIP говорит: высокоуровневые модули не должны зависеть от низкоуровневых напрямую; оба должны зависеть от абстракций. Это уменьшает связанность и позволяет подменять реализации без правки потребителя. Часто для достижения DIP используются интерфейсы и внедрение зависимостей.
Ниже пример на Python, демонстрирующий прямую зависимость и затем исправление через абстракцию и внедрение.
# Плохо: класс напрямую создаёт конкретный объект
class FileLogger:
def log(self, msg):
print("file:", msg)
class Service:
def __init__(self):
self.logger = FileLogger()
def do(self):
self.logger.log("start")
# Лучше: зависимость передаётся извне через интерфейс (протокол)
class Logger:
def log(self, msg): pass
class FileLogger2(Logger):
def log(self, msg): print("file:", msg)
class ConsoleLogger(Logger):
def log(self, msg): print("console:", msg)
class Service2:
def __init__(self, logger: Logger):
self.logger = logger
def do(self):
self.logger.log("start")
# Теперь Service2 можно использовать с разными логгерами
В одном моём проекте переход к внедрению зависимостей упростил интеграционные тесты: стало легко подставлять заглушки и симулировать ошибки внешних систем.
Краткая сводка и практические советы
Подытожим: SRP уменьшает области изменений, OCP даёт путь для расширения без правки кода, LSP сохраняет предсказуемость при наследовании, ISP делает интерфейсы компактными, а DIP снижает связанность между модулями. Вместе эти идеи облегчают поддержку и развитие системы.
Несколько практических правил, которые помогают применять принципы эффективно:
- Начинайте с простоты: не вводите абстракции преждевременно.
- Рефакторьте по мере роста требований: маленькие изменения легче контролировать.
- Пишите тесты: они дадут уверенность при выделении интерфейсов и композиций.
- Проверяйте, не создаёт ли новая абстракция избыточную сложность.
Таблица: когда применять и когда осторожничать
| Ситуация | Применять | Осторожно |
|---|---|---|
| Малый прототип | Минимум абстракций, фокус на результате | Чрезмерное разделение интерфейсов |
| Растущий проект | SRP и DIP для тестируемости | Слишком ранняя генерализация |
| Командная разработка | ISP и OCP для разделения обязанностей | Сильная связанность модулей |
Как начать применять принципы прямо сейчас
Начните с поиска классов, которые делают «слишком много». Это явная подсказка для SRP. Попробуйте вынести части логики в отдельные сервисы или репозитории. Маленькие шаги работают лучше крупных рефакторингов.
Далее обратите внимание на точки, где приходится менять один и тот же файл для разных задач — это сигнал к введению абстракций. Наконец, пользуйтесь тестами как защитой: они позволят реорганизовать код без страха сломать функциональность.
Применение принципов не означает слепого следования правилам. Важнее понимать, какую проблему решает каждый принцип и какие компромиссы вы делаете. Если вы начнёте с простых, понятных изменений, вскоре увидите, что код стал гибче, а команда — спокойнее при внедрении новых фич.

