Каждый java разработчик который работал с достаточно нетривиальным проектом на spring рано или поздно сталкивался с подобным логом при старте приложения.


***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  service1 defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\Service1.class]
↑     ↓
|  service3 defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\Service3.class]
↑     ↓
|  service2 defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\Service2.class]
└─────┘


Action:

Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.

Разберемся откуда это берется и что с этим делать на примере эволюции простенького сервиса.

комментарий от КЭПа про суть проблемы

Бины создаются в 2 этапа
1. создать объект
2. внедрить зависимости
(настройки и прочее опущу)

Проблема справедлива для инжекта в конструктор (целевой способ), потому что оба этапа объединятся и ситуация становится патовой.
Если шаги разделены, то проблем нет, так как связи можно делать сколь угодно сложными и запутанными, после того как все объекты созданы.

Но способ с инжектом в конструктор является целевым ни просто так по нескольким причинам
1. снижение зависимости от фреймворка
а. не нужны приключения с рефлексией при инжекте в поле, и любой объект можно ез проблем создать руками
б. не нужно делать специальных методов при инжекте через сеттер
2. снижаются возможности к получению невалидного объекта (который на пол пути создания)
3. из коробки работает контроль циклических зависимостей
4. сама система заставляет нас думать об архитектуре сразу, а не когда будет уже поздно

Рецепт 1: кладем на декомпозицию и SOLID (а именно Single responsibility)

Изначально у нас есть быстро сделанные сервисы
1. UserService - отвечает за все что касается пользователей
2. NosificationService - отвечает за отправку сообщений пользователям
3. NotificationService - просто отправляет письма на email

@Service
public class UserService {

    /**
     * Такая незамысловатая логики выписывания токенов
     */
    public String login(UUID id) {
        String token = "token " + getInfo(id)
        tokenRepo.put(id, token);
        return token;
    }

    public User getInfo(UUID id) {
        return new User(); // логика поиска
    }
}

@Service
public class TokenRepo {
    
    public void put (UUID userId, String token) {
        // локига сохранения
    }
}

@Service
@RequiredArgsConstructor
public class NotificationService {

    private final UserService userService;
    private final NotificationSender notificationSender;

    public void send(String message, UUID userId) {
        User user = userService.getInfo(userId);
        notificationSender.send(message, user.getEmail());
    }

}

@Service
public class NotificationSender {

    public void send(String message, String email) {
        // логика отправки сообщения
    }
}

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

@Service
@RequiredArgsConstructor
public class UserService {

    private final NotificationService notificationService; // новая зависимость
    
    /**
     * Такая незамысловатая логики выписывания токенов
     */
    public String login(UUID id) {
        String token = "token " + getInfo(id);
        notificationService.send("login ", id); // новая логика
        return token;
    }

    public User getInfo(UUID id) {
        return new User(); // логика поиска
    }
}

Циклическая зависимость в самом чистом виде и ошибка поднятия контекста.

***************************
APPLICATION FAILED TO START
***************************

Description:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  notificationService defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\bad\NotificationService.class]
↑     ↓
|  userService defined in file [C:\Users\d.starakojev\IdeaProjects\demo\target\classes\com\example\demo\bad\UserService.class]
└─────┘

Конечно же в боевом коде эта цепочка может достигать десятков звеньев, но способ лечения простой: ищем того, кто на себя слишком много взял и делим на 2 части. В нашем случае из UserService выделяем AuthService, а в самом UserService оставляем только ответственность за доступ к информации о пользователях

@Service
@RequiredArgsConstructor
public class AuthService {

    private final NotificationService notificationService;
    private final UserService userService;

    /**
     * Такая незамысловатая логики выписывания токенов
     */
    public String login(UUID id) {
        String token = "token " + userService.getInfo(id);
        notificationService.send("login ", id);
        return token;
    }
}


@Service
@RequiredArgsConstructor
public class UserService {

    public User getInfo(UUID id) {
        return new User(); // логика поиска
    }
}

Как показывает практика, следования этому принципу достаточно, чтобы не сталкивать с циклическими зависимостями очень долго.

Рецепт 2: просто жди ответ

