Oppitunti 19/159
Kirjallinen oppitunti · 19 min

Creating a Spring Boot Application

Generating the project for `BACKEND-101`, with only the dependencies the ticket needs.

Esikatselu

Katselet ilmaista esikatseluoppituntia.

Until now, we have intentionally avoided writing production code. We worked from the product requirement through acceptance criteria, engineering context, the backlog, the technical design, the RFC and ADRs to the design review. The design is now clear enough, and it is time to begin implementation with our first ticket, BACKEND-101 Bootstrap Order Management Backend.

In this lesson, we will create a minimal but production-oriented Spring Boot application. The goal is not to create as many folders, dependencies or abstractions as possible. The goal is:

Create a clean, reproducible Java application foundation on which the entire Order Management Backend can evolve.

Ticket: BACKEND-101

Our initial backlog defined the ticket as:

markdown
# BACKEND-101 — Bootstrap Order Management Backend

## Context

We need the initial Spring Boot application for backend implementation.

## Scope

- Create the Gradle project.
- Configure Spring Boot.
- Establish the application entry point.
- Add baseline project configuration.
- Verify the application can build and start locally.

## Acceptance Criteria

- The project builds successfully with Gradle.
- The Spring Boot application starts successfully.
- The repository contains the agreed baseline project structure.
- A clean checkout can be built without IDE-specific setup.

## Out of Scope

- Product APIs
- Database schema
- Authentication
- Order logic
- Payment integration
- Production Docker setup

This scope will guide our implementation.

Start With the Ticket, Not Spring Initializr

Creating a Spring Boot project technically takes only a few minutes. But a backend engineer's first question should not be "Which checkboxes should I select?" The better question is:

What is the minimum required to complete this ticket correctly?

Right now, we need a Java project with a Gradle build, the Spring Boot runtime, an application entry point, basic configuration and a repeatable build. We do not currently need PostgreSQL, Spring Data JPA, Flyway, Spring Security, an HTTP client, Testcontainers or a payment SDK. Those will arrive when later tickets actually require them.

Avoid Dependency Shopping

A common mistake when creating a Spring Boot project is immediately adding Spring Web, Spring Data JPA, PostgreSQL, Spring Security, Redis, Kafka, Actuator, Validation, WebFlux and Lombok, because "we will probably need them eventually". That creates a larger dependency surface, more configuration, more startup behaviour, more things to understand and more accidental architecture.

Our principle is:

Add a dependency when a current engineering requirement needs it.

The fact that a dependency may become useful later is not enough reason to install it now.

Technology Choices and Repository Name

Our engineering context already fixed the language (Java), the framework (Spring Boot) and the build tool (Gradle), so we do not repeat a technology comparison here. The project repository is order-management. Initially, it contains only the Gradle build and src/; files such as docs/, compose.yaml and a Dockerfile arrive with the work that needs them.

Generating the Project With Spring Initializr

Spring Initializr (start.spring.io, also built into IntelliJ IDEA and VS Code) generates the starting project, so nobody writes the wrapper or the first build file by hand. The choices follow directly from the ticket:

FieldValueWhy
ProjectGradle - GroovyOur agreed build tool
LanguageJavaOur agreed language
Spring Boot4.1.2The current release the team pins
Groupcom.tarkevOur organisation
Artifact and nameorder-managementThe repository and application name
Package namecom.tarkev.ordermanagementSet explicitly; the generated default would contain an underscore
PackagingJarSpring Boot runs as an executable JAR
Java21Our Java baseline
DependenciesnoneBACKEND-101 needs none yet

The generated project contains the Gradle Wrapper, build.gradle, settings.gradle, the main class, one test, src/main/resources/application.properties, a .gitignore, a .gitattributes (line endings for the wrapper scripts) and a HELP.md. We keep almost all of it, review every file, and make two small changes described below: configuration in YAML and a README.md in place of HELP.md. A generator is a starting point, not a decision we stop owning.

The Gradle Wrapper and Reproducible Builds

A Gradle project should be built with ./gradlew build (gradlew.bat build on Windows) instead of requiring every developer to install and manually manage a matching Gradle version. The Gradle Wrapper consists of gradlew, gradlew.bat and the gradle/wrapper/ directory, and these files are committed with the project. The wrapper's properties file pins the Gradle distribution the project uses:

<!-- file: gradle/wrapper/gradle-wrapper.properties -->
properties
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip

