Олег Марков
Циклические зависимости - circular-dependencies в коде и архитектуре
Введение
Циклические зависимости (circular dependencies) появляются, когда два или больше модулей, пакетов, классов или сервисов зависят друг от друга по кругу. На диаграмме это выглядит как стрелка, которая в итоге возвращается к исходной точке.
Смотрите, формально это выглядит так:
- Модуль A зависит от модуля B
- Модуль B зависит от модуля C
- Модуль C зависит от модуля A
В итоге ни один из них «логически» не может существовать без остальных.
На практике такие зависимости:
- усложняют сборку и запуск приложения
- осложняют тестирование и повторное использование кода
- повышают связанность (coupling) и усложняют изменения
- иногда приводят к ошибкам времени компиляции или рантайма (например, при статическом импорте или ленивой инициализации)
В этой статье вы увидите, что циклические зависимости могут возникать:
- между файлами и модулями (import / include кругом)
- между классами (взаимные ссылки, дружба, композиция)
- между слоями и сервисами в архитектуре
- в системах сборки и конфигурации (например, зависимости в DI-контейнере)
Давайте разберем, откуда они берутся, чем именно опасны и какие практические способы есть, чтобы их убрать без хаотичного рефакторинга.
Что такое циклическая зависимость более формально
Граф зависимостей и цикл
Зависимости удобно представлять в виде ориентированного графа:
- вершины — модули, классы, сервисы
- ребра — «A зависит от B» (стрелка из A в B)
Циклическая зависимость — это наличие цикла в таком графе. Если вы от модуля A, идя по стрелкам depend-on, сможете вернуться в A, значит есть цикл.
Простой пример на уровне модулей:
user-serviceимпортируетemail-serviceemail-serviceимпортируетuser-service
Здесь цикл длиной 2. Аналогично можно получить циклы длиной 3, 4 и больше.
Виды циклов
Полезно различать несколько видов:
Прямой цикл
A зависит от B, B зависит от A.Непрямой (опосредованный) цикл
A зависит от B, B от C, C от A.Цикл времени компиляции
Когда статический импорт/инклюд приводит к невозможности собрать проект или к странному порядку инициализации.Цикл времени исполнения
Когда объекты или сервисы создаются через DI-контейнер, и контейнер не может построить граф зависимостей или попадает в рекурсию.
Теперь давайте посмотрим, как это проявляется в конкретном коде.
Примеры циклических зависимостей в разных языках
Циклические зависимости между модулями в JavaScript / TypeScript
Смотрите, простой пример, который многие встречают при работе с модулями.
a.ts:
// a.ts
import { bFunc } from "./b";
export function aFunc() {
// Здесь вызываем функцию из модуля B
console.log("aFunc вызывается");
bFunc();
}
b.ts:
// b.ts
import { aFunc } from "./a";
export function bFunc() {
// Здесь вызываем функцию из модуля A
console.log("bFunc вызывается");
aFunc();
}
Что здесь происходит:
- Модуль A импортирует модуль B
- Модуль B импортирует модуль A
- В момент загрузки модулей интерпретатору нужно решить, в каком порядке инициализировать экспортируемые сущности
- В зависимости от системы модулей (
CommonJS,ESM) можно получить:- ошибку времени выполнения
- частично инициализированный модуль (undefined в экспортах)
- бесконечную рекурсию при вызове
aFunc()↔bFunc()
Обратите внимание, как взаимный вызов функций усиливает проблему: даже если импорты технически допустимы, вы легко уйдете в рекурсивный вызов без условия выхода.
Циклические зависимости между пакетами в Go
В Go компилятор жестко запрещает циклические импорты.
a/a.go:
// package a
package a
import "myproject/b"
// FuncA зависит от пакета b
func FuncA() {
// Здесь мы обращаемся к функции из пакета b
b.FuncB()
}
b/b.go:
// package b
package b
import "myproject/a"
// FuncB зависит от пакета a
func FuncB() {
// Здесь мы обращаемся к функции из пакета a
a.FuncA()
}
При сборке вы увидите ошибку вида:
import cycle not allowed
package myproject/a
imports myproject/b
imports myproject/a
Go прямо говорит, что такой цикл недопустим. Это удобный пример, чтобы увидеть, что проблема обнаруживается на уровне компиляции, а не в рантайме.
Циклические ссылки между классами в объектно-ориентированных системах
Представим две сущности: Order и User.
В псевдокоде:
// User.java
public class User {
private List<Order> orders; // Пользователь хранит список заказов
// Здесь методы работы с заказами пользователей
}
// Order.java
public class Order {
private User user; // Заказ хранит ссылку на пользователя
// Здесь методы работы с заказом
}
Здесь формально есть двунаправленная связь (bidirectional association). Это еще не обязательно «плохой» цикл, но:
- сериализация этих объектов (например, в JSON) может вести к рекурсивному обходу и StackOverflow
- ORM (Hibernate, JPA, Entity Framework и т.п.) требуют аккуратной настройки, чтобы не загружать данные по кругу
- при тестировании вам приходится создавать
User, затемOrder, затем обратно связывать, что усложняет изоляцию
Часто такие связи перерастают в более крупные циклы на уровне модулей.
Циклы в архитектуре слоев
Еще один частый сценарий: слои архитектуры «протекают» и начинают зависеть друг от друга в обе стороны.
Например:
Controller→Service→Repository— так обычно и должно быть- Но кто-то добавляет использование
ControllerвнутриService(например, для доступа к HTTP контексту или валидации) - Или
Repositoryначинает вызыватьService, чтобы дернуть какой-то бизнес-метод
В итоге:
- верхний слой зависит от нижнего
- нижний начинает зависеть от верхнего
- появляется цикл, и архитектура слоев разрушает свои принципы
Здесь проблема уже не на уровне синтаксиса, а на уровне архитектурных правил.
Почему циклические зависимости опасны
Усложняют понимание и сопровождение кода
Когда модуль A зависит от B и vice versa, вы уже не можете понять один из них в изоляции. Вам приходится:
- держать в голове оба (или весь цикл)
- бояться изменений, так как неясно, как они отразятся на другом конце цикла
- сталкиваться с «слепыми» зависимостями (например, A тянет половину дерева зависимостей B, о которых вы сразу не знаете)
Это приводит к высокой связанности (tight coupling) и снижает модульность.
Могут ломать сборку и запуск
Некоторые языки и окружения:
- запрещают циклические импорты полностью (Go, Rust, частично Java с модульной системой)
допускают их, но вводят особые правила инициализации (ES Modules, Python), что может привести к:
- неинициализированным переменным экспорта
- неожиданному порядку выполнения кода
- странным багам при первой загрузке модулей
На практике разработчики часто сталкиваются с ситуацией, когда «простая» перестановка кода в модуле неожиданно ломает работу из-за скрытого цикла.
Усложняют тестирование и внедрение зависимостей
Цикл может серьезно усложнить unit-тестирование:
- вы не можете поднять один сервис или класс без второго
- моки приходится прокидывать глубже
- тесты становятся интеграционными, а не модульными
Особенно это заметно в проектах с DI-контейнерами: контейнер либо не может построить граф зависимостей, либо вам приходится использовать хаки — ленивые зависимости, сервис-локаторы и т.п.
Уводят архитектуру от чистой структуры
Другая важная проблема — циклы часто означают, что уровни абстракции перепутаны:
- доменная модель начинает знать о инфраструктуре
- слой данных знает о веб-слое
- клиенты зависят от серверов и наоборот
Цикл — почти всегда сигнал: где-то нарушен принцип единственной ответственности и/или принцип направленности зависимостей (Dependency Inversion).
Как находить циклические зависимости
Инструменты анализа зависимости для разных языков
Вместо ручного поиска удобно использовать статический анализ или плагины:
- Java / JVM
- Maven/Gradle плагины (
maven-dependency-plugin,gradle-dependency-analysis) - архитектурные тесты на ArchUnit
- Maven/Gradle плагины (
- JavaScript / TypeScript
- ESLint правило
import/no-cycle madge(CLI-утилита, которая строит граф зависимостей)
- ESLint правило
- Python
pipdeptree(для внешних пакетов)- инструменты статического анализа (pylint, pydeps)
- Go
- встроенный компилятор сразу ругается на
import cycle not allowed - утилиты вроде
go list,go mod graph, сторонние вьюеры графов
- встроенный компилятор сразу ругается на
- .NET
- NDepend, ReSharper, интеграции в Rider/Visual Studio
Смотрите, как использовать madge для JS/TS, чтобы вы увидели цикл на наглядной схеме.
# Установка madge
npm install -g madge
# Анализ директорий src
madge src --circular
Комментарий:
- здесь
--circularвыводит только циклы - вы увидите список цепочек модулей, замыкающихся в круг
Вы также можете сгенерировать изображение:
madge src --image graph.png
Комментарий:
- граф «подсветит», где именно начинаются и заканчиваются циклы
- это удобно, когда проект уже крупный и связи неочевидны
Архитектурные тесты как защита от новых циклов
Хорошая практика — не только исправить текущие циклы, но и зафиксировать архитектурные правила в виде тестов.
Например, на Java с ArchUnit:
// Здесь мы определяем архитектурный тест, который запрещает циклы между пакетами
@ArchTest
static final ArchRule no_cycles_between_packages =
slices().matching("com.myapp.(*)..").should().beFreeOfCycles();
Комментарий:
slices().matching("com.myapp.(*)..")разбивает проект на «срезы» по пакетамbeFreeOfCycles()проверяет, что между ними нет циклов- если кто-то добавит новый цикл, тесты упадут
Это особенно полезно в долгоживущих проектах с большой командой.
Типичные причины появления циклов
Смешивание уровней ответственности
Частая ситуация:
- модуль, который должен быть «низкоуровневым» (например, доступ к БД), начинает обращаться к «верхнеуровневому» модулю (например, веб-контроллеры)
- бизнес-логика начинает ходить в слой UI для валидации или отображения сообщений
В итоге уровни, которые должны быть независимы, оказываются связанными.
Удобство «здесь и сейчас» вместо выделения общего слоя
Разработчик видит:
- «Мне нужна функция из того модуля, просто импортну его сюда»
- На первых этапах это кажется безобидным
- Через пару таких шагов появляется зависимость назад, и формируется цикл
Обычно это означает, что часть логики нужно вынести в отдельный модуль (utility, shared, core), но это откладывается «на потом».
Непродуманная двунаправленная навигация в доменной модели
Мы уже видели пример User ↔ Order. Похожие связи часто встречаются в доменных моделях:
Category↔ProductDepartment↔EmployeeParent↔Child
Двунаправленные связи удобны при навигации, но:
- носят избыточный характер (часто достаточно одной стороны)
- создают сложности с сериализацией и загрузкой
- провоцируют циклы на уровне модулей, если сущности разнесены по разным пакетам
Неправильная настройка DI-контейнера
В системах с DI:
- сервис A внедряет сервис B
- сервис B внедряет сервис C
- сервис C внедряет A
Контейнер при построении графа зависимостей может:
- выдать ошибку о циклической зависимости
- или потребовать ленивых зависимостей/фабрик, что уже симптом архитектурной проблемы
Давайте теперь посмотрим, как такие ситуации исправлять.
Стратегии устранения циклических зависимостей
1. Разделение модулей и выделение общего слоя
Самый базовый, но часто самый эффективный подход — вынести общую функциональность в отдельный модуль, который не зависит от тех, кто его использует.
Схема:
- Было: A ↔ B
- Становится: A → Common ← B
Теперь зависимости направлены в одну сторону: к общему модулю.
Пример на TypeScript
Допустим, у нас есть модули order.ts и user.ts, которые зависят друг от друга.
user.ts:
// user.ts
import { Order } from "./order";
export class User {
// Здесь пользователь хранит список заказов
orders: Order[] = [];
}
order.ts:
// order.ts
import { User } from "./user";
export class Order {
// Здесь заказ хранит ссылку на пользователя
constructor(public user: User) {}
}
Цикл: user.ts ↔ order.ts.
Решение — вынести общие интерфейсы в models.ts:
models.ts:
// models.ts
// Здесь мы выделяем общие интерфейсы для сущностей
export interface IUser {
// Интерфейс пользователя - минимальный набор полей
id: string;
}
export interface IOrder {
// Интерфейс заказа - минимальный набор полей
id: string;
userId: string; // Ссылка на пользователя через идентификатор
}
user.ts:
// user.ts
import { IOrder, IUser } from "./models";
export class User implements IUser {
// Реализация пользователя на основе интерфейса
constructor(
public id: string,
public orders: IOrder[] = [] // Используем интерфейс IOrder вместо конкретного класса
) {}
}
order.ts:
// order.ts
import { IOrder } from "./models";
export class Order implements IOrder {
// Реализация заказа на основе интерфейса
constructor(
public id: string,
public userId: string // Храним только идентификатор, а не объект пользователя
) {}
}
Теперь:
models.tsне зависит отuser.tsиorder.tsuser.tsиorder.tsзависят только отmodels.ts- цикл исчезает
Ключевая идея: общий модуль содержит лишь абстракции (интерфейсы, DTO, базовые типы), а не конкретные реализации.
2. Применение принципа Dependency Inversion (DIP)
Принцип инверсии зависимостей звучит примерно так:
- высокоуровневые модули не должны зависеть от низкоуровневых
- и те, и другие должны зависеть от абстракций
- абстракции не должны зависеть от деталей, детали должны зависеть от абстракций
Практически: вместо того чтобы модуль A напрямую зависел от конкретного класса/реализации модуля B, он зависит от интерфейса, который реализует B (или кто-то еще).
Пример с репозиторием и сервисом
Смотрите, стандартная ситуация:
// userService.ts
import { UserRepository } from "./userRepository";
export class UserService {
constructor(private repo: UserRepository) {}
// Здесь бизнес-логика, использующая репозиторий
}
// userRepository.ts
import { UserService } from "./userService";
export class UserRepository {
// Пример - репозиторий хочет использовать сервис для валидации
constructor(private service: UserService) {}
}
Цикл: userService.ts ↔ userRepository.ts.
Рефакторинг с использованием абстракций:
userRepository.ts:
// userRepository.ts
// Здесь мы определяем абстракцию репозитория
export interface IUserRepository {
// Абстракция операции поиска
findById(id: string): Promise<User | null>;
}
// Конкретная реализация репозитория
export class UserRepository implements IUserRepository {
// Здесь уже нет ссылки на UserService
async findById(id: string): Promise<User | null> {
// Логика доступа к базе данных
return null;
}
}
// Здесь определен тип User, чтобы пример был самодостаточным
export type User = {
id: string;
};
userService.ts:
// userService.ts
import { IUserRepository } from "./userRepository";
export class UserService {
// Сервис зависит только от абстракции репозитория
constructor(private repo: IUserRepository) {}
async getUser(id: string) {
// Здесь бизнес-логика получения пользователя
return this.repo.findById(id);
}
}
Теперь:
UserServiceзависит от интерфейсаIUserRepository, а не от конкретного класса, который в свою очередь может зависеть от чего угодно- если репозиторию по какой-то причине нужен сервис (что само по себе уже вопрос к архитектуре), его стоит выделить в отдельный компонент или использовать доменные сервисы без зависимости назад
3. Введение портов и адаптеров (Hexagonal / Clean Architecture)
В архитектурах типа Hexagonal или Clean Architecture:
- доменный слой определяет «порты» — абстрактные интерфейсы для внешнего мира
- инфраструктура поставляет адаптеры, реализующие эти порты
Зависимости направлены:
- от домена к абстрактным портам
- от адаптеров к домену и портам
- но не наоборот
Это почти тот же DIP, только на уровне всей архитектуры.
Схематично:
- Домен → Порты
- Адаптеры → Домен, Порты
- Веб, БД, очереди — все идут через порты
Циклы между инфраструктурой и доменом таким образом технически исключаются: домен не знает об адаптерах, он знает только о абстракциях.
4. Использование событий и шины сообщений вместо прямых вызовов
Еще один мощный прием — заменить прямые вызовы «A вызывает B» на событийную модель:
- A публикует событие «что-то произошло»
- B подписывается на это событие и реагирует
- A больше не знает о B, а B не знает об A напрямую (они зависят от контракта события)
Пример с сервисами оповещения
Предположим:
UserServiceпосле регистрации пользователя напрямую вызываетEmailServiceEmailServiceпо какой-то причине хочет запросить статус пользователя вUserService
Получаем цикл UserService ↔ EmailService.
Решение — ввести событие UserRegistered и EventBus.
// events.ts
// Здесь мы определяем интерфейс шины событий и тип события
export interface EventBus {
// Публикация события
publish(event: DomainEvent): void;
// Подписка на событие
subscribe(eventName: string, handler: (event: DomainEvent) => void): void;
}
export interface DomainEvent {
// Базовые поля доменных событий
name: string;
payload: unknown;
}
export interface UserRegisteredPayload {
// Поля события о регистрации пользователя
userId: string;
email: string;
}
userService.ts:
// userService.ts
import { EventBus, DomainEvent, UserRegisteredPayload } from "./events";
export class UserService {
constructor(private eventBus: EventBus) {}
async registerUser(email: string, password: string) {
// Логика создания пользователя в системе
const event: DomainEvent = {
name: "UserRegistered",
payload: {
userId: "generated-id",
email: email,
} as UserRegisteredPayload,
};
// Публикуем событие вместо прямого вызова EmailService
this.eventBus.publish(event);
}
}
emailService.ts:
// emailService.ts
import { EventBus, DomainEvent, UserRegisteredPayload } from "./events";
export class EmailService {
constructor(private eventBus: EventBus) {
// Подписываемся на событие регистрации пользователя
this.eventBus.subscribe("UserRegistered", this.handleUserRegistered);
}
private handleUserRegistered(event: DomainEvent) {
const payload = event.payload as UserRegisteredPayload;
// Отправляем письмо, используя данные из события
console.log("Отправляем приветственное письмо пользователю", payload.email);
}
}
Теперь:
UserServiceне знает оEmailServiceEmailServiceне знает непосредственно оUserService- они оба зависят от абстракции
EventBusи контракта события
Цикл разорван, а взаимодействие становится слабосвязанным.
5. Ленивая инициализация и фабрики (как временный костыль)
Иногда архитектурный рефакторинг трудоемок, а проблему нужно снять быстро. В таких случаях используют:
- ленивые зависимости (
Lazy<T>,Provider<T>, лямбда-фабрики) - сервис-локаторы
- отложенное связывание зависимостей на уровне конфигурации
Пример на псевдо-коде с DI-контейнером:
// Здесь мы используем ленивую зависимость, чтобы разорвать жесткий цикл конструкторов
class ServiceA {
// Вместо непосредственной зависимости от ServiceB - функция, которая вернет его по запросу
constructor(private getB: () => ServiceB) {}
doSomething() {
// Получаем ServiceB только когда он реально нужен
const b = this.getB();
// Дальше используем b
}
}
class ServiceB {
constructor(private a: ServiceA) {}
}
Комментарий:
- в конфигурации DI-контейнера вы можете зарегистрировать
ServiceAиServiceB, используя фабрики - такой подход снимает проблему «циклического конструктора», но по сути не решает архитектурный цикл
- это разумно как временная мера, но лучше все равно рефакторить
Практический пошаговый подход к устранению цикла
Давайте разберемся на условном примере, как вы можете системно подойти к разрыву цикла без «ломания всего» сразу.
Шаг 1. Найти и зафиксировать цикл
Используйте:
- статический анализ (madge, ArchUnit, IDE-плагины)
- компилятор (в Go, Rust)
- собственные скрипты обхода зависимостей
Зафиксируйте:
- какие модули/пакеты/классы входят в цикл
- какой тип цикла: прямой или опосредованный
Шаг 2. Определить целевую направленность зависимостей
Ответьте:
- какие модули по смыслу должны быть «выше» (большая бизнес-ценность, более абстрактные)
- какие ниже (техническая инфраструктура, утилиты)
Циклы почти всегда должны быть разорваны так, чтобы:
- верхние уровни зависели от нижних либо от абстракций
- низкие уровни не знали о верхних
Это позволит наметить, кто «неправильно» зависит от кого.
Шаг 3. Выделить абстракции или общий модуль
Варианты:
- вынести общие модели/интерфейсы в отдельный пакет
coreилиapi - определить интерфейсы (порты), которые будут реализованы с другой стороны
- применить событийную модель, если зависимость больше похожа на реакцию на событие, чем на «запрос-ответ»
Шаг 4. Изменить код и пересобрать
После изменения:
- убедитесь, что статический анализ больше не показывает цикл
- запустите тесты, особенно интеграционные
- при необходимости добавьте архитектурные тесты, чтобы не вернуть цикл случайно
Шаг 5. Почистить временные решения
Если на время вы использовали:
- сервис-локатор
- ленивые зависимости, которые не нужны после рефакторинга
- страшные «if (x == null) initX()» внутри кода
то постепенно уберите их за ненадобностью. Они не должны стать постоянным паттерном.
Типичные антипаттерны, ведущие к циклам
«God object» и «Manager для всего»
Когда в проекте появляется один «главный» класс/модуль:
AppManager,CoreService,MainControllerи так далее- в нем концентрируются вызовы ко всем компонентам
- в какой-то момент другие компоненты начинают ссылаться на него «для удобства»
Зависимости превращаются в сеть, где центр знает всех, а все знают центр. Появляются циклы:
UserService→CoreService→UserService
Решение:
- разделить «бога» на специализованные сервисы
- использовать DI и события вместо глобального менеджера
Service Locator
Service Locator позволяет «вытянуть» любой сервис «из воздуха»:
// Псевдо-пример сервис-локатора
class ServiceLocator {
private static services = new Map<string, unknown>();
static register(name: string, instance: unknown) {
// Регистрируем сервис
this.services.set(name, instance);
}
static resolve<T>(name: string): T {
// Получаем сервис по имени
return this.services.get(name) as T;
}
}
Комментарий:
- он скрывает зависимости и делает архитектурные циклы менее очевидными
- код «компилируется», но реальные зависимости размазываются по проекту
Лучше явно прокидывать зависимости через конструкторы или DI-контейнер, где possible.
Двунаправленные отношения по умолчанию
Когда автоматически для каждой связи в доменной модели создается обратная связь:
- «раз есть заказ у пользователя, то у заказа обязательно должен быть полный объект пользователя»
Это приводит к:
- сложным графам
- сложным мапперам DTO
- рискам бесконечной рекурсии при сериализации
Лучше:
- по умолчанию делать однонаправленные связи
- добавлять обратные ссылки только там, где это обосновано
- чаще использовать идентификаторы вместо полных объектов
Заключение
Циклические зависимости — это не просто неприятная ошибка компиляции, а индикатор архитектурных проблем:
- нарушенных уровней абстракции
- чрезмерной связанности модулей
- неясных или смешанных ответственностей
На уровне кода циклы часто проявляются через:
- взаимные импорты модулей
- двунаправленные ссылки между классами
- циклы в графе DI-контейнера
На уровне архитектуры — через:
- «протекание» слоев (UI → Service → Repository → UI)
- зависимость доменной логики от инфраструктуры
- «божественные» классы и глобальных менеджеров
Чтобы эффективно работать с circular-dependencies, полезно:
- уметь их обнаруживать с помощью статического анализа и арх-тестов
- понимать, как направить зависимости в нужную сторону
- применять принципы инверсии зависимостей, портов и адаптеров, событийное взаимодействие
- не бояться рефакторинга, но делать его осознанно и пошагово
Главная идея: зависимости должны строиться вокруг абстракций и стабильных центров модели, а не вокруг случайных реализаций. Тогда появление циклов можно либо предотвратить заранее, либо довольно легко устранить, не ломая полпроекта.
Частозадаваемые технические вопросы по теме и ответы
Вопрос 1. Как найти циклические зависимости только внутри одного конкретного модуля или директории
Используйте инструменты анализа, которые позволяют ограничить область. Например, в TypeScript с madge:
# Анализ только директории src/features/user
madge src/features/user --circular
Комментарий:
- так вы не «тонете» в глобальном графе всего проекта
- удобнее рефакторить область по частям
Вопрос 2. Как временно обойти циклическую зависимость в DI-контейнере Spring
Можно использовать аннотацию @Lazy:
@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(@Lazy ServiceB serviceB) {
// Ленивое внедрение зависимости
this.serviceB = serviceB;
}
}
Комментарий:
- это снимает ошибку цикла при создании бинов
- но архитектурный цикл остается, его лучше позже разорвать через рефакторинг
Вопрос 3. Что делать, если ORM (например, Hibernate) создает циклы при сериализации в JSON
Используйте аннотации для управления сериализацией. В Java + Jackson:
public class User {
@JsonManagedReference
private List<Order> orders;
}
public class Order {
@JsonBackReference
private User user;
}
Комментарий:
@JsonManagedReferenceи@JsonBackReferenceразрывают цикл при сериализации- на уровне доменной модели связь остается двунаправленной
Вопрос 4. Как убедиться, что новый модуль не создает циклов в монорепозитории
Включите автоматический анализ в CI:
- добавьте команду типа
madge packages/**/src --circularили аналог для вашего стека - настройте, чтобы пайплайн падал при обнаружении циклов
- добавьте правило в CONTRIBUTING, что новые модули не могут добавлять циклические зависимости
Комментарий:
- это позволяет ловить проблему на этапе Pull Request
Вопрос 5. Можно ли использовать forward-declaration в C++ как «решение» циклических зависимостей
В C++ можно объявить класс заранее:
// Здесь мы объявляем класс B, не включая его заголовок
class B;
class A {
B* b; // Указатель на B - достаточно forward declaration
};
Комментарий:
- это технически убирает необходимость двухстороннего include
- но если логика классов по-прежнему сильно переплетена, архитектурная проблема остается
- старайтесь все равно пересматривать дизайн классов и модулей, а не только заголовки