На самом деле приложений без циклических зависимостей не существует, такие особенности просто хорошо заметены под ковер (в JVM и фреймворки).
Например: Любая функция с возвращаемым значением, автоматически создает у нас циклическую зависимость.

public static void main(String[] args) {
    String foo = method();
    System.out.println(foo);
}

public static String method() {
    return "Hello";
}

Если нарисовать только "компоненты" и их зависимости в классическом понимании, когда один компонент знает адрес другого, то все выглядит складно

Но если на эту же схему наложить движение данных и управляющих сигналов, то тайное становится явным.

Почему это не становится проблемой?
Потому что на самом деле все исполняется примерно как на картинке ниже.
Наши "компоненты" друг с другом не общаются и друг о друге ничего не знают, всю работу делает JVM, которая позволяет нам игнорировать этот аспект, и все таки рассматривать методы как функциональные единицы с зависимостями.

Рецепт 3: просто добавь асинхрон

Разовьем пример выше, мы теперь хотим не просто нотификацию, а поддержать обратную связь, например если пользователь напишет со своего email, что это не он, тогда мы должны его разлогинить.
Такие задачи уже выходят из зоны ответственности JVM, и она за нас ничего не решит. Придется выкручиваться самостоятельно.
Вариантов реализации много, и функционально все будут работать правильно, но нам этого не достаточно, нужно чтобы решение было устойчивым, гибким и понятным, а именно чтобы сохранялась инкапсуляция и разделение ответственности.

  1. В AuthService добавляем метод unLogin, потому что этот сервис логинит - ему и разлогинивать

  2. Добавляем NotificationResponseListener, потому что принимать письма это принципиально новая работа для нашей системы

  3. В NotificationService добавляем логику сопоставления запроса с ответом, потому что он формирует запрос. Так же для логично и привычно, чтобы получать ответ в том же месте, куда его отправили.
    Дополнительно нам понадобится хранить отправленные сообщения, по этому добавляем класс MessageRepo (без этого никак не провести сопоставление)


@Service
@RequiredArgsConstructor
public class AuthService {

    private final NotificationService notificationService;
    private final UserService userService;
    private final TokenRepo tokenRepo;

    public String login(UUID id) {
        String token = "token " + userService.getInfo(id);
        tokenRepo.put(id, token);
        notificationService.send("login ", id);
        return token;
    }

    public void unLogin(UUID id) {
        tokenRepo.remove(id);
    }
}

@Service
@RequiredArgsConstructor
public class NotificationResponseListener {

    private final NotificationService notificationService;

    public void onMessage(Response response) {
        notificationService.handleResponse(response);
    }
}

@Service
@RequiredArgsConstructor
public class NotificationService {

    private final UserService userService;
    private final MessageRepo messageRepo;
    private final NotificationSender notificationSender;

    public void send(String messagePayload, UUID userId) {
        User user = userService.getInfo(userId);
        Message message = new Message(UUID.randomUUID(), messagePayload, user.getEmail());
        messageRepo.save(message);
        notificationSender.send(message);
    }

    public void handleResponse(Response response) {
        RequestResponseBundle bundle = match(response);
        // вызвать логику разлогина
    }

    private RequestResponseBundle match(Response response) {
        Message sourceMessage = messageRepo.getMessage(response.getSourceMessageId());
        return new RequestResponseBundle(sourceMessage, response);
    }
}

А теперь вся соль в том, как вызвать логику разлогина?
Если притащим в NotificationService AuthService, снова получим циклическую зависимость,
да и не положено NotificationService знать про авторизацию.

Тут на самом деле остается 2 варианта, вполне рабочих

  1. Добавить параметром Callback при отправке сообщения, и вызывать его в случае если пришел негативный ответ от пользователя.

  2. Реализовать генерацию события о том что пришел негативный ответ от пользователя
    (тут относительно первого способа появляется необходимость в регистрации слушателя и связь становится неявной, но зато событие могут слушать и другие сервисы)

И в том и в другом случае появляется необходимость в новом компоненте, который будет получать событие от NotificationService и вызывать нужный метод у AuthService.
Но какой бы способ мы не выбрали, циклическая зависимость никуда не денется.

Добавляем EventManager и EvenListenerRegistrar
Добавляем EventManager и EvenListenerRegistrar

