Фундамент: coupling, cohesion, SOLID
Coupling и Cohesion — единственные две метрики архитектуры, которые действительно важны. SOLID и GRASP — словарь для разговора о них.
TL;DR
- Coupling и Cohesion — не «академические метрики», а рабочие инструменты. Все SOLID сводятся к ним.
- SOLID — не догма. Каждый принцип часто понимают неверно: разберём точные формулировки.
- GRASP отвечает на вопрос «кому доверить эту ответственность». Дополняет SOLID, не заменяет.
- DRY, KISS, YAGNI постоянно применяют не там, где надо. Покажем частые ошибки.
Зачем эта глава
Половина статей про Clean Code бесполезна, потому что не отвечает на вопрос «почему так лучше?». Ответы типа «так чище» или «так принято» не работают, когда нужно принять решение под давлением.
Coupling и Cohesion — то, к чему сводится любое такое решение. Всё остальное — SOLID, GRASP, DRY — это словарь, чтобы разговаривать о них конкретно.
Coupling — связанность
Мера того, насколько сильно один модуль зависит от другого. Чем ниже coupling, тем легче менять модули независимо.
Виды coupling по Yourdon и Constantine (Structured Design, 1979), от худшего к лучшему:
Content coupling. Модуль A залезает во внутренности модуля B — читает приватные поля, вызывает приватные методы через рефлексию. Хуже не бывает.
Common coupling. Модули A и B общаются через глобальное состояние (глобальные переменные, синглтоны без явного контракта). Изменение поведения одного молча ломает другой.
Control coupling. A передаёт B флаг, который управляет тем, что B делает: foo(mode="delete") вместо delete(). B становится процедурным «if мод1, if мод2».
Stamp coupling. A передаёт B большой объект, из которого B использует только пару полей. B знает форму объекта больше, чем нужно.
Data coupling. A передаёт B ровно те данные, которые нужны. Хороший уровень.
Вторая, более практичная оптика — afferent (входящее) и efferent (исходящее) coupling из Clean Architecture:
- Ca (afferent) — сколько модулей зависит от данного.
- Ce (efferent) — от скольких модулей зависит данный.
- Instability I = Ce / (Ca + Ce) — от 0 (максимально стабилен, все зависят от него) до 1 (максимально нестабилен, зависит от всех).
Правило: стабильные модули должны быть абстрактными. Если ваш User из domain-слоя имеет I=0.9, что-то пошло не так — либо он не должен быть таким «зависимым», либо он не должен быть в domain.
Рефакторинг — ввести Port:
class NormalizerPort(Protocol):
async def normalize(self, properties: list[str]) -> list[dict]: ...
class OrderStandardizeUseCase:
def __init__(self, normalizer: NormalizerPort):
self._normalizer = normalizer
Теперь use case зависит от одной абстракции. Инверсия зависимости — это про Coupling, а не про «интерфейсы ради интерфейсов».
Cohesion — связность
Мера того, насколько элементы внутри модуля принадлежат друг другу. Чем выше cohesion, тем понятнее назначение модуля.
Уровни cohesion (Yourdon/Constantine), снова от худшего к лучшему:
- Coincidental. Функции сложены вместе по случайности («UserUtils»).
- Logical. Функции похожи по типу действия, но не по цели («StringHelper.trim, StringHelper.uuidGenerate»).
- Temporal. Функции вызываются в одно время («Startup.init_db, init_logger, init_cache»).
- Procedural. Функции — шаги одной процедуры («OrderProcessor.step1, step2, step3»).
- Communicational. Функции работают над одними данными («Report.build, Report.format, Report.send»).
- Sequential. Выход одной — вход следующей. Ближе к pipeline.
- Functional. Функции служат одной задаче («Money — все операции над валютой»).
Практический маркер: если имя класса содержит “and” или заканчивается на “Manager”/“Helper”/“Utils” — cohesion, скорее всего, coincidental.
▸ Почему «Manager» — красный флаг
«Manager» ничего не значит. Что делает UserManager? Что угодно, связанное с пользователями. Такое имя не сужает круг ответственности — оно расширяет его до бесконечности. Через полгода в UserManager живут: email-рассылка, генерация паролей, проверка прав, сериализация в JSON.
Признак: если вы не можете придумать точное имя классу — вероятно, вы не додумали его ответственность. Возможно, там прячутся два-три класса.
SOLID через призму coupling и cohesion
SOLID (Robert Martin, 2000) — не пять независимых правил. Это пять формулировок одной идеи: снизить coupling между модулями и повысить cohesion внутри них.
SRP — Single Responsibility Principle
Расхожая формулировка: «класс должен делать одну вещь». Она бесполезна: что такое «одна вещь»?
Точная формулировка (сам Martin потом уточнял): у класса должна быть одна причина для изменения. То есть один стейкхолдер, один источник требований.
Пример: Order внутри содержит calculate_tax(). Кто изменит этот код? Финансовый департамент (правила налогообложения). Также содержит render_pdf(). Кто изменит? Дизайн-команда. Две разные причины — два разных класса.
OCP — Open/Closed Principle
Классы должны быть открыты для расширения, закрыты для изменения. Мейер придумал через наследование, Мартин переформулировал через полиморфизм и dependency inversion.
Практически: если добавление нового типа платежа заставляет открыть PaymentService и дописать elif, OCP нарушен. Решение — новый тип реализует общий Port, DI-контейнер подставляет.
OCP — про coupling. Изменение в одном месте не должно требовать изменений в других.
LSP — Liskov Substitution Principle
Наследник должен быть подставим вместо родителя без изменения поведения программы. Часто понимают синтаксически («методы совпадают по сигнатуре»), но LSP — про семантику.
Правила:
- Наследник не должен усиливать precondition (не требовать больше от входа).
- Наследник не должен ослаблять postcondition (не гарантировать меньше на выходе).
- Наследник не должен вводить новые исключения, о которых родитель не заявлял.
Классический пример нарушения: Square extends Rectangle. Прямоугольник позволяет установить ширину независимо от высоты; квадрат — нет. При подстановке квадрата туда, где ожидался прямоугольник, программа ломается.
ISP — Interface Segregation Principle
Клиенты не должны зависеть от методов, которые не используют. Огромный интерфейс IUserService с 20 методами заставляет каждого клиента зависеть от всех 20, даже если он использует один.
Решение — разделить на маленькие интерфейсы по «клиентским группам»: IUserAuthentication, IUserProfile, IUserAdmin.
ISP — снова про coupling. Меньше поверхность зависимости — меньше причин для рассинхронизации.
DIP — Dependency Inversion Principle
Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба зависят от абстракций. Абстракции не должны зависеть от деталей — детали должны зависеть от абстракций.
Это ключевой принцип Clean Architecture. Про него — вся следующая глава.
The dependency rule: source code dependencies must point only inward, toward higher-level policies.
GRASP — общие обязанности
GRASP (Craig Larman, 1997) — не про принципы, а про распределение ответственностей. Ответ на вопрос «кому доверить этот метод?».
Ключевые:
- Information Expert. Ответственность отдают тому, у кого есть данные для её выполнения. Кто должен считать total у Order? Order — у него есть OrderLine со стоимостями.
- Creator. Кто создаёт объект B? Обычно объект A, если A содержит B, использует B тесно, или имеет данные для инициализации B.
- Controller. Кто принимает системный event (например, HTTP-запрос) и делегирует работу? Отдельный класс — не UI, не domain.
- Low Coupling, High Cohesion. Прямые формулировки того, что мы обсудили выше.
- Polymorphism. Вариации поведения — через полиморфизм, а не через
if isinstance. - Pure Fabrication. Иногда нужно создать класс, которого нет в domain-модели (например,
OrderRepository), чтобы удержать cohesion. - Indirection. Между A и B вставить C, чтобы разорвать прямую зависимость.
- Protected Variations. Изолировать точки изменений за стабильными интерфейсами.
DRY, KISS, YAGNI — где они врут
Эти принципы полезны, но неверная трактовка приводит к дурным решениям чаще, чем незнание.
DRY — Don’t Repeat Yourself
Что имеется в виду: каждое знание должно иметь единственное, недвусмысленное, авторитетное представление в системе (Hunt & Thomas, «The Pragmatic Programmer»).
Что часто читают: «код не должен повторяться».
Разница: DRY — про знание, а не про синтаксис. Два одинаковых куска кода, которые совпадают случайно и живут по разным причинам, — не дублирование. Их объединение сделает хуже: изменение одного случая заставит менять оба.
Правило Beck: rule of three. Два раза похожий код — оставьте. Три раза — задумайтесь. Раньше объединять — гадать.
KISS — Keep It Simple, Stupid
Простое решение лучше сложного. Проблема — «простое» неопределимо без контекста.
- Простое «в моменте написания» = мало кода.
- Простое «в модификации» = ясная структура, даже если больше кода.
- Простое «в понимании» = знакомые паттерны.
Часто эти три критерия конфликтуют. KISS сам по себе не даёт ответа — только напоминание задать вопрос: «а не переусложняю ли я это?».
YAGNI — You Aren’t Gonna Need It
Не пишите код на будущее. Если требования сейчас не требуют этого — не делайте.
Опасность: ошибочная трактовка «не делайте абстракции». Ports & Adapters выглядят как overengineering («Repository для одной таблицы? Зачем интерфейс?»), но они окупаются не через год-два, а через первый же интеграционный тест или первую замену источника данных.
Правильно: не добавляйте фичи, которые не запрошены. Абстракции, которые делают текущий код тестируемым и разделяют слои — не фича, а гигиена.
Trade-offs
| Принцип | Против | Что перевешивает |
|---|---|---|
| SRP | Больше классов, дольше писать | Изменения локальны, тесты чище |
| OCP | Больше абстракций | Не приходится трогать работающий код |
| DIP | Слой Port’ов, больше файлов | Тестируемость, замена реализаций |
| DRY | Слишком раннее объединение → жёсткая связь | Одно место — одно знание |
| YAGNI | Иногда позднее вводить абстракции больно | Не тратим время на предположения |
В твоём же коде
Возьмём фрагмент use case из анонимизированного микросервиса:
class OrderStandardizeUseCase:
@staticmethod
def _adapt_for_consumer(std_attr: dict, raw_attr: dict) -> dict:
"""Приводит ответ marketplace-api к формату matcher-service."""
std_attr["raw"] = {...}
if attr_type == "number":
v0["normalized"] = {...}
elif attr_type == "range":
...
Что видим по фундаменту:
- Coupling. Use case знает про формат matcher-service (внешний consumer). Меняется формат у consumer — правится use case. Content coupling через shared формат.
- Cohesion. Класс
OrderStandardizeUseCaseсодержит: (а) оркестрацию сценария; (б) маппинг для consumer’а; (в) сборку properties для marketplace. Три разные ответственности → cohesion procedural, ближе к coincidental. - SRP. Use case меняется по трём разным причинам: изменение бизнес-сценария, изменение формата matcher, изменение формата marketplace. Три стейкхолдера — три причины.
Правильно — вынести маппер в отдельный класс OutgoingMessageMapper в infrastructure/rabbit, а сборку properties — в MarketplaceRequestBuilder в infrastructure/marketplace. Use case остаётся тонким оркестратором.
Дальнейшее чтение
- Robert C. Martin. Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Prentice Hall, 2017. Главы 3–7 — про SOLID детально.
- Craig Larman. Applying UML and Patterns. Prentice Hall. Раздел про GRASP — полная формулировка каждого паттерна.
- Martin Fowler. Refactoring: Improving the Design of Existing Code. Addison-Wesley. Каталог code smells — обратная сторона тех же принципов.
- Kent Beck. Tidy First? A Personal Engineering Playbook. O’Reilly, 2023. Про rule of three и коммитную гигиену.
- David Farley. Modern Software Engineering. Addison-Wesley, 2021. Про coupling и cohesion как оптику для проектирования.
- Andy Hunt, David Thomas. The Pragmatic Programmer. Addison-Wesley, 1999. Оригинальная формулировка DRY.
Проверьте себя
Мини-quiz · закрепить
Проверьте себя
Q1. Какой уровень cohesion у класса, где методы объединены только потому, что их вызывают на старте приложения?
Q2. Какая формулировка SRP правильная?
Q3. Что НЕ является нарушением LSP?
Q4. Два фрагмента кода выглядят похоже, но существуют по разным бизнес-причинам. По DRY их нужно объединить?
Q5. Какой GRASP-паттерн отвечает на вопрос «кому отдать ответственность за метод, который меняет состояние по нескольким объектам»?