JavaRush /Курсы /Spring Boot /Anti-patterns Boot-проекта

Anti-patterns Boot-проекта

Spring Boot
28 уровень , 2 лекция
Открыта

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 тонким, и эта привычка очень хорошо переносится в “настоящие” сервисы.

1
Задача
Spring Boot, 28 уровень, 2 лекция
Недоступна
Один properties-класс вместо россыпи `@Value`
Один properties-класс вместо россыпи `@Value`
1
Задача
Spring Boot, 28 уровень, 2 лекция
Недоступна
Дата в query-параметре без `@EnableWebMvc`
Дата в query-параметре без `@EnableWebMvc`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