Она просто заметается под явление "позднее связывание", зеленая стрелка появляется не на этапе компиляции, а уже в рантайме.
В то же время EventListenerRegistrar можно после страта контекста выбрасывать в помойку за ненадобностью.
И это не плохо, так устроен мир)

P.S. Отдельная история как эти события сделать персистентными и не терять при рестартах приложения.

Экстра рецепт: Просто залезь в кишки

Тут важно начать с того, что приложение это не просто автомобиль, который мы купили/сделали а дальше остается только кататься да чинить.
Приложение это еще и сырье, и чертеж, и завод (и завод для завода), и дорога.
На разных этапах своего жизненного цикла оно принимает разные формы с совершенно разной архитектурой.

Просто посмотрим на псевдокодный жизненный цикл spring boot приложения:

  1. Притянуть библиотеки

  2. Скомпилировать исходники

  3. Упаковать образ

  4. Запустить JVM

  5. Поднять окружение Java

  6. Запустить ядро спринга

  7. Прочитать конфигрурации фабрики

  8. Построить фабрику

  9. Прочитать конфигурации контекста

  10. Построить контекст

  11. Настроить контекст

  12. Установить соединения с интеграциями

  13. Донастроить контекст

  14. Приступить к выполнению полезной работы

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

Больше стрелок богу стрелок

Добавим детализации.
Добавляем Mailbox - в нашем случае как внешнюю систему, в которой пользователь работает с письмами.
Добавим красных стрелок, которые будут отражать направление движения данных в тех случаях, когда оно не совпадает с направлением владельца связи.

Соль в том, что белую стрелку, которая представляет собой "зависимость по контракту", мы можем развернуть, а вот направление движения данных никак.
Для примера, развернем белую стрелку между NotificationService и UserService при помощи DependencyInjection и динамического скоупа.
Теперь NotificationService ни сам забирает данные о пользователе, а к моменту вызова они у него уже есть. Задача в том, чтобы NotificationService даже не догадывался о том, откуда берутся данные о пользователе.

public class AuthService {

    private final NotificationService notificationService;
    private final ActiveUserInjector activeUserInjector; // новая зависимость
    private final UserService userService;
    private final TokenRepo tokenRepo;

    public String login(UUID id) {
        String token = "token " + userService.getInfo(id);
        tokenRepo.put(id, token);
        activeUserInjector.injectActiveUser(id); // новая логика
        notificationService.sendToActiveUser("login ");
        return token;
    }
  }

public class ActiveUserInjector {

    private final UserService userService;
    private final NotificationService notificationService;

    public void injectActiveUser(UUID id) {
        notificationService.setActiveUser(userService.getInfo(id));
    }
}

public class NotificationService {

    private final ThreadLocal<User> activeUser = new ThreadLocal<>(); // меняем UserService на юзера
    private final MessageRepo messageRepo;
    private final NotificationSender notificationSender;

    public void sendToActiveUser(String messagePayload) {
        Message message = new Message(UUID.randomUUID(), messagePayload, activeUser.get().getEmail());
        messageRepo.save(message);
        notificationSender.send(message);
    }

    public void setActiveUser(User user) { // добавляем для упрщения инжекта
        activeUser.set(user);
    }
}

Конечно, реализация носит исключительно концептуальный характер для демонстрации того, что стрелки "отношений" можно вертеть как угодно, но направление движения данных осталось неизменным.

Так же подсвечу, что то, что мы сделали с разворотом стрелки, это не инверсия зависимостей по канонам SOLID.
Мы физически убрали зависимость NotificationService от UserService, SOLID же говорит о том, что не стоит завязывать на реализацию зависимости. Достигается это за счет выделения интерфейса. Стрелки зависимости по контракту разворачиваются автоматически.

Таким образом мы подошли с новому виду стрелки "направления передачи управления".
И в данном случае все они смотрят в разные стороны.
1. белая - зависимость по контракту
2. красная - направление движения данных
3. фиолетовая - направления передачи управления
(если красная или фиолетовая стрелка совпадает с белой, она не отрисовывается, дабы не загромождать диаграмму)

