11 · Project — REST API + Database CRUD Service¶
A capstone project combining everything from Level 3: Spring Boot controllers and dependency injection, JDBC-backed persistence (via Spring Data JPA), and clean layered design.
What you'll build¶
A Task management REST API backed by an H2 database, exposing full CRUD:
GET /tasks— list all tasksGET /tasks/{id}— fetch one taskPOST /tasks— create a taskPUT /tasks/{id}— update a taskDELETE /tasks/{id}— delete a task
Project layout¶
task-api/
pom.xml
src/
main/
java/
com/example/taskapi/
TaskApiApplication.java
Task.java
TaskRepository.java
TaskService.java
TaskController.java
resources/
application.properties
pom.xml¶
<!-- pom.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>
<groupId>com.example</groupId>
<artifactId>task-api</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.properties¶
# src/main/resources/application.properties
# File-based H2 database so data survives across restarts (use jdbc:h2:mem:... for pure in-memory)
spring.datasource.url=jdbc:h2:file:./data/taskdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# Automatically create/update tables from @Entity classes at startup
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
# Enables the H2 web console at /h2-console for inspecting data during development
spring.h2.console.enabled=true
Task.java — the entity¶
// src/main/java/com/example/taskapi/Task.java
package com.example.taskapi;
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;
protected Task() { } // required no-arg constructor for JPA
public Task(String title, boolean completed) {
this.title = title;
this.completed = completed;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public boolean isCompleted() { return completed; }
public void setTitle(String title) { this.title = title; }
public void setCompleted(boolean completed) { this.completed = completed; }
}
TaskRepository.java — data access¶
// src/main/java/com/example/taskapi/TaskRepository.java
package com.example.taskapi;
import org.springframework.data.jpa.repository.JpaRepository;
// Spring Data JPA generates the implementation at runtime -- CRUD methods
// like findAll(), findById(), save(), and deleteById() come for free.
public interface TaskRepository extends JpaRepository<Task, Long> {
}
TaskService.java — business logic layer¶
// src/main/java/com/example/taskapi/TaskService.java
package com.example.taskapi;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class TaskService {
private final TaskRepository repository;
public TaskService(TaskRepository repository) {
this.repository = repository;
}
public List<Task> findAll() {
return repository.findAll();
}
public Optional<Task> findById(Long id) {
return repository.findById(id);
}
public Task create(String title) {
return repository.save(new Task(title, false));
}
public Optional<Task> update(Long id, String title, boolean completed) {
return repository.findById(id).map(task -> {
task.setTitle(title);
task.setCompleted(completed);
return repository.save(task);
});
}
public boolean delete(Long id) {
if (!repository.existsById(id)) {
return false;
}
repository.deleteById(id);
return true;
}
}
TaskController.java — REST endpoints¶
// src/main/java/com/example/taskapi/TaskController.java
package com.example.taskapi;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
// Request/response payload for create and update -- keeps Task (the JPA
// entity) decoupled from the shape callers send over the wire.
public record TaskRequest(String title, boolean completed) { }
@GetMapping
public List<Task> getAll() {
return taskService.findAll();
}
@GetMapping("/{id}")
public ResponseEntity<Task> getOne(@PathVariable Long id) {
return taskService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Task> create(@RequestBody TaskRequest request) {
Task created = taskService.create(request.title());
return ResponseEntity.status(201).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<Task> update(@PathVariable Long id, @RequestBody TaskRequest request) {
return taskService.update(id, request.title(), request.completed())
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
boolean deleted = taskService.delete(id);
return deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build();
}
}
TaskApiApplication.java — entry point¶
// src/main/java/com/example/taskapi/TaskApiApplication.java
package com.example.taskapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TaskApiApplication {
public static void main(String[] args) {
SpringApplication.run(TaskApiApplication.class, args);
}
}
Running it¶
# Create a task
curl -X POST http://localhost:8080/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Write Level 3 capstone docs", "completed": false}'
# {"id":1,"title":"Write Level 3 capstone docs","completed":false}
# List all tasks
curl http://localhost:8080/tasks
# [{"id":1,"title":"Write Level 3 capstone docs","completed":false}]
# Fetch one task
curl http://localhost:8080/tasks/1
# {"id":1,"title":"Write Level 3 capstone docs","completed":false}
# Update a task
curl -X PUT http://localhost:8080/tasks/1 \
-H "Content-Type: application/json" \
-d '{"title": "Write Level 3 capstone docs", "completed": true}'
# {"id":1,"title":"Write Level 3 capstone docs","completed":true}
# Delete a task
curl -X DELETE http://localhost:8080/tasks/1 -i
# HTTP/1.1 204 No Content
# Confirm deletion
curl http://localhost:8080/tasks/1 -i
# HTTP/1.1 404 Not Found
The service layer (TaskService) sits between the controller and the
repository so validation or business rules can grow there later without
touching HTTP-handling code — the same layered structure real production
Spring Boot services use.
How It Actually Works¶
When a request hits TaskController, Spring's DispatcherServlet has already matched the URL and HTTP method against your @GetMapping/@PostMapping annotations using a registry built at startup by scanning the classpath for @RestController beans — there's no manual routing table, it's reflection-driven. TaskRepository extending Spring Data's interface means you never write the SQL: at startup, Spring generates a dynamic proxy implementing that interface, and each method name (like findByStatus) is parsed into a JPQL query by convention. Under the hood, every HTTP request runs on its own thread from Tomcat's thread pool, and TaskService's @Transactional boundary wraps the actual JDBC connection in a database transaction that commits or rolls back atomically when the method returns or throws.
Stretch goals¶
- Add request validation with
spring-boot-starter-validation(@NotBlankontitle, returning a 400 response for invalid input). - Add a
GET /tasks?completed=truefilter using a derived query method onTaskRepository, e.g.List<Task> findByCompleted(boolean completed). - Add pagination with
Pageable(GET /tasks?page=0&size=10). - Write integration tests with
@SpringBootTestandTestRestTemplatecovering all five endpoints. - Swap H2 for a real PostgreSQL database using Docker, changing only
application.propertiesand the JDBC dependency.
Completing this project means you're ready for Level 4 · Master.