1. Версионный хаос зависимостей
В небольших учебных проектах легко поверить, что зависимости — это «ну две-три библиотеки подключил, и всё». Но как только проект начинает напоминать реальный сервис, выясняется, что каждая библиотека тянет за собой другие, у каждой своя версия, и между собой они дружат не всегда. Это и есть dependency hell: вы хотели «просто логирование» или «просто JSON», а в итоге попали в сериал «Сезон 7: конфликт версий. Финал. Ничего не работает».
Представьте обычный сценарий: вам нужны web-приложение, JSON и внятные логи. В обычном Gradle-проекте без платформенной поддержки можно начать так:
dependencies {
// Явно фиксируем версии вручную — так обычно и начинается dependency hell
implementation("org.springframework:spring-webmvc:7.0.5")
// Jackson — это «семейство» модулей: если версии разъедутся, начнутся сюрпризы
implementation("tools.jackson.core:jackson-databind:3.0.4")
// Логирование — тоже связка (logback + SLF4J), и там версии тоже важны
implementation("ch.qos.logback:logback-classic:1.5.32")
}
На первый взгляд всё выглядит разумно: три библиотеки, три версии. Но это только верхушка айсберга. У spring-webmvc есть свои зависимости, у Jackson — целое семейство модулей, у logback — связка с SLF4J, и всё это должно оказаться совместимым в одном classpath. Чем чаще вы добавляете «ещё одну маленькую библиотеку», тем выше шанс, что несовместимость всплывёт не на компиляции, а в рантайме — и в самый «приятный» момент.
2. Два решения: зависимость и версия
Перед разговором про BOM важно увидеть одну вещь: в каждой строке dependencies спрятаны два независимых решения. Первое — что именно мы подключаем: какая библиотека, какой модуль, какой артефакт (обычно в формате group:artifact). Второе — какую версию этого артефакта берём. И самое важное: в хорошо собранном Boot-проекте вы почти всегда хотите принимать только первое решение, а второе отдавать платформе.
Можно представить это как заказ еды в доставке. Вы выбираете, что хотите: пиццу, салат, напиток. Но вы не должны выбирать, какой марки мука и дрожжи использованы на кухне, иначе заказ быстро превратится в инженерный тендер. В мире зависимостей «мука и дрожжи» — это версии и согласование библиотек.
Чтобы не путаться, удобно держать в голове простую схему:
flowchart LR %% Это концептуальная схема: она показывает, где именно «живут» решения о версиях A["Выбор зависимости
group:artifact"] --> B["Разрешение версий
(version resolution)"] C["BOM / managed versions
(правила версий)"] --> B B --> D["Итоговый classpath
(что реально поедет в рантайм)"]
Spring Boot как раз и построен вокруг этой идеи: разработчик выражает намерение — что подключить, а платформа даёт согласованный набор версий — что именно и в каких версиях приедет.
БОМ: «список деталей», а не библиотека
Термин BOM пришёл из мира производства, и здесь он очень к месту. В реальной жизни BOM — это ведомость материалов: список деталей и их характеристик, из которых собирают изделие. BOM — не сам автомобиль, не двигатель и не шина. Это документ, который говорит: «если собираем такую модель, то нужны вот такие детали и в таких спецификациях».
В Java-мире всё ровно так же, только вместо «болта M8» у нас jackson-databind версии 3.0.4. BOM не добавляет функциональность напрямую: он не приносит классы, которые вы импортируете в Java-код. Он приносит правила, по которым выбираются версии зависимостей.
Если совсем приземлённо, BOM — это таблица «артефакт → версия», которую кто-то заранее собрал и проверил на совместимость.
BOM (упрощённо)
├─ spring-webmvc -> 7.0.5
├─ jackson-databind -> 3.0.4
├─ micrometer-core -> 1.16.3
└─ logback-classic -> 1.5.32
И важный момент: BOM обычно составляют не «на глазок». Кто-то уже прошёл этот путь за вас. В случае Spring Boot это команда Boot: она собирает совместимую платформу и регулярно тестирует её как единое целое.
3. Managed versions в Spring Boot
Теперь соединяем понятия. Spring Boot публикует свой BOM — в мире Boot он известен как spring-boot-dependencies. В нём зафиксированы версии огромного числа библиотек, которые типично используются в Java backend. На этом и строятся managed versions: вы объявляете зависимость без версии, потому что версия живёт в управляемом слое. Именно так Boot лечит dependency hell — не магией, а дисциплиной: один источник истины о версиях и согласованный стек.
Для нашего курса это особенно важно, потому что мы фиксируем технический baseline. Он уже задан в ТЗ курса: Spring Framework, Jackson, Micrometer и Logback должны быть согласованы не «потому что повезло», а потому что так устроена Boot-платформа. В учебном catalog-service мы хотим воспроизводимый и предсказуемый проект: запускаете его сегодня, через неделю или на машине другого разработчика — и получаете один и тот же набор совместимых библиотек.
Чтобы было понятнее, о чём речь, вот примеры версий, которые в рамках курса считаются нормой именно как часть Boot-платформы:
| Компонент платформы | Пример библиотеки | Почему это важно |
|---|---|---|
| Spring Framework | spring-webmvc (линия Spring 7) | Базовый слой Spring, который должен быть согласован по всем модулям |
| JSON | jackson-databind 3.x | Стандартный JSON-стек Boot; версии внутри семейства Jackson должны совпадать |
| Метрики/наблюдаемость | micrometer-core | Micrometer — основа для метрик и Actuator; несовместимость тут быстро «стреляет» |
| Логирование | logback-classic | Логирование должно быть стабильным, потому что без логов вы потом «слепы» |
Не нужно сейчас заучивать номера версий как заклинание. Гораздо важнее запомнить мысль: в Boot-проекте версии — это часть платформы, а не ваша личная коллекция чисел, которую вы расставляете по build.gradle.kts «как чувствуете».
4. Gradle: зависимости без версий
Практически всё выглядит почти подозрительно просто, и именно поэтому новички иногда не верят. Видите в Gradle-скрипте зависимость без версии — и мозг кричит: «ААА, тут забыли версию!». Но в Boot-проекте это как раз норма. Версия Spring Boot в плагине определяет, какой BOM будет использован, а значит — какие managed versions вы получите.
На текущем snapshot catalog-service в build.gradle.kts выглядит так (упрощённо, только главное):
plugins {
// Базовый Java-плагин: компиляция, тесты, jar и т.п.
java
// Версия указывается здесь — это главный «якорь» платформы и managed versions
id("org.springframework.boot") version "4.0.3"
}
java {
toolchain {
// Фиксируем версию Java для воспроизводимости сборки
languageVersion = JavaLanguageVersion.of(25)
}
}
dependencies {
// Стартеры подключаем без версий: версии приезжают из BOM Spring Boot
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Тестовый стек тоже без версий — он так же управляется Boot-платформой
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Это и есть наша точка отсчёта дальше по дню: web starter и test starter задают базовый стек, а версионную часть держит платформа. Этого baseline достаточно, чтобы понять идею managed versions: Boot plugin, toolchain, web starter и test starter. Actuator, configuration-processor и другие добавки потом просто наращивают build, но не меняют его логику.
Обратите внимание на две вещи. Во-первых, версия здесь указана у плагина Spring Boot, а не у каждой зависимости. Во-вторых, starter’ы подключены без версий, и это нормально: они живут в мире managed versions.
Теперь сравним это с типичной «аккуратной, но лишней» привычкой: хранить версию Boot в переменной и подставлять её в зависимости.
// Плохая идея: появляется второй источник истины о версии Spring Boot
extra["bootVersion"] = "4.0.3"
val bootVersion: String by extra
dependencies {
// Дублируем версию в координатах зависимости — можно легко рассинхронизироваться с версией плагина
implementation("org.springframework.boot:spring-boot-starter-webmvc:$bootVersion")
}
Выглядит логично: «один раз указал версию и переиспользую». Но это логика из мира «я всем управляю вручную». В Boot-проекте вы уже управляете версией через плагин, и дублировать её в зависимостях — значит создавать два источника истины. Сегодня вы обновите плагин и забудете про bootVersion, завтра сборка начнёт вести себя странно, а послезавтра вы будете искать виноватого среди транзитивных зависимостей. Спойлер: виноваты будете вы и ext.
5. Читаемый Gradle-файл catalog-service
В учебном проекте catalog-service мы специально хотим, чтобы всё было объяснимо. И build.gradle.kts — это не «магический файл, который страшно трогать». Это документ, который должен читать человек. Причём желательно не тот, кто писал его вчера и всё ещё помнит контекст, а тот, кто откроет проект через три месяца и спросит: «Окей, что тут вообще подключено и почему?»
Если вы превращаете build.gradle.kts в таблицу версий, читаемость резко падает. Вместо «нам нужны web и тесты» получается «нам нужен web 4.0.3, но почему 4.0.3?.. а Jackson 3.0.4… а почему именно 3.0.4?..» — и очень быстро начинается цепочка вопросов, на которую в большинстве случаев ответ один: «потому что так было в туториале».
Если позже поверх этого текущего baseline понадобятся диагностика и удобная работа с конфигурацией, build естественно дорастёт до такого набора. Это всё ещё расширение baseline, а не новая точка отсчёта дня:
dependencies {
// Веб-слой (MVC)
implementation("org.springframework.boot:spring-boot-starter-webmvc")
// Наблюдаемость, метрики, health-check'и
implementation("org.springframework.boot:spring-boot-starter-actuator")
// Генерация метаданных для @ConfigurationProperties (удобнее IDE и проверок)
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
// Тестовый набор Boot (JUnit, AssertJ, Spring Test и т.д.) — без ручных версий
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
Логика при этом не меняется: прямые зависимости по-прежнему описывают возможности проекта, а версии остаются в платформе.
Такой build почти читается как русский текст: «веб», «Actuator», «процессор конфигурации», «тестовый стек». Версии при этом по-прежнему живут в BOM, привязанном к версии Spring Boot плагина. И это очень близко к тому, что мы хотим получить к концу курса: небольшой, но взрослый шаблон, где меньше ручного жонглирования и больше ясности.
6. Ручные версии: когда нужны
Полностью уйти от ручных версий не получится, и это нормально. Иногда библиотека не входит в managed set Boot, особенно если она нишевая. Иногда вам нужно срочно поднять версию из-за известной уязвимости или бага. Иногда у вас корпоративная среда, где «вот эта версия разрешена, а вот эта нет». Но важна не сама возможность указать версию, а дисциплина: делать это осознанно и понимать последствия.
Самое безопасное правило для junior-уровня такое: если зависимость не управляется Boot, вы указываете версию прямо в строке зависимости, и это нормально. Например, условная «сторонняя утилита» может выглядеть так:
dependencies {
// Эта библиотека не управляется BOM'ом Boot — поэтому версию задаём явно
implementation("com.acme:tiny-slugify:1.2.0")
// А это — стартер Boot, он приходит без версии, потому что версия уже «в платформе»
implementation("org.springframework.boot:spring-boot-starter-webmvc")
}
Но если библиотека управляется Boot, ручная версия — это уже режим вмешательства в платформу. Это как взять заводскую комплектацию автомобиля и сказать: «а я тут чуть-чуть поменяю двигатель». Может быть, вы инженер и всё рассчитали. А может быть, вы просто заменили одну деталь и получили неожиданный «скрежет» на втором повороте. В Java это часто выглядит как NoSuchMethodError или ClassNotFoundException в рантайме.
Поэтому взрослый подход к ручным версиям в Boot-мире выглядит так: сначала выяснить, управляется ли библиотека платформой, потом понять, зачем вам нужно отклониться от BOM, и только затем переопределять версию — так, чтобы это было видно и объяснимо. В учебном проекте мы почти всегда будем держаться за managed versions, потому что наша цель — не упражнение «кто лучше подберёт версии», а понимание платформенной модели Boot.
7. Типичные ошибки при работе с BOM и managed versions
Ошибка №1: воспринимать отсутствие версии как «недописанный build.gradle.kts».
Это самая частая реакция у новичков: «раз версии нет — значит, забыли». В Boot-проекте обычно наоборот: версию уже выбрали за вас в managed-модели. Если вы начнёте дописывать версии «для спокойствия», именно вы этот механизм и сломаете.
Ошибка №2: путать BOM и starter.
BOM отвечает за версии и совместимость. Starter отвечает за удобный набор зависимостей под сценарий. Если смешать эти роли, легко ждать от BOM «каких-то классов в проекте» или, наоборот, думать, что starter «управляет версиями». На практике starter приносит зависимости, а BOM определяет, какие версии этих зависимостей будут использованы.
Ошибка №3: заводить ext.bootVersion и подставлять его в зависимости.
Это выглядит как аккуратность, но обычно создаёт два источника истины: версия стоит и в плагине, и в ext. Потом обновляется только одно место, и вы получаете сборку, где часть артефактов подтягивается по одной версии, а часть — по другой. Это не «сложно», это просто неприятно диагностировать.
Ошибка №4: ставить ручную версию у зависимостей, которыми Boot уже управляет, «на всякий случай».
Фраза «на всякий случай» в dependency management звучит примерно как «на всякий случай отключу тормоза, вдруг они мешают». Обычно никаких плюсов это не даёт, зато увеличивает шанс рассинхрона со всем стеком. Если причина не формулируется одним предложением, то, скорее всего, причины нет.
Ошибка №5: ожидать, что managed versions всегда означают «самое новое».
Boot не обещает «самое свежее из интернета». Boot обещает «согласованное и проверенное вместе». Иногда это совпадает с самым новым, иногда отстаёт на релиз-два — и это нормально, потому что платформа выбирает стабильность связки, а не гонку номеров версий.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