Выводы

  1. Архитектура сервиса не статична в течение жизненного цикла.

  2. Зависимость - это явление, которое находится в состоянии суперпозиции, и то как она выглядит, зависит от того с какой точки зрения и в какое время мы смотрим.

  3. На один и тот же процесс или сервис можно нарисовать множество диаграмм, и нужно четко понимать что, для кого и с какой целью мы рисуем

  4. Циклически зависимости по своей сути не зло с которым нужно бороться любой ценой, а неотъемлемая часть устройства мира, которую нужно готовить осознанно

  5. Многие привычные нам понятия вроде методов, если достаточно глубоко капнуть, взаимодействуют друг с другом совершенно иначе чем чем нам удобно думать.

Я сюда ни философствовать пришел, что делать?

  1. Поддерживать баланс между разделением ответственности и инкапсуляцией логики

  2. Внимательно относиться к асинхрону и колбэкам - это та область где циклическая зависимость является нормой, но готовить красиво придется самим

  3. При получении ошибки поднятия контекста из-за циклической зависимости, нужно методично пройтись по всей цепочке
    а. сделать позднее связывание (инжект через setter или в поле) там где есть асинхрон
    б. распилить толстый класс на несколько там где ответственность расплывается
    в. в случае проблем в кишках, главное не подгонять бизнес логику под фрейворк, а зависимости вероятно прийдется доставать руками, рекомендуемым способом на том этапе где вы пытаетесь влезть

Комментарии (6)


  1. Farongy
    05.06.2025 10:43

    1. Добавляем NotificationResponseListener, потому что принимать письма это принципиально новая работа для нашей системы

    2. В NotificationService добавляем логику сопоставления запроса с ответом, потому что он формирует запрос. Так же для логично и привычно, чтобы получать ответ в том же месте, куда его отправили.

    Ну привычно допустим - каждому своё. А логично то почему? Особенно если "принимать письма это принципиально новая работа для нашей системы".

    Почему NotificationResponseListener не может заинджектить AuthService и разлогинить?


    1. dstarakozhev Автор
      05.06.2025 10:43

      Принципиально новая логика, потому что это интеграционное взаимодействие, в рамках которого нужно тянуть данные с почтового ящика. Это полноценная отдельная сложная работа, которую нельзя просто так вмазать в то что уже есть, нужно изолировать.

      Привычно и логично, потому что по своей сути это вызов функции с возвращаемым значением, и хотелось бы чтобы это так и выглядело (как вызов метода, для этого и был дан "рецепт 2"), независимо от того как оно устроено внутри. Не должно быть распадания на 2 независимых потока (исходящий и входящий).

      NotificationResponseListener  не имеет полномочий принимать решение о том что нужно разлогинить, это ответственность AuthService.
      NotificationResponseListener  только обеспечивает входной трафик, не вдаваясь в суть входящих сообщений.


      1. Farongy
        05.06.2025 10:43

        Не должно быть распадания на 2 независимых потока (исходящий и входящий).

        Это как? Сама физика мира разделяет на входящий и исходящий потоки.


        1. dstarakozhev Автор
          05.06.2025 10:43

          Да, физически это 2 отдельных потока, но в этом и смысл дизайна, чтобы не обременять AuthService особенностями физики, а дать понятную абстракцию "запрос-ответ-реакция".
          Вы же если в магазине заказываете товар которого нет в наличии приходите забирать его в этот же магазин, а не едете на склад.
          Да, поездка на склад вполне рабочий вариант, но будет не очень удобно


          1. Farongy
            05.06.2025 10:43

            Причём здесь AuthService? Я спрашивал зачем вы пытаетесь процесс "разлогинить пользователя" запихать в нотификации?

            Если у вас появится нотификация "предложить юзеру участие в акции". Вы пользователя к акции тоже будете в сервисе нотификаций подключать?


            1. dstarakozhev Автор
              05.06.2025 10:43

              Не, ни в коем случае я этого не пытаюсь сделать,
              задача NotificztionService соединить запрос с ответом и отдать событие о том, что ответ получен сервисуу который генерировал запрос. В нашем случае AuthService, а уже его задача принять решение о разлогине.

              Согласен что в примере не однозначно отразил идею, подумаю как скорректировать. Хотя я явно прописал, что в NotificationService нельзя явно вызывать логику разлогина