Spring Boot Explained: Build Your First Java Web App

Spring Boot removes the XML configuration, dependency wrangling, and server setup that made classic Spring projects painful. You write a main method, add a few annotations, and run a production-grade web server. This guide builds a working REST API from scratch, explains what each annotation does under the hood, and shows the refactoring steps that separate tutorial code from maintainable code.

Quick Takeaways

  • Spring Boot is an opinionated layer on top of the Spring Framework that auto-configures beans based on your classpath.
  • A complete web app needs one dependency (spring-boot-starter-web), one annotated class, and one command to run.
  • The standard architecture is Controller → Service → Repository, wired by constructor-based dependency injection.
  • Embedded Tomcat means you ship a single executable JAR, not a WAR deployed to an external server.
Feature Spring Framework Spring Boot
Configuration Manual XML or Java config Auto-configuration
Server External (deploy WAR) Embedded Tomcat/Jetty/Undertow
Dependency versions Managed by you Managed by starters and the BOM
Setup time Hours Minutes
Production metrics Manual wiring Actuator built in

What Spring Boot Actually Does

Spring Boot does three things.

  1. Starter dependencies. A starter such as spring-boot-starter-web pulls in a tested, version-aligned set of libraries: Spring MVC, Jackson, Tomcat, and validation support.
  2. Auto-configuration. At startup, Boot scans the classpath and creates beans conditionally. If it finds Tomcat and Spring MVC, it configures a DispatcherServlet for you.
  3. Embedded server. The server runs inside your application process. You start it with java -jar.

The annotation that triggers all of this is @SpringBootApplication. It combines three annotations:

Annotation Purpose
@Configuration Marks the class as a source of bean definitions
@EnableAutoConfiguration Activates classpath-based auto-configuration
@ComponentScan Finds @Component, @Service, @Repository, and @RestController classes in the current package and below

Prerequisites

You need:

  • JDK 17 or newer (Spring Boot 3.x requires Java 17 as the baseline)
  • Maven 3.9+ or Gradle 8+
  • An IDE such as IntelliJ IDEA, Eclipse, or VS Code with the Java extension pack

Verify your JDK:

java -version

The output should report version 17 or higher. If it reports 11 or 8, Spring Boot 3 will refuse to compile.

Step 1: Generate the Project

Open start.spring.io and select:

  • Project: Maven
  • Language: Java
  • Spring Boot: the latest stable 3.x release
  • Packaging: Jar
  • Java: 17 or newer
  • Dependencies: Spring Web, Spring Data JPA, H2 Database, Validation

Download the ZIP and extract it. You get this layout:

tasks-api/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/com/example/tasks/
    │   │   └── TasksApiApplication.java
    │   └── resources/
    │       └── application.properties
    └── test/

Inspect the pom.xml

The parent block is where dependency management happens:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>
    <relativePath/>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Notice that no dependency declares a version. The parent POM supplies them. Use the current stable version listed on start.spring.io when you generate your own project.

Step 2: The Entry Point

package com.example.tasks;

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

@SpringBootApplication
public class TasksApiApplication {

    public static void main(String[] args) {
        // Bootstraps the Spring context and starts embedded Tomcat on port 8080
        SpringApplication.run(TasksApiApplication.class, args);
    }
}

Run it:

./mvnw spring-boot:run

The console prints the Spring banner, then a line similar to Tomcat started on port 8080. The app serves no endpoints yet, so any request returns a 404 response. The server works. It just has nothing mapped.

Step 3: Model the Data with a JPA Entity

Create Task.java:

package com.example.tasks;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Entity
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Title is required")
    @Size(max = 120)
    private String title;

    private boolean completed;

    protected Task() {} // Required by JPA

    public Task(String title) {
        this.title = title;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public boolean isCompleted() { return completed; }
    public void setCompleted(boolean completed) { this.completed = completed; }
}

Note: Spring Boot 3 uses the jakarta.persistence namespace. Older tutorials use javax.persistence, which fails to compile on Boot 3.

The @Entity annotation maps the class to a table. @GeneratedValue delegates key generation to the database. Hibernate creates the schema automatically against the in-memory H2 database.

Step 4: Create the Repository

package com.example.tasks;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface TaskRepository extends JpaRepository<Task, Long> {

    // Spring Data derives the SQL from the method name
    List<Task> findByCompleted(boolean completed);
}

You write no implementation. Spring Data generates a proxy at runtime. JpaRepository already supplies save(), findById(), findAll(), and deleteById(). The derived query findByCompleted compiles to SELECT ... WHERE completed = ?.

