1. Цена одной зависимости
Когда ты только начинаешь, строка в build.gradle.kts выглядит невинно: ну что такого, добавил одну библиотеку — и всё. Но в Boot-проекте зависимость почти никогда не приходит одна. Она тащит транзитивные зависимости, может сдвинуть версии общих библиотек, а иногда меняет поведение приложения просто потому, что на classpath появился новый набор классов. И это не «плохой Spring», а обычная механика Java-мира: classpath — общий котёл, и если ты добавил туда новый ингредиент, вкус меняется у всего супа, а не только у одной ложки.
Здесь важно разделить два типа изменений. Первое — ты просто расширил возможности проекта: добавил что-то сверху, не ломая базовый набор версий, потому что Spring Boot управляет ими через свой BOM. Второе — ты нечаянно начал перетягивать версии вручную или добавил библиотеку, которая конфликтует с тем, что Boot уже привёл. Во втором случае проект начинает вести себя как машина после «тюнинга в гараже по ролику из интернета»: вроде едет, но звук странный, лампочки горят, и на поворотах хочется молиться.
И ещё одна важная мысль: starter’ы — это удобная абстракция уровня «сценарий», но в итоге они всё равно раскрываются в конкретный набор модулей. Поэтому «безопасно добавить starter» — значит не просто написать строку, а сделать это так, чтобы не сломать managed-модель, не продублировать уже существующие части графа и не притащить в проект случайные ручные версии. Сейчас мы как раз соберём понятный алгоритм, который позволяет добавлять зависимости спокойно — без магии, без паники и без ритуала «ну попробуем...».
2. Безопасное добавление зависимостей
Если ты унесёшь из этой лекции только одну вещь, пусть это будет порядок действий, а не «самая красивая команда Gradle». Ошибка новичка обычно не в том, что он не знает exclude. Ошибка в том, что он начинает «лечить симптомы» раньше, чем посмотрел на причины: добавляет версии, исключает модули, меняет starter’ы — и всё это до того, как увидел реальную картину графа зависимостей. Поэтому правильный алгоритм начинается не с кода и даже не с Gradle, а с формулировки того, что именно нужно проекту.
Представь, что тебе нужно «добавить возможность X». Не важно, что это за X: диагностика, валидация, быстрый цикл разработки или что-то ещё. Сначала формулируешь сценарий, потом ищешь правильную зависимость уровня Boot, обычно starter, добавляешь её без версии, кладёшь в правильную Gradle-конфигурацию (про них — в следующем разделе) и сразу проверяешь две вещи: как изменилось дерево зависимостей и собирается ли проект. Если что-то пошло не так, ты не «подсыпаешь ещё одну версию», а возвращаешься и выясняешь, кто с кем конфликтует.
Удобно держать в голове простой «маршрут» в виде схемы:
flowchart TD
A[Нужно новое поведение] --> B["Есть ли starter под сценарий"]
B -->|Да| C[Добавляем starter без версии]
B -->|Нет| D[Добавляем библиотеку и проверяем: managed ли она]
C --> E["Смотрим dependency tree / dependencyInsight"]
D --> E
E --> F["Проверяем сборку: test/bootRun"]
F --> G{"Всё ок"}
G -->|Да| H[Фиксируем изменение]
G -->|Нет| I[Откатываем и разбираем конфликт по фактам]
Чтобы эта схема не оставалась ритуалом, держи простое правило junior-уровня для Boot-проектов этого типа:
Официальные Boot starter’ы и Boot-артефакты вроде spring-boot-devtools или spring-boot-configuration-processor в рамках курса по умолчанию подключаем без версии.
Если библиотека внешняя и не выглядит частью набора, которым управляет Boot, начинаем с явной версии.
Если зависимость без версии Gradle не может нормально разрешить, это практический сигнал: managed-модель эту версию тебе не поставляет.
После добавления всё равно проверяем фактами runtimeClasspath или dependencyInsight, а не верим на слово одной строке в build.gradle.kts.
Обрати внимание: здесь нет шага «в отчаянии ставим версии руками». Версия — это инструмент, но он должен быть последним, а не первым. И да: «проверяем сборку» — это не «когда-нибудь потом». Зависимости — фундамент. Если фундамент треснул, второй этаж не строят.
В контексте catalog-service этот алгоритм особенно важен, потому что мы строим шаблон Boot-сервиса. А шаблон ценен тем, что его можно расширять дальше. Если мы уже на раннем этапе научимся добавлять зависимости аккуратно, потом будет намного проще: новые возможности начнут появляться контролируемо, а не как случайный набор библиотек, которые однажды поссорились друг с другом.
3. Gradle-конфигурации зависимостей
Самая частая ошибка начинающего — считать, что все зависимости одинаковые. Мол, «раз библиотека нужна, значит пишем в implementation и поехали». Но Gradle, а вместе с ним и Boot, специально дают нам разные конфигурации: что нужно в рантайме приложения, что нужно только тестам, что нужно только во время разработки, а что вообще нужно лишь компилятору как «генератор» кода или метаданных. Это не бюрократия, а способ держать проект чистым и предсказуемым.
Ниже — небольшой ориентир. Это не «надо выучить наизусть», но полезно понимать, почему одна и та же библиотека в разных местах означает разные вещи:
| Конфигурация Gradle | Где используется | Что означает простыми словами | Пример из Boot-мира |
|---|---|---|---|
| implementation | компиляция + запуск приложения | «Это часть приложения» | spring-boot-starter-actuator, spring-boot-starter-webmvc |
| testImplementation | компиляция + запуск тестов | «Это нужно только тестам» | spring-boot-starter-test |
| runtimeOnly | только запуск | «Во время компиляции не нужно, но в рантайме нужно» | встречается реже на старте курса |
| developmentOnly | локальная разработка (IDE/bootRun) | «Помощник для разработки, не тащим в прод» | spring-boot-devtools |
| annotationProcessor | компиляция (генерация кода/метаданных) | «Это не библиотека приложения, а инструмент компилятора» | spring-boot-configuration-processor |
Если сказать очень грубо, то implementation — это «везём в рюкзаке в поход», testImplementation — «это лежит дома на тренировке», developmentOnly — «это велосипедные ролики: помогают учиться, но в гонку не берём», а annotationProcessor — «это не еда, а повар, который помогает её приготовить».
Для нашего проекта catalog-service важно, чтобы зависимости были «по делу» и не расползались. Например, spring-boot-devtools — штука полезная, но она не должна по умолчанию оказываться в итоговом артефакте. А spring-boot-configuration-processor вообще не должен восприниматься как runtime-зависимость — он нужен IDE и сборке для генерации metadata, а не для работы приложения.
Именно поэтому «безопасно добавить starter» — это ещё и «положить его в правильную конфигурацию». Иначе ты можешь получить проект, который вроде запускается, но его артефакт неожиданно раздулся или, наоборот, тесты падают из-за отсутствия зависимостей, которые ты по ошибке положил не туда.
4. Добавляем spring-boot-starter-actuator
Теперь максимально практично: как выглядит безопасное добавление starter’а, если мы хотим расширить проект ещё одной платформенной возможностью и при этом не лезть в ручное управление версиями. Здесь важен не набор endpoint’ов Actuator сам по себе, а навык: добавить зависимость, проверить дерево, убедиться, что версиями управляет Boot, и что проект после этого живёт.
Возьмём текущий baseline дня — spring-boot-starter-webmvc плюс spring-boot-starter-test — и отдельно посмотрим, что меняется, если поверх него добавить один официальный starter для диагностики. Это именно изолированное изменение модели зависимостей: мы не объявляем Actuator новым обязательным состоянием проекта, а смотрим, как нарастить build одной строкой.
dependencies {
// Версию не указываем: Spring Boot управляет версиями starter’ов через свой BOM
implementation("org.springframework.boot:spring-boot-starter-actuator")
}
Обрати внимание: мы всё ещё не пишем версию. Это не «лень», а осознанный Boot-стиль. Версия starter’а и всех его внутренних библиотек выровнена через Spring Boot dependency management.
Теперь два быстрых способа проверить, что всё действительно подключилось так, как мы ожидаем. Первый — посмотреть дерево зависимостей именно для runtime classpath: так проще, чем читать весь отчёт подряд.
# Смотрим дерево зависимостей именно runtimeClasspath (то, что реально попадёт в runtime)
./gradlew dependencies --configuration runtimeClasspath
# ... в выводе ищем spring-boot-starter-actuator и его ветку
Второй — точечно проверить конкретную библиотеку, которая пришла транзитивно, и увидеть, почему выбрана именно эта версия. Например, если из планов курса мы знаем, что Boot приводит Micrometer, можно сделать так:
# Точечно выясняем: откуда пришёл micrometer-core и почему выбрана именно эта версия
./gradlew dependencyInsight --dependency micrometer-core --configuration runtimeClasspath
Тут важный психологический момент. Новичок часто ждёт, что после добавления starter’а он увидит «одну библиотеку». А увидит целый кусок графа. Это нормально. Starter — это curated dependency set: он приносит набор компонентов, которые обычно нужны вместе. Дерево может вырасти заметно — и это не признак того, что ты сделал что-то плохо. Плохой признак — когда дерево выросло, а ты не можешь объяснить почему: «ну, само как-то».
После проверки дерева делаем последнюю вещь, которая превращает нас из «редактора build.gradle.kts» в инженера: убеждаемся, что проект собирается. На раннем этапе не нужно усложнять — достаточно обычного:
./gradlew test
# BUILD SUCCESSFUL
Если сборка прошла, значит модель зависимостей согласована. И самое главное: ты добавил новую возможность, не ломая baseline и не превращая build в таблицу ручных номеров версий.
5. Ручные версии как крайняя мера
Теперь давай о самом популярном сценарии поломки: «я добавил версию, потому что видел в интернете». Интернет, конечно, прекрасен, но у него есть особенность: он не знает, что у тебя Spring Boot 4, а не Boot 2. Он не знает, что у тебя Jackson 3, а не Jackson 2. И ему вообще всё равно, что у тебя под капотом managed versions. Поэтому рецепт «вставьте вот эту зависимость с вот этой версией» в Boot-проекте часто работает как медвежья услуга: вроде помог, но дверной проём теперь шириной в медведя.
Классический плохой пример выглядит примерно так: разработчик хочет «настроить JSON» и решает добавить jackson-databind руками, причём ещё и с версией из старой статьи:
dependencies {
// Starter подключаем без версии (это правильно)
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// А вот тут начинается «вторая правда»: ручная версия, да ещё и потенциально не из Boot-managed линии
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.1")
}
Проблема здесь даже не в самой записи версии. Проблема в том, что это другая линия Jackson. В Boot 4 baseline — Jackson 3, и он может быть в другом groupId и с другими ожиданиями по совместимости модулей. Итог бывает разный: от «собралось, но упало на старте с NoSuchMethodError» до «всё работает, пока не тронешь определённую функциональность, а потом падает». И это самый неприятный тип ошибки — «плавающий».
Как здесь правильно мыслить. Если тебе нужен JSON в Boot, то почти всегда правильный ответ — не добавлять jackson-databind отдельно, а добавить или оставить starter, который приносит JSON-поддержку согласованно. В нашем курсе это делается через web starter, но даже без него принцип тот же: не подключай низкоуровневую библиотеку, если у тебя есть starter, который уже решает сценарий целиком.
Если ты уже добавил руками «не ту» библиотеку, исправление обычно скучное: удалить ручную зависимость, вернуться к managed-модели и снова посмотреть дерево:
dependencies {
// Оставляем starter: он притянет согласованный набор зависимостей, включая JSON
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// jackson-databind руками не добавляем
}
А дальше снова ./gradlew dependencies --configuration runtimeClasspath и проверка, какая версия Jackson реально пришла из Boot-managed набора.
И вот здесь появляется важное правило для junior-уровня, которое реально спасает проекты: если библиотека уже приходит из starter’а, добавлять её отдельно — почти всегда ошибка. Иногда бывают исключения, например тонкая настройка или замена реализации, но эти исключения должны быть объяснимыми и проверяемыми, а не «на всякий случай».
6. Optional-зависимости и прод
С понятием «optional dependency» у новичка обычно происходит маленькая путаница. Кажется, будто optional — это что-то вроде «можно поставить, можно не ставить, мир не рухнет». И это правда, но важнее другое: optional-зависимость обычно нужна не всем слоям и не во всех режимах жизни приложения. В Boot-проекте особенно важно отличать зависимости, которые должны попасть в итоговый артефакт, от зависимостей, которые нужны только для того, чтобы тебе было удобнее жить в IDE.
Возьмём два типичных примера из Boot-мира: DevTools и configuration processor. Первый помогает в разработке, второй помогает IDE и сборке генерировать metadata для конфигурации. Оба полезны, но оба не должны восприниматься как «часть приложения в рантайме».
В Gradle это выражается не словами «optional» (как в Maven), а тем, куда именно мы их кладём.
DevTools — хороший пример зависимости, которая нужна только в разработке, а в «боевой» сборке должна отсутствовать:
dependencies {
// Нужен только для локальной разработки (bootRun/IDE), в прод-артефакт попадать не должен
developmentOnly("org.springframework.boot:spring-boot-devtools")
}
Configuration processor — пример зависимости, которая нужна компилятору как инструмент:
dependencies {
// Нужен компилятору/IDE для генерации metadata, не является runtime-зависимостью приложения
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
}
Тут можно заметить интересную вещь: обе строки не используют версию. И это снова нормальный Boot-стиль: этими зависимостями тоже управляет Boot dependency management.
Почему это важно для catalog-service прямо сейчас, даже если мы ещё не чувствуем пользу от этих инструментов? Потому что мы собираем не просто проект, а шаблон с правильными привычками: зависимости должны подключаться по смыслу. Когда ты потом будешь собирать bootJar, запускать java -jar и переносить шаблон в следующий проект, тебе не придётся отрезать от него «лишние хвосты». Они просто не появятся там изначально.
Ещё одна практическая деталь, которая спасает нервы: если зависимость должна быть «не частью продакшена», не пытайся решать это дисциплиной вида «ну мы просто не будем запускать так». Лучше решать это структурой Gradle-конфигураций. Это как ремень безопасности: он нужен не потому, что ты планируешь аварии, а потому что ты человек.
7. exclude и замена starter’ов
Когда студент впервые узнаёт про exclude, очень хочется пойти и «почистить дерево зависимостей». Оно же длинное! Наверняка там много лишнего! И вот тут главное вовремя остановиться. Длинное дерево зависимостей — не болезнь. Болезнь — это конфликт версий, дублирование по смыслу и неуправляемые ручные overrides. exclude — это инструмент для точечного вмешательства, когда ты точно знаешь, что именно заменяешь и зачем. Если ты не можешь объяснить исключение одним предложением, лучше его не делать.
Самый понятный сценарий замены на уровне starter’ов — когда ты меняешь одну «реализацию сценария» на другую. Например, ты решил использовать альтернативный logging starter. В курсе мы не делаем это как обязательный шаг — мы держимся Boot defaults, — но как пример controlled replacement это идеально:
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webmvc") {
// Убираем дефолтный logging starter, чтобы заменить его на альтернативный
exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
}
// Подключаем альтернативный starter логирования (без ручных версий)
implementation("org.springframework.boot:spring-boot-starter-log4j2")
}
Обрати внимание на «хорошие манеры» этого куска конфигурации. Здесь нет ручных версий. Здесь явно видно, что мы делаем: исключаем конкретный модуль и добавляем альтернативный starter. Это контролируемо, объяснимо и проверяемо через dependency tree. Плохой вариант выглядел бы иначе: «насыплю log4j2-артефактов руками, исключу что-нибудь, а если не заработает — добавлю ещё пару исключений». Вот так и появляются проекты, где build.gradle.kts напоминает заклинание из фэнтези: выглядит мощно, но никто не знает, что произойдёт при произнесении.
После любого exclude, особенно связанного со starter’ами, обязательно возвращаемся к фактам: снова смотрим ./gradlew dependencies --configuration runtimeClasspath и убеждаемся, что замена действительно произошла так, как мы ожидаем. И снова прогоняем сборку. exclude — это операция, а не косметика. После операции пациент должен дышать.
Если держать эту дисциплину, то даже сложные изменения модели зависимостей перестают быть страшными. Они становятся пошаговыми: одно изменение — один факт в дереве — один прогон сборки.
8. Типичные ошибки при работе со starter’ами
Ошибка №1: добавлять starter с ручной версией «для надёжности».
Это звучит логично только до первого конфликта. В Boot-проекте версия starter’а почти всегда должна приходить из managed-модели. Ручная версия в строке зависимости делает build менее предсказуемым, потому что ты создаёшь второй источник истины: Boot говорит одно, ты — другое. При следующем обновлении Boot это почти гарантированно начнёт расходиться.
Ошибка №2: подключать низкоуровневую библиотеку рядом со starter’ом, который уже приносит её транзитивно.
Так появляется «двойная правда». Сегодня Gradle выбрал одну версию, завтра — другую, и ты получаешь неожиданные runtime-ошибки. Если тебе нужен сценарий, ищи starter. Если starter уже есть, не дублируй его внутренности руками, пока не понимаешь, зачем ты это делаешь и как проверишь результат.
Ошибка №3: класть всё в implementation, потому что “так проще”.
Это быстро превращает проект в тяжёлый, шумный и плохо переносимый. DevTools в implementation — классический пример: локально удобно, но потом странно, что артефакт другой и ведёт себя иначе. Configuration processor в implementation тоже бессмысленен: он нужен компилятору, а не приложению. Правильная конфигурация зависимости — это часть архитектуры сборки.
Ошибка №4: использовать exclude как способ сделать дерево “красивым”.
Красивое дерево — не цель. Цель — совместимый и предсказуемый classpath. Если ты исключаешь зависимости только потому, что их «слишком много», ты, скорее всего, вырежешь что-то нужное, а потом начнёшь чинить симптомы ещё большим количеством костылей. exclude должен быть объясним как замена или устранение конкретного конфликта.
Ошибка №5: менять несколько вещей в модели зависимостей за один шаг и потом не понимать, что именно сломало сборку.
Добавил starter, одновременно сменил версию какой-то библиотеки, ещё и добавил пару exclude — и вот у тебя проект, который не собирается, а виноват «Spring». На деле виноват процесс изменений. Делай одно изменение — проверяй дерево — проверяй сборку. Это скучно, но скука здесь означает контроль.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