1. Anti-pattern: проект стартует, но плохой
Anti-pattern — это не “код не по красоте” и не “я бы назвал переменную иначе”. Это повторяющееся решение, которое сначала даёт ощущение прогресса, а потом системно делает проект менее объяснимым, менее предсказуемым и более хрупким. У Spring Boot есть сильная сторона — sensible defaults, auto-configuration, удобная сборка проекта. Но ровно эти удобства легко испортить неправильными привычками.
В этом курсе мы постоянно держали мысль: catalog-service — это Boot-template, то есть маленький, но правильный сервис-шаблон. У шаблона важнее всего не количество фич, а то, что его можно безопасно расширять. Anti-patterns ломают именно это: расширяемость и объяснимость. И неприятный момент в том, что anti-pattern почти всегда выглядит как “быстрое решение проблемы”.
Чтобы фиксировать это не на уровне “мне не нравится”, удобно смотреть на ранние сигналы. Сигнал — это не ошибка компиляции. Это ощущение, что вы начинаете делать лишние “обходные манёвры”: включили странную настройку, добавили @Enable..., дописали allow-bean-definition-overriding, раскидали @Value по проекту, потому что “так быстрее”. Это почти всегда первые трещины.
Ниже разберём несколько anti-patterns, которые особенно часто встречаются в Boot-проектах junior-уровня, и посмотрим, как они выглядят на примере нашего catalog-service.
2. God-config: один @Configuration на всё
Есть почти магическая сила у файла, который называется AppConfig, ApplicationConfig, MainConfiguration, а внутри — “всё, что некуда положить”. В первые дни это даже кажется удобным: один класс, один импорт, одна точка правды. А через пару недель он превращается в “ящик ‘разное’”, где рядом лежат конфигурация MVC, бин для Jackson, настройки Actuator, кастомные formatters, какие-то Clock, RestTemplate “на будущее” и, конечно же, заклинание “не удаляй, оно без этого не стартует”.
Ранний сигнал god-config обычно очень простой: чтобы понять, что за сервис вы пишете, вам приходится читать один огромный конфигурационный класс вместо того, чтобы видеть маленькие тематические части. Второй сигнал — зависимости становятся неявными: вы не видите, что реально нужно web-слою, что относится к startup-диагностике, а что к observability. В итоге DI-граф “сплющивается” в одну кучу, и любое изменение кажется опасным.
Посмотрим, как выглядит “начало плохой дороги” (пример намеренно короткий, но идея видна):
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class ApplicationConfiguration {
@Bean
public WebMvcConfigurer mvcConfigurer() {
// ВАЖНО: это уже «точка входа» для того, чтобы начать складывать всё в один класс.
// Сегодня здесь форматтеры, завтра — Jackson, Actuator, Clock и «ещё один маленький бин».
return registry -> { /* форматтеры и конвертеры */ };
}
@Bean
public Object startupSummaryRunner() {
// Плохой сигнал: «затычка на время», которая потом остаётся навсегда.
return new Object(); // и так далее по списку...
}
}
Смешно то, что такой класс почти всегда появляется “временно”. А как мы знаем, нет ничего более постоянного, чем временное.
Более здоровый подход для Boot-проекта — маленькие тематические конфигурационные классы. Не потому что “так модно”, а потому что это держит границы. В нашем catalog-service это буквально часть архитектуры проекта: WebConfiguration, ActuatorConfiguration, StartupConfiguration.
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
// Здесь только web-кастомизация (MVC), и больше ничего: не Jackson, не Actuator, не «стартовые хелперы».
}
Идея тут такая: Boot даёт вам большую часть инфраструктуры из коробки, а ваши конфигурационные классы должны быть не “заменой Boot”, а тонкими надстройками над defaults. Это ещё и сильно помогает отладке: если проблема в MVC, вы открываете WebConfiguration, а не 900 строк “всего”.
Иногда помогает даже простая схема, чтобы мозг перестал сваливать всё в один контейнер:
flowchart TD
A["CatalogServiceApplication"] --> C["Spring Context"]
C --> W["WebConfiguration (MVC)"]
C --> S["StartupConfiguration (startup hooks)"]
C --> AC["ActuatorConfiguration (Actuator)"]
C --> CAT["catalog/* (domain, service, repository)"]
Если у вас в проекте образовался god-config, это почти всегда означает, что вы начали строить свой маленький фреймворк поверх Boot, даже не замечая этого. А Boot, в отличие от людей, не обижается — он просто перестаёт помогать.
3. @Value повсюду
@Value — не зло. Зло — это массовое и хаотичное использование @Value, когда конфигурация “растворяется” по проекту и превращается в набор строковых ключей, которые нигде не собраны в понятную модель. Новичку кажется, что это быстрый путь: “мне нужно число — вот я его инжектнул”. Но через неделю у вас три разных ключа, которые описывают одно и то же, два разных default value, и ни одной внятной валидации.
Ранний сигнал здесь тоже очень приземлённый: вы не можете ответить на вопрос “какие настройки у приложения вообще есть?”, не делая поиск по проекту @Value("${...}"). Ещё один сигнал — вы начинаете бояться переименовать ключ, потому что “где-то точно используется”.
Плохой стиль выглядит примерно так:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
@Service
public class FeaturedCoursesService {
// Плохой сигнал: строковый ключ размазан по коду, IDE почти не помогает, переименование опасно.
@Value("${app.catalog.max-featured-count}")
private int maxFeaturedCount;
// Плохой сигнал: default value спрятан в аннотации, его не видно как часть «модели конфигурации».
@Value("${app.catalog.default-published-only:true}")
private boolean publishedOnly;
}
Здесь сразу несколько проблем одновременно, и они неприятно накапливаются. Ключи — строками. Значения по умолчанию — размазаны по коду. Валидации почти нет (если max-featured-count=-100, приложение может “работать”, но уже странно). А ещё это field injection, что мы в курсе сознательно избегали.
Boot-friendly путь — @ConfigurationProperties с typed binding. В catalog-service у нас есть CatalogProperties как центральная модель пользовательской конфигурации app.catalog.*. И тогда сервис зависит не от строковых ключей, а от одного понятного объекта.
import org.springframework.stereotype.Service;
@Service
public class FeaturedCoursesService {
private final CatalogProperties properties;
public FeaturedCoursesService(CatalogProperties properties) {
// Хороший сигнал: зависимость одна и типизированная, а не набор строковых ключей.
this.properties = properties;
}
public int featuredLimit() {
// Хороший сигнал: единая точка, где «живёт» значение и (обычно) его валидация.
return properties.maxFeaturedCount();
}
}
И вот здесь внезапно “включается инженерный бонус”. Во-первых, у вас становится нормальная точка валидации (и это мы делали раньше через Bean Validation на конфигурации). Во-вторых, IDE может подсказывать свойства через metadata generation. В-третьих, вы можете тестировать binding и overrides централизованно, а не ловить баги “почему на проде лимит вдруг 0”.
Важный психологический момент: @ConfigurationProperties — это не “удобнее”, это архитектурно честнее. Вы признаёте, что конфигурация — отдельный слой, и у него есть модель, как у домена есть CourseCard.
4. Случайный @EnableWebMvc: рубильник Boot-MVC
Есть аннотации, которые работают как “маленький тюнинг”. А есть такие, которые работают как рубильник. @EnableWebMvc — классический рубильник. Новичок часто добавляет его с мыслью “ну мне надо чуть-чуть настроить MVC”. А по факту он говорит: “Boot, спасибо, дальше я сам”, и вылезает в ручной режим конфигурации MVC. После этого начинают происходить вещи: пропадают какие-то default настройки, неожиданно ломаются статические ресурсы, message converters ведут себя иначе… и начинается шаманство.
Ранний сигнал обычно звучит так: “После того как я добавил @EnableWebMvc, оно стало работать… но я не понимаю почему”. Спойлер: это не “стало работать”, это “поменяло режим”, и вы просто ещё не встретили последствия.
Плохой пример ровно тот, который хочется написать “по совету из интернета”:
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@Configuration
@EnableWebMvc // ВАЖНО: это не «чуть-чуть настроить», это переключение Boot MVC в ручной режим.
public class WebConfiguration {
}
Если вам нужно всего лишь добавить конвертеры/форматтеры или настроить что-то точечно, Boot-friendly путь — WebMvcConfigurer. Он как раз создан для мягкой настройки, не разрушая defaults.
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// ВАЖНО: это «мягкая» кастомизация — вы добавляете своё, не выкидывая Boot defaults.
// точечная настройка query params: track, level, launchedAfter...
}
}
Разница по смыслу тут как между “прикрутить полочку” и “построить дом заново, потому что дверь скрипит”. @EnableWebMvc — это дом заново.
5. Profile-chaos: конфигурация как лес
Профили и YAML-конфигурация — один из главных бонусов Spring Boot. Но именно поэтому их очень легко превратить в хаос. Как выглядит хаос? У вас есть application.yaml, application-dev.yaml, application-local.yaml, ещё где-то multi-document YAML, ещё импорт, ещё external override… и в какой-то момент никто не может уверенно сказать: “а какое итоговое значение у app.catalog.max-featured-count в dev?”.
Ранний сигнал profile-chaos — когда вы начинаете “чинить” проблему копированием одного и того же ключа в разные файлы. Второй сигнал — вы добавляете свойства “на всякий случай” в профиль, хотя это общая настройка. Третий сигнал — вы смешиваете YAML и .properties, потому что “вот тут в статье было в properties”.
Вот мини-пример того, как хаос начинает расти: значения одного и того же свойства появляются в нескольких местах, и вы больше не понимаете, какое победит.
# application.yaml
app:
catalog:
# База: общий дефолт для всех сред.
max-featured-count: 4
# application-dev.yaml
app:
catalog:
# Профиль: изменение только потому, что именно dev отличается от базы.
max-featured-count: 10
# где-то ещё в multi-document YAML...
spring:
config:
activate:
on-profile: dev
app:
catalog:
# Плохой сигнал: третье место, где переопределяется то же свойство.
max-featured-count: 7
Да, это всё может “работать”. И именно поэтому это опасно: ошибка не громкая, она тихо создаёт непредсказуемость.
Здоровая стратегия профилей в нашем курсе была простой: базовый application.yaml содержит общие значения, профильные файлы содержат минимальные отличия, а данные каталога (список курсов) лежат отдельно и подключаются через spring.config.import.
Чтобы мозгу было проще, полезно держать простую таблицу обязанностей:
| Слой конфигурации | Пример файла | Идея | Что туда класть |
|---|---|---|---|
| Base | application.yaml | общая база | имя приложения, дефолтные флаги, общий baseline |
| Profile | application-local.yaml | отличие среды | уровни логов, actuator exposure, management port |
| Import | catalog-data.yaml | отдельная “единица данных” | список курсов, если он большой |
| External override | ./config/catalog-extra.yaml | внешняя надстройка | локальные эксперименты/переопределения без пересборки |
Это не “обязательная схема мира”, но она делает конфигурацию предсказуемой. И вот это слово важнее всего: предсказуемость. Конфигурация — это не место для квеста “найди, где переопределилось”.
Ещё один очень характерный anti-pattern внутри profile-chaos — когда профили начинают использоваться как замена feature flags. “А давайте включим эту фичу в профиле dev2, а ту — в профиле dev3…” и вы неожиданно получаете девять “окружений” в одном проекте. В маленьком сервисе это почти всегда проигрыш: сложность растёт быстрее, чем польза.
6. Версии и exclusions вручную
Следующий классический anti-pattern — случайная кастомизация Boot “по вдохновению”. Из серии: “мне кажется, тут лишняя auto-configuration — выключу”, “мне кажется, Jackson надо обновить — поставлю версию руками”, “мне кажется, зависимостей слишком много — исключу половину”. В итоге вы теряете главное преимущество Boot: платформенную согласованность.
Ранний сигнал — когда вы начинаете добавлять exclude и ручные версии без чёткой причины, которую можете объяснить в двух предложениях. Второй сигнал — когда вы включаете свойства вроде spring.main.allow-bean-definition-overriding=true, потому что “иначе ругается”. Это как заклеить лампочку “Check Engine” изолентой: машина поедет, но радоваться рано.
Вот как выглядит “ручная версия там, где не надо”, на примере Gradle:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Плохой сигнал: версия задана руками, хотя эту библиотеку уже согласует Boot BOM.
implementation("tools.jackson.core:jackson-databind:3.0.4") // ручной pin managed dependency
}
В Boot-курсе мы старались жить по правилу: если зависимость управляется BOM’ом Boot — не задаём версию руками. В том числе потому, что иначе вы можете случайно собрать несовместимую комбинацию. Нормальный вариант:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Хороший сигнал: версии Jackson приходят через Boot BOM и согласованы между собой.
}
Та же история с exclusions. Исключение auto-configuration — это не “настройка”, это “я беру ответственность”. Иногда это оправдано, но чаще у новичка это побочный эффект непонимания, почему Boot сделал так, а не иначе. Если вы ловите себя на желании “отключить всё лишнее”, лучше остановиться и спросить: “А я точно понимаю, что лишнее? Или я просто не знаю, откуда оно взялось?”.
7. Толстый controller: власть web-слоя
Контроллер в Boot-сервисе — это граница. Он должен принимать запрос, аккуратно передавать параметры дальше и вернуть результат. Когда контроллер становится местом фильтрации, сортировки, бизнес-логики, чтения конфигурации и “ещё чуть-чуть — и репозиторий тоже сюда”, это почти всегда означает, что границы слоёв начали расползаться.
Ранний сигнал — если вы открываете controller и видите там “мини-приложение”: много stream(), много if, много работы с датами, куча логики “если limit не задан — взять дефолт” и всё это живёт рядом с @GetMapping. Следующий сигнал — контроллер начинает зависеть от слишком многих вещей: properties, repository, каких-то helper-классов, “чтобы не заводить сервис”.
Плохой пример (укороченный, но узнаваемый):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CourseCatalogController {
@GetMapping("/api/catalog/featured")
public Object featured() {
// Плохой сигнал: внутри метода живёт «мини-приложение», а не тонкая граница web-слоя.
// фильтрация, лимиты, бизнес-логика — всё тут
return null;
}
}
Хороший вариант — контроллер тонкий, логика в сервисе, а данные берёт repository. И да, “внутри памяти” (in-memory) тоже можно иметь нормальные слои — мы это делали весь курс.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CourseCatalogController {
private final CourseCatalogService service;
public CourseCatalogController(CourseCatalogService service) {
// Хороший сигнал: контроллер зависит от сервиса, а не от «всего подряд».
this.service = service;
}
@GetMapping("/api/catalog/featured")
public Object featured() {
// Хороший сигнал: делегирование прикладной логики в сервис.
return service.findFeatured();
}
}
Тут не столько важен тип возвращаемого значения (у нас read-only проект, DTO-слой не обязателен), сколько граница ответственности. Когда граница есть, проект легче расширять. Когда границы нет, проект начинает “плыть”, даже если всё ещё стартует.
8. Логи и Actuator без вреда
Логи и Actuator в Boot-проекте — это огромный плюс. Но, как и любой плюс, их можно превратить в минус. Самый частый сценарий у новичков: либо логов нет (только System.out.println), либо логов слишком много и всё в ERROR, либо в лог попадают чувствительные значения (а потом кто-то очень удивляется). С Actuator похожая история: в local хочется “открыть всё”, и иногда это случайно доезжает до prod, потому что профили размазаны.
Ранний сигнал noisy logging — когда вы не можете найти полезный сигнал в логах: там всё либо одинаково важное, либо одинаково шумное. Ещё один сигнал — вы логируете целые объекты конфигурации “для отладки”, и среди них внезапно появляется то, что в логах быть не должно. У Actuator ранний сигнал простой: вы видите include: "*" и говорите “удобно же”.
Плохой вариант exposure (особенно если это в application-prod.yaml или “случайно попало в base”):
management:
endpoints:
web:
exposure:
# Плохой сигнал: вы открываете все эндпоинты и почти наверняка откроете лишнее.
include: "*"
Более здоровый baseline — минимальный и безопасный набор (а расширение — только там, где это действительно нужно и точно контролируется профилем):
management:
endpoints:
web:
exposure:
# Хороший baseline: минимально необходимое, без случайного «всё наружу».
include: "health,info"
И то же самое с логами: вместо System.out.println и случайных сообщений используем нормальный Logger, и пишем так, чтобы сообщение было полезным, но не “сливало” лишнее.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class StartupSummaryRunnerSupport {
private static final Logger log =
LoggerFactory.getLogger(StartupSummaryRunnerSupport.class);
public static void logTitle(String title) {
// ВАЖНО: логируем полезный факт, но не вываливаем весь конфиг/контекст «для отладки».
log.info("Catalog title: {}", title);
}
}
Само по себе это не rocket science. Главное — помнить, что observability-слой должен повышать объяснимость сервиса, а не создавать новую головную боль.
Красный флаг: критерий “работает в IDE”
Есть отдельный тип anti-pattern’а, который не про аннотации и не про YAML, а про мышление: если проект проверяется только запуском из IDE, он очень легко остаётся “полурабочим”. В Boot-мире это особенно важно, потому что запуск как jar, запуск с внешним конфигом и запуск с разными профилями — это не экзотика, а обычная жизнь сервиса. И если вы этого не проверяете, ошибки приходят “внезапно”, хотя на самом деле они просто ждали своего часа.
Ранние сигналы здесь обычно бытовые: в проекте нет минимальных smoke-tests, или они не запускаются; конфигурация “предполагает IDE” (например, пути, которые есть только в проекте, но не будут в jar-артефакте); DevTools маскирует проблемы рестартами; вы не можете быстро и честно объяснить другому человеку, как запустить сервис из терминала. Важно: это не повод превращать курс в инфраструктурный ад, просто это красный флаг, что шаблон ещё не зрелый.
9. Типичные ошибки при борьбе с anti-patterns
Ошибка №1: лечить симптомы “магическими” настройками вместо причины.
Самая классика — включить spring.main.allow-bean-definition-overriding=true, добавить @EnableWebMvc, выключить auto-configuration через exclude, “потому что так стартует”. Это часто заставляет проект запускаться, но делает его менее объяснимым. Правильнее остановиться и понять, почему именно возник конфликт, а не замазать его.
Ошибка №2: считать, что “маленький проект” не нуждается в структуре.
Именно в маленьком проекте структура должна быть особенно явной, иначе он быстро превращается в один пакет с пятью классами, которые знают всё обо всех. catalog-service маленький, но он шаблон. А шаблон без структуры — это не шаблон, а случайная заготовка.
Ошибка №3: путать “быстрее сейчас” и “дешевле навсегда”.
@Value в одном месте действительно быстрее, чем @ConfigurationProperties. Но @Value в двадцати местах превращается в конфигурационную пыль, которую невозможно нормально валидировать и тестировать. Boot-стиль почти всегда про то, чтобы один раз сделать слой конфигурации правильно.
Ошибка №4: складывать всю инфраструктуру в один конфиг-класс ради ощущения контроля.
God-config создаёт иллюзию, что вы “всё держите в руках”. По факту вы теряете локальность изменений и смешиваете ответственности. Тематические конфигурационные классы кажутся “лишними файлами” ровно до момента, когда вам надо что-то поменять, не задевая всё остальное.
Ошибка №5: превращать контроллеры в место жизни бизнес-логики.
Толстый controller почти всегда означает, что вы больше не видите границу между web-слоем и прикладной логикой. Это делает тестирование, расширение и даже чтение проекта тяжелее. В нашем курсе мы сознательно делали controller тонким, и эта привычка очень хорошо переносится в “настоящие” сервисы.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