On first use, the wrapper downloads exactly that version, so a developer machine, the CI environment and another engineer's machine all build with the same Gradle. This directly supports our acceptance criterion: a clean checkout can be built without IDE-specific setup.

A weak project setup "works from IntelliJ on my machine". A stronger one works with git clone followed by ./gradlew build in any supported Java environment. Developers may use IntelliJ IDEA, VS Code or another IDE, but the IDE is not the source of truth for the build: the repository and the Gradle build definition are. This becomes especially important when CI is introduced, because CI has no IDE at all.

The Build Files

settings.gradle has a simple responsibility: it defines the Gradle project name. We do not need application architecture configuration there.

<!-- file: settings.gradle -->
groovy
rootProject.name = 'order-management'

For our choices, Spring Initializr generates this build.gradle:

<!-- file: build.gradle -->
groovy
plugins {
    id 'java'
    id 'org.springframework.boot' version '4.1.2'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.tarkev'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

tasks.named('test') {
    useJUnitPlatform()
}

The three plugins each have one job. java compiles and tests Java code. org.springframework.boot adds the Spring Boot tasks we use below, such as bootRun and bootJar. io.spring.dependency-management imports Spring Boot's managed dependency versions, which is why the starters have no version numbers: the Spring Boot version decides them, so they always fit together. The junit-platform-launcher line is there because Gradle 9 no longer puts the JUnit Platform launcher on the test runtime classpath by itself.

The versions are pinned on purpose, and 0.0.1-SNAPSHOT is simply an early development artifact version; release versioning belongs to later delivery work, not to BACKEND-101.

Why Java Toolchains?

The java { toolchain { ... } } section declares the Java baseline the project expects. Gradle compiles, tests and runs the application with a Java 21 JDK, whichever JDK happens to start Gradle itself, and fails with a clear message when it cannot find one instead of silently using another version. Without it, one engineer building with Java 21, another with a different version and CI with a third could see subtle differences in compilation, language features and runtime behaviour.

Why Only spring-boot-starter?

At this point, the ticket requires only a Spring Boot application that can start successfully. The core starter, spring-boot-starter, is sufficient: it brings Spring Boot, auto-configuration, logging and YAML support. We are not exposing REST endpoints yet. Later work brings its own starters:

  • REST and API work: spring-boot-starter-webmvc
  • Persistence work: spring-boot-starter-data-jpa, the PostgreSQL driver and spring-boot-starter-flyway
  • Security work: spring-boot-starter-security

Dependencies should arrive with a reason. The test starter, spring-boot-starter-test (JUnit Jupiter, AssertJ, Mockito and Spring's test support), is already here because testing is part of our Definition of Done: the Testing module deepens the strategy, but every change already carries tests appropriate to its behaviour.

Source Structure and Base Package

Our project follows the standard Java structure, and Spring Boot works naturally with it:

text
src/
├── main/
│   ├── java/        application source code
│   └── resources/   application resources and configuration
└── test/
    └── java/        automated tests

Our Tarkev namespace is com.tarkev.ordermanagement, so application source lives under src/main/java/com/tarkev/ordermanagement/. Java package names provide a namespace, organise the code and, in Spring Boot, mark the component scanning boundary. Consistency matters more than the exact choice: once the convention is established, we do not need repeated discussions about ordermanagement versus orderservice versus order_management.

Why the Main Class Belongs at the Root

Our application entry point is com.tarkev.ordermanagement.OrderManagementApplication. This placement works naturally with Spring Boot's default component scanning:

text
com.tarkev.ordermanagement
├── OrderManagementApplication
├── product
├── inventory
└── order

Spring can discover application components in subpackages beneath the main class's package. That makes the root a natural home for the main application class.

Application Entry Point

Create the main class:

java
package com.tarkev.ordermanagement;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OrderManagementApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrderManagementApplication.class, args);
    }
}

A normal Java application begins with public static void main(String[] args), and Spring Boot does not change that Java rule: the JVM still starts from main. Inside it, SpringApplication.run(...) bootstraps Spring:

Piirretään kaaviota…

We will explore the Application Context in more detail later in this module.

@SpringBootApplication

The @SpringBootApplication annotation marks our main Spring Boot application configuration. For now, think of it as the application bootstrap annotation: it enables Spring Boot's core startup conventions, including auto-configuration and component discovery. Later lessons in this module look at the Beans and Application Context behind it; what matters now is the responsibility it fulfils, not the annotation name.

Application Configuration

