Принципы разработки Зависимости: компонентные принципы, циклы и цена чужого кода
0%

Зависимости: компонентные принципы, циклы и цена чужого кода

Зависимости: компонентные принципы, циклы и цена чужого кода

В статье про связанность и связность мы разбирали отношения между классами и функциями: кто про кого знает, насколько сильно и как это измерить. Эта статья — про уровень выше. Когда файлов становится больше сотни, единицей проектирования перестаёт быть класс: вы принимаете решения про компоненты — пакеты, модули, библиотеки, собираемые артефакты. И почти каждое такое решение сводится к одному вопросу: кто имеет право знать о ком.

Разница между уровнями не косметическая. Плохую связанность внутри модуля чинит один разработчик за день Move Function. Плохой граф зависимостей между компонентами чинится кварталами, потому что от него зависят порядок сборки, границы команд, скорость CI и возможность выкатывать части системы по отдельности. Причём главный источник боли здесь — не тот код, который вы написали, а тот, который вы подключили: медианный сервис на Node.js или Python тянет за собой сотни чужих пакетов, каждый со своим темпом обновлений, своим сопровождающим и своей вероятностью однажды исчезнуть.

Разберём три темы, которые обычно рассматривают порознь, хотя это одна тема:

  1. Компонентные принципы — как резать свой код на модули и в какую сторону направлять стрелки.
  2. Циклы — почему они смертельны, как их находить алгоритмически и как разрывать.
  3. Чужой код — как считать стоимость зависимости и как сделать её удаляемой.

1. Зависимость — это обещание, а не строка импорта

Формально import — просто указание компоновщику. Практически каждая зависимость означает набор обязательств, которые вы взяли на себя, часто не заметив:

  • Обязательство обновляться. Библиотека выпустит мажор — вы либо мигрируете, либо остаётесь на непатчируемой версии.
  • Обязательство пересобираться. Изменение в компоненте, от которого вы зависите, требует вашей пересборки и прогонки тестов; в монорепозитории это буквально минуты CI.
  • Обязательство совместимости. Если от вас зависят двое, вы больше не можете свободно менять свой публичный интерфейс — об этом целиком следующая статья про совместимость.
  • Обязательство доверия. Код зависимости исполняется с вашими правами, в вашем процессе, с вашими секретами в окружении.

Полезное рабочее определение: зависимость — это то, изменение чего может сломать вас. Из него сразу следует, что зависимости бывают невидимыми для сборщика: соглашение об именах колонок в общей таблице, порядок полей в JSON, формат лога, который кто-то парсит. Такие зависимости не видны в графе импортов, но ломаются точно так же.

Три уровня, о которых пойдёт речь, различаются только скоростью обратной связи:

Уровень Что связывает Как быстро узнаете о поломке Чем управляется
Внутримодульный классы, функции компилятор, секунды рефакторинг, SOLID
Компонентный пакеты, библиотеки одного репозитория сборка/CI, минуты правила импортов, архитектурные тесты
Внешний сторонние пакеты, сервисы обновление или инцидент, недели политика зависимостей, изоляция

2. Что считать компонентом

Компонент — минимальная единица, которую можно выпустить и подключить отдельно: пакет в Go, jar в Java, npm-пакет, .dll, модуль монорепозитория с собственным BUILD-файлом. Ключевое слово — «выпустить»: если два куска кода всегда собираются и деплоятся вместе, это один компонент, как бы красиво ни выглядела структура директорий.

Отсюда первое практическое следствие: директории — не компоненты. Раскладка services/, repositories/, models/ создаёт иллюзию модульности при полностью связанном графе — каждый слой знает про каждый. Признак настоящей границы: вы можете сформулировать, что произойдёт, если компонент вынести в отдельный репозиторий и подключать по версии. Если ответ «ничего не соберётся, там всё завязано на всё» — границы нет.

