Карта зависимостей: как находить скрытые циклы и утечки ответственности в проекте
Разберём методы визуализации связей модулей/сервисов и поиска нежелательных зависимостей. Подскажем, как рефакторить границы без переписывания всего.
Содержание
Карта зависимостей: как находить скрытые циклы и утечки ответственности в проекте
В зрелом проекте “зависимости” — это не просто связи между модулями в коде. Это способ организации ответственности, гарантий и допущений: кто что знает, кто кому доверяет и где именно со временем накапливается технический долг. Когда связи становятся неуправляемыми, возникают два типичных симптома:
- Скрытые циклы (неочевидные обратные связи по направлению вызовов или данных).
- Утечки ответственности (модуль “впитывает” чужие полномочия: начинает обращаться к инфраструктуре, знать доменную логику, смешивает уровни абстракций).
Оба явления часто проявляются не сразу. Сначала это ощущается как “сложно менять”, потом — как “опасно трогать”, а затем — как “всё держится на удаче”. Ключевой инструмент против этой деградации — карта зависимостей: визуализированная модель того, как элементы системы связаны, и какие зависимости возникают при сборке, импортах, вызовах и передаче данных.
Ниже разберём, как построить карту зависимостей на практике, как находить нежелательные зависимости (включая циклы и утечки ответственности), и как рефакторить границы без переписывания всего проекта.
Что именно считать зависимостью: уровни, направление, тип связи
Прежде чем строить “карту”, важно договориться о модели. Обычно зависимости бывают нескольких видов:
Импортные зависимости (compile-time / build-time)
Например, в TypeScript/Java/Python: кто от кого импортирует. Это фиксируется статически и удобно для визуализации.
Зависимости на уровне архитектуры (layering)
Например: “домен не должен знать про БД”, “инфраструктура не должна напрямую вызывать адаптеры”, “UI не должен вызывать репозитории напрямую”.
Это уже не столько “техническая” зависимость, сколько архитектурное ограничение, которое можно проверить по фактам: где лежат классы, как они ссылаются, какие слои имеют право зависеть от каких.
Зависимости на уровне исполнения (runtime)
Когда модуль/сервис вызывает другой модуль, или когда события/сообщения “ведут” в обратные направления. Такие циклы могут отсутствовать в импортах, но появляться в логике: через событийную шину, коллбеки, клиентские вызовы, очереди, RPC и т.д.
Зависимости по данным
Даже если нет “вызовов”, есть обмен схемами, DTO, событиями. Если доменная модель начинает “подстраиваться” под инфраструктуру, это тоже разновидность утечки ответственности.
Вывод: карту зависимостей можно и нужно строить с разных уровней, но начните с того, что даёт максимальную ясность быстро — обычно импортные зависимости и отношения “слой ↔ слой”.
Карта зависимостей как инженерный инструмент, а не картинка
Цель карты — не красивая схема для презентации, а средство расследования:
- Где образовался цикл, который затрудняет изменения?
- Какие направления зависимостей нарушают архитектурные правила?
- Какие модули “разрастаются” и начинают тащить чужие зависимости?
- Где интерфейсы “размываются”: вместо контрактов начинают использоваться детали реализации?
Чтобы карта работала, она должна быть:
- обновляемой (хотя бы в CI),
- сопоставимой между версиями (чтобы видеть дрейф),
- достаточно точной (чтобы ошибки были воспроизводимыми),
- с фильтрами (иначе диаграмма превращается в “лапшу” без смысла).
Как визуализировать зависимости модулей: практические подходы
Подход 1: импортные графы (статический анализ)
Это самый быстрый путь. Идея: собрать граф “A импортирует B” и визуализировать.
JavaScript/TypeScript: популярные инструменты строят dependency graph по import/require. В Python — по import в AST. В Java — по bytecode/модулям.
На практике важны два решения:
- Что считать узлом графа?
- папка,
- пакет,
- библиотека,
- модуль (файл),
- класс.
- Как считать ребро?
- только прямые импорты,
- или транзитивные зависимости,
- учитывать ли динамические импорты.
Чем “мелче” узлы — тем точнее диагностика, но тем больше шума. Начните с уровня “пакет/директория”, а “разобраться глубже” можно уже по проблемным узлам.
Подход 2: граф архитектурных слоёв (layer graph)
Если в коде есть условные слои (domain, application, infrastructure, ui), полезно строить не только граф модулей, но и граф между слоями. Он покажет:
- насколько слой “чистый”,
- кто кого нарушает,
- где архитектура “плывёт” со временем.
Технически это делается маппингом: каждому узлу назначается слой по пути или по метаданным, а дальше считается матрица зависимостей “слой → слой”.
Подход 3: runtime-связи и вызовы (observability)
Для циклов по исполнению (например, события, запросы, RPC) статический граф может быть неполным. Тут помогают:
- трассировка (distributed tracing),
- анализ логов корреляцией,
- графы на основе зависимостей сервисов (service-to-service).
Карта становится “динамической”: узлы — сервисы, ребра — реальные запросы/сообщения, а также их тип.
Поиск скрытых циклов: как отличить “плохой цикл” от “нормального сообщества”
Циклы бывают разными:
- Короткие технические циклы между модулями (часто из-за удобного “импорта по пути”).
- Длинные поведенческие циклы через события (“инициатор публикует → подписчик делает и публикует обратно”).
- Нормальные зависимости на уровне контрактов (например, общий интерфейс, который цитируют и домен, и инфраструктура).
Для диагностики удобно использовать понятия из теории графов:
- SCC (Strongly Connected Components) — сильно связные компоненты. Это множества узлов, внутри которых достижимость взаимная.
- Цикл по направлению — когда граф “слой → слой” содержит обратные направления, противоречащие архитектурным правилам.
Практика: поиск SCC в импортном графе
Допустим, вы получили граф импорта. Тогда:
- Строите направленный граф: ребро
A -> Bозначает “A зависит от B”. - Находите SCC.
- Все SCC размером > 1 почти всегда требуют внимания: это кандидаты на утечки границ или циклические контрактные связи.
Обычно “нормальные” случаи — когда цикл возникает из-за общего типа (например, shared models). Но “технические” циклы чаще связаны с тем, что один слой начинает импортировать детали другого.
Почему циклы часто “скрыты”
Цикл может отсутствовать в очевидной форме, но появляться через:
- события и реакторы,
- обратные вызовы (callbacks),
- фабрики, которые создают “всё подряд”,
- сериализацию доменных объектов “как есть” (тем самым слои становятся зависимыми друг от друга).
Поэтому SCC — лишь первый фильтр. Далее нужно подтверждение: что именно связывает цикл и можно ли разорвать его контрактом.
Утечки ответственности: когда граф зависимостей “неявно ломает архитектуру”
Утечки ответственности проявляются не только в циклах. Иногда зависимости “вроде бы односторонние”, но уровень абстракции смешан. Признаки:
- Доменный модуль импортирует инфраструктурный (например, знает про ORM-репозитории, HTTP-клиенты, файловые системы).
- UI слой начинает использовать детали транзакций, маппинг БД или логику идемпотентности.
- Приложение знает о конкретной очереди/топике и формате ретраев.
- Инфраструктура начинает содержать доменную логику: правила, которые “должны жить в domain”.
Как это увидеть на карте
Есть простой способ: ввести правила архитектуры и проверить их:
- Какие импорты запрещены между слоями?
- Какие зависимости разрешены (например, только
application -> domain,infrastructure -> applicationчерез интерфейсы)? - Какие типы “утекают” вниз: DTO, сущности, исключения, конфигурация?
Даже без сложных формальных проверок можно:
- агрегировать импортные зависимости по категориям,
- смотреть “топ узлов” по входящим/исходящим ребрам,
- выявлять “черные дыры”: модуль, к которому стекаются зависимости из многих мест.
Типичная форма утечки: “утилитарный модуль”
Часто появляется общий модуль utils, который постепенно превращается в “всё для всех”:
- в него складывают бизнес-правила,
- он начинает импортировать инфраструктуру,
- через него проходят ссылки “в обе стороны”.
На карте он выглядит как узел с огромным числом ребер и как точка пересечения SCC (или “мостик” для почти всех циклов). Лечится разделением на несколько слоёв и введением интерфейсов вместо прямых импортов.
Рефакторинг границ без переписывания всего: стратегии разрыва зависимостей
Полезное правило: не пытайтесь “разорвать все циклы сразу”. Делайте постепенные, проверяемые шаги, иначе вы упрётесь в каскадные изменения.
Ниже — несколько стратегий, которые работают именно в существующих кодовых базах.
Стратегия 1: разрывайте циклы через контракты (порт ↔ адаптер)
Если цикл существует потому, что одна сторона “знает детали” другой, сделайте контракт:
- домен/приложение объявляет интерфейс (порт),
- инфраструктура реализует его (адаптер),
- связь проходит через инверсию зависимостей.
Пример (условно на TypeScript): доменный слой не должен знать про конкретный storage.
// domain/ports/UserRepository.ts
export interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// infrastructure/db/UserRepositoryPg.ts
import { UserRepository } from "../../domain/ports/UserRepository";
import { User } from "../../domain/entities/User";
export class UserRepositoryPg implements UserRepository {
async findById(id: string): Promise<User | null> {
// обращение к БД ...
return null;
}
async save(user: User): Promise<void> {
// insert/update ...
}
}
// application/services/UserService.ts
import { UserRepository } from "../../domain/ports/UserRepository";
export class UserService {
constructor(private readonly repo: UserRepository) {}
async register(id: string) {
const user = /* ... доменная логика ... */;
await this.repo.save(user);
}
}
Что важно: контракт живёт в “верхнем” слое, реализация — в “нижнем”. Так вы обычно ломаете обратные зависимости и уменьшаете SCC.
Стратегия 2: “поднять” типы и исключения в общий доменный контракт
Утечки часто происходят из-за того, что:
- домен выбрасывает исключения инфраструктуры,
- приложение опирается на DTO из API,
- модель события копирует схему транспорта.
Решение — определить канонические типы:
- доменные события/команды (или хотя бы доменные интерфейсы),
- доменные исключения (или коды ошибок, инвариантные к инфраструктуре),
- контрактные модели, не привязанные к транспорту.
Вместо того чтобы “доменные сущности сериализовать как есть”, введите слой преобразований. Это может быть отдельный модуль “mappers”, но следите, чтобы он не стал свалкой.
Стратегия 3: ограничение зависимостей в коде (lint/CI guardrails)
Чтобы рефактор не “развалился” через две недели, зафиксируйте правила:
- запрещён импорт из
domainвinfrastructure, - запрещено импортировать “хардовые” библиотеки из неправильного слоя,
- запрещено использовать конкретные HTTP/DB клиенты в домене.
Технически это делается через:
- линтеры правил импорта,
- скрипты проверки графа зависимостей в CI,
- архитектурные тесты (например, в виде “dependency matrix” и порогов).
Даже минимальный guardrail резко снижает регрессы.
Стратегия 4: “вынести” инфраструктурные штуки в края и заменить прямые вызовы на события/очереди (аккуратно)
Если цикл поведенческий и держится на синхронных вызовах, иногда лучше сделать границу асинхронной:
- приложение публикует событие домена,
- адаптер доставки отправляет в очередь/шину,
- обработчик на другой стороне вызывает нужные сервисы.
Но здесь подводный камень: можно получить “зависимость по данным” (когда формат события начинает тянуть инфраструктуру). Тогда всё равно придётся формализовать контракт события на уровне домена или приложения.
Стратегия 5: итеративное снижение “межслойных” ребер
Практический подход, если проект большой:
- Выберите 3–5 наиболее проблемных узлов по карте (высокие входящие/исходящие ребра, SCC).
- Для каждого узла определите, какая ответственность лишняя.
- Разделите модуль на два-три меньших по роли (интерфейсы/контракт, логика, адаптация).
- Обновите зависимости постепенно: сначала интерфейсы, затем реализации, затем “внутренние” типы.
Так вы избегаете “переписывания всего” и сохраняете историю изменений.
Мини-кейс: от “лапши импортов” к управляемым границам
Представим типичную ситуацию в средних проектах:
domainимпортируетinfrastructure/config— потому что нужно значение фичей.infrastructureимпортируетapplication/useCases— потому что там “удобная” функция для валидации.applicationимпортируетdomain— “всё логично”.- В итоге получается SCC между
domain,applicationиinfrastructure.
Что делают команды обычно (ошибка):
- “переносим конфиг в domain” (а значит, домен начинает знать про источник данных конфигурации),
- “переносим валидацию в infrastructure” (и домен теряет инварианты),
- в итоге SCC просто смещается.
Что работает:
- В
applicationилиdomainоставляем интерфейсFeatureFlags:// domain/ports/FeatureFlags.ts export interface FeatureFlags { isEnabled(flag: string): boolean; } infrastructureреализует этот интерфейс через конкретный конфиг-стор:// infrastructure/config/FeatureFlagsLocal.ts import { FeatureFlags } from "../../domain/ports/FeatureFlags"; export class FeatureFlagsLocal implements FeatureFlags { constructor(private readonly flags: Record<string, boolean>) {} isEnabled(flag: string) { return !!this.flags[flag]; } }- Использование фичей проходит через инъекцию зависимостей — домен не импортирует инфраструктуру.
- Функции “для валидации” выносятся в
domainкак часть доменной логики или вapplicationкак оркестрация, но так, чтобы не приходилось импортировать обратно.
На карте зависимостей SCC распадается, а направления становятся контролируемыми. И самое важное — появляется механизм, который удерживает границы: контракты и guardrails.
Как сделать карту полезной: фильтры, матрицы и “порог критичности”
Чтобы карта не стала “схемой на стену”, введите практики:
1) Смотрите на матрицу зависимостей, а не только на граф
Матрица “слой → слой” часто компактнее. Если в проекте слои А, B, C, вы получите количество ребер или факт “есть/нет зависимости” по паре слоёв. Это быстро показывает нарушения.
2) Используйте топ-списки
- Узлы с максимальным числом входящих ребер (куда “течёт” зависимость).
- Узлы с максимальным числом исходящих ребер (кто “тянет” за собой всё).
Это приближает к местам, где ответственности размазаны.
3) Фиксируйте регресс по изменению карты
Если карта строится автоматически, вы можете сравнивать версии:
- увеличилось число SCC,
- появилась новая нежелательная связь “domain → infrastructure”,
- выросли “входящие ребра” конкретного модуля.
Это превращает архитектуру в наблюдаемую сущность.
Подводные камни: что ломает интерпретацию карты
-
Смешение уровней в одном узле.
Если узлом является “папка”, где лежат разные роли, то зависимости будут выглядеть “непонятно”. Разделяйте узлы по смыслу (контракты/реализация/адаптация). -
Динамические импорты и рефлексия.
Статический анализ может пропустить runtime-зависимости. Карта по импортам — не гарантия отсутствия циклов исполнения. -
Общие типы и “shared”.
Общий модуль может выглядеть как зло, хотя он нужен для контрактов. Проблема начинается,
Комментарии
Пока нет комментариев