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

Принципы проектирования

Разработчику Архитектору Аналитику
Теория данных (раздел 3)

Принципы проектирования

Принципы — это критерии оценки. Они позволяют задать вопрос — "Если бы мы сделали иначе, что пошло бы не так через год?" Хороший код сегодня — это рабочий код и тот, который можно безопасно изменить завтра.

Ниже рассматриваются принципы, доказавшие свою ценность в реальных промышленных проектах. Их применение не гарантирует успех, но их игнорирование почти всегда ведёт к накоплению технического долга.

Связность и сцепление модулей

Здесь — SOLID, DRY, KISS на уровне классов.

Классическая типология cohesion/coupling для модулей и экзамена по конструированию — в отдельной статье Связность и сцепление модулей.


1. SOLID

SOLID — это акроним, введённый Робертом С Мартином, обобщающий пять принципов, направленных на повышение гибкости, тестируемости и сопровождаемости кода.

Play ITЗагрузка интерактивного демо…


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 тесно связан с Проектирование 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) автоматизируют этот процесс.

Play ITЗагрузка интерактивного демо…

Play ITЗагрузка интерактивного демо…


2. DRY — Don’t Repeat Yourself

Формулировка (Энди Хант и Дэйв Томас) — "Каждая единица знания должна иметь единственное, однозначное, авторитетное представление в системе".

DRY относится к знанию (правила, формулы, бизнес-инварианты), а не к похожим строкам в редакторе. Две функции с одинаковым телом, но разными причинами изменения, — это совпадение, а не дублирование знания. Один расчёт налога в трёх сервисах — дублирование знания: ставка меняется в одном месте по смыслу, а править приходится везде.

Подробнее про дублирование как запах кода — в статье Горизонтальное дублирование.


Что считать "единицей знания"

Это одно знание (имеет смысл DRY)Это разное знание (DRY не требует слияния)
Формула НДС для заказа, отчёта и APIВалидация email и валидация ИНН — разные домены
Правило "заказ нельзя отменить после отгрузки"Похожие CRUD-эндпоинты для разных сущностей
Маппинг ошибок платёжного шлюза в коды APIДва if (x > 0) в разных модулях "на всякий случай"

Практический тест: если завтра изменится правило, сколько мест в коде и в голове команды придётся синхронизировать? Если больше одного — кандидат на обобщение.


Пример нарушения — размножённая логика

Один и тот же расчёт налога скопирован в веб-сервис, фоновую задачу и CLI:

КЛАСС ВебЗаказы
метод Итог(позиции)
сумма := сумма_цен(позиции)
вернуть сумма + сумма * 0.2 // ставка «зашита» здесь
конец
КОНЕЦ

КЛАСС ОтчётПоПродажам
метод ИтогЗаДень(позиции)
сумма := сумма_цен(позиции)
вернуть сумма + сумма * 0.2 // та же формула — второе представление знания
конец
КОНЕЦ

Изменение ставки или освобождения от НДС потребует охоты по репозиторию и риска, что в одном месте останется 0.2, в другом — 0.22.

Справочно на Python (нарушение DRY)

# Плохо: знание «как считать налог» размножено
class WebOrders:
def total(self, items):
subtotal = sum(i.price for i in items)
return subtotal + subtotal * 0.2

class SalesReport:
def daily_total(self, items):
subtotal = sum(i.price for i in items)
return subtotal + subtotal * 0.2

Корректная реализация — одно авторитетное место

МОДУЛЬ Налоги
функция НалогСЗаказа(суммаБезНалога) → число
вернуть суммаБезНалога * СтавкаНДС()
конец
КОНЕЦ

КЛАСС ВебЗаказы
метод Итог(позиции)
сумма := сумма_цен(позиции)
вернуть сумма + Налоги.НалогСЗаказа(сумма)
конец
КОНЕЦ

Справочно на Python

# Хорошо: одно представление правила
def vat_amount(subtotal: float) -> float:
return subtotal * settings.VAT_RATE

class WebOrders:
def total(self, items):
subtotal = sum(i.price for i in items)
return subtotal + vat_amount(subtotal)

В крупных системах "модуль" может быть общей библиотекой, доменным пакетом или отдельным сервисом с контрактом — важно, чтобы правило жило в одном месте по смыслу, а не в одном файле ради файла.


DRY на разных уровнях

  • Функция / класс — общий расчёт, валидатор, формат даты.
  • Модуль / пакет — shared-библиотека для нескольких приложений команды.
  • Конфигурация и инфраструктура — один источник правды для портов, URL, feature flags (дублирование в docker-compose, Helm и README ломается так же, как дублирование 0.2 в коде).
  • Документация — дублирование требований в Confluence, ТЗ и комментариях ведёт к рассинхрону; лучше ссылка на одно описание или генерация из кода (OpenAPI, схемы).

DRY не запрещает денормализацию в БД, кэш и копии данных для скорости — там дублируются данные, а не правило: правило пересчёта или инвалидации всё равно должно быть одним.


Когда объединять рано — вредно

Два фрагмента выглядят одинаково, но меняются по разным причинам:

функция ОкруглитьДляЧека(сумма) // меняется кассовым регламентом
функция ОкруглитьДляОтчёта(сумма) // меняется бухгалтерией
// сейчас обе: вернуть round(сумма, 2)
КОНЕЦ

Слияние в Округлить(сумма) с параметром режим свяжет независимые акторы: правка для чека сломает отчёт. Это конфликт с SRP и рост связанности. Здесь уместнее правило трёх (Rule of Three): обобщать после третьего осмысленного повторения, когда видна общая причина изменений.

Похожие идеи в индустрии:

  • WET (Write Everything Twice / We Enjoy Typing) — ироничное напоминание, что слепое копирование дороже рефакторинга.
  • AHA (Avoid Hasty Abstractions) — не торопиться с абстракцией, пока не прояснится, что именно повторяется.

DRY и SRP работают вместе: DRY убирает копии одного знания, SRP не даёт одному модулю нести чужие причины изменений.


Чек-лист перед выносом в общий код

  1. Совпадает ли смысл (не только текст кода)?
  2. Будут ли изменения идти синхронно в ближайшие месяцы?
  3. Станет ли общий API проще для читателя, а не "универсальным комбайном"?
  4. Можно ли покрыть общее место одним набором тестов?

Если на (2) ответ "нет" — оставьте дублирование до прояснения или дайте функциям разные имена, явно фиксируя разные домены.


3. KISS — Keep It Simple, Stupid

Формулировка в духе Эдсгера Дейкстры: "Простота — предварительное условие надёжности". В инженерной культуре KISS (Keep It Simple, Stupid) — напоминание выбирать минимально достаточное решение под текущие требования, а не "самое модное".

Простота — это понятность и предсказуемость для команды, которая будет сопровождать систему. Примитивность ради экономии строк, игнорирование безопасности или отказ от тестов — уже не KISS, а технический риск.

Соседний принцип YAGNI (You Aren’t Gonna Need It) — не строить функциональность "на вырост"; KISS — не строить инфраструктуру и абстракции шире, чем требует нагрузка и команда. Их часто применяют в паре.


Простое решение vs "умное"

СитуацияПроще (KISS)Избыточно для старта
Два сервиса, десятки вызовов в деньHTTP + общая БД или один монолитKafka, сага, отдельный mesh
Кэш не нужен по метрикамЗапрос в БД с индексомRedis "на будущее"
Один разработчик, CRUDФреймворк "из коробки"Микросервисы по слоям DDD
Конфиг из пяти переменных.env + валидация при стартеСобственный config-сервер

Вопрос на ревью — "Какой самый простой вариант ещё выполняет требования и допускает измеримый рост?" Иногда ответ — монолит, иногда — файловый обмен, иногда — отсутствие кэша.


Пример — лишняя инфраструктура

Требование: раз в сутки выгрузить отчёт из сервиса A в сервис B.

// «Архитектурно красиво», но дорого в эксплуатации:
очередь Kafka → consumer → retry → dead letter → мониторинг топиков

Паттерны retry, ACK и DLQброкеры сообщений.

KISS-вариант при низкой нагрузке:

по cron: HTTP GET /export из A → сохранить файл или POST в B

Если позже появятся тысячи сообщений в секунду, усложнение обосновано метриками, а не страхом "вдруг вырастем".


Пример в коде — преждевременная абстракция

Справочно на Python (усложнение без требований)

Код ITЗагрузка примера кода…

Пока есть только карта, достаточно:

# Хорошо для текущего масштаба
def pay(amount: float) -> None:
charge_card(amount)

Когда появится второй способ с тем же контрактом для клиентов — тогда OCP и интерфейс оправданы. KISS на старте не отменяет SOLID при росте; он откладывает слой, который пока не окупается.


KISS и читаемость

Простой код:

  • укладывается в голову за один проход (функция, файл, сценарий);
  • имеет прямой поток без лишних indirection;
  • использует знакомые команде паттерны и имена из глоссария.

Избыточная "гениальность" — вложенные callback’и там, где хватит async/await, метапрограммирование ради одной строки, generic-ад на трёх типах — усложняет онбординг и отладку. KISS не запрещает абстракции; он требует, чтобы каждая абстракция платила упрощением сопровождения.


Связь с SOLID и DRY

ПринципРоль
KISSНе усложнять архитектуру и код без измеримой выгоды
YAGNIНе писать функции и API "про запас"
DRYНе размножать знание; убирает шум, который маскируется за "простотой копипасты"
SOLIDДержит структуру понятной, когда проект уже вырос и простой монолит перестал помещаться в голову

SOLID и DRY помогают сохранять простоту в масштабе: без них "простой" прототип быстро превращается в комок if/else и скрытых зависимостей. KISS задаёт потолок сложности; SOLID/DRY — как не пробить пол, когда потолок поднимают обоснованно.

Полный разбор SOLID с примерами на нескольких языках — в 4.07. Принципы SOLID.


4. Закон Конвея

Формулировка (оригинал, 1967) — "Организации, проектирующие системы, ограничены созданием конструкций, которые являются копиями структуры коммуникаций в этих организациях".

Это не шутка и не метафора — это эмпирически подтверждённое наблюдение. Если в компании три отдела (фронтенд, бэкенд, инфраструктура), система почти неизбежно будет состоять из трёх слоёв, разделённых API-границами — даже если с технической точки зрения эффективнее было бы объединить два из них.

Последствия:

  • При слиянии компаний — конфликты архитектур.
  • При реорганизации команд — необходимость рефакторинга системы.
  • При построении микросервисов — границы сервисов часто повторяют штатное расписание.

Проектировщик должен учитывать организационный контекст. Хорошая архитектура — та, что устойчива к изменениям в структуре команды. Это достигается через чёткие контракты (API, события), независимые циклы разработки и отказ от "серых зон" ответственности. Другие эмпирические «законы» индустрии (Брукс, Паркинсон, Мёрфи…) — IT-законы и эмпирические принципы.


5. SOC — Separation of Concerns (Разделение ответственности)

Формулировка — "Разделение системы на части, каждая из которых отвечает за отдельную, независимую задачу".

SOC — самый общий принцип, лежащий в основе всех остальных. SRP, DIP, архитектурные слои — всё это его проявления.

Примеры уровней разделения:

  • Представление / Логика / Данные (MVC, MVP, MVVM)
  • Domain / Application / Infrastructure (Clean Architecture)
  • UI / API / Данные (Three-tier architecture)

Цель — локализация изменений. Если меняется способ отображения, не должно требоваться переписывание расчёта. Если меняется СУБД, не должен переписываться API-контракт.

SOC выражается через:

  • Чёткие интерфейсы между компонентами.
  • Запрет прямых зависимостей между слоями (только через абстракции).
  • Изоляцию побочных эффектов (например, логирование, кэширование — отдельные cross-cutting concerns, реализуемые через AOP или middleware).