Слева стрелка repos/ → services/ замыкает цикл: изменение сервиса требует пересборки репозиториев и наоборот. Справа общий компонент один, он максимально стабилен, а связь между предметными областями сделана асинхронной. О том, как выбирать границы предметных областей, подробно говорит трек DDD — ограниченные контексты.


3. Три принципа связности: что класть в один компонент

Роберт Мартин сформулировал шесть принципов уровня компонентов ещё в 1990-е (сведены в книге Clean Architecture, часть IV). Первые три отвечают на вопрос «что должно лежать вместе».

REP — Reuse/Release Equivalence Principle. Единица переиспользования равна единице релиза. Если кто-то использует ваш компонент, он должен иметь возможность сказать «я на версии 2.3.1» и получить нотификацию об изменениях. Практический смысл: у компонента должны быть версия, changelog и осмысленная граница — иначе переиспользовать его нельзя, можно только скопировать.

CCP — Common Closure Principle. В один компонент собирается то, что меняется по одной причине и в одно время. Это SRP, поднятый на уровень выше: цель — чтобы типичное изменение требований затрагивало один компонент, а не семь. CCP прямо борется с запахом Shotgun Surgery из каталога запахов.

CRP — Common Reuse Principle. В один компонент не кладут то, что используется порознь. Формулировка Мартина: «не заставляйте потребителей зависеть от того, что им не нужно» — это ISP на уровне компонентов. Классический антипример — пакет common или utils, куда стекается всё подряд: подключив его ради одной функции форматирования даты, вы получили зависимость от HTTP-клиента, от драйвера БД и от их транзитивных зависимостей.

Эти три принципа противоречат друг другу, и это не дефект, а конструкция:

Треугольник напряжений компонентной связности: REP, CCP, CRP

  • Жертвуете REP → получаете удобные для изменения, но неудобные для переиспользования компоненты.
  • Жертвуете CCP → любое изменение размазывается по многим компонентам (Shotgun Surgery, дорогие релизы).
  • Жертвуете CRP → потребители получают лишние зависимости и лишние пересборки.

Мартин прямо пишет, что позиция внутри треугольника меняется со временем: молодому проекту важнее CCP (удобство изменения), зрелому — REP и CRP (стабильность и точность переиспользования). Это тот же сюжет, что и с YAGNI: не бывает правильной точки навсегда, бывает правильная точка для текущей фазы.

Практический тест на нарушение CRP — коэффициент использования:

"""Сколько символов компонента реально нужны каждому его потребителю.

Низкое среднее использование = компонент стоит разрезать (нарушение CRP).
Считается по графу «файл -> импортированные символы», который выдаёт любой
парсер импортов (ast в Python, go/packages в Go, ts-morph в TypeScript).
"""
from collections import defaultdict


def crp_report(usage: dict[str, set[str]], exported: set[str]) -> dict[str, float]:
    """usage: потребитель -> множество символов компонента, которые он импортирует.

    Возвращает долю использования по каждому потребителю. O(n) по числу пар.
    """
    return {consumer: len(symbols) / len(exported) for consumer, symbols in usage.items()}


usage = {
    "billing.invoice": {"format_money"},
    "catalog.search":  {"slugify"},
    "web.handlers":    {"format_money", "slugify", "parse_date", "HttpClient"},
}
exported = {"format_money", "slugify", "parse_date", "HttpClient", "RetryPolicy", "Cache"}

report = crp_report(usage, exported)
# billing.invoice: 0.17 — тянет весь common ради одной функции
# catalog.search:  0.17
# web.handlers:    0.67
avg = sum(report.values()) / len(report)   # ≈ 0.33 → компонент надо резать

Правило, которым удобно пользоваться на ревью: если среднее использование компонента ниже трети — он не компонент, а свалка. Разрежьте по кластерам совместного использования: символы, которые всегда импортируются вместе, и есть будущие компоненты.


4. Три принципа связывания: куда направлять стрелки

