Day 26: Mini Project - Spring Boot Todo API
It’s time to wrap up the journey. We’ll use everything learned — Java fundamentals, OOP, collections, streams, exception handling, JDBC, and Spring Boot — to build a complete Todo REST API. The goal is a production-ready project you can use right away.
Project Setup and Domain Model
Design the project skeleton and core domain.
// build.gradle.kts
/*
plugins {
java
id("org.springframework.boot") version "3.2.0"
id("io.spring.dependency-management") version "1.1.4"
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
*/
// Domain entity: using JPA
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "todos")
public class Todo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(length = 500)
private String description;
@Column(nullable = false)
private boolean completed = false;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Priority priority = Priority.MEDIUM;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = this.createdAt;
}
@PreUpdate
void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
// Constructor, getters, setters omitted
public enum Priority { HIGH, MEDIUM, LOW }
}
Repository and Service Layers
Implement Spring Data JPA and business logic.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
// Repository: Spring Data JPA auto-generates the implementation
@Repository
interface TodoRepository extends JpaRepository<Todo, Long> {
List<Todo> findByCompleted(boolean completed);
List<Todo> findByPriority(Todo.Priority priority);
List<Todo> findByTitleContainingIgnoreCase(String keyword);
@Query("SELECT t FROM Todo t WHERE t.completed = false ORDER BY " +
"CASE t.priority WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 ELSE 3 END")
List<Todo> findPendingOrderByPriority();
long countByCompleted(boolean completed);
}
// DTOs
record CreateTodoRequest(
@jakarta.validation.constraints.NotBlank(message = "Title is required")
@jakarta.validation.constraints.Size(max = 100) String title,
@jakarta.validation.constraints.Size(max = 500) String description,
Todo.Priority priority
) {}
record UpdateTodoRequest(
@jakarta.validation.constraints.NotBlank String title,
String description,
Todo.Priority priority,
Boolean completed
) {}
record TodoResponse(Long id, String title, String description,
boolean completed, String priority,
String createdAt, String updatedAt) {
static TodoResponse from(Todo todo) {
return new TodoResponse(
todo.getId(), todo.getTitle(), todo.getDescription(),
todo.isCompleted(), todo.getPriority().name(),
todo.getCreatedAt().toString(),
todo.getUpdatedAt() != null ? todo.getUpdatedAt().toString() : null
);
}
}
record TodoStats(long total, long completed, long pending,
double completionRate) {}
// Service
@Service
@Transactional
class TodoService {
private final TodoRepository repository;
TodoService(TodoRepository repository) {
this.repository = repository;
}
TodoResponse create(CreateTodoRequest request) {
Todo todo = new Todo();
todo.setTitle(request.title());
todo.setDescription(request.description());
todo.setPriority(request.priority() != null ?
request.priority() : Todo.Priority.MEDIUM);
return TodoResponse.from(repository.save(todo));
}
@Transactional(readOnly = true)
List<TodoResponse> findAll(Boolean completed, Todo.Priority priority,
String keyword) {
List<Todo> todos;
if (keyword != null && !keyword.isBlank()) {
todos = repository.findByTitleContainingIgnoreCase(keyword);
} else if (completed != null) {
todos = repository.findByCompleted(completed);
} else if (priority != null) {
todos = repository.findByPriority(priority);
} else {
todos = repository.findAll();
}
return todos.stream().map(TodoResponse::from).toList();
}
@Transactional(readOnly = true)
TodoResponse findById(Long id) {
return repository.findById(id)
.map(TodoResponse::from)
.orElseThrow(() -> new TodoNotFoundException(id));
}
TodoResponse update(Long id, UpdateTodoRequest request) {
Todo todo = repository.findById(id)
.orElseThrow(() -> new TodoNotFoundException(id));
todo.setTitle(request.title());
if (request.description() != null) todo.setDescription(request.description());
if (request.priority() != null) todo.setPriority(request.priority());
if (request.completed() != null) todo.setCompleted(request.completed());
return TodoResponse.from(repository.save(todo));
}
TodoResponse toggleComplete(Long id) {
Todo todo = repository.findById(id)
.orElseThrow(() -> new TodoNotFoundException(id));
todo.setCompleted(!todo.isCompleted());
return TodoResponse.from(repository.save(todo));
}
void delete(Long id) {
if (!repository.existsById(id)) throw new TodoNotFoundException(id);
repository.deleteById(id);
}
@Transactional(readOnly = true)
TodoStats getStats() {
long total = repository.count();
long completed = repository.countByCompleted(true);
long pending = total - completed;
double rate = total > 0 ? (double) completed / total * 100 : 0;
return new TodoStats(total, completed, pending, Math.round(rate * 10) / 10.0);
}
}
Controller and Global Exception Handling
REST endpoints and unified error handling.
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import jakarta.validation.Valid;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/todos")
class TodoController {
private final TodoService service;
TodoController(TodoService service) { this.service = service; }
@PostMapping
ResponseEntity<TodoResponse> create(@Valid @RequestBody CreateTodoRequest req) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req));
}
@GetMapping
List<TodoResponse> findAll(
@RequestParam(required = false) Boolean completed,
@RequestParam(required = false) Todo.Priority priority,
@RequestParam(required = false) String keyword) {
return service.findAll(completed, priority, keyword);
}
@GetMapping("/{id}")
TodoResponse findById(@PathVariable Long id) {
return service.findById(id);
}
@PutMapping("/{id}")
TodoResponse update(@PathVariable Long id,
@Valid @RequestBody UpdateTodoRequest req) {
return service.update(id, req);
}
@PatchMapping("/{id}/toggle")
TodoResponse toggle(@PathVariable Long id) {
return service.toggleComplete(id);
}
@DeleteMapping("/{id}")
ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/stats")
TodoStats stats() { return service.getStats(); }
}
// Exception class
class TodoNotFoundException extends RuntimeException {
TodoNotFoundException(Long id) { super("Todo not found (ID: " + id + ")"); }
}
// Global exception handling
@RestControllerAdvice
class GlobalExceptionHandler {
record ErrorBody(int status, String error, String message, String timestamp) {}
@ExceptionHandler(TodoNotFoundException.class)
ResponseEntity<ErrorBody> notFound(TodoNotFoundException e) {
return ResponseEntity.status(404)
.body(new ErrorBody(404, "Not Found", e.getMessage(),
LocalDateTime.now().toString()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ErrorBody> validation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(err -> err.getField() + ": " + err.getDefaultMessage())
.reduce((a, b) -> a + "; " + b).orElse("Validation failed");
return ResponseEntity.badRequest()
.body(new ErrorBody(400, "Bad Request", msg,
LocalDateTime.now().toString()));
}
@ExceptionHandler(Exception.class)
ResponseEntity<ErrorBody> general(Exception e) {
return ResponseEntity.status(500)
.body(new ErrorBody(500, "Internal Server Error", e.getMessage(),
LocalDateTime.now().toString()));
}
}
Test Code
Write API integration tests.
import org.junit.jupiter.api.*;
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.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.hamcrest.Matchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class TodoApiIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
@Order(1)
@DisplayName("POST /api/todos - Create a todo")
void createTodo() throws Exception {
String json = """
{"title": "Study Java", "description": "Complete the 26-day course", "priority": "HIGH"}
""";
mockMvc.perform(post("/api/todos")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("Study Java"))
.andExpect(jsonPath("$.completed").value(false))
.andExpect(jsonPath("$.priority").value("HIGH"));
}
@Test
@Order(2)
@DisplayName("GET /api/todos - Get all todos")
void findAll() throws Exception {
mockMvc.perform(get("/api/todos"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1))));
}
@Test
@Order(3)
@DisplayName("GET /api/todos/{id} - Returns 404 for non-existent ID")
void findByIdNotFound() throws Exception {
mockMvc.perform(get("/api/todos/9999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404));
}
@Test
@Order(4)
@DisplayName("POST /api/todos - Returns 400 when title is missing")
void createWithoutTitle() throws Exception {
String json = """
{"description": "No title"}
""";
mockMvc.perform(post("/api/todos")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest());
}
@Test
@Order(5)
@DisplayName("GET /api/todos/stats - Get statistics")
void getStats() throws Exception {
mockMvc.perform(get("/api/todos/stats"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").isNumber())
.andExpect(jsonPath("$.completionRate").isNumber());
}
}
Course Summary and Next Steps
// Summary of what we learned over the course:
//
// Week 1 (Day 1-7): Java Fundamentals
// - Environment setup, variables/types, operators, conditionals, loops, arrays, strings
//
// Week 2 (Day 8-14): Object-Oriented Programming
// - Methods, classes/objects, encapsulation, inheritance, polymorphism
// - Abstract classes, interfaces, inner/anonymous classes
//
// Week 3 (Day 15-21): Core APIs and Functional Programming
// - Exception handling, collections (List, Set, Map), generics
// - Lambdas, Stream API, Optional
//
// Week 4 (Day 22-26): Real-World Development
// - File I/O, JDBC, build tools, JUnit 5
// - Spring Boot, REST API, mini project
// Next steps roadmap:
// 1. Spring Security (authentication/authorization)
// 2. JPA/Hibernate deep dive
// 3. Docker + deployment
// 4. Messaging (Kafka, RabbitMQ)
// 5. Microservices architecture
public class CourseComplete {
public static void main(String[] args) {
System.out.println("Congratulations! You've completed the Java 26-Day Course!");
System.out.println("Now go build real-world projects.");
}
}
Today’s Exercises
-
Add Categories: Add a category field (Work, Personal, Study, etc.) to Todo, and implement APIs for filtering by category and providing per-category statistics.
-
Due Date Feature: Add a
dueDatefield to Todo. Implement a/api/todos/overdueendpoint to retrieve overdue items, and add the ability to sort by due date. -
Full Project Build: Integrate all the code into a single Spring Boot project. Build with
./gradlew build, pass all tests with./gradlew test, run with./gradlew bootRun, and test all APIs using curl or Postman.