Step 5: Add the Service Layer

package com.example.tasks;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
public class TaskService {

    private final TaskRepository repository;

    // Constructor injection: no @Autowired needed with a single constructor
    public TaskService(TaskRepository repository) {
        this.repository = repository;
    }

    @Transactional(readOnly = true)
    public List<Task> findAll() {
        return repository.findAll();
    }

    @Transactional(readOnly = true)
    public Task findById(Long id) {
        return repository.findById(id)
                .orElseThrow(() -> new TaskNotFoundException(id));
    }

    @Transactional
    public Task create(Task task) {
        return repository.save(task);
    }

    @Transactional
    public Task update(Long id, Task incoming) {
        Task existing = findById(id);
        existing.setTitle(incoming.getTitle());
        existing.setCompleted(incoming.isCompleted());
        return existing; // Dirty checking flushes the change on commit
    }

    @Transactional
    public void delete(Long id) {
        if (!repository.existsById(id)) {
            throw new TaskNotFoundException(id);
        }
        repository.deleteById(id);
    }
}

The update method never calls save(). Hibernate tracks the managed entity and writes the change when the transaction commits. This behavior is called dirty checking.

Define the exception:

package com.example.tasks;

public class TaskNotFoundException extends RuntimeException {
    public TaskNotFoundException(Long id) {
        super("Task not found: " + id);
    }
}

Step 6: Expose the REST Controller

package com.example.tasks;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/tasks")
public class TaskController {

    private final TaskService service;

    public TaskController(TaskService service) {
        this.service = service;
    }

    @GetMapping
    public List<Task> list() {
        return service.findAll();
    }

    @GetMapping("/{id}")
    public Task get(@PathVariable Long id) {
        return service.findById(id);
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Task create(@Valid @RequestBody Task task) {
        return service.create(task);
    }

    @PutMapping("/{id}")
    public Task update(@PathVariable Long id, @Valid @RequestBody Task task) {
        return service.update(id, task);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

@RestController combines @Controller and @ResponseBody. Return values serialize to JSON through Jackson automatically. @Valid triggers Bean Validation on the request body.

Step 7: Handle Errors Globally

Without a handler, a missing task returns a 500 error with a stack trace. Fix that:

package com.example.tasks;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(TaskNotFoundException.class)
    public ProblemDetail handleNotFound(TaskNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(e -> e.getField() + ": " + e.getDefaultMessage())
                .findFirst()
                .orElse("Validation failed");
        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, message);
    }
}

ProblemDetail implements RFC 7807, the standard JSON error format, and has been supported since Spring Framework 6.

Step 8: Test the Running App

Start the app and send requests:

# Create a task
curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Learn Spring Boot"}'

# List all tasks
curl http://localhost:8080/api/tasks

# Trigger validation failure
curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":""}'

Expected behavior:

  • The first call returns 201 Created with {"id":1,"title":"Learn Spring Boot","completed":false}.
  • The second returns 200 OK with an array containing that task.
  • The third returns 400 Bad Request with a problem detail naming the title field.

Step 9: Write an Automated Test

package com.example.tasks;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class TaskControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void createsTask() throws Exception {
        mockMvc.perform(post("/api/tasks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"Write tests\"}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.title").value("Write tests"));
    }

    @Test
    void rejectsBlankTitle() throws Exception {
        mockMvc.perform(post("/api/tasks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"title\":\"\"}"))
                .andExpect(status().isBadRequest());
    }
}

Run with ./mvnw test. Both tests pass without starting a real HTTP server because MockMvc calls the DispatcherServlet directly.

Configuration with application.properties

# Change the server port
server.port=8081

# Show generated SQL while learning
spring.jpa.show-sql=true

# Enable the H2 browser console (development only)
spring.h2.console.enabled=true

Prefer application.yml if you like hierarchical config. Use profiles (application-prod.properties) to separate environments, and activate them with --spring.profiles.active=prod.

Dependency Injection: Three Styles Compared

Style Testability Immutability Recommended
Constructor injection Easy (pass mocks directly) Yes (final fields) Yes
Setter injection Moderate No Optional dependencies only
Field injection (@Autowired) Hard (needs reflection) No No

Bad Code vs. Good Code

Anti-pattern 1: Field injection and business logic in the controller

// BAD: field injection, repository in the controller, entity returned with no validation
@RestController
public class BadController {

    @Autowired
    private TaskRepository repository;

    @PostMapping("/tasks")
    public Task create(@RequestBody Task task) {
        if (task.getTitle() == null) {
            throw new RuntimeException("bad");
        }
        return repository.save(task);
    }
}