ADP — Acyclic Dependencies Principle. В графе зависимостей не должно быть циклов.

Цикл — это не эстетическая проблема. Компоненты в цикле образуют один компонент де-факто: их нельзя собрать по отдельности, нельзя протестировать по отдельности, нельзя выпустить по отдельности и нельзя понять по отдельности. Мартин называет эффект «morning after syndrome»: вы приходите утром, а ваш модуль сломан чужим изменением, потому что через цикл вы зависите от всех.

SDP — Stable Dependencies Principle. Зависеть можно только в сторону большей стабильности. Стабильность здесь — не «редко меняется», а «дорого менять»: компонент, от которого зависят двадцать других, стабилен вынужденно. Метрика нестабильности I = Ce / (Ca + Ce) и её вывод — в статье про связанность.

SAP — Stable Abstractions Principle. Стабильный компонент обязан быть абстрактным, иначе система становится жёсткой. Это DIP уровнем выше, и вместе с SDP он даёт «главную последовательность» A + I = 1.

Три принципа удобно проверять одним обходом графа. Циклы ищутся классическим алгоритмом Тарьяна (поиск компонент сильной связности), детали — в обходах графов:

"""Поиск циклов в графе зависимостей: компоненты сильной связности (Тарьян).

Время O(V + E), память O(V) — один DFS с двумя массивами меток.
Любая SCC размера > 1 (или петля) — нарушение ADP.
"""
from collections import defaultdict


def strongly_connected(graph: dict[str, set[str]]) -> list[set[str]]:
    index: dict[str, int] = {}
    low: dict[str, int] = {}
    on_stack: set[str] = set()
    stack: list[str] = []
    result: list[set[str]] = []
    counter = 0

    def visit(v: str) -> None:
        nonlocal counter
        index[v] = low[v] = counter
        counter += 1
        stack.append(v)
        on_stack.add(v)

        for w in graph.get(v, ()):
            if w not in index:
                visit(w)
                low[v] = min(low[v], low[w])
            elif w in on_stack:                 # ребро назад: нашли цикл
                low[v] = min(low[v], index[w])

        if low[v] == index[v]:                  # v — корень компоненты
            component: set[str] = set()
            while True:
                w = stack.pop()
                on_stack.discard(w)
                component.add(w)
                if w == v:
                    break
            result.append(component)

    for node in list(graph):
        if node not in index:
            visit(node)
    return [c for c in result if len(c) > 1 or any(n in graph.get(n, ()) for n in c)]


deps = {
    "orders":   {"billing", "catalog"},
    "billing":  {"notifications"},
    "notifications": {"orders"},      # ← цикл orders → billing → notifications → orders
    "catalog":  set(),
}
assert strongly_connected(deps) == [{"orders", "billing", "notifications"}]

На больших репозиториях рекурсивный DFS упирается в лимит стека — используйте итеративный вариант или готовые инструменты (import-linter, madge, go list -deps, deptrac, jdeps).

Как разрывать цикл

Способов ровно три, и выбор между ними — содержательное проектное решение.

Инверсия — самый частый ход, и она наглядно выглядит на диаграмме классов: стрелка компиляционной зависимости меняет направление, стрелка вызова во время выполнения остаётся прежней.

Ключевая деталь, которую пропускают: интерфейс должен лежать в компоненте потребителя, а не в компоненте реализации. Если NotifierPort живёт вместе с Notifications, вы не разорвали цикл, а только переименовали его. В Go это идиома «интерфейс определяет потребитель», в C#/Java — вопрос того, в каком проекте/пакете лежит интерфейс. Механика внедрения зависимостей разобрана в отдельной статье трека паттернов.

Третий способ — событие — стоит выбирать, когда вызов по смыслу является уведомлением («заказ размещён»), а не запросом («посчитай стоимость»). Тогда исчезает не только цикл, но и временная связанность. Цена: асинхронность, доставка «хотя бы один раз» и необходимость идемпотентности — см. обработку ошибок и отказоустойчивость.

