Scaffolding a Spring Boot Application
Published Updated Spring Boot 12 min read
Generating a project from the command line without opening a browser, what a starter actually contains, and the package-placement rule that decides whether component scanning finds your beans.
A new Spring Boot project is a generated directory, and the generator is worth knowing beyond the web
form. start.spring.io is an HTTP API, which means a project can be scaffolded from a shell script,
and the one structural decision it makes for you, where the main class lives, is the one that
silently breaks component scanning if you move it later.
Written against Spring Boot 3.2, Maven and Java 17.
Spring Initializr from the command line
The site is a front end over an API that answers plain curl:
curl https://start.spring.io/starter.zip \
-d type=maven-project \
-d language=java \
-d bootVersion=3.2.2 \
-d javaVersion=17 \
-d groupId=com.example \
-d artifactId=articles-api \
-d packageName=com.example.articles \
-d dependencies=web,data-jpa,validation,postgresql,actuator \
-o articles-api.zip
unzip articles-api.zip -d articles-api
dependencies takes the starter ids, comma-separated. To see what is available for a given Boot
version:
curl -s https://start.spring.io/dependencies | head -40
curl -s https://start.spring.io -H 'Accept: text/plain'
The second prints the whole capability list as a readable table (every id, every supported Boot version, every Java version) which is faster than the web form once you know what you want.
type accepts maven-project, gradle-project and gradle-project-kotlin. packaging=war exists
and is almost never what you want; an executable jar with an embedded server is the default for good
reason.
What a starter actually is
A starter contains no code. It is a POM listing the dependencies for one capability, so that
spring-boot-starter-data-jpa pulls Hibernate, Spring Data JPA, the transaction manager and a
connection pool as one coherent set.
./mvnw dependency:tree -Dincludes=org.hibernate*
The version numbers come from spring-boot-dependencies, a bill of materials imported by the parent
POM. That is why a Spring Boot POM lists dependencies without versions:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.2</version>
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Overriding one of those versions by hand is possible and usually a mistake, the set is tested together, and a newer Hibernate against an older Spring Data is the kind of incompatibility that appears at runtime.
If a parent POM is unavailable because the project already has one, import the BOM instead:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.2.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
The generated layout, and the rule inside it
articles-api/
├── mvnw, mvnw.cmd, .mvn/
├── pom.xml
└── src/
├── main/java/com/example/articles/ArticlesApiApplication.java
├── main/resources/application.properties
└── test/java/com/example/articles/ArticlesApiApplicationTests.java
package com.example.articles;
@SpringBootApplication
public class ArticlesApiApplication {
public static void main(String[] args) {
SpringApplication.run(ArticlesApiApplication.class, args);
}
}
@SpringBootApplication is three annotations: @Configuration, @EnableAutoConfiguration and
@ComponentScan. The last one scans the package of the annotated class and everything below it,
and that is the structural rule the generator is quietly enforcing by putting the main class at the
root package.
Move it into com.example.articles.config and every @Service and @Controller in
com.example.articles.web becomes invisible. The failure is a NoSuchBeanDefinitionException, or —
worse, an endpoint that simply 404s because the controller was never registered.
The fix is to move the class back rather than to add @ComponentScan("com.example.articles"), which
works and hides the convention from the next reader.
The Maven wrapper
mvnw is not decoration either. It downloads the exact Maven version recorded in
.mvn/wrapper/maven-wrapper.properties and uses it, so the build does not depend on what is
installed:
./mvnw clean package
./mvnw spring-boot:run
java -jar target/articles-api-0.0.1-SNAPSHOT.jar
Commit mvnw, mvnw.cmd and the .mvn directory. A CI image then needs a JDK and nothing else.
./mvnw spring-boot:run and java -jar are not equivalent during development. The plugin runs the
application with src/main/resources on the classpath as a directory, so a template or a properties
file edited on disk is picked up; the jar carries its own copy. That difference is behind most
“my change had no effect” confusion early in a project.
Spring Boot CLI
The CLI is a separate tool that wraps the same Initializr API:
sdk install springboot
spring init --list
spring init --build=maven --java-version=17 \
--dependencies=web,data-jpa,postgresql \
--package-name=com.example.articles \
articles-api
spring init --list prints the same capability table as the curl above. The CLI also runs Groovy
scripts as applications, which is a demo feature rather than something to build on.
Whether it is worth installing depends on how often you scaffold. For occasional use the curl form
needs nothing installed and is easy to keep in a shell function.
Configuration to set immediately
The generated application.properties is empty. Three settings earn their place before any code:
spring.application.name=articles-api
server.port=${PORT:8080}
management.endpoints.web.exposure.include=health,info
The name appears in logs, metrics and traces, and is what distinguishes one service from another once there is more than one. Reading the port from the environment costs nothing now and is required by every managed platform later. The health endpoint is what a container orchestrator or a load balancer probes: the Actuator walkthrough covers the rest of what it exposes.
What the generated test is for
@SpringBootTest
class ArticlesApiApplicationTests {
@Test
void contextLoads() { }
}
An empty test body looks like a placeholder and is not one. @SpringBootTest starts the whole
application context, so this test fails whenever a bean cannot be constructed, a required property is
missing, or two beans of the same type exist with no way to choose between them. It is a build-time
check that the application can start at all, and it catches a surprising share of configuration
mistakes before anything is deployed.
It is also the slowest kind of test, because it starts everything. Keep it as the single smoke test
and use narrower slices for the rest: @WebMvcTest for a controller with the web layer only,
@DataJpaTest for repositories against an in-memory or containerised database. Each starts a subset
of the context and runs in a fraction of the time.
If the context fails to load, the message is long and the useful line is near the bottom: Spring
prints a Description and an Action block that names the missing bean or property directly.
Choosing the Java version
The generator offers the versions its Boot release supports, and the sensible choices are the long-term-support releases, 17 and 21. Spring Boot 3 requires 17 as a floor, so that is the lowest option available.
<properties>
<java.version>17</java.version>
</properties>
Changing it later is that one property plus a rebuild, so this is not a decision to labour over. The practical reason to take 21 is virtual threads; the practical reason not to is whatever in the deployment chain has not been tested on it yet.
Package by feature, not by layer
The generator gives you one package; the first structural decision is what goes beside it. The reflexive layout groups by technical role:
com.example.articles
├── controller
├── service
├── repository
└── model
It works, and it means every change touches four packages. Grouping by feature keeps a change local:
com.example.articles
├── article (ArticleController, ArticleService, ArticleRepository, Article)
├── comment
└── common
Both satisfy the component-scan rule as long as they sit below the main class. The second makes it possible to see what an application does from its directory listing.
Once the project exists, the REST API walkthrough is the next step. More in the Spring Boot guides.
Frequently asked questions
Can I generate a project without the website?
Yes. start.spring.io is an HTTP API, curl it
with -d parameters and unzip the result. curl -s https://start.spring.io -H 'Accept: text/plain'
lists every option.
What does a starter contain?
No code: a POM that pulls a tested set of dependencies for one
capability. The versions come from the spring-boot-dependencies bill of materials.
Should I override a dependency version from a starter?
Rarely, the set is tested together; replacing one member is how you get a runtime incompatibility that no compile catches.
Where must the main class live?
In the root package of the application. @SpringBootApplication
scans its own package and everything below it, so a main class in a sub-package makes its siblings
invisible.
Why does Spring say there is no bean of my service type?
Most often the class is outside the
component-scan root. Check where the @SpringBootApplication class sits relative to the bean.
Do I need Maven installed?
No, if the wrapper is committed. ./mvnw downloads the recorded Maven
version, which also makes the build reproducible across machines.
Jar or war packaging?
Jar. An executable jar with an embedded server is the default and the simplest thing to run anywhere. War exists for deploying into an existing application server.
Is the Spring Boot CLI worth installing?
Only if you scaffold often. It wraps the same API as the
curl form and adds a Groovy runner that is a demo feature rather than a foundation.
Package by layer or by feature?
By feature. Both satisfy the scanning rule; grouping by feature keeps a change inside one package and makes the domain visible in the directory listing.
What should go into application.properties first?
spring.application.name, a port read from the
environment, and the health endpoint. All three are needed the moment the application runs anywhere
other than a laptop.