1. Anti-pattern’ы в Spring Core
Anti-pattern — это не «ошибка компиляции» и не «так нельзя по спецификации». Это как ездить на машине с ручником: формально она едет, иногда даже быстро, но запах горелого и странные звуки появляются гарантированно. В Spring anti-pattern’ы особенно коварны, потому что фреймворк очень терпеливый: он многое позволяет, а расплачиваться за это вы будете позже — поддержкой и тестами.
Важно правильно прочувствовать, зачем этот разговор вообще нужен. В конце курса у вас уже есть рабочая mental model контейнера и рабочий ContextFlow, который демонстрирует хорошие практики. Если сейчас не проговорить «как оно ломается», вы очень быстро встретите ситуацию из реального мира: проект работает, релизы выходят, но любой небольшой рефакторинг вызывает эффект домино — и в какой-то момент команда начинает бояться трогать код. Это не драматизация: это буквально стандартная судьба проекта, где Spring превратился в магию.
Чтобы было проще ориентироваться, давайте зафиксируем небольшую «карту местности» anti-pattern’ов в виде таблицы. Это не список «всё запретить», а набор сигналов: если вы видите симптом, вы знаете, куда смотреть.
| Anti-pattern | Как выглядит в коде | Почему плохо | Здоровая альтернатива |
|---|---|---|---|
| Annotation soup | класс оброс аннотациями, но непонятна его роль | аннотации начинают скрывать дизайн вместо того, чтобы его выражать | разделить роли: service отдельно, config отдельно, listeners отдельно |
| Hidden dependencies | зависимости спрятаны в полях/статике/`getBean()` | невозможно понять контракт класса и сложно тестировать | constructor injection + явный wiring |
| Service locator / static context | бизнес-код ищет bean сам | это DI наоборот: IoC ломается | контейнер выбирает зависимости на старте, а не во время сценария |
| Mutable singleton | singleton-bean хранит изменяемое состояние | баги «из прошлого», flaky tests, нелогичное поведение | stateless services + краткоживущие объекты/сессии |
| Overused SpEL | конфигурация превращается в ребус в строках | читать и дебажить сложно, ошибки late-runtime | properties + обычный Java-код конфигурации |
| Event soup | основной сценарий спрятан в цепочке listeners | поток выполнения становится неявным | events только для side effects, orchestration — прямым кодом |
| Framework leakage | домен и application слой «говорят на Spring» | бизнес-логика становится зависимой от контейнера | Spring-специфику держим в config/support |
2. Annotation soup
Annotation soup обычно начинается невинно: «ну я добавлю ещё одну аннотацию, чтобы заработало». Потом ещё одну. Потом ещё. И однажды вы открываете класс — а он выглядит так, будто его наряжали всей командой на корпоративе: каждый повесил по игрушке. Проблема не в количестве аннотаций как таковом, а в том, что роль класса перестаёт быть ясной.
Самый неприятный эффект здесь в том, что аннотации начинают подменять архитектуру. Вместо того чтобы честно разделить обязанности (use-case сервис, конфигурация, инфраструктура, listener, аспект), мы делаем одного «универсального солдата» и пытаемся им закрыть все дырки. В ContextFlow мы как раз строили структуру domain / application / infrastructure / support / config, чтобы этого не случилось.
Вот пример «ёлки», где по коду уже неясно: это сервис? конфигурация? инфраструктура? часть профилей? (пример намеренно карикатурный — как мем, но такие мемы обычно основаны на реальных событиях):
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
@Service // класс одновременно объявлен как service (use-case слой?)
@Configuration // ...и как конфигурация (wiring слой?) — роли смешаны
@Profile("demo") // ...и ещё привязан к профилю, что делает поведение неочевидным
public class OrderPlacementService {
// Пустой класс тут намеренно: проблема именно в смешении ролей аннотациями.
}
Проблема даже не в том, что Spring «не любит» такую комбинацию. Проблема в вас через две недели: вы будете открывать класс и думать, почему use-case сервис вдруг стал конфигурацией и почему он живёт только в профиле demo. В итоге вместо объяснимой схемы получится «ну оно так исторически сложилось».
Здоровая версия выглядит скучнее, но скука здесь — комплимент. Сервис остаётся сервисом, а профили и wiring живут в конфигурации:
import org.springframework.stereotype.Service;
@Service // Одна роль: use-case / application service
public class OrderPlacementService {
// use-case orchestration, без трюков
}
А profile-specific решения — там, где им место: в config.profiles (или в @Bean-методах с @Profile). Такой подход делает проект читабельным: вы понимаете, что меняется при переключении профиля, и где именно это описано. И если очень хочется «повесить ещё одну аннотацию» — это хороший внутренний сигнал остановиться и спросить: «а не смешал ли я роли?».
3. Hidden dependencies
Скрытые зависимости — это как скрытая проводка в стене: пока вы не пытаетесь просверлить дырку, всё выглядит отлично. Но как только начинается рефакторинг, тестирование или просто перенос логики в другой модуль — вы вдруг обнаруживаете, что класс не может жить без контейнера, а его реальная форма «контракта» спрятана где-то внутри. Spring может подставить зависимости почти как угодно, но ваша задача — не прятать их от человека.
Самая классическая форма hidden dependencies — field injection. Мы уже обсуждали её раньше, а сейчас просто фиксируем как анти-паттерн, который очень легко «подцепить», потому что он короткий. Короткий — да. Хороший — нет.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
@Autowired
private NotificationSender sender; // Зависимость спрятана в поле: снаружи не видно контракта класса.
// В тестах такой объект сложно создать "по-человечески" без Spring/рефлексии.
}
Почему это плохо ощущается на практике? Потому что по классу не видно, что ему нужно для работы. Вы не можете создать объект «как обычный Java-объект» в тесте без рефлексии, а IDE не помогает вам понять, какие зависимости обязательны. Плюс появляется соблазн добавлять новые зависимости «по тихому» — просто ещё одно поле, и готово.
Нормальный вариант для ContextFlow — constructor injection. Он делает зависимости частью публичного контракта класса:
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final NotificationSender sender; // Явно фиксируем обязательную зависимость.
public NotificationDispatchService(NotificationSender sender) {
// Конструктор показывает контракт: без sender этот сервис не работает.
this.sender = sender;
}
}
Да, тут больше строк. Но это те строки, которые экономят вам часы в будущем. И что приятно: Spring в таком стиле работает максимально предсказуемо. Если зависимости не хватает — вы падаете на старте (fail-fast), а не где-то в середине сценария, когда пользователь уже успел получить пол-результата.
4. Service locator и static context
Если constructor injection — это «класс получает готовые зависимости», то service locator — это «класс сам бегает и ищет, что ему нужно». То есть DI переворачивается назад, а IoC превращается в I-am-in-control (и да, это звучит как название токсичного трека). В Spring это обычно проявляется двумя способами: прямой applicationContext.getBean(...) в бизнес-коде или статический holder контекста, чтобы можно было дергать контейнер откуда угодно.
Вот типичный «не делайте так» пример из бизнес-класса:
public class OrderPlacementService {
public void place(String message) {
// Бизнес-код сам лезет в контейнер: зависимость не видна в конструкторе.
NotificationSender sender =
StaticContextHolder.getBean(NotificationSender.class);
// Тестировать и читать такой код больно: нужен Spring-контекст даже для простого сценария.
sender.send(message);
}
}
На первый взгляд — удобно. На второй — у вас ломается половина курса. Вы только что сделали зависимость неявной, спрятали её от конструктора, а ещё привязали бизнес-логику к контейнеру. Такой код трудно тестировать, трудно читать и почти невозможно гарантировать, что он будет вести себя одинаково в разных профилях.
Чтобы было совсем понятно, как возникает «статический доступ», вот минимальный пример holder-а (его легко написать за минуту, поэтому он так часто появляется в реальных проектах — и так же часто становится проблемой):
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class StaticContextHolder implements ApplicationContextAware {
// Глобальное состояние: один раз записали — и оно живёт "везде" и "всегда".
private static ApplicationContext context;
public void setApplicationContext(ApplicationContext c) {
// Контекст устанавливается при инициализации Spring.
// В тестах/нескольких контекстах это легко приводит к перезаписи и неожиданностям.
context = c;
}
public static <T> T getBean(Class<T> type) {
// Фактически это Service Locator: любой код может запросить зависимость "по месту".
return context.getBean(type);
}
}
Проблема здесь даже не в «статике» как таковой. Проблема в том, что вы получаете глобальное состояние с неявным временем жизни. В тестах один контекст может перезаписать другой, разные профили начнут конфликтовать, а порядок инициализации начинает иметь значение. И самое неприятное: код выглядит «обычным Java-кодом», но на самом деле он зависит от того, поднят ли Spring вообще.
Правильное лекарство обычно простое и скучное: вернуться к DI и сделать зависимость явной. Если нужно выбирать реализацию динамически, это тоже решается DI-инструментами (например, @Qualifier, @Primary, Map<String, T> injection), а не поиском по контейнеру во время выполнения.
5. Mutable singleton
Mutable singleton-bean — это когда сервис тихо запоминает что-то между вызовами. Иногда это делается «ради удобства», иногда — «ради кэша», а иногда просто потому, что в классе появился new ArrayList<>(), и никто не задал вопрос: «а сколько живёт этот объект?». В Spring по умолчанию большинство beans — singleton scope, то есть живут столько же, сколько живёт ApplicationContext. А это обычно весь запуск приложения.
Вот учебный пример буфера, который выглядит невинно, но легко превращается в ловушку:
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service // Важно помнить: по умолчанию это singleton на весь ApplicationContext.
public class ReportBuffer {
// Это изменяемое состояние, которое будет жить между вызовами и тестами.
private final List<String> lines = new ArrayList<>();
public void add(String line) {
// Каждое добавление меняет состояние singleton-bean.
lines.add(line);
}
public List<String> snapshot() {
// Копию отдаём безопасно, но источник проблемы остаётся: состояние копится.
return List.copyOf(lines);
}
}
Если такой буфер внедрить в ReportingService и вызывать генерацию отчёта несколько раз за один запуск, вы можете получить «призраков прошлого»: второй отчёт содержит строки из первого. В тестах это проявляется ещё веселее: тест A что-то добавил, тест B внезапно увидел эти данные и упал, а вы потом час ищете «почему тесты flaky». Это то самое чувство, когда код «иногда работает», а вы начинаете подозревать фазу Луны.
Здоровый подход для ContextFlow обычно один из двух. Либо состояние живёт в method-local структурах, то есть создаётся на каждый вызов:
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class ReportingService {
public List<String> buildReportLines() {
// Состояние создаётся на каждый вызов: между вызовами ничего не "залипает".
List<String> lines = new ArrayList<>();
lines.add("HEADER");
return List.copyOf(lines);
}
}
Либо, если вам нужен объект-сессия (мы это уже проходили в теме scopes), делаете короткоживущую сессию и получаете её через provider, а не храните состояние в singleton-сервисе:
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
@Service
public class ReportingService {
// Provider позволяет каждый раз получать новый экземпляр "сессии" генерации.
private final ObjectProvider<ReportGenerationSession> sessions;
public ReportingService(ObjectProvider<ReportGenerationSession> sessions) {
this.sessions = sessions;
}
public void generateDailyReport() {
// Важная мысль: состояние лежит внутри ReportGenerationSession, а не в singleton-сервисе.
sessions.getObject().run();
}
}
Смысл один: singleton-сервисы в application.service по умолчанию должны быть stateless, иначе проект становится непредсказуемым. И да, даже в non-web приложении. «Но у нас же один поток!» — звучит убедительно ровно до тех пор, пока вы не начнёте запускать несколько сценариев подряд или параллельно гонять тесты.
6. Overused SpEL
SpEL (Spring Expression Language) — полезная штука в небольших дозах. Как перец чили: иногда делает блюдо лучше, но если вы высыпали туда половину банки, вы уже не едите суп, вы проходите испытание. Anti-pattern начинается там, где SpEL используется как «язык программирования внутри строк», и важные решения становятся нечитаемыми и плохо тестируемыми.
Вот пример, который выглядит «умно», но на практике быстро превращается в головоломку:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ReportOutputSettings {
// Логика вычисления пути спрятана в строке:
// смешаны system properties, placeholders и конкатенация.
@Value("#{systemProperties['user.home'] + '/' + '${contextflow.app-name}' + '/reports'}")
private String outputDir;
}
Почему это плохо? Во-первых, вы смешали сразу несколько источников: system properties и placeholders. Во-вторых, вы спрятали логику вычисления пути в строку, которую IDE почти не рефакторит и не проверяет. В-третьих, ошибки проявятся поздно: строка может быть некорректной, ключ может отсутствовать, user.home может быть неожиданным — и вы узнаете об этом в рантайме.
Для ContextFlow здоровая альтернатива обычно выглядит так: пусть properties хранят значение, а преобразование и нормализация делается в обычном Java-коде конфигурации. Например, вы храните contextflow.report.output-dir как строку, а в конфиге превращаете в Path:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.file.Path;
@Configuration
public class ReportingPathsConfig {
@Bean
Path reportOutputDir(@Value("${contextflow.report.output-dir}") String dir) {
// Вся "логика" теперь в обычном коде:
// легко дебажить, тестировать и рефакторить.
return Path.of(dir);
}
}
Это читается, дебажится, тестируется и нормально рефакторится. И главное: вы не создаёте «магическую строку», смысл которой понятен только автору (и то первые два дня). SpEL остаётся инструментом «точечно помочь», а не способом спрятать половину конфигурационной логики в аннотации.
7. Event soup
Application events — классная штука, но у них есть одна суперспособность: ими очень легко злоупотребить. Event soup возникает, когда вы начинаете делать событиями всё подряд, и в какой-то момент основной сценарий приложения перестаёт быть читаемым без обхода десяти listeners. Это выглядит как «гибкая архитектура», а ощущается как «мне страшно нажимать Run без отладчика».
Чтобы прочувствовать разницу, полезно увидеть две схемы. Сначала — здоровая модель для ContextFlow, где use-case сервис делает основную работу, а события — только для side effects:
flowchart TD
A[OrderPlacementService.placeOrder] --> B[OrderStore.save]
A --> C[OrderPricingService.calculate]
A --> D[publish OrderCreatedEvent]
D --> E[Audit listener]
D --> F[Notification listener]
D --> G[Statistics listener]
А теперь — event soup, где use-case сервис превращается в «почтовый ящик», а логика расползается по listeners:
flowchart TD
A[OrderPlacementService.placeOrder] --> D[publish CreateOrderCommand event]
D --> B["Listener #1: save order"]
D --> C["Listener #2: calculate price"]
D --> E["Listener #3: audit"]
D --> F["Listener #4: notify"]
На бумаге «всё модульно». В жизни — вы потеряли точку, где сценарий действительно контролируется. Особенно неприятно становится, когда кто-то добавляет ещё один listener, меняет @Order, добавляет condition, и всё начинает зависеть от порядка и условий.
Вот карикатурный, но очень узнаваемый код, который ведёт в event soup:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
// Зависимость на publisher сама по себе нормальная.
this.publisher = publisher;
}
public void place(CreateOrderCommand cmd) {
// Плохой сигнал: публикуем "команду" как событие и ждём, что listeners сделают основную работу.
publisher.publishEvent(cmd); // "пусть кто-нибудь сделает заказ"
}
}
Сервис больше не «place order». Он «попросить вселенную сделать order». А кто именно сделает, как именно, и что будет, если один listener упадёт — всё это перестаёт быть очевидным.
Здоровая версия остаётся простой: сервис выполняет обязательные шаги (id, store, pricing, статус), а потом публикует событие как сигнал «заказ создан» для побочных эффектов:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
private final ApplicationEventPublisher publisher;
public OrderPlacementService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void place(String orderId) {
// Обязательная orchestration здесь (save, price, status...)
// Событие — уведомление о факте: заказ создан.
publisher.publishEvent(new OrderCreatedEvent(orderId));
}
}
Правило, которое хорошо работает в голове: event — это “уведомление о факте”, а не “просьба выполнить основную работу”. Это удерживает архитектуру от превращения в кашу.
8. Framework leakage
Framework leakage — это когда бизнес-код и доменная часть начинают зависеть от Spring-специфики сильнее, чем от собственных контрактов. Обычно это проявляется не сразу, а как серия «маленьких удобств»: тут Environment прочитаю, тут ApplicationContext подержу, тут Aware реализую «для диагностики», а потом внезапно выясняется, что без контейнера вы вообще не можете понять, как работает use-case.
Классический маркер leakage — Aware в application/service слое. Например, сервис внезапно начинает реализовывать EnvironmentAware:
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService implements EnvironmentAware {
// Spring-инфраструктура "протекает" в бизнес-сервис: появляется зависимость на Environment.
private Environment env;
public void setEnvironment(Environment environment) {
// Теперь сервис потенциально может принимать runtime-решения на основе окружения.
// Это делает поведение менее предсказуемым и усложняет тестирование.
this.env = environment;
}
}
Само по себе Spring это проглотит. Но архитектурно вы сделали так, что сервису нужен «контекст окружения», а значит он начинает принимать runtime-решения внутри бизнес-методов: какой канал уведомлений выбрать, куда писать, как форматировать… В итоге вы возвращаетесь к той же проблеме, от которой спасались профилями и конфигурацией: вариативность «внутри кода» вместо вариативности «в сборке контейнера».
В ContextFlow мы специально фиксировали дисциплину: бизнес-сервисы не реализуют Aware, а если нужен diagnostic bean — он живёт отдельно в support.diagnostics. И если сервису нужна настройка, она приходит как обычная зависимость или типизированное значение, а не как «пульт управления контейнером».
Очень похожий leakage — попытка лечить self-invocation проблему AOP «трюками» вместо рефакторинга, например через доступ к прокси изнутри. Мы это уже обсуждали в AOP-части: технически можно, но как привычка это превращается в «магический Spring-стиль», который сложно сопровождать. Сильный базовый принцип курса сохраняется: сначала исправляем дизайн, потом применяем механики Spring, а не наоборот.
9. Диагностика anti-pattern’ов
Когда вы видите проект впервые (или свой же код через месяц), вам нужно быстро понять: тут Spring используется как инженерный инструмент или как набор заклинаний. Хорошая новость: anti-pattern’ы обычно оставляют заметные следы. Плохая новость: мозг быстро привыкает к «ну оно же работает». Поэтому полезно иметь несколько простых вопросов, которые вы задаёте коду.
Первый вопрос звучит почти по-детски: «Если я открою класс, я пойму, что ему нужно для работы?» Если зависимости не видны в конструкторе, если внутри getBean(), если где-то статический holder — вы на территории hidden dependencies. Второй вопрос: «Могу ли я прочитать основной сценарий создания заказа в одном месте?» Если нет, и вы вынуждены прыгать по listeners, аспектам и post-processors, чтобы понять, что реально происходит, — пахнет event soup или чрезмерной “магией”.
Третий вопрос: «Этот класс — про бизнес или про Spring?» Если это business, но он реализует Aware, дергает Environment, читает ресурсы напрямую, хранит ссылки на контекст — это leakage. Четвёртый вопрос: «Где лежит вариативность?» Если она описана в конфигурации (profiles, разные beans) — хорошо. Если вариативность в if (profile == ...) внутри сервиса — вы снова сделали manual wiring, просто в более дорогой обёртке.
Чтобы закрепить, вот короткая табличка-диагност:
| Сигнал в коде | Вероятный anti-pattern | Что обычно сделать |
|---|---|---|
| @Autowired на поле | hidden dependencies | перевести на constructor injection |
| ApplicationContext.getBean() в сервисе | service locator | передать зависимость через DI, использовать @Qualifier/Map |
| статический context | static context access | удалить holder, перестроить wiring |
| List/Map в singleton-сервисе как состояние | mutable singleton | перенести состояние в method-local или session/prototype |
| сложный @Value("#{...}") | overused SpEL | вынести в properties + Java config |
| события «делают всё» | event soup | вернуть orchestration в сервис, events оставить для side effects |
| Aware в application/domain | framework leakage | изолировать в support.*, сервисам дать обычные зависимости |
Если вы держите эту таблицу в голове, code review становится проще: вы не спорите «нравится/не нравится», вы обсуждаете наблюдаемые признаки и последствия. Это особенно полезно для junior-уровня: вместо вкусовщины появляется понятная инженерная логика.
10. Типичные ошибки при работе со Spring Core
В конце курса легко попасть в ловушку: увидеть anti-pattern, кивнуть «ага, понял», и через неделю повторить его в своём проекте, потому что «так быстрее». Поэтому полезно зафиксировать несколько типичных ошибок именно в формате поведения, а не терминов. Речь не про то, что «так делать нельзя», а про то, что так делать почти всегда заканчивается одинаково: код становится непрозрачным, а исправления — дорогими.
Ошибка №1: воспринимать anti-pattern как “редкий случай”, который бывает только у плохих разработчиков.
На практике anti-pattern’ы появляются у нормальных людей под давлением времени: «срочно надо демо», «не успеваем», «потом поправим». Проблема в том, что “потом” часто не наступает, а код уже стал частью системы. Лечится это привычкой задавать себе два вопроса: видно ли зависимости и читается ли основной сценарий без прыжков.
Ошибка №2: лечить архитектурный запах ещё одной аннотацией.
Если класс непонятен, добавление @Lazy, @DependsOn, @Order, condition и прочих украшений редко делает его понятнее. Обычно это сигнал, что роль класса размыта или границы ответственности поплыли. Починка часто скучная: разделить класс на два, вынести wiring в конфигурацию, убрать “универсального солдата”.
Ошибка №3: “разрешить себе” service locator, потому что “так иногда можно”.
Да, есть случаи, когда ObjectProvider уместен в инфраструктуре. Но как только вы разрешили getBean() в бизнес-коде, вы получаете утечку стиля на весь проект. Через месяц ApplicationContext начнёт появляться везде “для удобства”, а DI превратится в декорацию. Лучше держать границу жёстко: business code зависимости не ищет, он их получает.
Ошибка №4: хранить состояние в singleton-bean, потому что “мы же просто аккуратно почистим список”.
Это одна из самых частых причин странных багов и flaky tests. “Аккуратно почистим” обычно превращается в “забыли почистить в одном из ранних return”. В ContextFlow мы сознательно показывали: state живёт либо в краткоживущих объектах (session/prototype), либо в локальных переменных метода. Singleton-сервисы — stateless, как швейцарские банки (по крайней мере в теории).
Ошибка №5: превращать events в главный способ писать бизнес-логику.
События нужны, чтобы увести side effects и уменьшить связность. Но если вы сделали event-ами “создание заказа” как процесс, а не как факт, вы потеряли orchestration и сделали систему неявной. Внутри одного приложения это особенно обидно: вы буквально могли написать прямой код, но решили сделать себе квест.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