Правила импортов, записанные в репозитории

Договорённость «домен не импортирует инфраструктуру» живёт ровно до первого дедлайна, если её не проверяет машина. Минимальный конфиг import-linter для Python:

# setup.cfg — правила проверяются в CI командой `lint-imports`
[importlinter]
root_packages = shop

[importlinter:contract:layers]
name = Слои: домен ничего не знает о внешнем мире
type = layers
layers =
    shop.web
    shop.application
    shop.domain

[importlinter:contract:no-cycles]
name = Ациклический граф внутри домена
type = independence
modules =
    shop.domain.billing
    shop.domain.catalog
    shop.domain.notifications

[importlinter:contract:forbid-orm-in-domain]
name = ORM не протекает в домен
type = forbidden
source_modules = shop.domain
forbidden_modules =
    sqlalchemy
    django.db

Аналоги: ArchUnit (Java/Kotlin), NetArchTest (.NET), deptrac (PHP), dependency-cruiser (JS/TS), go vet + собственные анализаторы или depguard в golangci-lint. Подробно про то, как встраивать такие проверки, чтобы они не бесили команду, — в статье про автоматические проверки.


5. Чужой код: решение «взять или написать»

Здесь начинается часть, где ошибаются чаще всего, потому что стоимость видна не сразу. Импорт пакета занимает секунду, а сопровождение — годы.

Честная формула стоимости зависимости:

Стоимость = интеграция
          + обновления × (частота × сложность миграции)
          + отладка чужих багов
          + риск (безопасность, лицензия, заброшенность)
          + стоимость выхода

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

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

  1. Криптографию, разбор форматов безопасности и парсеры недоверенного ввода не пишут сами. Цена ошибки катастрофическая, а корректность не проверяется тестами вашей команды.
  2. Ядро предметной области не отдают библиотеке. Если правила тарификации — это ваш бизнес, то универсальный «движок правил» из npm станет ограничителем ровно тогда, когда бизнес попросит что-то нетипичное.

Чек-лист оценки зависимости

Перед добавлением пакета в манифест — пять минут и семь вопросов:

Вопрос Красный флаг Где смотреть
Живой ли проект? последний релиз > 18 месяцев, открытые issue без ответов репозиторий, changelog
Сколько сопровождающих? ровно один аноним contributors, bus factor
Сколько тянет транзитивно? десятки пакетов ради одной функции npm ls, pipdeptree, go mod graph
Какая лицензия? GPL/AGPL в проприетарном продукте, отсутствие лицензии LICENSE, см. лицензирование
Стабилен ли API? мажорные версии каждые полгода changelog, политика версионирования
Можно ли это удалить? типы библиотеки в сигнатурах домена ваш собственный код
Что если завтра исчезнет? нет ответа forks, vendoring, альтернативы

Отдельно про размер: правило «маленькая зависимость безопасна» ложно. Стоимость зависимости определяется не размером кода, а количеством точек отказа — сопровождающих, транзитивных пакетов, публикаций в реестр. Микропакет на десять строк даёт полную стоимость доверия при нулевой пользе. Отсюда практика больших команд: пакеты дешевле определённого объёма инлайнят (копируют с указанием источника и лицензии) — это тот случай, когда копирование дешевле зависимости, что мы обсуждали в разборе DRY.

Цепочка поставок: чем это заканчивалось на практике

Выводы, которые индустрия из этого сделала и которые стоит применять у себя:

  • Lock-файл обязателен и коммитится. package-lock.json, poetry.lock, go.sum, Cargo.lock фиксируют не только версию, но и хеш содержимого. Без него сборка невоспроизводима, а установка «свежей минорной версии» происходит в момент деплоя.
  • Обновления — поток, а не событие. Renovate/Dependabot с автомержем патчей и еженедельным разбором минорных лучше, чем «большое обновление раз в год», которое всегда откладывается.
  • Сканирование SCA в CI (npm audit, pip-audit, govulncheck, Trivy, OSV-Scanner) плюс SBOM для инвентаризации. Детали — в треке безопасности: цепочка поставок и безопасность конвейера.
  • Зеркало реестра. Внутренний прокси (Artifactory, Nexus, Athens) защищает от удаления пакета и от dependency confusion.

