1. Роль главного класса в Boot
Стартеры и зависимости задают, какой рантайм Boot вообще может собрать. Но одного classpath мало: от этого приложение само не оживает. Нужен якорь, от которого этот рантайм начнёт подниматься, — класс, по которому Boot поймёт, что запускать и где искать остальное приложение.
Если вы раньше писали в основном консольные приложения, мысль «главный класс — это тот, где main()» кажется очевидной и почти скучной. В Spring Boot это по-прежнему так, но появляется второй слой смысла: главный класс — не только точка входа JVM, но и «географическая отметка», от которой Spring начинает искать ваши компоненты. То есть это одновременно и стартовая кнопка, и карта местности. Если карта начинается не там, потом придётся долго искать «потерявшиеся» бины.
В обычной Java-программе класс с main() можно положить куда угодно, хоть в пакет com.example.tmp.justforfun: JVM не волнует ваша архитектурная эстетика. Spring Boot тоже не обижается на эстетические решения, но у него есть вполне инженерная причина предпочитать один главный класс в верхнем пакете: Boot построен вокруг идеи convention over configuration. То есть он старается делать за вас разумные предположения. Одно из них такое: «раз главный класс лежит в пакете X, значит, проект, скорее всего, живёт внутри X и его подпакетов».
И тут важно договориться о термине: когда мы говорим «главный класс», обычно имеем в виду один и тот же класс, который одновременно:
- помечен @SpringBootApplication;
- содержит public static void main(String[] args);
- передаётся в SpringApplication.run(...).
В нашем проекте это будет CatalogServiceApplication. Он не должен быть «самым умным» классом приложения. Скорее наоборот: чем скучнее он выглядит, тем лучше. Его задача — стать стабильным якорем, от которого Spring соберёт приложение.
2. Состав @SpringBootApplication
На первых этапах Spring часто кажется набором заклинаний: написал одну аннотацию — и «всё само». В Boot это ощущение ещё сильнее, потому что @SpringBootApplication выглядит как одна супер-кнопка. Но это не магия, а композиция уже знакомых вам механизмов. Как только вы мысленно раскладываете её на части, становится спокойнее: перед вами не «необъяснимая сила», а три понятных рычага, которые вместе и дают Boot его стартовую мощь.
Начнём с канонического вида главного класса Boot-приложения. Именно такой класс должен быть в catalog-service, и дальше весь маршрут старта будет крутиться вокруг него:
package com.example.catalogservice;
// Точка входа Spring Boot: отсюда мы запускаем приложение
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication // Главная “комбо-аннотация”: конфигурация + scan + автоконфигурация
public class CatalogServiceApplication {
public static void main(String[] args) {
// Запускаем Spring-контейнер и весь Boot-runtime
SpringApplication.run(CatalogServiceApplication.class, args);
}
}
Это и есть канонический базовый вид приложения — наш CatalogServiceApplication. Дальше будут меняться только отдельные строки в main(), а сам класс останется тем же.
Здесь переплетаются две роли: обычная Java-точка входа (main) и Boot-точка входа (@SpringBootApplication + SpringApplication.run(...)). Важно не смешивать их: main() отвечает за то, как JVM начинает программу, а @SpringBootApplication — за то, как Spring понимает, что именно нужно собрать.
Теперь главное: @SpringBootApplication на самом деле объединяет три идеи. На первом проходе удобно думать о ней так, будто вы написали вот это:
package com.example.catalogservice;
// “Распаковка” @SpringBootApplication на составные части
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration // Говорим Spring: “это конфигурационный класс”
@EnableAutoConfiguration // Разрешаем Boot подтягивать автоконфигурацию по classpath и properties
@ComponentScan // Включаем поиск компонентов (стартует с пакета этого класса)
public class CatalogServiceApplication {
// Обычно тут пусто: главный класс держим максимально минимальным
}
Этот пример специально выглядит чуть «некрасиво» — так и задумано. В реальном коде вы почти всегда будете писать одну аннотацию @SpringBootApplication, потому что так читать проще. Но для понимания полезно один раз увидеть распаковку.
Давайте проговорим смысл каждой части так, чтобы она «цеплялась» за то, что вы уже знаете.
| Часть внутри @SpringBootApplication | Что это даёт | Как проявится в catalog-service |
|---|---|---|
| @Configuration | Класс становится источником конфигурации Spring: в нём можно объявлять @Bean, и Spring будет воспринимать его как «настройку приложения». | Мы будем держать конфигурацию в отдельных классах, но главный класс всё равно останется центральной стартовой конфигурационной точкой. |
| @ComponentScan | Spring начинает сканировать пакеты и искать ваши компоненты (@Component, @Service, @Configuration, …). | Если вы положите CatalogServiceApplication в корневой пакет, Spring увидит все подпакеты проекта. |
| @EnableAutoConfiguration | Spring Boot включает «умолчания»: автоматически добавляет инфраструктурные бины и настройки на основе classpath и свойств. | Благодаря этому Boot поднимет нужный рантайм без ручной сборки «по болтикам». Здесь важен сам факт механизма, без ухода в детали. |
Если хочется запомнить это совсем по-человечески, можно представить такую схему:
# Схема: из каких частей состоит @SpringBootApplication
@SpringBootApplication
├─ @Configuration -> "вот откуда берётся конфигурация"
├─ @ComponentScan -> "вот где искать ваши компоненты"
└─ @EnableAutoConfiguration -> "включи boot-умолчания"
И вот теперь ключевая мысль: среди этих трёх частей самая чувствительная к расположению класса — @ComponentScan. Именно поэтому «где лежит главный класс» — не придирка, а реальный фактор работоспособности приложения.
3. Пакет главного класса и component scan
Пакеты в Java — не просто «папки для порядка». В Spring они становятся границами, по которым фреймворк решает, что считать «нашим кодом» и что включать в контекст. По умолчанию @ComponentScan (который «внутри» @SpringBootApplication) стартует сканирование с пакета, где лежит класс, и идёт вниз по подпакетам.
То есть если CatalogServiceApplication лежит в пакете:
com.example.catalogservice
то Spring будет сканировать:
com.example.catalogservice.*
и увидит всё, что вы положите внутрь: config, catalog, actuator, support и так далее. Для нашего проекта это выглядит максимально естественно:
com.example.catalogservice
├─ CatalogServiceApplication
├─ config
├─ catalog
└─ actuator
А теперь покажу «подлянку», на которую наступают очень многие, особенно когда IDE предлагает «создать пакет покрасивее». Допустим, вы положили главный класс вот так:
package com.example.catalogservice.catalog.web;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Плохой вариант: главный класс слишком глубоко в дереве пакетов
// Component scan стартует именно отсюда и “не видит” соседние/родительские пакеты.
@SpringBootApplication
public class CatalogServiceApplication {
// Даже если тут будет main(), граница сканирования всё равно останется этой.
}
На первый взгляд это выглядит логично: «раз приложение про каталог, пусть главный класс живёт рядом с web». Но Spring читает это иначе. Для него это сигнал: «окей, твой проект начинается в com.example.catalogservice.catalog.web, значит, сканировать нужно только его и подпакеты».
И тут появляется проблема: пакеты вроде com.example.catalogservice.config или com.example.catalogservice.catalog.service — это не подпакеты catalog.web. Они находятся выше или рядом, а значит, не попадут в область сканирования.
Если бы это сводилось к «не нашли пару классов», можно было бы пожать плечами. Но обычно всё заканчивается тем, что приложение не стартует или стартует странно «полупустым». Самый частый симптом — ошибка на старте про недостающий бин: один компонент зависит от другого, а второго в контексте просто нет.
Типичный фрагмент ошибки выглядит примерно так. Не пугайтесь: это не «ужас Spring», а вполне логичный диагноз.
# Симптом: Spring не нашёл бин, потому что component scan “не дошёл” до нужного пакета
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException:
No qualifying bean of type 'com.example.catalogservice.catalog.service.CourseCatalogService' available
Человеческий перевод такой: «я пытался собрать объект, которому нужен CourseCatalogService, но такого бина в контексте просто нет». И дальше начинается классический квест начинающего разработчика: «но он же помечен @Service, почему не нашёлся?». Ответ банальный: потому что @Service — не телепорт в контекст. Это только метка, а найти её должен component scan. А scan до нужного пакета просто не дошёл.
Отсюда рождается очень практическое правило, которое мы фиксируем для catalog-service уже сейчас, до всех веб-контроллеров и прочего: главный класс лежит в самом верхнем пакете приложения. Не «рядом с web», не «рядом с доменом», не «куда IDE перетащит», а именно в корне.
Для нашего курса это будет:
com.example.catalogservice.CatalogServiceApplication
И всё остальное — внутри com.example.catalogservice.*.
4. Главный класс вне корня
Иногда жизнь (или коллега, который «чуть-чуть прибрался») уже положила главный класс не туда, и вы внезапно ловите NoSuchBeanDefinitionException. Чаще всего это лечится не магическими настройками, а возвратом к нормальной структуре: переносом CatalogServiceApplication в корневой пакет. Это самый дешёвый и понятный ремонт, потому что он возвращает соглашения Boot на место.
Но бывает и другой сценарий: у вас действительно есть код вне корневого пакета приложения. В учебном проекте мы так делать не будем, но полезно знать, что Spring не оставляет вас без ручки управления. Можно явно сказать, откуда начинать сканирование, даже если главный класс лежит глубже. Например, через параметр scanBasePackages:
package com.example.catalogservice.catalog.web;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Компромиссный вариант: вручную задаём корень сканирования
// Работает, но часто маскирует проблему структуры пакетов.
@SpringBootApplication(scanBasePackages = "com.example.catalogservice")
public class CatalogServiceApplication {
}
Технически это работает: Spring будет сканировать com.example.catalogservice и увидит нужные компоненты. Но методически и инженерно это чаще всего костыль, который маскирует структурную проблему. Вы как будто говорите фреймворку: «слушай, я специально положил главную точку входа в странное место, но ты, пожалуйста, делай вид, что это не важно».
Иногда это оправдано — например, если вы пишете библиотеку или не можете менять package layout. Но в обычном приложении проще и честнее держать главный класс в корне. Так и приложение легче объяснять, и отладка проще, и случайных сюрпризов меньше.
И ещё один важный нюанс: когда вы «вручную» управляете сканированием, вы берёте на себя ответственность за то, чтобы оно не стало чрезмерным. Новички иногда ставят scanBasePackages = {"com"} (да-да, было и такое), после чего Spring начинает сканировать половину планеты, включая чужие классы на classpath, а вы получаете странные конфликты и «лишние» бины. Это не путь самурая, это путь «почему оно вообще живёт».
Главный класс: минимализм
Очень хочется сделать главный класс «главным» в буквальном смысле: и main, и конфигурация, и немного бизнес-логики, и пару System.out.println, и «вот тут я быстренько создам сервис через new». Но в Spring Boot главный класс выигрывает именно тогда, когда остаётся минимальным. Он не центр доменной логики. Он центр старта и сборки контейнера.
Поэтому у CatalogServiceApplication есть несколько простых правил хорошего тона.
Во-первых, не стоит вручную создавать прикладные объекты через new прямо в main(). Если вы делаете так, вы как будто строите дом, а потом говорите архитектору: «спасибо, но стены я сам из коробок сложу». Spring-контейнер нужен именно для того, чтобы управлять зависимостями и жизненным циклом. Ручная сборка в main ломает эту идею и приводит к тому, что часть объектов живёт «вне» Spring, а часть — «внутри». Это почти всегда дорога к странным багам.
Во-вторых, главный класс не должен разрастаться в конфиг-монстра с десятками @Bean методов. Да, технически @SpringBootApplication включает @Configuration, и вы можете писать там @Bean. Но это место лучше держать чистым: пусть главный класс остаётся точкой старта, а конфигурация живёт в тематических конфиг-классах. И да, они тоже должны лежать в подпакетах корня, чтобы component scan их увидел.
В-третьих, в приложении обычно должен быть один класс с @SpringBootApplication. Несколько таких классов возможны — например, для разных режимов запуска, демо или тестов, — но для новичка это частая причина путаницы: вы запускаете «не тот main», а потом удивляетесь, почему половины бинов нет. Если нужно поэкспериментировать, лучше временно менять код в одном месте, а не плодить альтернативные точки входа, которые потом забудутся в репозитории как «второй чайник на кухне».
Когда такой якорь стоит на месте и не тянет на себя лишнюю логику, следующий вопрос уже не про пакеты, а про сам запуск: что именно делает SpringApplication.run(...), когда вы нажимаете старт, и как из этого класса появляется живой рантайм.
5. Типичные ошибки при работе с главным классом
Ошибка №1: положить CatalogServiceApplication слишком глубоко, потому что «так красивее».
Это очень человеческая ошибка: хочется, чтобы всё лежало «по папочкам», а главный класс вроде бы относится к web-слою — значит, пусть живёт в catalog.web. Но Spring воспринимает это как границу component scan и перестаёт видеть пакеты-соседи (config, catalog.service, actuator). Почти всегда лечится одним движением: переносом главного класса в корневой пакет приложения.
Ошибка №2: считать, что @SpringBootApplication — это аннотация «запустить сервер».
На практике она делает больше: включает конфигурацию, запускает component scan и разрешает Boot применять автоконфигурацию. Если думать о ней только как о «сервере», вы потом неизбежно удивитесь, почему «не подхватились компоненты» или «не применились умолчания». Полезно держать в голове распаковку на три части: @Configuration, @ComponentScan, @EnableAutoConfiguration.
Ошибка №3: лечить проблему структуры пакетов чрезмерным @ComponentScan или scanBasePackages «на всякий случай».
Иногда студент видит, что что-то не находится, и начинает расширять сканирование всё шире: сначала до com.example, потом до com, потом до «ну пусть всё сканирует». В итоге появляются лишние бины, конфликты, неожиданные автосрабатывания и ощущение, что Spring «живёт своей жизнью». Чаще всего правильнее исправить причину — положение главного класса и структуру пакетов, — чем увеличивать зону поиска до размеров континента.
Ошибка №4: писать «важный код» до SpringApplication.run(...) в main().
Кажется логичным: «перед стартом приложения прочитаю файл, соберу список курсов, создам сервис». Но до run у вас ещё нет контейнера, нет конфигурации, нет нормального DI — вы всё ещё в мире «ручной Java». В результате вы сами себе строите второй параллельный мир объектов, а потом удивляетесь, почему их нельзя нормально внедрить в Spring. Главный класс должен запускать Spring, а не пытаться заменить его.
Ошибка №5: завести несколько классов с @SpringBootApplication и запускать «какой-то из них».
IDE иногда делает это слишком легко: создали новый класс, поставили аннотацию, добавили main, и вот у вас уже «вторая точка входа». Потом вы запускаете не тот класс, получаете другой набор scanned-пакетов и другой контекст. Если очень нужно несколько вариантов запуска — это делается осознанно и обычно сопровождается явным выбором main class в настройках запуска, а не случайным кликом по зелёной кнопке.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