Project Setup: Initializr, Starters and Package Structure
What a starter actually contains, why dependency management means you stop writing versions, and the package layout component scanning rewards.
Start From Initializr, Always
start.spring.io generates a correct, buildable Spring Boot project. Use it even when you know exactly what you want, because it gets three things right that are tedious to assemble by hand: a dependency set that is version-compatible, a build file wired to the Spring Boot plugin, and a main class positioned correctly for component scanning.
The same generator is built into IntelliJ IDEA (New Project → Spring Boot) and available from the command line:
curl https://start.spring.io/starter.zip \
-d dependencies=web,data-jpa,postgresql,validation,actuator \
-d bootVersion=4.0.0 \
-d javaVersion=25 \
-d type=gradle-project \
-d groupId=com.example -d artifactId=shop \
-d packageName=com.example.shop \
-o shop.zipA note on versions, since this phase sits at the start of a roadmap you may work through over months: Spring Boot 4.0 shipped in November 2025 alongside Spring Framework 7. Its baseline is Java 17, and Java 25 — the current LTS — is the better choice for new work. If you are on an existing Boot 3.x service, the 3.5.x line is the sensible place to sit; our Spring Boot 4 write-up covers what the upgrade actually costs. Nothing in this phase's fundamentals changed between 3 and 4.
What a Starter Actually Is
A starter is a published module with almost no code in it — just a dependency list. Starters are themselves published as POMs (that is the format Maven Central uses, whatever build tool consumes them), and the whole of spring-boot-starter-web amounts to this set of dependencies:
org.springframework.boot:spring-boot-starter
org.springframework.boot:spring-boot-starter-json
org.springframework.boot:spring-boot-starter-tomcat
org.springframework:spring-web
org.springframework:spring-webmvcSo in your own build, one line pulls all five:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
}That is it — a curated, compatible set of dependencies under one coordinate. There is no clever logic. The starter's only job is to put the right JARs on your classpath.
Which, from the previous guide, is the entire point: those JARs carry AutoConfiguration.imports files, and the classes they bring satisfy @ConditionalOnClass. A starter is a classpath decision; auto-configuration turns that into beans. The two halves of the model fit together exactly here.
The starters worth knowing early
| Starter | Brings |
|---|---|
spring-boot-starter-web | Spring MVC, embedded Tomcat, Jackson — the servlet-stack REST default |
spring-boot-starter-webflux | Reactive stack on Netty. An alternative to web, not a companion |
spring-boot-starter-data-jpa | Spring Data JPA, Hibernate, transaction management |
spring-boot-starter-jdbc | JdbcClient/JdbcTemplate and HikariCP, without an ORM |
spring-boot-starter-validation | Jakarta Bean Validation (Hibernate Validator) for @Valid |
spring-boot-starter-security | Authentication and authorisation |
spring-boot-starter-actuator | Health, metrics, and the /actuator/conditions endpoint from guide 2 |
spring-boot-starter-test | JUnit 5, AssertJ, Mockito, @SpringBootTest — included by default |
spring-boot-starter-validation is a frequent surprise. @Valid on a controller parameter compiles without it, and silently does nothing — validation annotations are simply never enforced. It was bundled with starter-web long ago and hasn't been since Boot 2.3. If @NotBlank isn't rejecting blank input, check this dependency first.
Dependency Management: Why You Stop Writing Versions
A generated build.gradle opens with this:
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.0'
id 'io.spring.dependency-management' version '1.1.7'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}Notice that no dependency carries a version. That is the io.spring.dependency-management plugin at work: it applies spring-boot-dependencies, a bill of materials pinning several hundred artifacts — Jackson, Hibernate, Netty, SLF4J, Micrometer, the whole transitive world — to versions tested together against that Boot release. The version comes from the org.springframework.boot plugin version you declared.
This solves a genuinely nasty class of problem. Spring Data JPA and Spring Security both depend on Spring Framework; pick versions independently and you can land a combination that compiles and then fails at runtime with NoSuchMethodError. One managed version set removes that failure mode.
Three configurations appear above and the distinction is worth keeping straight:
| Configuration | Meaning |
|---|---|
implementation | Needed to compile and run |
runtimeOnly | Needed only at runtime — a JDBC driver, a logging backend |
compileOnly | Needed only to compile — annotations processed away |
testImplementation | Test code only |
developmentOnly | Excluded from the production artifact — DevTools |
runtimeOnly for the PostgreSQL driver is the idiomatic choice: your code never imports it, Spring loads it reflectively from the classpath. Putting it on implementation works and widens your compile classpath for nothing.
To override a managed version, set the documented property rather than pinning the dependency:
ext['postgresql.version'] = '42.7.4'./gradlew dependencies shows the resolved graph, and ./gradlew dependencyInsight --dependency postgresql explains why a particular version won — which is the one you actually want on a "but I declared version X" problem. Learn the second command; it answers the question the first only hints at.
Gradle's own platform support
The io.spring.dependency-management plugin predates Gradle having native BOM support. Gradle can now consume a BOM directly with platform():
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.0'
}
dependencies {
implementation platform('org.springframework.boot:spring-boot-dependencies:4.0.0')
implementation 'org.springframework.boot:spring-boot-starter-web'
}Either approach gives you managed versions. The differences are real but narrow: the Spring plugin lets you override versions with ext['...'] properties as above and applies the BOM to every configuration automatically, while platform() is standard Gradle with no extra plugin and uses Gradle's own constraint rules, which are stricter — a version you declare explicitly can be rejected rather than silently winning.
Use the Spring plugin unless you have a reason not to; it is what Initializr generates and what most documentation assumes.
Package Structure, and Why It Isn't Cosmetic
Recall from guide 2 that @ComponentScan defaults to the package of the class annotated with @SpringBootApplication, and everything below it. That single rule dictates the layout:
com.example.shop
├── ShopApplication.java ← the root. everything below is scanned
├── order
│ ├── OrderController.java
│ ├── OrderService.java
│ ├── OrderRepository.java
│ └── Order.java
├── catalog
│ ├── CatalogController.java
│ └── ...
└── config
└── SecurityConfig.java
Two consequences follow, and the first is the single most common setup bug in Spring Boot:
Put ShopApplication in com.example.shop.app and a @Service in com.example.shop.order, and that service is never found. No error, no warning — just a NoSuchBeanDefinitionException later, or more confusingly, an endpoint that 404s because its controller was never registered. The main class belongs in the root package of your application. When something you annotated doesn't exist, check this before anything else.
Second: this is a good default, not a constraint. You can scan elsewhere when you genuinely need to — a shared library module, say:
@SpringBootApplication(scanBasePackages = { "com.example.shop", "com.example.shared" })Package by feature, not by layer
The structure above groups by feature (order, catalog). The alternative groups by layer:
com.example.shop
├── controller ← every controller in the app
├── service ← every service
├── repository
└── model
Layer-first is what most tutorials show, and it is fine for a sample app. It degrades at scale for a specific reason: a change to ordering touches four packages, and nothing in the structure tells you where the ordering feature begins or ends. Feature-first keeps related code together, makes a feature's surface visible, and leaves the door open to extracting a module later. The cohesion argument is the same one behind the modular monolith — and it applies inside a single service too.
Either way, keep classes package-private unless they are genuinely part of a feature's API. OrderService used only within com.example.shop.order need not be public. The compiler then enforces your boundaries, which is a far stronger guarantee than a naming convention.
Check yourself
A new @RestController is added at com.example.shop.api.OrderController. The main class is com.example.shop.ShopApplication. The endpoint returns 404. What is the most likely cause?
The Build Plugin and the Runnable JAR
The org.springframework.boot plugin adds a bootJar task, which produces an executable JAR:
./gradlew clean bootJar
java -jar build/libs/shop-0.0.1-SNAPSHOT.jar./gradlew build runs bootJar as part of a full build with tests, which is what CI should use.
That artifact is a Spring Boot fat JAR — your classes plus every dependency JAR nested inside, with a custom loader that reads nested JARs directly. It is not a shaded JAR: dependencies are kept whole rather than unpacked and merged, so no file-level conflicts between libraries and signed JARs keep working.
For development, prefer:
./gradlew bootRunAnd add spring-boot-devtools on the developmentOnly configuration for automatic restart on recompile:
developmentOnly 'org.springframework.boot:spring-boot-devtools'developmentOnly is the configuration that keeps it out of bootJar entirely — Gradle's equivalent of Maven's optional scope for this purpose, and a cleaner guarantee. DevTools also disables itself when running from a fat JAR, so there are two independent reasons it cannot follow you into production.
A Checklist for a New Service
What Initializr doesn't do for you, and is worth doing on day one:
spring-boot-starter-actuator— health and metrics are not a later concern, and/actuator/conditionsis your auto-configuration debugger.- A profile layout —
application.ymlfor shared settings,application-dev.ymlandapplication-prod.ymlfor the rest. The next guide covers this properly. - No secrets in the repo. Database passwords and API keys come from environment variables. Next guide.
- A
.gitignorethat coversbuild/,.gradle/,.envand IDE files — Initializr's covers the basics. - One real test that starts the context. The generated
contextLoadstest looks trivial and is not: it catches every misconfiguration that only appears at startup, which is most of them.
Check yourself
You declare a dependency with an explicit version that differs from the one Spring Boot's dependency management supplies. What happens?
The Mental Model, Restated
- Generate from Initializr. Compatible dependencies and correct structure for free.
- A starter is a curated dependency list — no logic. It makes a classpath decision that auto-configuration then acts on.
- The dependency-management plugin supplies versions so you don't write them, and don't hit incompatible combinations.
- The main class goes in the root package. Component scanning starts there; a misplaced main class is the classic invisible-bean bug.
- Package by feature, and keep classes package-private where you can, so the compiler enforces boundaries.
What's Next
You have a project that builds, runs, and finds its beans. What it doesn't yet have is anything environment-specific — and hardcoded URLs and credentials are the next thing that will hurt. The final guide in this phase covers externalised configuration: @ConfigurationProperties for type-safe binding, profiles for per-environment differences, and the property precedence order that explains why the value you set isn't the value you got.