Транзитивные зависимости и «алмаз»

Прямых зависимостей у сервиса обычно 20–50, полный граф — сотни. Основная проблема графа — конфликт версий: два ваших пакета требуют разные мажорные версии третьего.

Полезно знать модель разрешения версий именно вашей экосистемы: Go применяет minimal version selection (берётся минимальная версия, удовлетворяющая всем требованиям — детерминированно), npm допускает дублирование деревьев, Maven берёт «ближайшее объявление», Python — одну версию в окружении и падает при неразрешимом конфликте. Это напрямую влияет на то, насколько агрессивно можно указывать диапазоны версий в манифесте.


6. Изоляция: делайте зависимости удаляемыми

Главная защита от всего перечисленного — архитектурная, а не организационная: чужой код должен касаться вашего в одном месте.

# ПЛОХО: тип библиотеки протёк в домен. Замена HTTP-клиента = правки в 40 файлах,
# а тесты домена требуют мокать httpx.
import httpx

class PricingService:
    def quote(self, request: httpx.Request) -> httpx.Response:
        ...

# ХОРОШО: домен говорит на своём языке, библиотека спрятана за портом.
from dataclasses import dataclass
from typing import Protocol


@dataclass(frozen=True)
class Quote:
    total: "Money"
    valid_until: "datetime"


class PricingGateway(Protocol):
    """Порт объявлен потребителем: домен диктует форму, а не библиотека."""
    def quote(self, cart_id: str) -> Quote: ...


class HttpPricingGateway:
    """Единственное место в системе, которое знает про httpx, таймауты и ретраи."""

    def __init__(self, client: "httpx.Client") -> None:
        self._client = client

    def quote(self, cart_id: str) -> Quote:
        response = self._client.get(f"/quotes/{cart_id}", timeout=2.0)
        response.raise_for_status()
        payload = response.json()
        return Quote(total=Money.parse(payload["total"]), valid_until=parse_ts(payload["valid_until"]))

Три следствия такой раскладки:

  1. Стоимость выхода фиксирована: замена клиента — один файл и один набор тестов.
  2. Домен тестируется без сети: в тестах подставляется фейк, реализующий PricingGateway (о разнице между фейком и моком — в принципах тестирования).
  3. Чужие ошибки транслируются в ваши: адаптер обязан превратить httpx.TimeoutException в доменную ошибку, иначе исключения библиотеки станут частью вашего контракта.

Не любая зависимость требует адаптера — оборачивать json или стандартную библиотеку глупо. Рабочий критерий: адаптер нужен там, где зависимость (а) вероятно заменится, (б) пересекает границу процесса или (в) навязывает свой словарь домену. Каталог приёмов для таких границ — паттерны границ; в терминах DDD это антикоррупционный слой.


7. Зависимости между людьми

Граф компонентов быстро становится графом команд, и обратное тоже верно (закон Конвея). Тут работает простое правило: зависимость от кода стоит часы, зависимость от чужой команды — недели. Поэтому решение «взять готовый внутренний сервис» иногда дороже, чем написать свою реализацию: вы получаете не библиотеку, а очередь в чужом бэклоге.

Практические ходы, снижающие эту стоимость: CODEOWNERS как явная карта ответственности, внутренний open source (inner source) с правом присылать PR в чужой компонент, чёткие контракты вместо «мы вам потом расскажем». Подробнее — в статье про топологии команд и монорепозиториях.


