6.11. Принципы проектирования
Принципы проектирования
Принципы — это критерии оценки. Они позволяют задать вопрос: «Если бы мы сделали иначе, что пошло бы не так через год?» Хороший код сегодня — это рабочий код и тот, который можно безопасно изменить завтра.
Ниже рассматриваются принципы, доказавшие свою ценность в реальных промышленных проектах. Их применение не гарантирует успех, но их игнорирование почти всегда ведёт к накоплению технического долга.
1. SOLID
SOLID — это акроним, введённый Робертом Си Мартином, обобщающий пять принципов, направленных на повышение гибкости, тестируемости и сопровождаемости кода.
S — Single Responsibility Principle (Принцип единственной ответственности)
Формулировка: «Класс должен иметь одну и только одну причину для изменения».
Ключевое уточнение: «причина для изменения» — это актор, внешняя сущность, требования которой влияют на модуль. Актор может быть человеком (пользователем, администратором), системой (платёжным шлюзом) или процессом (аудит, регламент).
Пример нарушения: класс OrderService, который:
- вычисляет итоговую стоимость (для клиента),
- логирует действия в аудиторский журнал (для безопасности),
- отправляет email-уведомление (для маркетинга).
Если требования к логированию изменятся (например, нужно шифровать записи), придётся перекомпилировать и перетестировать модуль, отвечающий за расчёт. Это нарушает SRP.
Решение — выделить отдельные модули:
OrderCalculator— только расчёт,AuditLogger— только запись логов,NotificationService— только отправка сообщений.
Теперь изменения в одном не затрагивают другие.
💡 SRP работает на уровне класса, модуля, микросервиса, даже репозитория. Например, монорепозиторий, содержащий бэкенд, фронтенд и инфраструктурные скрипты, нарушает SRP на уровне кодовой базы.
O — Open/Closed Principle (Принцип открытости/закрытости)
Формулировка: «Программные сущности должны быть открыты для расширения, но закрыты для модификации».
Это не означает, что код нельзя менять. Речь о том, что новые требования должны реализовываться через добавление кода, а не изменение существующего.
Как этого добиться? Через абстракции. Если поведение вынесено в интерфейс или абстрактный класс, то новые реализации можно добавлять без затрагивания клиентов.
Пример: система расчёта скидок. Вместо if (customer.Type == "VIP") … else if … — создаётся интерфейс IDiscountStrategy, и классы StandardDiscount, VipDiscount, SeasonalDiscount реализуют его. При добавлении нового типа скидки — пишется новый класс, основная логика не трогается.
ОCP лежит в основе архитектурных подходов вроде Port and Adapters (Hexagonal Architecture) и Clean Architecture. В них внутренние слои (domain, application) не содержат зависимостей от внешних (БД, UI, инфраструктуры). Все зависимости направлены внутрь, к абстракциям. Это позволяет, например, заменить веб-API на CLI или gRPC, не трогая бизнес-логику.
L — Liskov Substitution Principle (Принцип подстановки Барбары Лисков)
Формулировка: «Объекты в программе могут быть заменены их подтипами без изменения правильности выполнения программы».
Иначе говоря: подкласс не должен нарушать контракт, заданный суперклассом. Если метод родителя обещает: «не бросает исключений», — дочерний тоже не должен. Если родитель допускает null в параметрах, а дочерний — нет, это нарушение.
Классический контрпример — Square как подкласс Rectangle. У прямоугольника ширина и высота независимы; у квадрата — связаны. Если клиент рассчитывает на независимость (rect.Width = 5; rect.Height = 10), замена на Square приведёт к ошибке.
Правильное решение — вынести общий интерфейс IShape с методом GetArea(), а Rectangle и Square сделать независимыми реализациями. Наследование здесь — установление контракта.
LSP тесно связан с Design by Contract (предусловия, постусловия, инварианты), впервые формализованной в языке Eiffel. Современные языки частично поддерживают его через аннотации (@NotNull, Contract.Requires), но полный контроль остаётся за разработчиком.
I — Interface Segregation Principle (Принцип разделения интерфейсов)
Формулировка: «Клиенты не должны зависеть от методов, которые они не используют».
«Толстый» интерфейс — это технический долг. Если класс DocumentProcessor реализует IPrintable, IScannable, IFaxable, но на устройстве есть только сканер, ему придётся реализовывать методы Print() и Fax() как throw new NotImplementedException(). Это ломает полиморфизм: клиент, ожидающий IPrintable, получит исключение.
Решение — дробить интерфейсы до уровня роли:
IPrintJobIScanJobIFaxJob
Теперь SimpleScanner реализует только IScanJob; AllInOneDevice — все три. Зависимости становятся точными.
ISP особенно важен при проектировании API. Публичный интерфейс должен быть минималистичным и сфокусированным на сценарии использования.
D — Dependency Inversion Principle (Принцип инверсии зависимостей)
Формулировка:
- Модули верхнего уровня не должны зависеть от модулей нижнего уровня.
- Оба типа модулей должны зависеть от абстракций.
- Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Это инверсия направления зависимостей. В традиционной архитектуре приложение зависит от БД: App → PostgreSQL. При DIP оба зависят от абстракции:
App → IPersistence ← PostgreSQL.
Результат:
- Тестируемость: можно подменить
PostgreSQLнаInMemoryRepositoryв unit-тестах. - Заменяемость: переход с одного хранилища на другое не затрагивает бизнес-логику.
- Стабильность: изменения в инфраструктуре не приводят к перекомпиляции ядра.
DIP реализуется через Dependency Injection — механизм передачи зависимостей извне (через конструктор, свойство или метод). Контейнеры DI (например, Microsoft.Extensions.DependencyInjection, Autofac, Guice) автоматизируют этот процесс.
2. DRY — Don’t Repeat Yourself
Формулировка: «Каждая единица знания должна иметь единственное, однозначное, авторитетное представление в системе».
DRY — не про «не дублируй строки кода». Дублирование логики — проблема; дублирование данных в определённых контекстах может быть оправдано (например, денормализация для производительности).
Пример нарушения: один и тот же алгоритм расчёта налога реализован в трёх местах — в веб-сервисе, в фоновом job’е и в CLI-утилите. Изменение ставки налога потребует правки в трёх файлах, риск рассогласования высок.
Решение — вынести логику в общий модуль, библиотеку или микросервис.
Важно: не следует применять DRY слепо. Если два фрагмента похожи сейчас, но причины их изменения различны, объединение их в одну функцию приведёт к нарушению SRP и росту связанности. DRY сильнее всего работает, когда совпадают семантика и причины изменений.
3. KISS — Keep It Simple, Stupid
Формулировка: «Простота — предварительное условие надёжности» (цитата Дейкстры).
KISS не означает «делай примитивно». Это призыв избегать излишней сложности — той, которая не оправдана требованиями.
Пример: использование Kafka для обмена сообщениями между двумя микросервисами, если нагрузка — 10 запросов в день. Здесь выигрыш от отказоустойчивости и масштабируемости не покрывает стоимость эксплуатации.
KISS требует постоянного вопроса: «Какой самый простой способ, который всё ещё работает?» Иногда это монолит; иногда — файловый обмен; иногда — отсутствие кэша.
Связь с другими принципами: KISS часто вступает в противоречие с «изящными» архитектурами. SOLID и DRY — инструменты, которые помогают сохранять простоту в масштабе; без них простота быстро превращается в беспорядок.
4. Закон Конвея
Формулировка (оригинал, 1967): «Организации, проектирующие системы, ограничены созданием конструкций, которые являются копиями структуры коммуникаций в этих организациях».
Это не шутка и не метафора — это эмпирически подтверждённое наблюдение. Если в компании три отдела (фронтенд, бэкенд, инфраструктура), система почти неизбежно будет состоять из трёх слоёв, разделённых API-границами — даже если с технической точки зрения эффективнее было бы объединить два из них.
Последствия:
- При слиянии компаний — конфликты архитектур.
- При реорганизации команд — необходимость рефакторинга системы.
- При построении микросервисов — границы сервисов часто повторяют штатное расписание.
Проектировщик должен учитывать организационный контекст. Хорошая архитектура — та, что устойчива к изменениям в структуре команды. Это достигается через чёткие контракты (API, события), независимые циклы разработки и отказ от «серых зон» ответственности.
5. SOC — Separation of Concerns (Разделение ответственности)
Формулировка: «Разделение системы на части, каждая из которых отвечает за отдельную, независимую задачу».
SOC — самый общий принцип, лежащий в основе всех остальных. SRP, DIP, архитектурные слои — всё это его проявления.
Примеры уровней разделения:
- Представление / Логика / Данные (MVC, MVP, MVVM)
- Domain / Application / Infrastructure (Clean Architecture)
- UI / API / Data (Three-tier architecture)
Цель — локализация изменений. Если меняется способ отображения, не должно требоваться переписывание расчёта. Если меняется СУБД, не должен переписываться API-контракт.
SOC выражается через:
- Чёткие интерфейсы между компонентами.
- Запрет прямых зависимостей между слоями (только через абстракции).
- Изоляцию побочных эффектов (например, логирование, кэширование — отдельные cross-cutting concerns, реализуемые через AOP или middleware).