Spring Initializr created src/main/resources/application.properties containing spring.application.name=order-management. Spring Boot supports several configuration formats, and for structured configuration this project uses YAML, so we replace that file with src/main/resources/application.yml:

<!-- file: src/main/resources/application.yml -->
yaml
spring:
  application:
    name: order-management

YAML is a project convention, not an architectural decision: what matters is consistency, not a debate about YAML versus .properties.

The configuration stays very small. It gives the application an explicit name, which later appears in logs, metrics, configuration and deployment; consistent naming from the beginning reduces drift. We do not add future database, payment or security configuration yet.

Even in the bootstrap phase, establish one habit: never commit secrets into configuration. This is bad when committed to Git:

yaml
payment:
  api-key: super-secret-key

Credentials should come from external configuration or the environment. We will design production configuration more fully in the production-readiness module.

Initial Project Structure

After BACKEND-101, our project looks like this:

text
order-management/
├── build.gradle
├── settings.gradle
├── gradlew
├── gradlew.bat
├── gradle/
│   └── wrapper/
└── src/
    ├── main/
    │   ├── java/
    │   │   └── com/
    │   │       └── tarkev/
    │   │           └── ordermanagement/
    │   │               └── OrderManagementApplication.java
    │   └── resources/
    │       └── application.yml
    └── test/
        └── java/
            └── com/
                └── tarkev/
                    └── ordermanagement/
                        └── OrderManagementApplicationTests.java

That is enough for the initial foundation. The product and order packages do not exist yet, because BACKEND-101 does not implement products or orders.

Avoid Architecture by Empty Folder

A poor bootstrap might create:

text
product/     api/ application/ domain/ persistence/ dto/ mapper/
inventory/   api/ application/ domain/ persistence/
order/       api/ application/ domain/ persistence/ dto/ mapper/

while every directory remains empty. That is not meaningful architecture; it is speculative folder generation. Good architecture becomes visible as real responsibilities arrive.

First Build

Run:

bash
./gradlew build

Gradle reads build.gradle, resolves dependencies, compiles the main and test sources, runs the tests and packages the application. A successful build, an important part of BACKEND-101, ends with:

text
BUILD SUCCESSFUL

We build before running because starting the application directly from an IDE can hide repository-level build problems. ./gradlew build verifies the project definition, dependency resolution, compilation, tests and packaging, and we want the repository build to be healthy before feature work begins.

Running the Application

Run:

bash
./gradlew bootRun

Spring Boot starts the application and logs its startup:

text
 :: Spring Boot ::                (v4.1.2)

INFO ... [order-management] ... : Starting OrderManagementApplication using Java 21.0.8 with PID 38668 (...)
INFO ... [order-management] ... : No active profile set, falling back to 1 default profile: "default"
INFO ... [order-management] ... : Started OrderManagementApplication in 0.336 seconds (process running for 0.478)

Then the process exits. At this stage, we have no HTTP server, because we deliberately have not added Spring Web, and nothing else keeps the application running. That is fine. Our acceptance criterion says "Spring Boot application starts successfully". It does not say "GET /api/v1/products works"; product APIs belong to later work. Notice also that the log already carries our application name, order-management.

./gradlew build also produces an executable JAR, build/libs/order-management-0.0.1-SNAPSHOT.jar, which runs without the IDE and without Gradle. This demonstrates that the application is genuinely runnable from its build artifact:

bash
java -jar build/libs/order-management-0.0.1-SNAPSHOT.jar

First Test

Spring Initializr also generated a minimal Spring Boot bootstrap test:

java
package com.tarkev.ordermanagement;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class OrderManagementApplicationTests {

    @Test
    void contextLoads() {
    }
}

The test appears almost empty, but its purpose is specific: Can Spring successfully create the application context? Suppose startup configuration later becomes invalid because of a missing required bean, broken dependency wiring or invalid Spring configuration. An application-context test catches these classes of bootstrap failure.

Don't overvalue a passing contextLoads(), though. It does not prove that business logic, database behaviour, the REST API or authorization are correct. For BACKEND-101, whose behaviours are "the project compiles, the context starts and the build succeeds", it is appropriate coverage; product, order and PostgreSQL tests arrive with those behaviours.

Build Output and .gitignore

After building, build/ contains generated output. That directory should not be committed to Git, because it is reproducible build output. The repository should contain source, the build definition, configuration, migrations and documentation rather than generated artifacts.

The generated .gitignore covers several IDEs; its essentials are:

text
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar

.idea
*.iml
.vscode/

