Перейти к основному содержимому

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, получит исключение.

Решение — дробить интерфейсы до уровня роли:

  • IPrintJob
  • IScanJob
  • IFaxJob

Теперь SimpleScanner реализует только IScanJob; AllInOneDevice — все три. Зависимости становятся точными.

ISP особенно важен при проектировании API. Публичный интерфейс должен быть минималистичным и сфокусированным на сценарии использования.

D — Dependency Inversion Principle (Принцип инверсии зависимостей)

Формулировка:

  1. Модули верхнего уровня не должны зависеть от модулей нижнего уровня.
  2. Оба типа модулей должны зависеть от абстракций.
  3. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

Это инверсия направления зависимостей. В традиционной архитектуре приложение зависит от БД: 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).