SOLID
Общее
SOLID — это акроним, объединяющий пять фундаментальных принципов объектно-ориентированного и компонентного проектирования. Каждая буква обозначает один принцип: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation и Dependency Inversion. Совокупно они формируют набор эвристик, помогающих проектировать программные системы, устойчивые к изменениям и удобные для сопровождения.
Термин SOLID ввёл Роберт Мартин (Robert C. Martin, известный как Uncle Bob) в начале 2000-х годов. Сам акроним впервые появился в статье, написанной Робертом Мартином по просьбе Майкла Фэзерса (Michael Feathers) примерно в 2000 году. Сами принципы, однако, опираются на более ранние работы: принцип подстановки сформулировала Барбара Лисков (Barbara Liskov) в 1987 году, а принципы единственной ответственности, открытости/закрытости и разделения интерфейсов восходят к трудам Бертрана Мейера (Bertrand Meyer) и других исследователей объектно-ориентированного проектирования. Мартин собрал и систематизировал их, показав взаимосвязь, и описал в книге Agile Software Development: Principles, Patterns, and Practices (2002), а позднее — в Clean Architecture (2017).
SOLID исторически связан с объектно-ориентированным программированием (ООП), где принципы применяются к классам и интерфейсам. Однако ценность SOLID выходит за рамки отдельного класса: те же идеи — разделение ответственности, защита от изменений, подставляемость компонентов, узкие контракты и инверсия зависимостей — масштабируются на уровень модулей, компонентов и архитектурных слоёв. Именно поэтому SOLID считается отправной точкой для понимания модульности, паттернов проектирования и Clean Architecture.
Назначение SOLID — не набор догм, а фильтр против типичных ошибок проектирования: разрастания классов до «божественных объектов», хрупкости иерархий наследования, жёстких зависимостей между модулями и раздутых интерфейсов, которые заставляют клиентов зависеть от того, чем они не пользуются. Каждый принцип можно сформулировать коротко: он отвечает на конкретный проектный вопрос и указывает на характерный «запах» (code smell), возникающий при его нарушении.
Краткая сводка принципов:
| Буква | Принцип | Суть |
|---|---|---|
| S | Single Responsibility | У класса одна причина для изменения |
| O | Open/Closed | Открыт для расширения, закрыт для изменения |
| L | Liskov Substitution | Подтип можно подставить вместо базового типа |
| I | Interface Segregation | Клиенты не зависят от неиспользуемых методов |
| D | Dependency Inversion | Зависимость направлена на абстракции, а не на реализации |
Принципы SOLID
Single Responsibility Principle
Оригинальная формулировка. A class should have one, and only one, reason to change. — У класса должна быть одна и только одна причина для изменения.
Мотивация. «Причина для изменения» в формулировке Мартина — это, по сути, группа заинтересованных лиц или аспект системы, инициирующий изменения. Если класс отвечает за несколько разнородных задач (например, за бизнес-логику, сохранение в базу данных и формирование отчёта), его меняют по независимым поводам: изменение формата отчёта не должно затрагивать логику расчётов. Смешение ответственностей приводит к тому, что любое касательное изменение рискует сломать не связанные с ним функции, а класс быстро превращается в «божественный объект» (God Object).
Антипаттерн-нарушение. Класс, совмещающий бизнес-правила и работу с хранилищем:
class Report {
public string Title { get; set; }
public string Body { get; set; }
public void Generate() {
// бизнес-логика формирования отчёта
Body = "...";
}
public void SaveToDatabase() {
// работа с хранилищем: SQL, соединения, транзакции
}
public void ExportToPdf() {
// форматирование и рендер PDF
}
}
Класс Report меняется по трём независимым поводам: изменится структура отчёта — правится Generate; поменяется схема базы данных — правится SaveToDatabase; обновится библиотека генерации PDF — правится ExportToPdf. Каждое изменение затрагивает один и тот же файл, что повышает риск регрессий.
Исправление. Разнести ответственности по отдельным классам, каждый из которых имеет одну причину для изменения:
class Report {
public string Title { get; set; }
public string Body { get; set; }
public void Generate() { /* бизнес-логика */ }
}
class ReportRepository {
public void Save(Report report) { /* работа с хранилищем */ }
}
class PdfExporter {
public byte[] Export(Report report) { /* рендер PDF */ }
}
Теперь изменение формата PDF не затрагивает ни бизнес-логику, ни хранилище. Принцип не требует разнесения ради самого разнесения — он требует, чтобы у каждого класса была одна согласованная ответственность.
Open/Closed Principle
Оригинальная формулировка. Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification. — Программные сущности должны быть открыты для расширения, но закрыты для модификации.
Мотивация. Принцип введён Бертраном Мейером в 1988 году. Его цель — сделать систему устойчивой к добавлению новой функциональности. «Открыт для расширения» означает, что поведение сущности можно дополнить; «закрыт для модификации» — что для этого не нужно править исходный код уже работающей и протестированной сущности. Если добавление каждого нового варианта требует менять существующие классы (добавлять switch/if-else ветки), система становится хрупкой: любое изменение рискует сломать уже работающие сценарии.
Антипаттерн-нарушение. Класс калькулятора скидок, который нужно править при каждом новом типе клиента:
class DiscountCalculator {
public double calculate(Customer customer) {
if (customer.getType().equals("regular")) {
return 0.05;
} else if (customer.getType().equals("vip")) {
return 0.20;
} else if (customer.getType().equals("employee")) {
return 0.30;
}
return 0.0;
}
}
Добавление нового типа клиента («wholesale») требует открыть класс DiscountCalculator и добавить новую ветку — то есть модифицировать уже работающий и протестированный код.
Исправление. Вынести вариативное поведение в абстракцию (полиморфизм или стратегию), чтобы новые варианты добавлялись новыми классами:
interface DiscountPolicy {
double calculate(Customer customer);
}
class RegularDiscount implements DiscountPolicy {
public double calculate(Customer c) { return 0.05; }
}
class VipDiscount implements DiscountPolicy {
public double calculate(Customer c) { return 0.20; }
}
class EmployeeDiscount implements DiscountPolicy {
public double calculate(Customer c) { return 0.30; }
}
Добавление нового типа сводится к созданию класса WholesaleDiscount — существующий код не трогается. Принцип достигается через абстракции (интерфейсы, абстрактные классы) и полиморфизм, а также через паттерны Strategy, Template Method, Decorator.
Liskov Substitution Principle
Оригинальная формулировка. Сформулирована Барбарой Лисков в 1987 году: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2, then S is a subtype of T. — Если для каждого объекта o1 типа S существует объект o2 типа T, такой что все программы P, определенные в терминах T, не меняют своего поведения при замене o2 на o1, то S является подтипом T.
Роберт Мартин дал более прагматичную переформулировку: Functions that use pointers or references to base classes must be able to use objects of derived classes without knowing it. — Функции, использующие ссылки на базовый класс, должны иметь возможность использовать объекты производных классов, ничего не зная о них.
Мотивация. Принцип подстановки гарантирует, что иерархия наследования действительно выражает отношение «является» (is-a), а не просто повторное использование кода. Если подтип нарушает контракт базового типа — усиливает предусловия, ослабляет постусловия, выбрасывает неожиданные исключения или не выполняет обещанные действия, — то клиенты, рассчитывающие на базовый тип, сломаются при подстановке подтипа. Это делает полиморфизм ненадёжным и вынуждает клиентов проверять конкретный тип (instanceof), что разрушает абстракцию.
Антипаттерн-нарушение. Классический пример — «квадрат и прямоугольник»:
class Rectangle {
protected int width;
protected int 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; }
}
Клиент, работающий через базовый тип, логично ожидает независимого изменения сторон:
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(10);
assert r.area() == 50; // ломается для Square: площадь = 100
}
Square нарушает контракт Rectangle: независимая установка ширины и высоты перестаёт работать. Подстановка подтипа меняет поведение программы — принцип Лисков нарушен.
Исправление. Не использовать наследование там, где не выполняется подстановка. Квадрат не является прямоугольником с точки зрения изменяемого интерфейса — у него одна сторона. Решения:
- Не делать
SquareнаследникомRectangle; ввести общий предок (например, абстракцию формы) или сделать их независимыми классами. - Использовать неизменяемые объекты (immutable): неизменяемый квадрат и неизменяемый прямоугольник, созданные через фабрики, не нарушают контракт, так как не имеют сеттеров.
Принцип Лисков также формализуется через контракты (Design by Contract): подтип не должен усиливать предусловия и не должен ослаблять постусловия; не должен выбрасывать новые типы исключений, кроме подтипов исключений базового типа.
Interface Segregation Principle
Оригинальная формулировка. Clients should not be forced to depend upon interfaces that they do not use. — Клиенты не должны вынужденно зависеть от методов, которые они не используют. Принцип сформулирован Робертом Мартином при работе над Xerox и связан с именами Рекса Шмидта (Rex Schmidt) и др.
Мотивация. «Толстый» интерфейс, объединяющий много методов, вынуждает каждого клиента зависеть от всего интерфейса целиком — даже от тех методов, которые он не вызывает. Это приводит к непреднамеренным связям: изменение метода, не используемого клиентом, всё равно затрагивает его (перекомпиляция, риск поломки при изменении сигнатуры, фиктивные реализации методов-заглушек). Кроме того, классы вынуждены реализовывать методы, которые для них бессмысленны, что часто выражается в выбросе NotImplementedException или UnsupportedOperationException.
Антипаттерн-нарушение. Единый интерфейс для многофункционального устройства:
interface MultiFunctionDevice {
void print(Document d);
void scan(Document d);
void fax(Document d);
}
class SimplePrinter implements MultiFunctionDevice {
public void print(Document d) { /* печать */ }
public void scan(Document d) { throw new UnsupportedOperationException(); }
public void fax(Document d) { throw new UnsupportedOperationException(); }
}
SimplePrinter вынужден зависеть от scan и fax и фиктивно их реализовывать. Добавление нового метода в интерфейс затронет даже те клиенты, которым он не нужен.
Исправление. Разделить «толстый» интерфейс на узкие, сфокусированные интерфейсы (role interfaces):
interface Printer { void print(Document d); }
interface Scanner { void scan(Document d); }
interface Fax { void fax(Document d); }
class SimplePrinter implements Printer {
public void print(Document d) { /* печать */ }
}
class MultiFunctionPrinter implements Printer, Scanner, Fax {
public void print(Document d) { /* ... */ }
public void scan(Document d) { /* ... */ }
public void fax(Document d) { /* ... */ }
}
Теперь простой принтер зависит только от того, что использует. Принцип ISP тесно связан с SRP: узкие интерфейсы соответствуют узким ответственностям и снижают связанность между клиентами.
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. — Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Мотивация. Без инверсии зависимостей высокоуровневая политика (бизнес-логика) напрямую использует низкоуровневые детали (конкретные классы доступа к данным, внешние сервисы, библиотеки). Это означает, что бизнес-правила жёстко связаны с инфраструктурой: замена базы данных, почтового сервиса или HTTP-клиента требует изменения бизнес-логики. Более того, такая зависимость препятствует тестированию — бизнес-логику невозможно проверить без живой базы данных или сети.
Инверсия зависимостей переворачивает направление связей: и высокоуровневый, и низкоуровневый модули зависят от абстракции (интерфейса), которая принадлежит высокоуровневому модулю. Детали (конкретные реализации) зависят от этой абстракции, а не наоборот.
Антипаттерн-нарушение. Сервис бизнес-логики напрямую создаёт и использует конкретный репозиторий:
class OrderService {
private SqlOrderRepository repository = new SqlOrderRepository();
public void placeOrder(Order order) {
// бизнес-логика зависит от конкретного SqlOrderRepository
repository.save(order);
}
}
OrderService (высокоуровневый модуль) жёстко зависит от SqlOrderRepository (низкоуровневой детали). Замена на MongoOrderRepository или использование заглушки в тестах требует правки OrderService.
Исправление. Ввести абстракцию, которой принадлежат оба модуля, и внедрить зависимость (dependency injection):
interface OrderRepository {
void save(Order order);
}
class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository; // внедрение через конструктор
}
public void placeOrder(Order order) {
repository.save(order); // зависимость от абстракции
}
}
class SqlOrderRepository implements OrderRepository {
public void save(Order order) { /* реализация для SQL */ }
}
Теперь OrderService зависит от OrderRepository (абстракции), а SqlOrderRepository — реализует её. Направление зависимости «перевёрнуто»: низкоуровневый модуль зависит от абстракции высокоуровневого. Подставляя mock-реализацию в тестах, бизнес-логику можно проверять изолированно. Принцип DIP лежит в основе паттернов Dependency Injection, Inversion of Control и механизмов фреймворков (Spring, .NET DI и др.).
SOLID на уровне архитектуры
Ценность SOLID не исчерпывается уровнем отдельных классов. Роберт Мартин в Clean Architecture показывает, что те же пять принципов масштабируются на уровень компонентов и слоёв архитектуры, образуя фундамент модульности и защищённости границ.
От классов к компонентам. На уровне компонентов (модулей, библиотек, микросервисов) действуют те же идеи, переформулированные как принципы композиции компонентов (REP, CCP, CRP, ADP, SDP, SAP). Суть остаётся прежней: компонент должен иметь согласованные ответственности (аналог SRP), быть открытым для расширения (аналог OCP), предоставлять узкие, сфокусированные интерфейсы (аналог ISP), и зависимости между компонентами должны быть направлены в сторону стабильности — то есть в сторону абстракций (аналог DIP).
Границы компонентов (component boundaries). Архитектурная граница — это линия, разделяющая компоненты, которая проводится там, где направление зависимости важно. Хорошо проведённая граница гарантирует, что низкоуровневые детали (база данных, веб-фреймворк, внешние API) зависят от высокоуровневой политики, а не наоборот.
Dependency Inversion как фундамент границ. Именно DIP позволяет провести архитектурную границу в системе, где исходно зависимость направлена «не туда». Если бизнес-логика должна вызывать инфраструктуру, но не должна зависеть от неё, вводится интерфейс на стороне бизнес-логики; инфраструктура реализует этот интерфейс, и направление зависимости переворачивается. На этом механизме строятся архитектурные шаблоны:
- Ports and Adapters (Hexagonal Architecture, Алистер Кокбёрн): ядро приложения определяет «порты» (интерфейсы), а адаптеры (БД, веб, UI) их реализуют.
- Clean Architecture (Роберт Мартин): концентрические слои — Entities, Use Cases, Interface Adapters, Frameworks & Drivers; правило зависимостей (Dependency Rule) направляет стрелки внутрь, к абстракциям.
- Onion Architecture (Джеффри Палермо): аналогичная идея слоёв, ориентированных на доменное ядро.
Связь с Clean Architecture. В Clean Architecture каждый слой зависит только от внутренних, более стабильных слоёв. Принцип подстановки Лисков обеспечивает корректность полиморфных замен между слоями; принцип открытости/закрытости позволяет добавлять новые варианты (новые контроллеры, новые адаптеры БД) без правок ядра; принцип разделения интерфейсов не даёт ядру зависеть от избыточных деталей адаптеров. Таким образом, SOLID на уровне классов и SOLID на уровне архитектуры — это один и тот же набор идей, применённый в разных масштабах.
Критика и границы применимости
SOLID — не универсальное решение, и его механическое применение может принести вред. Критика принципов и границы их применимости сводятся к нескольким аспектам.
Переусложнение. Слепое применение SOLID к каждой задаче ведёт к избыточной абстракции: появляется множество мелких классов, интерфейсов с единственной реализацией и уровней косвенности, которые не приносят пользы, но усложняют понимание кода. Мартин Фаулер и другие авторы предостерегают от «догматического SOLID» — принципы должны служить средству, а не самоцели. Часто простой процедурный код понятнее, чем раздутое многоуровневое ООП.
Не везде нужен ООП. SOLID сформулирован в парадигме объектно-ориентированного проектирования. В функциональном программировании (ФП) многие проблемы, которые SOLID решает через классы и интерфейсы, вообще не возникают: неизменяемые данные и чистые функции естественным образом изолируют ответственность, а композиция функций заменяет наследование. В ФП вместо подстановки типов используется композиция, вместо инверсии зависимостей — передача функций как аргументов (higher-order functions). SOLID остаётся осмысленным концептуально, но его буквальная реализация через интерфейсы и классы неприменима.
Динамические языки. В динамически типизированных языках (Python, Ruby, JavaScript) формальные интерфейсы и абстрактные классы часто отсутствуют. Часть принципов (ISP, DIP) реализуется через утиную типизацию (duck typing), протоколы (PEP 544 в Python) или структурную типизацию (TypeScript). LSP проверяется на уровне поведения и тестов, а не на уровне системы типов. Это не отменяет SOLID, но меняет инструментарий.
YAGNI vs SOLID. Принцип YAGNI («You Aren’t Gonna Need It» — из XP Кента Бека) предостерегает от добавления функциональности «на вырост». SOLID, особенно OCP и DIP, при буквальном прочтении подталкивает к раннему введению абстракций «чтобы было легко расширять». Конфликт разрешается прагматично: абстракции вводятся, когда второе аналогичное изменение подтверждает потребность (правило трёх), а не заранее. Переход от конкретного к абстрактному выполняется через рефакторинг, когда накапливается достаточно информации о направлениях изменения.
Эмпирическая неоднозначность. Ряд исследований и прагматичных практиков (например, Дэн Норт, автор BDD, в докладе «SOLID is not solid») указывают, что буквальное соблюдение SOLID коррелирует с ростом числа классов и косвенности, что не всегда улучшает сопровождаемость. Принципы полезны как диагностические вопросы («есть ли у класса несколько причин для изменения?»), но не как обязательные к исполнению правила.
Плюсы
Устойчивость к изменениям. Хорошо спроектированные по SOLID системы локализуют изменения: новая функциональность добавляется новыми классами, а не правками в существующих. Это снижает риск регрессий.
Слабая связанность и высокая связность. Разделение ответственностей и узкие интерфейсы уменьшают зависимости между модулями. Компоненты можно изменять и тестировать независимо.
Тестируемость. Инверсия зависимостей позволяет подставлять mock-объекты и заглушки, делая модульное тестирование изолированным и быстрым. Бизнес-логику можно проверять без базы данных, сети и внешних сервисов.
Переиспользуемость. Классы и модули с единственной ответственностью и узкими интерфейсами легче переносятся между проектами и контекстами.
Понятность и коммуникация. Единый словарь принципов облегчает обсуждение проектных решений внутри команды: формулировки SRP или DIP служат общим языком для аргументации.
Масштабируемость на архитектуру. Те же принципы работают на уровне компонентов и слоёв, образуя фундамент Clean Architecture и паттернов проектирования — единый набор идей от класса до системы.
Защита от типичных ошибок. Каждый принцип нацелен на конкретный антипаттерн (God Object, жёсткие зависимости, хрупкие иерархии, раздутые интерфейсы), давая эвристику для их распознавания и устранения.
Минусы
Риск переусложнения. Без меры SOLID порождает избыточную абстракцию: интерфейсы с единственной реализацией, лишние уровни косвенности и мелкие классы, затрудняющие понимание потока выполнения.
Зависимость от парадигмы. Принципы сформулированы для ООП; в функциональном и процедурном коде их буквальное применение через классы и интерфейсы неприменимо или избыточно.
Кривая обучения. Корректное применение SOLID требует опыта: начинающие разработчики часто применяют принципы механически, ухудшая, а не улучшая код.
Затраты на начальное проектирование. Введение абстракций и разделение ответственностей требует времени и усилий, что может быть избыточно для прототипов и одноразовых скриптов.
Конфликт с YAGNI. Раннее абстрагирование «на вырост» противоречит принципу YAGNI; баланс между гибкостью и простотой требует инженерного суждения.
Невозможность механической проверки. В отличие от форматирования, SOLID нельзя полностью проверить автоматически: «причина для изменения» или «нарушение контракта» — понятия семантические, требующие контекста предметной области.
Связанные статьи
- Введение в архитектуру — общие понятия архитектуры ПО, в том числе место SOLID среди базовых принципов.
- Паттерны проектирования — порождающие, структурные и поведенческие шаблоны, реализующие принципы SOLID на практике (Strategy и OCP, Adapter и ISP, Decorator и OCP).
- ADR — Architecture Decision Records — практика фиксации архитектурных решений, включая выбор и обоснование применения SOLID.
- Clean Architecture — планируемая статья о послойной архитектуре с правилом зависимостей, где SOLID (в первую очередь DIP) формирует архитектурные границы.