.DS_Store

Exact IDE rules depend on team conventions; .DS_Store is our own addition. The important principle is:

Do not commit machine-generated local state unless the repository intentionally owns it.

The Gradle Wrapper is different. gradlew, gradlew.bat and gradle/wrapper/ are intentionally versioned, because they are part of the project's reproducible build tooling. The !gradle/wrapper/gradle-wrapper.jar line exists for this reason: it keeps the wrapper JAR committed even when a global ignore rule excludes *.jar files.

README at Bootstrap Stage

Spring Initializr generates a HELP.md with links to Spring documentation. We replace it with a lightweight README.md that helps another engineer start the project:

markdown
# Order Management Backend

Backend application for the Commerce Order Management project.

## Requirements

- Java 21

## Build

```bash
./gradlew build
```

## Run

```bash
./gradlew bootRun
```

The README should describe what works now. Do not fill it with fictional future commands.

What We Deliberately Do Not Add

Each dependency we leave out is a decision, not an oversight. PostgreSQL belongs to BACKEND-102 Configure PostgreSQL and Flyway; adding it here would make first startup depend on database infrastructure and combine two separate concerns in one ticket. Spring Web (spring-boot-starter-webmvc) arrives with the first REST API, and Actuator when health checks and metrics become real production-readiness requirements. Lombok and MapStruct solve no current problem: modern Java gives us records and constructors, and we have no meaningful DTO-to-domain mapping yet.

Build Configuration Is Production Engineering

Developers sometimes think of src/main/java as the real code and build.gradle as setup. That distinction is misleading. The build defines the dependencies, the Java version, the plugins, test execution and packaging, and a broken build.gradle can block the entire team. Versions follow the same discipline: an upgrade can affect runtime behaviour, security posture and compatibility, so dependencies change as explicit engineering changes, not simply because a newer version exists.

Definition of Done Review

Now apply our project Definition of Done:

markdown
### Acceptance Criteria

- [x] `./gradlew build` succeeds
- [x] Spring Boot application starts
- [x] Baseline project structure exists
- [x] Build does not depend on IDE-specific setup

### Testing and Build

- [x] Spring application-context startup test added
- [x] Existing tests pass
- [x] Clean Gradle build succeeds

### Scope Discipline

- [x] No product API, database, security, payment or production Docker setup

The last section matters as much as the others. A feature is not more complete because it includes unrelated future work.

Example Pull Request

A reasonable first pull request could be:

markdown
## What

Bootstraps the Order Management Backend as a Spring Boot Gradle application.

## Why

Provides the application foundation required for subsequent backend capabilities.

## Changes

- Added Gradle project and wrapper
- Added Spring Boot application entry point
- Added baseline application configuration
- Added application-context bootstrap test
- Added basic project documentation

## Testing

- `./gradlew build`
- Application started with `./gradlew bootRun`

## Risks

Low. This introduces the initial application foundation and no business functionality.

This is a small, focused, reviewable PR.

What Should the Reviewer Check?

Review should focus on the ticket rather than cosmetic details. Useful questions include:

  • Does a clean build work?
  • Is the Java baseline explicit?
  • Are unnecessary dependencies included?
  • Is the package root correct?
  • Can the application start outside the IDE?
  • Is configuration minimal and safe?
  • Does the change stay within BACKEND-101 scope?

This keeps review aligned with engineering intent. A good example of feedback:

PostgreSQL is included, but this ticket does not configure or use a database. Can we add it in BACKEND-102 instead?

The response is simply to remove the unused dependency; an empty package hierarchy for Product, Inventory and Order gets the same answer.

Small PR, Buildable Main Branch

Our first implementation PR is intentionally small: easy review, clear scope, simple rollback and a Git history that reads ticket by ticket (BACKEND-101, BACKEND-102 Configure PostgreSQL and Flyway, BACKEND-103 Model Product Domain). After BACKEND-101 merges, ./gradlew build should still succeed on the main branch, and this becomes an ongoing expectation: every future change begins from a healthy baseline and should leave one behind.

Practice Exercise 1: Generate and Compare

Solutions: lesson 3.09.