Problems: hidden dependencies, no transaction boundary, a generic RuntimeException that produces a 500, and validation written by hand.

// GOOD: constructor injection, validation annotation, logic delegated to a service
@RestController
@RequestMapping("/api/tasks")
public class TaskController {

    private final TaskService service;

    public TaskController(TaskService service) {
        this.service = service;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Task create(@Valid @RequestBody Task task) {
        return service.create(task);
    }
}

Anti-pattern 2: Exposing entities directly

Returning JPA entities couples your API contract to your database schema. Adding a column leaks a new field to every client. Use a record as a DTO:

public record TaskResponse(Long id, String title, boolean completed) {
    public static TaskResponse from(Task task) {
        return new TaskResponse(task.getId(), task.getTitle(), task.isCompleted());
    }
}

Return TaskResponse from the controller and map inside the service or controller layer. For larger projects, MapStruct automates this mapping.

Anti-pattern 3: Catching exceptions in every controller method

// BAD: repeated try/catch in each endpoint
@GetMapping("/{id}")
public ResponseEntity<?> get(@PathVariable Long id) {
    try {
        return ResponseEntity.ok(service.findById(id));
    } catch (Exception e) {
        return ResponseEntity.status(500).body("error");
    }
}

The @RestControllerAdvice class from Step 7 replaces every one of these blocks.

Common Edge Cases

  • Component scan misses your classes. The main class must sit in a package at or above all other packages. A controller in com.other will not be found from com.example.tasks.
  • Port already in use. Change server.port or stop the conflicting process.
  • Lazy loading failures. Accessing a lazy relationship outside a transaction throws LazyInitializationException. Fetch what you need inside the service method.
  • H2 data disappears on restart. That is expected for in-memory mode. Switch to PostgreSQL or MySQL for persistence and add the matching driver and spring.datasource.* properties.
  • Open Session in View. Boot enables it by default and logs a warning. Set spring.jpa.open-in-view=false for stricter, more predictable data access.

Packaging and Running in Production

Build the executable JAR:

./mvnw clean package
java -jar target/tasks-api-0.0.1-SNAPSHOT.jar

Add Actuator for health checks and metrics:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Expose the health endpoint:

management.endpoints.web.exposure.include=health,info

Then GET /actuator/health returns {"status":"UP"}. Kubernetes and load balancers use this for liveness and readiness probes.

For containers, build an image without writing a Dockerfile:

./mvnw spring-boot:build-image

This uses Cloud Native Buildpacks and produces a layered, optimized image.

Where to Go Next

Goal Add this
Authentication Spring Security with JWT or OAuth2 resource server
Real database PostgreSQL driver plus Flyway migrations
API docs springdoc-openapi for Swagger UI
Caching spring-boot-starter-cache with Caffeine or Redis
Async messaging Spring for Apache Kafka or RabbitMQ
Fast startup GraalVM native image support

Frequently Asked Questions

What is Spring Boot used for?

Spring Boot builds standalone, production-ready Java applications with minimal configuration. Teams use it for REST APIs, microservices, web applications, batch jobs, and event-driven systems.

What is the difference between Spring and Spring Boot?

Spring is the core framework providing dependency injection, MVC, data access, and more. Spring Boot sits on top and adds auto-configuration, starter dependencies, and an embedded server so you skip manual setup.

Do I need to learn Spring before Spring Boot?

No, but you need to understand dependency injection and inversion of control. Boot hides the configuration, not the concepts. Learning the underlying Spring ideas helps you debug auto-configuration problems faster.

Which Java version does Spring Boot 3 require?

Spring Boot 3.x requires Java 17 or newer. It also moves from javax.* to jakarta.* package names, so code written for Boot 2 needs import changes when you upgrade.

Hot this week

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Topics

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Robot Computers in 2026: Raspberry Pi 5, Jetson, and Industrial PCs Compared

Raspberry Pi 5 vs NVIDIA Jetson vs industrial PCs for ROS 2 robots. See specs, latency math and a decision matrix, then pick your compute.

AI in Robotics: Programmed Behavior vs Machine Learning (and When You Need Each)

Programmed control or machine learning? Compare PID, FSMs, and RL with ROS2 code, benchmarks, and a decision framework. Pick the right one.

What Is ROS 2? A Beginner’s Guide to the Robot Operating System in 2026

Learn what ROS 2 is, how nodes, topics, and DDS work, and build your first publisher and subscriber. Start your ROS 2 project today.

Related Articles

Popular Categories