Spring Boot Essentials

Project: Your First Spring Boot App

18 min Lesson 10 of 13

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.