8. Типичные ошибки

  1. Пакет common / utils / shared. Нарушение CRP в чистом виде: подключается всеми, зависит от всего, меняется постоянно. Лечение — резать по кластерам совместного использования.
  2. Слои вместо компонентов. services/, models/, repositories/ — это раскладка по техническим ролям, при которой одно изменение требований трогает все директории (нарушение CCP).
  3. Интерфейс в пакете реализации. Формально DIP соблюдён, фактически цикл сохранён.
  4. Диапазоны версий без lock-файла. «У меня локально работает» — потому что у вас версия от прошлого месяца, а в CI поставилась вчерашняя.
  5. Обновление «когда-нибудь». Год без обновлений превращает минорную миграцию в проект на квартал.
  6. Библиотека как архитектура. Если фреймворк диктует структуру домена, вы зависите не от кода, а от чужих решений — и мигрируете вместе с их мажорами.
  7. Оценка зависимости по звёздам GitHub. Звёзды не коррелируют с числом сопровождающих и скоростью выпуска патчей безопасности.
  8. Вендоринг вместо изоляции. Скопировать чужой код в репозиторий — не изоляция, а форк, который надо сопровождать; изоляция — это узкий интерфейс.
  9. Цикл, признанный «не багом». Команды часто оставляют цикл, потому что «компилируется же». Через год эти компоненты нельзя разделить между командами.

9. Как это выглядит в проде

Что делают команды, у которых с зависимостями порядок:

  • Правила импортов в CI — обязательная проверка, падающая так же, как тесты; список исключений ведётся в файле и уменьшается со временем.
  • Граф зависимостей строится автоматически и публикуется (например, madge --image или go mod graph + graphviz в артефактах сборки), чтобы деградация была видна.
  • Политика добавления зависимости: PR с новой зависимостью требует заполнить шаблон (зачем, альтернативы, лицензия, кто сопровождает у нас). Это ADR в миниатюре — см. код-ревью и стандарты.
  • Renovate с расписанием: патчи автомержатся при зелёном CI, минорные разбираются раз в неделю, мажорные заводятся как задачи.
  • Внутренний реестр-прокси и SBOM для каждого релиза.
  • Регулярная «прополка»: раз в квартал — отчёт по неиспользуемым зависимостям (depcheck, deptry, go mod tidy) и удаление мёртвых.
  • Бюджет на новые зависимости: в некоторых командах добавление прямой зависимости требует явного согласия владельца компонента — не бюрократии ради, а чтобы решение было осознанным.

10. Мини-итог

  • Зависимость — это обязательство обновляться, пересобираться, сохранять совместимость и доверять. Решение о зависимости стоит принимать так же серьёзно, как решение о найме.
  • Компонент — то, что можно выпустить отдельно. Директория компонентом не является.
  • REP, CCP, CRP отвечают на вопрос «что лежит вместе» и противоречат друг другу; точка баланса зависит от зрелости проекта.
  • ADP, SDP, SAP отвечают на вопрос «куда направлены стрелки»: без циклов, в сторону стабильности, стабильное — абстрактно.
  • Циклы ищутся алгоритмом Тарьяна за O(V + E) и разрываются тремя способами: разделить, инвертировать, заменить событием.
  • Стоимость сторонней библиотеки складывается из интеграции, обновлений, отладки, риска и стоимости выхода. Последняя определяется тем, протекли ли её типы в ваш код.
  • Криптографию не пишут сами; ядро домена не отдают библиотеке; микропакеты дешевле скопировать.
  • Правила зависимостей должны проверяться машиной, иначе они деградируют.

Источники


Что дальше

Мы разобрались, кто на кого имеет право ссылаться. Но как только у вашего компонента появился хотя бы один потребитель, возникает вторая половина задачи: его больше нельзя свободно менять. Дальше — о том, как эволюционировать контракт, которым уже пользуются: закон Хайрама, виды совместимости, семантическое версионирование, эволюция схем данных и депрекация как процесс.

Совместимость: как менять то, чем уже пользуются

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов