Принципы проектирования
Модель и масштаб — Основы БД, опорные темы, проектирование БД, пакетная работа. Карта — о разделе.
Принципы проектирования
Принципы — это критерии оценки. Они позволяют задать вопрос — "Если бы мы сделали иначе, что пошло бы не так через год?" Хороший код сегодня — это рабочий код и тот, который можно безопасно изменить завтра.
Ниже рассматриваются принципы, доказавшие свою ценность в реальных промышленных проектах. Их применение не гарантирует успех, но их игнорирование почти всегда ведёт к накоплению технического долга.
Здесь — 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, получит исключение.
Решение — дробить интерфейсы до уровня роли:
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) автоматизируют этот процесс.
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 не даёт одному модулю нести чужие причины изменений.
Чек-лист перед выносом в общий код
- Совпадает ли смысл (не только текст кода)?
- Будут ли изменения идти синхронно в ближайшие месяцы?
- Станет ли общий API проще для читателя, а не "универсальным комбайном"?
- Можно ли покрыть общее место одним набором тестов?
Если на (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).