Инженерия ПО
SOLID принципы
Цели урока
- Знать все пять букв SOLID: SRP, OCP, LSP, ISP, DIP, и проблему, которую решает каждая
- Видеть отличие 'один класс - одна ответственность' (SRP) от 'один класс - одна функция'
- Применять OCP через интерфейсы и наследование, не правя существующий код
- Распознавать нарушение LSP по сигнатуре подкласса и поведению контрактов
- Использовать DIP, чтобы тестировать бизнес-логику без БД и сети
Предварительные знания
- Базовое ООП: классы, наследование, интерфейсы, полиморфизм
- Опыт с любым языком, поддерживающим интерфейсы или абстрактные классы
- Понимание зачем нужны unit-тесты
2003 год. Команда Microsoft начинает переписывать Windows Vista. 10 000 разработчиков, 50 миллионов строк кода. 2006: Microsoft переносит релиз на год. 2007: Vista выходит с многочисленными проблемами качества. Постмортем называет coupling как первопричину: изменение одного компонента ломало десятки других. Те же 5 принципов SOLID, опубликованных Robert Martin в 2000 году, описывают архитектурные решения, которые могли предотвратить этот $6 миллиардный провал.
- **Google** SRE book описывает SOLID как основу testability: DIP и ISP - необходимые условия unit testing в микросервисной архитектуре
- **Netflix** OSS (Hystrix, Ribbon, Eureka) построены на OCP: новые стратегии (fallback, retry) добавляются без изменения core библиотек
- **Spring Framework** весь построен на DIP: IoC container - промышленная реализация Dependency Injection на миллионах production приложений
Robert Martin и рождение SOLID
Принципы SRP и OCP были сформулированы ещё в 1990s, LSP - Barbara Liskov в 1987. Robert Martin объединил их в единую систему в статье 'Design Principles and Design Patterns' (2000) и книге 'Agile Software Development' (2002). Акроним SOLID придумал Michael Feathers около 2004 года. Barbara Liskov получила Turing Award в 2008 году - за LSP и другие работы в области программирования. SOLID стал де-факто стандартом OOP архитектуры, однако критики (в частности, Дэн Норт) указывают что эти принципы лучше работают как heuristics, а не строгие правила.
Single Responsibility Principle
**SRP**: класс должен иметь только одну причину для изменения. Формулировка Robert Martin (Uncle Bob): 'A module should be responsible to one, and only one, actor' - где actor это группа людей (stakeholders), заинтересованная в изменении. Нарушение SRP приводит к coupling несвязанных concerns: изменение бизнес-логики ломает отчётность, изменение формата вывода требует трогать доменную логику.
**Размер не определяет SRP**: класс с 500 строками может соблюдать SRP, а класс из 30 строк - нарушать. Вопрос не в количестве методов, а в количестве акторов (stakeholders), которые могут потребовать изменений. Классический антипаттерн: `UserService` с методами `getUserById`, `sendEmail`, `validatePassword`, `generateReport`.
Класс `OrderService` содержит методы: `calculateTotal()`, `sendConfirmationEmail()`, `saveToDatabase()`, `generateInvoicePdf()`. Сколько нарушений SRP?
Open/Closed Principle
**OCP**: программные сущности должны быть открыты для расширения, но закрыты для изменения. Бертран Мейер, 1988. Практический смысл: добавление нового поведения не должно требовать модификации существующего протестированного кода. Механизм: полиморфизм через абстракции (интерфейсы, абстрактные классы).
**OCP не означает 'никогда не менять код'**: первая реализация feature всегда требует написания кода. OCP говорит о том, что после первой версии расширение должно происходить через новые классы, а не модификацию старых. Практика: при втором аналогичном изменении - рефакторинг на абстракцию.
Команда добавляет поддержку нового payment provider (PayPal). Какой подход соответствует OCP?
Liskov Substitution Principle
**LSP**: подтип должен быть подставим вместо базового типа без изменения корректности программы. Barbara Liskov, 1987, Turing Award 2008. Формальнее: если для объекта base типа B верно свойство P, то для объекта sub типа S (подтипа B) свойство P тоже должно быть верным. Нарушение LSP: подкласс бросает исключения там где базовый класс их не бросает, или изменяет семантику метода.
**Design by Contract**: LSP требует что подкласс не усиливает preconditions (принимает не меньший диапазон входных данных), не ослабляет postconditions (гарантирует не меньше базового класса), не бросает новых unchecked exceptions. Нарушение: `NotImplementedException` в методе интерфейса - типичный признак неверной иерархии.
Класс `ReadOnlyList` наследует `List` и переопределяет `add()` так, чтобы бросать `UnsupportedOperationException`. Это нарушает LSP?
Interface Segregation Principle
**ISP**: клиенты не должны зависеть от методов, которые они не используют. Robert Martin, ~1996. Толстые интерфейсы создают ненужные зависимости: изменение метода A, которым пользуется клиент X, вынуждает перекомпилировать клиент Y (который A не использует). В языках с duck typing (Python, Go) ISP важен семантически - даже если нет компиляции.
**ISP в микросервисах**: API контракт - тоже интерфейс. GraphQL решает ISP на уровне HTTP: клиент запрашивает только нужные поля, не весь объект (как в REST). gRPC service definitions позволяют разделить большой сервис на несколько узких, каждый с малым interface.
Интерфейс `Printer` содержит методы: `print()`, `scan()`, `fax()`, `staple()`. Простой принтер реализует только print(). Какое решение лучше?
Dependency Inversion Principle
**DIP** состоит из двух правил: 1. модули высокого уровня не должны зависеть от модулей низкого уровня - оба должны зависеть от абстракций 2. абстракции не должны зависеть от деталей - детали должны зависеть от абстракций. Инверсия: раньше BusinessLogic зависела от конкретного Database. Теперь оба зависят от интерфейса `DataStore`. Dependency Injection (DI) - паттерн реализации DIP.
**DI containers** (NestJS, Spring, Angular): автоматически разрешают зависимости через reflection и декораторы. `@Injectable()` в NestJS - декларация что класс может быть создан DI container. `@Inject()` - запрос зависимости. Container сам создаёт объекты в правильном порядке и инжектирует их.
DIP означает что нужно использовать DI framework (Spring, NestJS)
DIP - принцип проектирования, Dependency Injection - паттерн его реализации. DI можно реализовать вручную (constructor injection), без framework
DI framework автоматизирует создание объектного графа, но принцип работает и без него. Передача зависимости через конструктор - уже DIP. Framework добавляет удобство (lifecycle management, scoping), но не является обязательным условием.
PaymentService использует `new StripeGateway()` внутри метода `charge()`. Как исправить согласно DIP?
Ключевые идеи
- **SRP**: один класс - одна причина для изменения (один актор-stakeholder). Вопрос не 'сколько методов', а 'кто требует изменений'
- **OCP**: расширение без модификации через абстракции. Новые типы скидок/провайдеров/стратегий - новые классы, не if-else в старых
- **LSP**: подтип подставим вместо базового без нарушения корректности. Square-is-a-Rectangle ломает LSP
- **ISP**: клиент зависит только от нужных методов. Толстые интерфейсы создают невидимые coupling между несвязанными клиентами
- **DIP**: оба уровня зависят от абстракции. DI через конструктор - делает зависимости видимыми и testable
Вопросы для размышления
- В codebase найден класс `ApiController` с 2000 строк: парсинг HTTP запросов, бизнес-логика, SQL запросы, форматирование ответа, логирование, валидация. Как разбить его согласно SOLID? Какие принципы применить и в каком порядке?