Олег Марков
Циклические зависимости circular-dependencies - причины проблемы и решения
Введение
Циклическая зависимость (circular dependency) возникает, когда два или больше модулей, классов, пакетов или сервисов зависят друг от друга по кругу. Это может выглядеть безобидно, но на практике приводит к целому набору проблем: от ошибок компиляции и сложностей с тестированием до хрупкой архитектуры, которую трудно сопровождать.
Вы наверняка уже сталкивались с ситуацией, когда:
- модуль А импортирует модуль B,
- а модуль B в свою очередь импортирует модуль А.
Или когда несколько слоев системы начинают “простреливать” архитектуру, обращаясь напрямую друг к другу. Снаружи все вроде бы работает, но любое изменение в одном месте тянет за собой изменения по всей системе.
В этой статье я разберу, что именно считается циклической зависимостью, почему они возникают, какие есть виды таких зависимостей и что с ними делать. Покажу, как выявлять циклы и как шаг за шагом от них избавляться на уровне кода, архитектуры и инфраструктуры.
Что такое циклическая зависимость
Базовое определение
Циклическая зависимость — это ситуация, когда два или более компонента (модуля, класса, пакета, сервиса) прямо или косвенно зависят друг от друга по замкнутому кругу.
Говоря проще:
- Модуль A зависит от модуля B.
- Модуль B зависит от модуля C.
- Модуль C зависит от модуля A.
Здесь уже есть цикл A → B → C → A. Он может быть:
- прямым (A зависит от B, B зависит от A),
- косвенным (между ними цепочка других модулей).
Типы зависимостей
Чтобы было проще анализировать, давайте разделим зависимости по уровням.
1. Циклы на уровне модулей и пакетов
Самый распространенный вариант:
- файлы/модули импортируют друг друга;
- пакеты/namespace-секции образуют цикл импорта.
Пример на псевдо-JavaScript (но структура типична и для других языков):
// file: userService.js
// Здесь мы импортируем userRepository
import { getUser } from "./userRepository.js";
export function getUserProfile(id) {
// Здесь используем репозиторий
const user = getUser(id);
return { id: user.id, name: user.name };
}
// file: userRepository.js
// Здесь мы импортируем userService
import { getUserProfile } from "./userService.js";
export function getUser(id) {
// Представим, что нам "зачем-то" нужен профиль пользователя
const profile = getUserProfile(id); // Цикл вызовов
// Возвращаем объект пользователя
return { id: id, name: profile.name };
}
Комментарии:
- userService импортирует userRepository;
- userRepository импортирует обратно userService;
- у нас прямой цикл импорта модулей.
В зависимости от языка:
- компилятор может выдать ошибку,
- рантайм может подставить недоинциализированный объект,
- или программа будет работать нестабильно.
2. Циклы на уровне классов и объектов
Даже если файлы не импортируют друг друга напрямую, классы могут образовывать цикл.
Пример на Java:
// Order.java
// Здесь мы храним ссылку на Customer
public class Order {
// Ссылка на клиента
private Customer customer;
public Order(Customer customer) {
this.customer = customer;
}
// Здесь мы вызываем метод клиента
public String getCustomerName() {
return customer.getName();
}
}
// Customer.java
// Здесь мы храним список заказов
import java.util.List;
public class Customer {
private String name;
// Список заказов клиента
private List<Order> orders;
public Customer(String name, List<Order> orders) {
this.name = name;
this.orders = orders;
}
public String getName() {
return name;
}
// Здесь мы обращаемся к заказам, которые ссылаются на Customer
public int getOrdersCount() {
return orders.size();
}
}
Здесь:
- объект Customer содержит список Order,
- каждый Order содержит ссылку на Customer.
Это уже объектный цикл: объекты ссылаются друг на друга. В одних случаях это допустимо (например, в ORM-моделях), но:
- усложняется сериализация в JSON (риск бесконечной рекурсии),
- появляются проблемы при сборке мусора в некоторых реализациях,
- растет связность.
3. Циклы на уровне слоев и сервисов
Цикл может возникнуть и на уровне архитектуры.
Представим трехслойное приложение:
- Controller (слой представления/REST),
- Service (бизнес-логика),
- Repository (доступ к данным).
Идея: Controller → Service → Repository. Но иногда:
- сервис начинает вызывать контроллер (например, для отправки уведомлений),
- репозиторий начинает тянуть зависимости из сервисного слоя.
Получается архитектурный цикл между слоями.
Почему циклические зависимости опасны
Проблемы с компиляцией и загрузкой модулей
Во многих языках циклы импорта прямо запрещены или приводят к нестабильному поведению.
Распространенные эффекты:
- Ошибки компиляции: «circular dependency detected».
- Ошибки при загрузке модулей (особенно в динамических языках).
- Частично инициализированные объекты (поля еще не заданы, но уже используются).
Посмотрим простой пример на TypeScript/JavaScript с CommonJS:
// a.js
// Импортируем b
const b = require("./b");
console.log("a.js загружен");
// Вызываем функцию из модуля b
b.runFromB();
// b.js
// Импортируем a
const a = require("./a");
console.log("b.js загружен");
// Экспортируем функцию
exports.runFromB = function () {
// Здесь мы используем модуль a
console.log("runFromB вызван");
console.log("Тип модуля a", typeof a);
};
Если вы попытаетесь это запустить, вы можете получить:
- a получает объект b, который еще не до конца сформирован;
- b, загружая a, тоже попадает в ситуацию частичной инициализации.
Комментарии к примеру:
- порядок загрузки модулей становится критичным;
- в одном из модулей вы можете увидеть undefined вместо ожидаемых функций.
Повышенная связность и хрупкая архитектура
Циклические зависимости создают сильную связность:
- модуль А знает детали модуля B;
- и наоборот, B знает детали А.
Из-за этого:
- нельзя изменить интерфейс одного модуля, не переписывая другой,
- тяжелее выделять модули в отдельные пакеты/библиотеки,
- снижается возможность многократного использования компонентов.
В архитектурных терминах нарушается принцип ацикличности зависимостей (Acyclic Dependencies Principle, ADP): зависимостям нужно образовывать направленный ациклический граф (DAG).
Проблемы с тестированием
С точки зрения тестов:
- модуль A зависит от B,
- B зависит от A.
Вы хотите протестировать A:
- но вам нужно замокать B,
- а B в свою очередь тянет A.
В итоге:
- unit-тесты превращаются в интеграционные,
- становится сложно подменить зависимости,
- модули плохо изолируются.
Трудности при загрузке конфигурации и DI-контейнера
В DI-контейнерах (Spring, NestJS, Angular, .NET DI и т.д.) циклы приводят к:
- ошибкам при создании контейнера,
- ленивой инициализации, которая маскирует ошибки до рантайма,
- невозможности использовать конструкторную инъекцию без специальных обходных механизмов.
Пример на псевдо-Java (Spring-похожий код):
// PaymentService зависит от NotificationService
public class PaymentService {
private final NotificationService notificationService;
// Инъекция через конструктор
public PaymentService(NotificationService notificationService) {
this.notificationService = notificationService;
}
public void processPayment() {
// Логика платежа
notificationService.sendPaymentNotification();
}
}
// NotificationService зависит от PaymentService
public class NotificationService {
private final PaymentService paymentService;
public NotificationService(PaymentService paymentService) {
this.paymentService = paymentService;
}
public void sendPaymentNotification() {
// Здесь, например, получаем детали платежа
// через paymentService
}
}
DI-контейнер попытается:
- создать PaymentService, но ему нужен NotificationService,
- создать NotificationService, но ему нужен PaymentService.
Без специальных механизмов (ленивая инъекция, фабрики, события) это цикл.
Как обнаруживать циклические зависимости
1. Статический анализ и инструменты
Смотрите, проще всего не пытаться искать циклы глазами, а использовать инструменты анализа зависимостей.
Для JavaScript/TypeScript
- ESLint с плагинами:
- eslint-plugin-import (правило import/no-cycle),
- eslint-plugin-dependency.
- madge (отдельный инструмент).
Пример использования madge:
# Здесь мы анализируем директорию src и выводим циклы
madge src/ --circular
Вы увидите список файлов, образующих циклы.
Для Java
- IntelliJ IDEA / Eclipse умеют строить граф зависимостей.
- ArchUnit (библиотека для написания архитектурных тестов).
- Maven/Gradle плагины для анализа зависимостей модулей.
Пример ArchUnit (очень упрощенный):
// Здесь мы описываем правило - модули должны быть без циклов
ArchRule rule = slices().matching("com.example.(*)..")
.should().beFreeOfCycles();
// Дальше запускаем правило как тест
rule.check(importedClasses);
2. Визуализация графа зависимостей
Многие инструменты позволяют:
- построить граф зависимостей модулей,
- подсветить циклы.
Это удобнее, когда проект большой: на диаграмме сразу видно “красные” узлы.
3. Простые эвристики
Если у вас нет инструментов, можно ориентироваться на:
- двусторонние импорты (A импортирует B и наоборот),
- слои, которые неожиданно знают друг о друге (Controller → Service, а Service → Controller),
- DTO/модели, которые начинают ссылаться на сущности более высокого уровня.
Стратегии устранения циклических зависимостей
Теперь давайте перейдем к практике. Я покажу несколько подходов, которые помогают разрывать циклы и предотвращать их появление.
Принцип: зависимости должны образовывать DAG
В идеале:
- зависимости между модулями образуют направленный ациклический граф;
- есть “верхние” слои (UI, API), есть “нижние” (модели, инфраструктура);
- зависимости всегда направлены вниз (от верхнего уровня к нижнему).
Если вы чувствуете, что модуль из “низа” начинает тянуться вверх — это сигнал к рефакторингу.
Разделение ответственности и выделение общего модуля
Частая причина циклa: два модуля делят между собой функциональность, которая логически должна быть где-то в третьем месте.
Пример: общий тип в двух модулях
Представьте:
- module A содержит логику пользователей,
- module B содержит логику заказов.
Оба используют общий тип UserDto. Каждый хочет его “держать у себя”.
// userModule.ts
// Здесь мы описываем тип UserDto
export interface UserDto {
id: string;
name: string;
}
// Импортируем заказы
import { OrderDto } from "./orderModule";
export function getUserOrders(userId: string): OrderDto[] {
// Возвращаем список заказов
return [];
}
// orderModule.ts
// Импортируем UserDto
import { UserDto } from "./userModule";
export interface OrderDto {
id: string;
// Здесь мы ссылаемся на пользователя
user: UserDto;
}
Получается цикл:
- userModule → orderModule → userModule.
Решение: вынести общий код в отдельный модуль
Давайте создадим общий модуль models.
// models.ts
// Здесь мы выделяем общие типы
// Тип пользователя
export interface UserDto {
id: string;
name: string;
}
// Тип заказа
export interface OrderDto {
id: string;
userId: string; // Ссылка по id вместо вложенного объекта
}
// userModule.ts
// Импортируем общий тип
import { UserDto, OrderDto } from "./models";
export function getUserOrders(userId: string): OrderDto[] {
// Возвращаем список заказов по id пользователя
return [];
}
// orderModule.ts
// Импортируем только OrderDto
import { OrderDto } from "./models";
export function createOrder(order: OrderDto): void {
// Создаем заказ
}
Что изменилось:
- общий код (типы) вынесен в нижележащий модуль models;
- userModule и orderModule зависят только от models;
- прямого цикла больше нет.
Внедрение зависимостей и инверсии зависимостей
Иногда цикл невозможно разорвать только переносом типов. Тогда помогает инверсия зависимостей (Dependency Inversion).
Идея: высокоуровневый модуль определяет интерфейс, а низкоуровневый реализует его. Но зависимость направлена не от high-level к low-level, а от обеих к интерфейсу.
Пример цикла сервисов
Допустим, у нас два сервиса:
- PaymentService вызывает NotificationService;
- NotificationService для формирования сообщения читает данные из PaymentService.
// paymentService.ts
import { notificationService } from "./notificationService";
export class PaymentService {
// Здесь мы вызываем уведомление
processPayment(paymentId: string) {
// Логика платежа
notificationService.sendPaymentSuccess(paymentId);
}
// Метод для получения деталей платежа
getPaymentInfo(paymentId: string) {
return { id: paymentId, amount: 100 };
}
}
export const paymentService = new PaymentService();
// notificationService.ts
import { paymentService } from "./paymentService";
export class NotificationService {
// Здесь мы обращаемся к paymentService
sendPaymentSuccess(paymentId: string) {
const info = paymentService.getPaymentInfo(paymentId);
// Отправляем уведомление с суммой
console.log("Платеж на сумму", info.amount, "прошел успешно");
}
}
export const notificationService = new NotificationService();
Здесь:
- paymentService импортирует notificationService,
- notificationService импортирует paymentService.
Решение: выделить интерфейс и использовать внедрение зависимости
Давайте инвертируем зависимость: NotificationService будет зависеть не от конкретного PaymentService, а от интерфейса PaymentInfoProvider.
// paymentTypes.ts
// Здесь мы описываем интерфейс для получения информации о платеже
// Тип информации о платеже
export interface PaymentInfo {
id: string;
amount: number;
}
// Интерфейс поставщика данных о платеже
export interface PaymentInfoProvider {
getPaymentInfo(paymentId: string): PaymentInfo;
}
// paymentService.ts
// Импортируем интерфейс
import { PaymentInfoProvider, PaymentInfo } from "./paymentTypes";
export class PaymentService implements PaymentInfoProvider {
// Реализация интерфейса
getPaymentInfo(paymentId: string): PaymentInfo {
// Возвращаем детали платежа
return { id: paymentId, amount: 100 };
}
// Метод обработки платежа
processPayment(paymentId: string, notify: (paymentId: string) => void) {
// Логика платежа
notify(paymentId); // Вызываем переданную функцию-уведомитель
}
}
// notificationService.ts
// Импортируем интерфейс
import { PaymentInfoProvider } from "./paymentTypes";
export class NotificationService {
private paymentInfoProvider: PaymentInfoProvider;
constructor(paymentInfoProvider: PaymentInfoProvider) {
// Внедряем зависимость через конструктор
this.paymentInfoProvider = paymentInfoProvider;
}
sendPaymentSuccess(paymentId: string) {
const info = this.paymentInfoProvider.getPaymentInfo(paymentId);
console.log("Платеж на сумму", info.amount, "прошел успешно");
}
}
// app.ts
// Здесь мы связываем реализации между собой
import { PaymentService } from "./paymentService";
import { NotificationService } from "./notificationService";
// Создаем экземпляры
const paymentService = new PaymentService();
// Внедряем PaymentService как PaymentInfoProvider
const notificationService = new NotificationService(paymentService);
// Передаем notificationService.sendPaymentSuccess как колбэк в PaymentService
paymentService.processPayment("p1", (id) =>
notificationService.sendPaymentSuccess(id)
);
Комментарии:
- общий интерфейс PaymentInfoProvider вынесен в отдельный модуль;
- NotificationService зависит только от интерфейса, а не от конкретного PaymentService;
- связывание реализаций происходит снаружи (в app.ts) — это и есть внедрение зависимостей;
- прямого цикла импортов больше нет.
Использование событий и посредников
Иногда циклическая зависимость появляется, когда два модуля должны “знать” о действиях друг друга. Тогда хорошо работает паттерн “события” (observer, event bus) или “посредник” (mediator).
Пример: UI и бизнес-логика
Представим:
- UI-модуль вызывает бизнес-логику (service),
- сервис напрямую вызывает UI, чтобы что-то показать или обновить.
Это создает цикл: UI → Service → UI.
Решение: использовать шину событий
Давайте введем простой eventBus.
// eventBus.ts
// Простой механизм подписки и публикации событий
type Handler = (payload: any) => void;
class EventBus {
// Здесь мы храним обработчики по имени события
private handlers: Record<string, Handler[]> = {};
on(eventName: string, handler: Handler) {
// Регистрируем обработчик события
if (!this.handlers[eventName]) {
this.handlers[eventName] = [];
}
this.handlers[eventName].push(handler);
}
emit(eventName: string, payload?: any) {
// Вызываем все обработчики для события
(this.handlers[eventName] || []).forEach((h) => h(payload));
}
}
export const eventBus = new EventBus();
// service.ts
// Импортируем только eventBus
import { eventBus } from "./eventBus";
export function processData(data: string) {
// Обрабатываем данные
const processed = data.toUpperCase();
// Публикуем событие об окончании обработки
eventBus.emit("dataProcessed", processed);
}
// ui.ts
// Импортируем eventBus и сервис
import { eventBus } from "./eventBus";
import { processData } from "./service";
// Подписываемся на событие
eventBus.on("dataProcessed", (result) => {
// Здесь мы обновляем UI
console.log("Обработанные данные:", result);
});
// Запускаем обработку
processData("hello");
Что здесь важно:
- service ничего не знает о UI;
- UI подписывается на события;
- зависимость направлена: UI → service, UI → eventBus, service → eventBus;
- цикла нет.
Разрыв циклов в ORM и моделях данных
На уровне доменной модели циклы могут быть допустимы, но важно уметь их контролировать.
Пример: двусторонние связи в ORM
Классический пример на ORM (например, JPA/Hibernate):
// Customer.java
// Здесь мы описываем клиента
@Entity
public class Customer {
@Id
private Long id;
// Двусторонняя связь с заказами
@OneToMany(mappedBy = "customer")
private List<Order> orders;
}
// Order.java
// Здесь мы описываем заказ
@Entity
public class Order {
@Id
private Long id;
// Обратная ссылка на клиента
@ManyToOne
private Customer customer;
}
Здесь:
- на уровне объектов есть цикл Customer → Order → Customer;
- на уровне БД — тоже (внешний ключ).
Проблемы при сериализации
Если вы попытаетесь сериализовать Customer в JSON “как есть”, библиотека может:
- рекурсивно пойти в orders,
- затем в каждом Order снова пойти в customer,
- и так далее до переполнения стека.
Варианты решения
Разорвать цикл на уровне DTO:
- использовать отдельные DTO для передачи данных наружу,
- передавать только id связанной сущности, а не целый объект.
Использовать аннотации/настройки сериализации:
- в Jackson — @JsonIgnore, @JsonManagedReference, @JsonBackReference,
- в других библиотеках — аналогичные механизмы.
Пример:
// CustomerDto.java
// DTO без обратной ссылки заказ -> клиент
public class CustomerDto {
public Long id;
public String name;
// Список id заказов вместо вложенных объектов
public List<Long> orderIds;
}
Здесь вы явно контролируете форму данных, разрывая циклы на уровне API.
Рефакторинг циклических зависимостей: практическая последовательность
Чтобы вам было проще, давайте я соберу все в пошаговую инструкцию.
Шаг 1. Найти циклы
- Запустите статический анализ или специальный инструмент (madge, ArchUnit и т.п.).
Получите список:
- какие файлы/модули образуют циклы;
- какие пакеты/сборки связаны взаимозависимостями.
Шаг 2. Понять природу этой связи
Для каждой пары/набора модулей ответьте:
- Зачем один модуль знает о другом?
- Какие сущности переиспользуются (типы, интерфейсы, модели, утилиты)?
- Есть ли тут скрытый “общий слой”, который можно вынести?
Шаг 3. Выбрать стратегию
Возможные варианты:
- Вынести общий код (типы, интерфейсы, утилиты) в отдельный модуль.
- Ввести интерфейс и инверсию зависимостей.
- Использовать события/посредников для декуплинга.
- Разделить модуль на два (например, интерфейсный и реализационный).
- Упростить модель данных (убрать двусторонние связи из DTO).
Шаг 4. Реализовать изменения итеративно
- Начните с самого маленького и понятного цикла.
Внесите изменения:
- создайте новый модуль/пакет,
- вынесите туда общие сущности,
- перепроверьте импорты.
Запустите тесты и анализ зависимостей снова.
Шаг 5. Зафиксировать архитектурные правила
Чтобы циклы не вернулись:
- добавьте статический анализ в CI-пайплайн,
- опишите правила зависимостей между слоями (например, в документации или в виде архитектурных тестов),
- договоритесь в команде: нижележащие модули никогда не зависят от верхних.
Практические советы по предотвращению циклических зависимостей
Всегда думайте о направленности зависимостей
Когда добавляете import или using:
- задайте себе вопрос: этот модуль действительно должен знать о том?
- не нарушает ли это идею “слоев” (UI → Application → Domain → Infrastructure)?
Разделяйте:
- доменную модель и транспортные DTO,
- интерфейсы и реализации,
- контракты и инфраструктуру.
Частое правило:
- интерфейсы и контракты лежат в более “верхнем” или отдельном модуле;
- реализации — в более “нижнем” модуле.
Избегайте “god-объектов” и “god-модулей”
Если есть модуль utils или common, который начинает импортировать всех подряд, он легко превращается в центр циклов. Стоит:
- разделять common по областям (common-domain, common-web, common-db),
- не позволять нижним модулям тянуться к верхним через “общий” пакет.
Используйте явный composition root
Composition root — место, где вы:
- создаете все объекты,
- связываете их зависимости.
Например, main-файл, стартовый модуль, конфигурация DI-контейнера. Тогда:
- модули не создают друг друга напрямую,
- а только описывают свои зависимости (через конструкторы, интерфейсы),
- циклы легче обнаружить и устранить.
Заключение
Циклические зависимости — это не просто “ошибка импорта”, а сигнал о проблемах в архитектуре: смешении ответственности, недостатке слоев, неявных контрактах. Они:
- усложняют компиляцию и загрузку модулей,
- повышают связность,
- затрудняют тестирование и рефакторинг,
- могут приводить к скрытым ошибкам во время выполнения.
Чтобы с ними работать, полезно:
- уметь находить циклы с помощью инструментов,
- понимать, на каком уровне возникла проблема (модули, классы, слои, сервисы),
- применять разные стратегии разрыва: вынесение общих сущностей, инверсию зависимостей, события, DTO, выделение интерфейсов.
Если вы будете планировать зависимости как направленный ациклический граф, использовать слои и явно разделять контракты и реализации, циклы перестанут возникать регулярно и превратятся в редкие исключения, которые легко отловить и исправить.
Частозадаваемые технические вопросы по теме и ответы
Как быстро проверить проект на наличие циклов без настройки сложных инструментов
Если у вас JavaScript или TypeScript, можно использовать madge. Установите утилиту глобально и запустите анализ:
npm install -g madge
# Анализирует директорию src и покажет только циклы
madge src/ --circular
Это даст список модулей, образующих циклы. Для небольших проектов этого уже достаточно, чтобы понять, где проблема.
Что делать, если DI-контейнер сообщает о циклической зависимости между сервисами
Сначала посмотрите, кто реально кому нужен. Обычно один сервис использует только часть функционала другого. Вынесите этот функционал в интерфейс или отдельный порт, разместите его в общем модуле и внедряйте зависимости через конструктор. Либо замените прямой вызов на событие или колбэк, который пробрасывается снаружи при инициализации.
Как разорвать цикл между микросервисами
Цикл между сервисами A и B часто означает, что у вас неправильные границы. Есть три варианта: 1) вынести общую функциональность в третий сервис C и направить зависимости к нему, 2) использовать асинхронное взаимодействие через очередь сообщений или шину событий вместо прямых REST вызовов, 3) пересмотреть распределение ответственности так, чтобы один сервис стал явным владельцем конкретного сценария и данных.
Можно ли оставить циклы в доменной модели если ORM с ними справляется
Технически — да, ORM обрабатывает двусторонние связи. Но лучше разрывать их на уровне внешних контрактов. Оставляйте циклы только внутри домена, контролируя их сериализацию и загрузку (lazy loading). Снаружи (в API, DTO, сообщениях) используйте однонаправленные ссылки и идентификаторы вместо вложенных объектов.
Как организовать модули в монорепозитории чтобы избежать циклов
Выделите несколько уровней пакетов, например core, domain, application, infrastructure, ui. Введите правило: зависимости разрешены только сверху вниз (ui → application → domain → core, ui → infrastructure, но не наоборот). Закрепите это через конфигурацию инструмента анализа зависимостей или архитектурные тесты, чтобы CI блокировал пул-реквесты, нарушающие иерархию.