Project: Your First Spring Boot App
Everything you have learned across this tutorial — starters, auto-configuration, the embedded server, application properties, lifecycle hooks, logging, and DevTools — comes together in a single, complete, runnable application. This lesson walks you through building a small but realistic Task Manager REST API from scratch: creating the project, wiring the layers, understanding every decision, and running it end-to-end. By the end you will have something deployable, not just a hello-world.
What We Are Building
A JSON REST API that manages a list of tasks. It supports four operations:
- GET /api/tasks — list all tasks
- GET /api/tasks/{id} — fetch a single task
- POST /api/tasks — create a task
- DELETE /api/tasks/{id} — remove a task
Persistence is backed by an in-memory H2 database (zero setup) with Spring Data JPA doing all the SQL. The application exposes an Actuator health endpoint, uses a custom CommandLineRunner to seed sample data on startup, and reads its server port from application.properties.
Why this stack? Spring Web + Spring Data JPA + H2 + Actuator covers the most common combination you will meet in real projects. Swapping H2 for PostgreSQL later requires changing exactly one dependency and two properties — the rest of the code is identical.
Step 1 — Bootstrap the Project
Generate the project at start.spring.io (or use the Spring Initializr inside your IDE) with these settings:
- Project: Maven | Language: Java | Spring Boot: 3.3.x
- Group:
com.example | Artifact: taskmanager
- Dependencies: Spring Web, Spring Data JPA, H2 Database, Spring Boot Actuator, Spring Boot DevTools
The generated pom.xml starters look like this (versions managed by the Boot BOM):
<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>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
Step 2 — Application Properties
Open src/main/resources/application.properties and add:
# Server
server.port=8080
spring.application.name=task-manager
# H2 in-memory database
spring.datasource.url=jdbc:h2:mem:taskdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA / Hibernate
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# H2 Console (useful during development)
spring.h2.console.enabled=true
# Actuator — expose health and info over HTTP
management.endpoints.web.exposure.include=health,info
ddl-auto=create-drop recreates the schema on every startup and drops it on shutdown — perfect for H2 development. For a real database use validate (or none when you manage migrations with Flyway/Liquibase).
Step 3 — The Domain Model
A JPA entity is a plain Java class annotated with @Entity. Spring Data JPA will generate the corresponding table automatically:
package com.example.taskmanager.model;
import jakarta.persistence.*;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private boolean completed = false;
// Required by JPA
protected Task() {}
public Task(String title) {
this.title = title;
}
// Getters and setters
public Long getId() { return id; }
public String getTitle() { return title; }
public void setTitle(String t) { this.title = t; }
public boolean isCompleted() { return completed; }
public void setCompleted(boolean c) { this.completed = c; }
}
Two things worth noting: the no-arg constructor is protected (JPA requires it but you should not call it yourself), and GenerationType.IDENTITY delegates auto-increment to the database, which is the right choice for H2 and most relational databases.
Step 4 — The Repository
Spring Data JPA generates a full CRUD implementation at runtime from a single interface declaration:
package com.example.taskmanager.repository;
import com.example.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
// JpaRepository already provides: findAll, findById, save, deleteById, count, ...
// Add custom queries here if needed, e.g.:
// List<Task> findByCompletedFalse();
}
There is no implementation class to write. Spring Boot's auto-configuration detects JpaRepository on the classpath and registers a proxy bean automatically.
Step 5 — The Service Layer
The service holds business logic and keeps the controller thin. It is the right place for transactions and any rules that span multiple repository calls:
package com.example.taskmanager.service;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.repository.TaskRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional(readOnly = true)
public class TaskService {
private final TaskRepository repo;
public TaskService(TaskRepository repo) { // constructor injection — no @Autowired needed
this.repo = repo;
}
public List<Task> findAll() {
return repo.findAll();
}
public Task findById(Long id) {
return repo.findById(id)
.orElseThrow(() -> new RuntimeException("Task not found: " + id));
}
@Transactional // overrides readOnly for write operations
public Task create(String title) {
return repo.save(new Task(title));
}
@Transactional
public void delete(Long id) {
repo.deleteById(id);
}
}
Why readOnly = true at class level? It tells Hibernate to skip dirty-checking on read operations, which reduces overhead. Override with @Transactional (no flag) only where you write data.
Step 6 — The REST Controller
The controller translates HTTP requests into service calls and maps return values to JSON responses:
package com.example.taskmanager.controller;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.service.TaskService;
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) // returns HTTP 201 instead of 200
public Task create(@RequestParam String title) {
return service.create(title);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204 — success with no body
public void delete(@PathVariable Long id) {
service.delete(id);
}
}
@RestController is a shortcut for @Controller + @ResponseBody. Every method return value is serialised to JSON by Jackson (pulled in transitively by spring-boot-starter-web) without any extra configuration.
Step 7 — Seeding Data with CommandLineRunner
A CommandLineRunner bean runs after the application context is fully started, making it ideal for populating the database for development or demo purposes:
package com.example.taskmanager;
import com.example.taskmanager.service.TaskService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DataSeeder {
@Bean
CommandLineRunner seed(TaskService service) {
return args -> {
service.create("Set up Spring Boot project");
service.create("Write the Task entity");
service.create("Build the REST API");
System.out.println("Sample tasks loaded.");
};
}
}
Step 8 — The Main Class
The entry point was generated for you. Note that it is exactly as discussed in Lesson 1 — nothing to change:
package com.example.taskmanager;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TaskManagerApplication {
public static void main(String[] args) {
SpringApplication.run(TaskManagerApplication.class, args);
}
}
Step 9 — Run and Verify
Start the app with mvn spring-boot:run (or run the main class from your IDE). You will see Hibernate print the CREATE TABLE tasks DDL, the seeder log line, and finally the Tomcat port. Then test with curl:
# Create a task
curl -X POST "http://localhost:8080/api/tasks?title=Learn+Spring+Boot"
# List all tasks
curl http://localhost:8080/api/tasks
# Fetch task with id 1
curl http://localhost:8080/api/tasks/1
# Delete task with id 2
curl -X DELETE http://localhost:8080/api/tasks/2
# Health check via Actuator
curl http://localhost:8080/actuator/health
H2 data is ephemeral. Because we used ddl-auto=create-drop with an in-memory URL, all data vanishes when the JVM exits. To persist across restarts during development, switch to jdbc:h2:file:./data/taskdb and set ddl-auto=update.
Project Structure Review
The final package layout follows the standard layered architecture:
src/main/java/com/example/taskmanager/
TaskManagerApplication.java <-- @SpringBootApplication entry point
DataSeeder.java <-- CommandLineRunner seed bean
model/
Task.java <-- @Entity domain object
repository/
TaskRepository.java <-- JpaRepository interface
service/
TaskService.java <-- @Service business logic
controller/
TaskController.java <-- @RestController HTTP layer
src/main/resources/
application.properties <-- all configuration in one place
Each layer has a single responsibility and depends only on the layer below it. The controller knows nothing about JPA; the service knows nothing about HTTP. This separation makes each class easy to test in isolation.
What to Try Next
- Add a
PUT /api/tasks/{id} endpoint to mark a task complete.
- Replace the
@RequestParam with a JSON request body using @RequestBody and a DTO record.
- Add a custom
@ExceptionHandler (or @ControllerAdvice) to return a structured JSON error when a task is not found instead of a 500.
- Swap H2 for PostgreSQL: add the PostgreSQL driver dependency, change the datasource URL and credentials in
application.properties, set ddl-auto=validate, and run a Flyway migration — the Java code is untouched.
This is the real power of Spring Boot. Infrastructure concerns — connection pooling, JSON serialisation, transaction management, schema creation — are handled by auto-configuration. You write business logic; Boot wires the plumbing. Every lesson in this tutorial explained one piece of that plumbing so you can understand, customise, and troubleshoot it when you need to.