This exercise starts the order-management repository you will grow for the rest of the course. Create it in your workspace from module 1, next to docs/ (for example backend-course/order-management/).

  1. Generate the project with Spring Initializr using exactly the values in the table above, and unpack it. Before you change anything, list every file and compare the list with the lesson. Which file does the lesson's list of kept files not mention, and what is it for? What does the generated .gitignore already do with HELP.md?
  2. Generate a second, throwaway project with the same values but leave the package name at its default. Which package does the main class end up in? Delete that project afterwards.
  3. In the real project, replace application.properties with the application.yml above and HELP.md with the README, then run ./gradlew build, ./gradlew bootRun and java -jar build/libs/order-management-0.0.1-SNAPSHOT.jar. Which log line shows the application name, and which shows that startup succeeded?

Practice Exercise 2: The First Commit

Run git init in the project and ./gradlew build once, so that build/ and .gradle/ exist. Before you look, write down which paths git status --short --untracked-files=all will list. Then run it, and check the three interesting paths with git check-ignore -v build .gradle gradle/wrapper/gradle-wrapper.jar, which prints the .gitignore rule that matches each path. Add .DS_Store to .gitignore and commit everything as BACKEND-101 Bootstrap Order Management Backend.

Practice Exercise 3: A Dependency Too Early

A teammate adds implementation 'org.springframework.boot:spring-boot-starter-data-jpa' to build.gradle, "because BACKEND-102 needs it anyway". As a throwaway experiment, add the line, predict what ./gradlew bootRun does now, run it, and remove the line again. Then write the review comment you would leave on the pull request.

Knowledge Check

Question 1

Every engineer already has Gradle installed. Why does the repository still commit the Gradle Wrapper?

Question 2

./gradlew bootRun logs Started OrderManagementApplication and the process exits. Why is that not a failure of BACKEND-101?

Question 3

The starters in build.gradle have no version numbers. What decides their versions, and what happens to them when the team raises the Spring Boot plugin version?

Question 4

Why can a project with a Java 21 toolchain still build correctly when Gradle itself is started by a different JDK?

Question 5

contextLoads() passes. Name one problem it would have caught and one it cannot catch.

Knowledge Check Answers

Answer 1

The wrapper pins one Gradle version for every machine and for CI, and downloads it on first use. Installed Gradle versions differ between machines, so "it builds for me" would depend on what each engineer happens to have.

Answer 2

The project has no web server or anything else that keeps the JVM alive, so once the context has started there is nothing left to do. The acceptance criterion is that the application starts, and the log proves it did.

Answer 3

The io.spring.dependency-management plugin imports the versions Spring Boot manages, so the plugin's Spring Boot version decides them. Raising the plugin version moves all starters together to versions tested with each other.

Answer 4

The toolchain tells Gradle to compile, test and run with a Java 21 JDK, independently of the JDK that launched Gradle. If no Java 21 is available, the build fails with a clear message instead of silently using another version.

Answer 5

It catches bootstrap failures, such as a missing required bean, broken dependency wiring or invalid Spring configuration. It cannot catch wrong business behaviour, wrong SQL or a wrong HTTP response, because it never exercises them.

Engineering Principle

A project bootstrap should establish the minimum reproducible foundation required for the next piece of meaningful work.

Two consequences follow. Dependencies, packages and infrastructure are added because the current system needs them, not because we expect every backend might eventually use them. And if the project only works through one developer's IDE, the project is not properly bootstrapped.

Summary

  • BACKEND-101 is the first implementation ticket: a Gradle-based Spring Boot 4.1 project generated with Spring Initializr, with only spring-boot-starter and spring-boot-starter-test.
  • The repository build, not the IDE, is the source of truth: the committed Gradle Wrapper pins Gradle, and a Java toolchain pins Java 21.
  • The package root is com.tarkev.ordermanagement, with OrderManagementApplication at the root so component scanning finds everything beneath it.
  • @SpringBootApplication marks the bootstrap configuration, and SpringApplication.run(...) starts Spring from the ordinary Java main method.
  • Configuration stays minimal (spring.application.name in application.yml), and secrets are never committed.
  • ./gradlew build, ./gradlew bootRun and java -jar must work from a clean checkout; build/ is ignored while the wrapper files are committed.
  • A contextLoads() test gives startup-level confidence for the bootstrap ticket, and testing begins with the work rather than being postponed.
  • PostgreSQL, Spring Web, Actuator, Lombok, MapStruct and empty capability packages wait until a requirement needs them; the result is a small, reviewable PR that leaves the main branch buildable.

In the next lesson, Understanding Spring Boot Project Structure, we examine the project we just created and how package placement affects Spring Boot's behaviour.

Mieti ensin

Tulossa pian

Välikysymyksiä, joissa valitset vastauksen ennen kuin oppitunti näyttää sen.