01-spring-foundations

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.

October 8, 2026
spring-bootspring-initializrstartersgradleproject-structuredependency-management

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:

bash
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.zip

A 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:

text
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-webmvc

So in your own build, one line pulls all five:

groovy
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

StarterBrings
spring-boot-starter-webSpring MVC, embedded Tomcat, Jackson — the servlet-stack REST default
spring-boot-starter-webfluxReactive stack on Netty. An alternative to web, not a companion
spring-boot-starter-data-jpaSpring Data JPA, Hibernate, transaction management
spring-boot-starter-jdbcJdbcClient/JdbcTemplate and HikariCP, without an ORM
spring-boot-starter-validationJakarta Bean Validation (Hibernate Validator) for @Valid
spring-boot-starter-securityAuthentication and authorisation
spring-boot-starter-actuatorHealth, metrics, and the /actuator/conditions endpoint from guide 2
spring-boot-starter-testJUnit 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:

groovy
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:

ConfigurationMeaning
implementationNeeded to compile and run
runtimeOnlyNeeded only at runtime — a JDBC driver, a logging backend
compileOnlyNeeded only to compile — annotations processed away
testImplementationTest code only
developmentOnlyExcluded 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:

groovy
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():

groovy
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:

text
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:

java
@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:

text
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:

bash
./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:

bash
./gradlew bootRun

And add spring-boot-devtools on the developmentOnly configuration for automatic restart on recompile:

groovy
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/conditions is your auto-configuration debugger.
  • A profile layout — application.yml for shared settings, application-dev.yml and application-prod.yml for 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 .gitignore that covers build/, .gradle/, .env and IDE files — Initializr's covers the basics.
  • One real test that starts the context. The generated contextLoads test 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

  1. Generate from Initializr. Compatible dependencies and correct structure for free.
  2. A starter is a curated dependency list — no logic. It makes a classpath decision that auto-configuration then acts on.
  3. The dependency-management plugin supplies versions so you don't write them, and don't hit incompatible combinations.
  4. The main class goes in the root package. Component scanning starts there; a misplaced main class is the classic invisible-bean bug.
  5. 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.