Принципы модульности (SoC, Law of Demeter)
Назначение: систематизация принципов модульности — SoC, LoD, cohesion/coupling: как проводить границы между частями системы, как ограничивать знание модуля о других и какими мерами оценивать качество разбиения.
Аудитория: разработчики, техлиды, архитекторы.
Статус: устоявшиеся принципы (SoC — Эдсгер Дейкстра, 1974; LoD — проект Demeter, Северо-Восточный университет, 1987; cohesion/coupling — Ларри Константайн, 1970-е; Connascence — Мейлир Пейдж-Джонс, 1995).
Не путать с: SOLID — набором принципов проектирования классов (модульность — шире: от функции до сервиса); DRY / KISS / YAGNI — принципами экономии, а не структуры; GRASP — эвристиками назначения обязанностей, двумя из которых являются Low Coupling и High Cohesion.
Общее
Модульность (modularity) — свойство системы, при котором она построена из дискретных, самостоятельно осмысленных частей — модулей, — каждый из которых скрывает свои внутренности за границей и взаимодействует с остальными через явно определённый интерфейс. Модулем может быть функция, класс, пакет, слой, сервис: принципы модульности не привязаны к масштабу и потому одинаково работают от строки кода до границы микросервиса.
Пара «модуль + интерфейс» — минимальный словарь, без которого принципы не формулируются. Модуль — единица замены и владения: его можно понять, протестировать и заменить целиком, не читая соседей. Интерфейс — явный контракт модуля: набор операций с договорённой семантикой, через который проходит всё взаимодействие. Всё, что не вошло в контракт, — внутренности, деталь реализации, о которой внешний мир не должен знать. Это определение восходит к работам Дэвида Парнаса начала 1970-х (см. ниже) и не изменилось с тех пор: изменились только масштабы модулей — от процедур и классов до команд и сервисов.
Модульность — ответ на центральную проблему инженерии ПО: сложность. Брукс в No Silver Bullet разделил её на существенную (принадлежит самой задаче) и случайную (привнесённую решением); модульность — главный инструмент борьбы со второй. Она не уменьшает суммарную сложность системы — она ограничивает сложность, которую приходится удерживать в голове одновременно: в каждый момент времени разработчик работает внутри одного модуля, зная его внутренности и контракт его соседей, но не их устройство. Все остальные выгоды модульности — тестируемость, параллельная работа, переиспользование — производные от этого ограничения.
Модульность отвечает на три вопроса, из которых складывается вся дисциплина декомпозиции:
- Как делить? — по каким признакам проводить границы между частями. Ответ даёт Separation of Concerns (SoC, «разделение ответственности»): Эдсгер Дейкстра, эссе On the role of scientific thought (EWD447), 1974.
- Сколько знать о соседях? — какое знание о других модулях допустимо. Ответ даёт Law of Demeter (LoD, «закон Деметры», он же Principle of Least Knowledge — «принцип наименьшего знания»): проект Demeter, Северо-Восточный университет (Northeastern University, Бостон), 1987, Карл Либерхерр (Karl Lieberherr) и Ян Холланд (Ian Holland).
- Чем измерять результат? — как оценить, хорошее ли получилось разбиение. Ответ дают связность (cohesion) и связанность (coupling) — метрики структурного проектирования Ларри Константайна (Larry Constantine) 1970-х годов, уточняемые Connascence Мейлира Пейдж-Джонса (Meilir Page-Jones).
| Принцип | Автор, год | Вопрос | Суть |
|---|---|---|---|
| Separation of Concerns | Дейкстра, 1974 | Как делить систему? | Непересекающиеся области ответственности |
| Law of Demeter | Либерхерр, Холланд, 1987 | Сколько знать о других? | «Не разговаривай с незнакомцами» |
| Cohesion / Coupling | Константайн, 1970-е | Хорошо ли разбито? | Высокая связность, слабая связанность |
| Connascence | Пейдж-Джонс, 1995 | Чем ещё измерять? | Сила и масштаб взаимозависимостей |
Принципы старше объектно-ориентированного программирования и не зависят от парадигмы: SoC сформулирован в эссе о научном мышлении вообще, cohesion/coupling — из структурного проектирования 1970-х. ООП лишь дало им самый естественный носитель — инкапсуляцию. Тот же словарь применяется к функциям, модулям, компонентам, сервисам и командам: закон Конвея превращает границы модулей в границы организаций, и принципы модульности становятся принципами устройства разработки.
Краткая историческая линия: Парнас (1972) — модуль как сокрытие решения; Дейкстра (1974) — разделение ответственности как приём мышления; Константайн и Йордан (1979) — связность и связанность как меры разбиения; Либерхерр и Холланд (1987–1989) — ограничение знания о структуре (LoD); Чидамбар и Кемерер (1994) — количественные метрики классов; Пейдж-Джонс (1995) — connascence; Ларман (1997) и Мартин (2000-е) — включение этих идей в GRASP, SOLID и принципы пакетов. Линия непрерывна: каждый следующий шаг уточняет предыдущий, не отменяя его.
Место принципов модульности среди соседних систем: SOLID конкретизирует их для классов (Single Responsibility — частный случай SoC), GRASP включает Low Coupling и High Cohesion в набор эвристик назначения обязанностей, а слоистые стили — от слоистой архитектуры до Clean Architecture — суть SoC, применённый к направлению зависимостей целого приложения.
Separation of Concerns (SoC)
Формулировка и происхождение
Термин separation of concerns («разделение областей внимания/ответственности») ввёл Эдсгер Дейкстра в эссе On the role of scientific thought (1974), известном по нумерации его записок как EWD447. Эссе посвящено не программированию, а природе научной мысли: разум человека слишком мал для сложных задач, и единственный известный способ мышления о сложности — интеллектуальное удержание одной вещи за раз. Дейкстра называет «разделение ответственности» главным приёмом такого мышления: задачу нужно препарировать на непересекающиеся части, каждую из которых можно охватить мыслью отдельно, не думая об остальных. Знаменитая метафора эссе — «наш ум — слишком маленький стакан»: доливать в него всё сразу бесполезно, нужно пить порциями.
Перенесённый в программную инженерию, принцип звучит так: система делится на области ответственности (concerns), которые не пересекаются; каждая область инкапсулируется своим фрагментом кода, а взаимодействие между областями проходит через явные границы. «Область ответственности» — любое связное множество требований или решений: бизнес-правило, формат хранения, способ доставки, политика безопасности, протоколирование.
Ключевое слово — непересекающиеся. SoC требует не просто «разложить по разным местам», а именно рассечь задачу так, чтобы каждая часть имела собственную причину существования и собственную причину изменения. Если два фрагмента меняются всегда вместе — это одна область ответственности, ошибочно разрезанная надвое; если один фрагмент меняется по двум независимым причинам — это две области, ошибочно слитые вместе.
Важное уточнение к слову «concern». Область ответственности не обязана быть функциональной частью системы: concern’ом может быть сквозная функциональность (cross-cutting concern) — протоколирование, безопасность, транзакционность, — которая присутствует во многих частях системы сразу. Именно сквозные области не поддаются обычному разрезу «по функциям» и породили отдельные механизмы: фильтры и middleware, AOP, декораторы, событийные шины. Не выделять их вообще — значит размазать одно знание по всей кодовой базе; выделять неуклюже — получить «суп из аспектов», в котором теряется линейный поток выполнения.
Information hiding: критерий Парнаса
Как именно выбирать ось разреза, за два года до Дейкстры ответил Дэвид Парнас в статье On the Criteria To Be Used in Decomposing Systems into Modules (1972) — одной из самых цитируемых работ программной инженерии. Парнас сравнил два способа декомпозиции: по шагам процесса (функциональная декомпозиция — «сначала распарсить, потом посчитать, потом вывести») и по проектным решениям, которые скорее всего будут меняться. Вывод: модули следует образовывать вокруг скрываемых решений (design decisions likely to change) — каждое решение, изменчивость которого хочется изолировать, прячется за границей модуля и становится его «секретом». Классический пример Парнаса — хранилище данных: модуль, скрывающий способ хранения (список в памяти, файл, БД), позволяет сменить его, не затронув клиентов.
Information hiding связывает SoC с инкапсуляцией: граница области ответственности — это одновременно граница знания. Модуль скрывает не «строки кода», а решение: как хранится, как сортируется, как вычисляется, по какому протоколу ходит запрос. Отсюда практический критерий проведения границ, объединяющий Дейкстру и Парнаса: области ответственности = предсказанные оси изменения. Режут систему не там, где её удобно нарисовать, а там, где ожидается независимое движение частей.
Классические примеры
Самые известные воплощения SoC — давно ставшие нормой разрезы:
- Слои приложения. Представление, бизнес-логика и доступ к данным — три области ответственности, размещённые в разных слоях; смена СУБД не должна затрагивать UI. Базовая инстанция — слоистая архитектура, развитие — Clean Architecture и гексагональная архитектура с их правилом зависимостей.
- MVC. Model–View–Controller разделяет доменные данные (Model), их показ (View) и обработку ввода (Controller) — три области, эволюционирующие по независимым причинам.
- HTML / CSS / JS. Веб-платформа спроектирована как триада SoC: структура документа, его представление и поведение разделены по файлам и технологиям.
- Инфраструктура и домен. Twelve-factor-приложение отделяет конфигурацию от кода; домен не знает о транспорте, базах и очередях — это области инфраструктурных адаптеров.
Стоит подчеркнуть иерархичность декомпозиции: области ответственности вкладываются друг в друга. Система делится на подсистемы, подсистемы — на модули, модули — на классы и функции; на каждом уровне действует один и тот же принцип — режь по непересекающимся областям, — но ось разреза может меняться от уровня к уровню (система — по бизнес-возможностям, модуль внутри — на слои, функция внутри модуля — на шаги). Ошибка уровня — перенос оси верхнего уровня вниз без нужды: разрезание класса внутри модуля «на слои presentation/domain/data» воспроизводит архитектуру там, где нужна одна функция.
SoC как базис архитектуры
Практически весь словарь архитектуры — это словарь проведённых границ: слой, компонент, сервис, контекст, адаптер, порт. Каждая архитектурная единица есть материализованная область ответственности, а каждый архитектурный стиль — позиция о том, по какой оси резать систему. Слоистые стили режут по типу ответственности (UI, логика, данные); DDD — по предметным подобластям; микросервисы — по бизнес-возможностям с независимым развёртыванием. SoC не предписывает, какая ось правильная, — он требует лишь, чтобы ось была одна и та же на уровне разреза и чтобы области не пересекались: гибридные разрезы («слой наполовину по типу, наполовину по домену») порождают самые запутанные кодовые базы.
Обратная сторона принципа: разрезы вводят границы, а границы требуют кода взаимодействия. Чем больше областей, тем больше интерфейсов, маппингов и точек согласования. SoC не требует максимального разбиения — он требует правильного: области должны соответствовать реальным независимым осям изменения, иначе границы станут препятствием (типичные следствия — «слоистые пироги» из папок по одному классу и транзакции, растянутые на четыре слоя без пользы).
Ортогональность
Идеальная реализация SoC — ортогональность: области ответственности не просто не пересекаются, а независимы в математическом смысле — изменение в одной не требует изменений в другой. Практический тест на ортогональность локален: «если я изменю требование X, сколько модулей придётся тронуть?» Ортогональная декомпозиция даёт ответ «один»; неортогональная — «несколько, и заранее неизвестно, какие». Классический пример неортогональности — сквозная функциональность, размазанная по коду (логирование в каждом методе, try/catch с транзакционными деталями в каждом сервисе): одно изменение политики расползается по всей базе. Достичь полной ортогональности нельзя — области всегда связаны через данные и процессы, — но направление задано: минимизировать число модулей, затрагиваемых типовым изменением.
Law of Demeter (LoD)
Формулировка и происхождение
Law of Demeter (LoD, «закон Деметры») — принцип, ограничивающий знание модуля о структуре других модулей. Сформулирован в 1987 году в ходе работ по проекту Demeter в Северо-Восточном университете (Бостон) под руководством Карла Либерхерра; популяризован в статье Либерхерра и Яна Холланда Assuring Good Style for Object-Oriented Programs (IEEE Software, 1989) и развёрнут в книге Либерхерра Adaptive Object-Oriented Software: The Demeter Method (1996). Имя принципу дала греческая богиня земледелия Деметра — покровительница проекта; сам закон в узком кругу называют «The Suggestion of Demeter», подчёркивая, что это эвристика, а не теорема. Общеупотребительный синоним — Principle of Least Knowledge, «принцип наименьшего знания».
Классическая формулировка: метод M объекта O может вызывать методы только следующих объектов:
- самого O (self/this);
- объектов, переданных M как аргументы;
- объектов, созданных внутри M (в том числе результатов вызовов п. 1–2);
- прямых компонентов O (его полей и элементов коллекций, которыми он владеет);
- глобальных переменных, доступных O в области видимости M.
Всё остальное — «незнакомцы» (strangers): объекты, добытые у знакомых через цепочку вызовов. Народная формулировка — «не разговаривай с незнакомцами» (don’t talk to strangers) и её продолжение: «говори только со своими друзьями; друг друга твои друзья не знают». В терминах графов: метод общается с вершинами на расстоянии не более одного ребра от себя.
Канонический пример нарушения
Нарушение выглядит как цепочка обращений к внутренностям чужих объектов:
// нарушение: поезд знает устройство локомотива, локомотив — топлива...
const fuelType = train.getEngine().getFuel().getType();
// то же в общем виде: a.getB().getC().doSomething()
order.getCustomer().getAddress().getCity();
Такие цепочки называют train wreck — «железнодорожная катастрофа»: точечные вагоны get().get().get() при чтении сливаются в неразборчивую колею. Нарушена не синтаксическая красота: вызывающий код стал знать структуру трёх чужих объектов — что у поезда есть локомотив, у локомотива топливо, у топлива тип. Любое изменение этой структуры (локомотив сменил модель топлива на состав) сломает вызывающего.
Исправление: Tell, Don’t Ask
Лекарство — принцип Tell, Don’t Ask («поручи, а не спрашивай»): не выгребать данные из объекта, чтобы посчитать результат у себя, а переместить поведение к данным — попросить объект выполнить работу самому:
// было: знание о структуре Customer и Address утекло наружу
const city = order.getCustomer().getAddress().getCity();
if (city === 'Москва') { /* ... */ }
// стало: поведение у владельца данных, вызывающий знает только Order
if (order.isDeliveredTo('Москва')) { /* ... */ }
class Order {
constructor(private readonly customer: Customer) {}
isDeliveredTo(city: string): boolean {
return this.customer.livesIn(city); // один уровень доверия
}
}
class Customer {
constructor(private readonly address: Address) {}
livesIn(city: string): boolean {
return this.address.matchesCity(city); // и здесь
}
}
После рефакторинга каждый вызов остаётся в пределах «друзей»: Order общается со своим компонентом Customer, Customer — со своим Address. Изменение структуры (у адреса появился переезд, у клиента — несколько адресов) затрагивает один класс, а не всех его клиентов. Это и есть механика LoD: инкапсуляция не только данных, но и знания о структуре.
LoD и уровни абстракции
Точная диагностика нарушения — не длина цепочки, а пересечение уровня абстракции. Цепочка cart.items().first().product().category().title() длинна и при этом тянет читателя с уровня «корзина» на уровень «позиция», «товар», «категория» — четыре разные области знания. Цепочка report.rows().filter(r => r.isTotal()).sum() может быть любой длины, но остаётся на одном уровне — работе со строками отчёта. Отсюда практическое правило: нарушение LoD имеет место, когда для выражения мысли высокого уровня («доставляется ли заказ в Москву») приходится спускаться через несколько уровней чужой структуры. Рефакторинг — вернуть формулировке её уровень: поднять операцию туда, где живёт знание, необходимое для её выполнения (Information Expert из GRASP).
Цена нарушения
Нарушение LoD имеет три измеримых последствия. Связанность со структурой: вызывающий код зависит от формы чужого графа объектов, а не от смысла операции; количество скрытых зависимостей растёт быстрее длины цепочки. Хрупкость при рефакторинге: любой, казалось бы, безобидный переход от поля к вычислению (getFuel() становится resolveCurrentFuel()) становится ломающим изменением для всех, кто тянул цепочку. Расползание знаний: один и тот же обход a.getB().getC() копируется по кодовой базе, и точка, где знание о структуре живёт, исчезает.
Важные оговорки. Во-первых, свободные данные — не нарушение: если объект является прозрачной структурой данных (DTO, запись конфигурации, строка JSON), обращение к её полям — это и есть использование её интерфейса; LoD адресует поведение и инварианты, а не датаклассы. Во-вторых, транзитивные вызовы самого языка не в счёт: цепочки утилит (list.filter(...).map(...).join()) возвращают новые значения того же уровня абстракции и нарушением не считаются. Критерий один: тянет ли вызывающий через цепочку знание о внутреннем устройстве чужой доменной сущности.
Отдельный легитимный случай — код, чья работа и состоит в чтении структуры: сериализаторы, мапперы на границе подсистем, генераторы отчётов, read-модель в CQRS. Такие компоненты знают структуру по контракту — это их единственная ответственность, и их изолируют на границе, чтобы это знание не расползалось дальше. Разница с нарушением — в направлении: маппер читает структуру, чтобы погасить знание о ней на границе (за маппером начинается мир, где этой структуры нет), а нарушающий код — чтобы разнести знание о ней по системе.
Формы закона и практическое применение
В оригинальных работах закон сформулирован в двух формах. Классовая форма оперирует типами: метод класса C может обращаться к методам самого C, компонентов C, параметров метода и объектов, созданных внутри метода. Объектная форма строже — она ограничивает конкретные экземпляры в рантайме (методу доступны только объекты, достижимые из данного вызова, а не «любой объект данного типа»). Практические линтеры (например, правило law-of-demeter в ESLint-плагинах, проверки в IDE) реализуют приближенную классовую форму: помечают цепочки вида a.b().c(), оставляя финальное суждение разработчику.
Исторический контекст проекта Demeter объясняет необычную силу формулировки: проект исследовал адаптивное программирование — способы писать код, не завязанный на структуру графов объектов (в терминах проекта — «рост класса» и распространение вызовов по структуре). LoD возник как условие, при котором такое отвязывание возможно: если код не ходит по чужим графам, изменение структуры графов не задевает код. Отсюда же практическое следствие для тестирования: метод, соблюдающий LoD, можно проверить с малым числом тестовых двойников — ему не нужно собирать три этажа вложенных заглушек (mock.getCustomer().getAddress()...), достаточно одного стаба прямого партнёра.
LoD на уровне модулей и сервисов
Хотя закон сформулирован для методов и объектов, он дословно масштабируется: модуль общается только с прямыми зависимостями, объявленными в его контракте, и не тянется к внутренностям их зависимостей. На уровне классов нарушение — цепочка a.getB().getC(); на уровне модулей — ordersRepo.db.connection.executeRaw(...), обращение к приватной схеме соседнего модуля или userSvc.teamsClient.cache.invalidate(). Симптом один и тот же: знание о внутреннем устройстве чужой территории, добытое через её публичный интерфейс. В сообществе микросервисов эта идея закреплена как запрет «умных» клиентов, знающих, как сервис хранит данные: клиент говорит на языке API провайдера (POST /orders/{id}/cancellation), а не его базы (UPDATE orders SET status=...). Чем сильнее нарушен LoD на границе, тем дороже всякое изменение провайдера — вплоть до того, что «независимый деплой» становится фикцией.
Cohesion и Coupling
Две меры модульности
Качество разбиения оценивается парой метрик из структурного проектирования (Ларри Константайн; каноническая книга — Константайн и Йордан, Structured Design, 1979):
- Связность (cohesion) — насколько элементы одного модуля принадлежат единой задаче; измеряется «изнутри» модуля.
- Связанность (coupling) — насколько сильно разные модули зависят друг от друга; измеряется «между» модулями.
Цель формулируется одной строкой: high cohesion, low coupling — высокая связность, слабая связанность. Модуль с высокой связностью — это цельная ответственность, которую нельзя разрезать без потери смысла; слабая связанность означает, что изменение одного модуля почти не затрагивает остальные. Обе меры с 1970-х выдержали без изменений перенос из структурного программирования в ООП, компоненты и сервисы; в GRASP они вошли как два из девяти паттернов — High Cohesion и Low Coupling.
Связность: виды, от лучшей к худшей
Классическая шкала Константайна упорядочивает модули по тому, почему элементы оказались вместе:
| Вид | Элементы вместе, потому что… | Пример |
|---|---|---|
| Функциональная | выполняют одну функцию целиком | parseDate — лексинг, разбор, валидация одной даты |
| Последовательная | выход одного — вход следующего | этапы конвейера обработки |
| Коммуникативная | работают с одними данными | поиск и обновление одной записи |
| Процедурная | выполняются в одном порядке | шаги инициализации без общих данных |
| Временная | выполняются в один момент | onStartup: лог, кэш, миграции |
| Логическая | относятся к одной категории | «все функции валидации» |
| Случайная | никакой связи; «положили в одну папку» | утилиты-помойки Utils, Helpers |
Чем выше по таблице, тем лучше: функционально связный модуль можно переименовывать, тестировать, переносить и заменять целиком; случайно связный — нельзя трогать по частям, потому что никто не знает, какие части от чего зависят. Связность связана с SoC напрямую: низкая связность модуля означает, что в нём смешаны несколько областей ответственности.
Разница между полюсами шкалы на мини-примере:
// случайная связность: вместе только потому, что «про заказ»
class OrderUtils {
static validate(order: Order): boolean { /* ... */ }
static sendEmail(to: string): void { /* ... */ } // про письма
static roundPrice(p: number): number { /* ... */ } // про деньги
}
// функциональная связность: одна функция — одна задача, всё вместе
function orderTotalWithDiscount(order: Order, tier: Tier): Money { /* ... */ }
OrderUtils нельзя ни переименовать (имя ничего не значит), ни протестировать как целое, ни выкинуть частично; функция orderTotalWithDiscount меняется по ровно одной причине — изменению правила подсчёта.
Связность определена для любого масштаба: у пакета она измеряется общностью доменной темы его классов, у сервиса — принадлежностью одной бизнес-возможности (осмысленный сервис отвечает на вопрос «что он делает» одним предложением без союза «и»), у команды — единством владения своим участком кодовой базы. Наивная ошибка — переносить метрику на уровень, где она теряет смысл: «высокосвязанный» микросервис из десяти доменно несвязанных модулей связностью не обладает — он лишь заключил случайную связность в сетевую границу.
Количественная метрика связности для классов — LCOM (Lack of Cohesion of Measures, Чидамбар и Кемерер, 1994): подсчёт долей методов, не работающих над общими полями; у хорошо связного класса LCOM стремится к нулю. Метрика груба (не отличает «поля разные, задача одна»), но полезна как детектор запаха.
Связанность: уровни, от лучшего к худшему
Симметричная шкала для зависимостей между модулями упорядочивает их по силе:
| Уровень | Модуль зависит от… | Пример |
|---|---|---|
| По данным (data) | простых параметров | calculateTotal(order: OrderSummary) |
| По штампу (stamp) | составных структур, из которых нужна часть | передача всего Order ради поля city |
| По управлению (control) | флагов, управляющих чужой логикой | save(order, validate: boolean) |
| Внешняя (external) | протокола среды: ОС, сеть, I/O | прямой вызов файлов, сокетов |
| Общая (common) | глобального состояния | общая БД, глобальные переменные, синглтоны |
| По содержимому (content) | внутренностей другого модуля | правка чужих приватных полей, friend, рефлексия |
Чем ниже по таблице, тем хуже: связанность по данным позволяет менять модуль, не зная внутренностей партнёра; связанность по содержимому означает, что модули фактически слились — «два модуля» лишь иллюзия разбиения. Внешняя связанность опасна тем, что привязывает к среде исполнения (её локализуют адаптерами и портами); общая — тем, что создаёт невидимые зависимости через состояние, которое «просто там лежит».
Сравнение на мини-примере:
// слабая связанность: contract — примитивные данные, логика локальна
function discountFor(tier: 'regular' | 'premium', total: number): number {
return tier === 'premium' ? total * 0.1 : 0;
}
// сильная связанность: по штампу (весь Order) и по управлению (флаг)
function applyDiscount(order: Order, mode: 'loyalty' | 'promo'): void {
if (mode === 'loyalty') {
order.customer.loyalty.points -= order.total() * 0.5; // и LoD нарушен
}
}
Connascence
Обобщение и уточнение обеих шкал — Connascence («сорождённость», Мейлир Пейдж-Джонс, What Every Programmer Should Know About Object-Oriented Design, 1995): две программные сущности connascent, если изменение одной требует изменения другой для сохранения корректности. Connascence классифицирует виды такой сопряжённости — от слабой к сильной: connascence of name (согласованы имена: имя метода, имя поля — самый слабый и неустранимый вид, им и являются все вызовы через интерфейс), of type (согласованы типы), of meaning (согласована семантика значений: status: 2 — что такое 2?), of position (согласован порядок: позиционные аргументы, кортежи), of algorithm (воспроизведён один и тот же алгоритм в двух местах), а также динамические виды — of execution, of timing, of value (согласованы значения нескольких переменных), of identity.
Правила Пейдж-Джонса делают из классификации инструмент: минимизировать connascence вообще; устранять сильную в пользу слабой (name — целевой вид); не допускать сильной connascence через границы инкапсуляции (внутри модуля позиционная согласованность терпима, через интерфейс — нет; так save(order, true) на границе модуля — запах, а соседние строки внутри функции — норма). Connascence уточняет coupling тем, что отличает зависимость «имя метода» (слабая, управляемая контрактом) от «порядок аргументов» и «одинаковый алгоритм в двух местах» (сильная, хрупкая).
Принципы пакетов: модульность по Мартину
Перенос cohesion/coupling на уровень пакетов и библиотек выполнил Роберт Мартин в статье Granularity (1997) и книге Agile Software Development (2002) — так называемые принципы пакетов (package principles). Три принципа связности отвечают, что класть вместе: REP (Reuse/Release Equivalence Principle — переиспользуемое группируется в переиспользуемые и версионируемые единицы), CRP (Common Reuse Principle — классы, используемые вместе, живут вместе), CCP (Common Closure Principle — классы, меняющиеся вместе, живут вместе; буквальное «область ответственности = ось изменения» на уровне пакета). Три принципа связанности управляют зависимостями: ADP (Acyclic Dependencies Principle — граф зависимостей между пакетами ацикличен), SDP (Stable Dependencies Principle — зависеть от более стабильного), SAP (Stable Abstractions Principle — стабильный пакет абстрактен).
К этим принципам Мартин приложил количественные меры уровня пакета: афферентную связанность Ca (сколько пакетов зависят от данного) и эфферентную Ce (от скольких зависит он), из которых выводится нестабильность I = Ce / (Ca + Ce): I → 0 — пакет стабильный и «влиятельный» (его трудно менять), I → 1 — нестабильный и «безответственный» (его менять легко). Метрики сырые, но дают архитектуре то, чего нет у шкал Константайна: численный разговор о направлении зависимостей — и прямо готовят почву для правила зависимостей Clean Architecture.
Практический чек-лист, объединяющий шкалы этого раздела, — вопросы к любому разбиению:
- Можно ли назвать responsibility модуля одной фразой без союза «и»? Если нет — связность низкая.
- Изменение какого требования затронет модуль? Если любое изменение трогает несколько модулей сразу — связанность избыточна, ось разреза неверна.
- Что модуль знает о соседях? Если структуру их внутренностей — нарушен LoD.
- Проходит ли взаимодействие только через контракт? Если есть доступ к чужому состоянию (общая таблица, глобальный синглтон) — coupling уровня «common», границы фиктивны.
- Можно ли удалить (заменить заглушкой) модуль, не читая соседей? Финальная проверка работоспособности границы.
Связь с SOLID, DDD и модульным монолитом
SoC, LoD и SOLID
Принципы модульности — общий слой, из которого SOLID вырос как конкретизация для классов. Single Responsibility — SoC на уровне одного класса: одна область ответственности, одна причина изменения. Interface Segregation — SoC на уровне контракта: разрезание «толстых» интерфейсов по областям использования, чтобы клиент не зависел от чужой ему области. Dependency Inversion — операционное обеспечение SoC: зависимости направляются на границу (абстракцию), а не внутрь чужой области. LoD напрямую опирается на инкапсуляцию и смыкается с ISP и DIP: узкий интерфейс ограничивает круг «друзей», инверсия зависимости делает «незнакомца» другом через контракт собственного уровня абстракции. Сопоставление с SOLID и GRASP: Low Coupling / High Cohesion из GRASP — это дословно метрики Константайна, возведённые в эвристики распределения обязанностей.
DDD: bounded context как единица модульности
В DDD модульность получает предметное основание: ограниченный контекст (bounded context) — область, в которой модель и её язык (ubiquitous language) имеют единственное согласованное значение. Контекст — это SoC, проведённый по оси смысла: «Клиент» в контексте продаж и «Клиент» в контексте доставки — разные модели, и их слияние в один класс — нарушение separation of concerns семантического уровня. Тактические паттерны DDD работают на ту же цель: агрегат инкапсулирует инварианты (LoD на уровне графа доменных объектов — внешние ссылаются только на корень), репозиторий изолирует хранение, антикоррупционный слой — чужую модель. Контракт между контекстами — явная граница с переводом моделей, то есть управление coupling: сильная connascence с чужим доменом заменяется слабой — через опубликованный язык.
Модульный монолит и микросервисы
Модульный монолит — применение принципов модульности на уровне компонентов одного приложения: система делится на модули с явными границами (собственный домен, собственная схема данных, публичный API модулем наружу), а коммуникация через границы — только по контрактам. Правила те же: high cohesion внутри модуля, low coupling между, LoD на границе («модуль не тянется в таблицы соседа»), SoC при проведении самих границ. Ценность — модульность без цены распределённости: границы дисциплинируются архитектурными тестами (например, проверкой зависимостей на уровне сборки), а не сетью. Типовая структура такого модуля повторяет структуру миниатюрного сервиса:
billing/
domain/ # домен: сущности, инварианты — внутреннее
application/ # сценарии — внутреннее
api/ # публичный контракт модуля: интерфейс и DTO
infrastructure/ # адаптеры: БД, очереди — внутреннее
Наружу смотрит только api/; всё остальное — «внутренности», на которые соседям нельзя ссылаться ни в коде, ни в схеме данных. Именно это отличие — публичный контракт вместо «общей кодовой базы, где всё доступно» — и делает монолит модульным, а не просто большим.
Микросервисы — крайняя степень той же линии: границы модулей становятся границами развёртывания. Разрез по бизнес-возможностям — SoC; запрет shared-БД — устранение coupling через общее состояние (каждый сервис владеет своими данными); контракт по сети — обмен «connascence of name» на версионированный API. Именно поэтому качество микросервисной системы ограничено качеством её модульности: сервисы, нарезанные без cohesion (выделенные «по слоям» или по техническим командам), наследуют все проблемы плохого разбиения, добавляя к ним сетевые издержки. Подробнее — в статьях Микросервисы и Монолитная архитектура.
Сводное соответствие уровней:
| Принцип модульности | В SOLID | В GRASP | На уровне архитектуры |
|---|---|---|---|
| SoC | SRP, ISP | Controller, Information Expert | Слои, контексты DDD, сервисы |
| LoD | ISP, DIP (через инкапсуляцию) | Information Expert, Pure Fabrication | Агрегаты DDD, публичные API модулей |
| High Cohesion | SRP | High Cohesion, Pure Fabrication | Одна бизнес-возможность на сервис |
| Low Coupling | DIP, ISP | Low Coupling, Indirection | Контракты, антикоррупционные слои, события |
Плюсы, минусы и типичные ошибки
Плюсы
Локальность изменений. Хорошая модульность концентрирует каждое изменение в одном модуле: следствие high cohesion (причина изменения одна — затрагивается одна область) и low coupling (область не тянет за собой соседей). Это главный измеримый эффект: статистически большинство правок в модульной системе касаются одного модуля.
Тестируемость. Модуль со слабой связанностью проверяется изолированно: зависимости подменяются стабами, сценарий покрывается без базы, сети и файловой системы. Модуль со случайной связностью тестировать можно только со всей системой сразу.
Параллельная работа. Явные границы позволяют командам работать одновременно, не конфликтуя: контракт зафиксирован, внутренности — нет. Закон Конвея работает в обе стороны: модульная система позволяет организовать команды по модулям (и обратно — границы системы отражают границы организации).
Переиспользование. Reusability — не самоцель, а производная модульности: переносимым оказывается то, что имеет высокую связность (полезно целиком) и слабую связанность (не тянет контекст). Модуль, смешивающий области, переиспользовать нельзя — придётся тащить всё вперемешку.
Управляемое замещение. Модуль с чистой границей заменяется целиком: другая реализация, другой вендор, другой движок. Это же делает возможной поэтапную миграцию и эволюцию системы без «большого переписывания».
Понятность и онбординг. Система из именованных модулей с публичными контрактами читается по карте, а не по коду: новому разработчику достаточно изучить один модуль и контракты его соседей. Словарь модулей становится словарём команды — границы одновременно являются и границами обсуждения («это вопрос биллинга, а не доставки»).
Изоляция отказов и изменений. Слабая связанность ограничивает и распространение ошибок, и цену экспериментов: модуль можно выключить, заменить заглушкой или выпустить в отдельной ревизии, не координируя глобальный релиз. В пределе эта же изоляция даёт независимое масштабирование — но это уже свойство архитектурных стилей, а не самих принципов.
Минусы и границы применимости
Стоимость границ. Каждый разрез — это интерфейс, маппинг, версионирование и точка согласования. На малых системах издержки границ могут превышать выигрыш от локальности: прототип из трёх файлов, размеченный слоями и портами, платит за структуру, которая не успеет окупиться.
Косвенность. Слабая связанность достигается посредниками (интерфейсы, адаптеры, события); поток выполнения становится менее очевидным — «в каком месте реально происходит работа» требует навигации. Это плата, а не дефект, но она реальна и растёт с числом границ.
Критичность правильной оси. Все минусы усиливаются при неверном разрезе: границы, проведённые не по осям изменения, превращаются из средства локализации изменений в источник распределения одного изменения по многим модулям — хуже, чем их отсутствие.
Производительность границ. Модульность в рантайме не бесплатна: пересечение границы — это вызов через контракт (часто с маппингом данных), а в распределённых системах — сетевой прыжок с сериализацией. Поэтому внутри модуля допускают более сильную connascence, чем через границу (правило Пейдж-Джонса), а границу проводят там, где частота обмена низка относительно выгоды изоляции. Слепое вынесение всего «по модулям» порождает chattiness — болтливые интерфейсы, где один сценарий требует десяти переходов.
Типичные ошибки
Слепое применение LoD: god-object и прокси-методы. Механическое обёртывание каждой цепочки порождает классы-посредники, слепо пересылающие вызовы: Order.isDeliveredTo → Customer.livesIn → Address.matchesCity — и так на каждую операцию с любым вложенным данным. Итог — раздутые интерфейсы и, в пределе, god-object, через который проходит всё. LoD требует перемещать поведение к данным, а не строить тоннели для обхода инкапсуляции; если вызывающему реально нужны сами данные (отчёт, сериализация, DTO) — это легитимный контракт, а не нарушение.
Дробление ради дробления. «Больше модулей — модульнее» — ошибка: SoC требует непересекающихся осмысленных областей, а не максимума файлов. Признаки передробления: классы из одного метода, слои из одного класса, изменение одной фичи, разрозненное по шести модулям (низкая cohesion каждого и высокая coupling между ними — модульность формально есть, реально нет).
Ложная модульность через общее состояние. Модули, формально разделённые в коде, но неявно связанные общей базой данных, глобальным конфигом или shared-кэшем: coupling уровня «common» невидим на диаграмме компонентов, но связывает модули насмерть — изменение схемы бьёт по всем. Это главный антипаттерн «модульного» монолита и главная причина, по которой микросервисы запрещают shared-БД; детектор — вопрос «может ли модуль измениться (или быть удалён) без согласования с другими». Если нет — границы не работают.
Гибридные оси разбиения. Смешение разрезов «по слоям» и «по доменам» в одной кодовой базе: половина границ проходит по типу ответственности, половина — по предметной области. Каждое изменение тогда пересекает обе сети границ. Лекарство — явное решение об оси разбиения и его последовательное проведение.
Слепая вера метрикам. Cohesion, coupling и connascence — средства диагностики, а не цели оптимизации: код с идеальными показателями и нечитаемой логикой — провал, код с «жёлтыми» показателями и ясными границами — нормален. Метрики не понимают предметную область: класс с LCOM выше порога может быть цельным по смыслу, а «идеальный» по метрикам сервис — связывать два домена. Практический порядок: сначала смысл и оси изменения, затем — метрики как проверка, не разошлась ли интуиция с реальностью графа зависимостей.
Связанные статьи
- SOLID — пять принципов проектирования классов: SRP и ISP как конкретизации SoC, DIP как механизм поддержания слабой связанности.
- GRASP — девять паттернов назначения обязанностей; Low Coupling и High Cohesion — метрики Константайна в роли эвристик, Information Expert — обоснование LoD.
- DRY / KISS / YAGNI — принципы экономии: противовес избыточному дроблению и преждевременным границам.
- Domain-Driven Design (DDD) — bounded context как единица модульности, проведённая по оси смысла; агрегаты как инкапсуляция инвариантов.
- Clean Architecture — SoC в виде правила зависимостей: домен отделён от инфраструктуры и не знает о ней.
- Слоистая архитектура — классическое разбиение по типу ответственности; его сильные и слабые стороны.
- Гексагональная архитектура и Onion — порты и адаптеры: управление coupling с внешним миром через явные границы.
- Монолитная архитектура и Микросервисы — модульность без границ развёртывания и с ними; modular monolith как промежуточная точка.
- Введение в архитектуру — контекст: место принципов модульности среди задач архитектуры ПО.