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.
- 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.
- 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.
- 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
titlefield.
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.otherwill not be found fromcom.example.tasks. - Port already in use. Change
server.portor 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=falsefor 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.




