Build a Todo REST API with Spring Boot

Build a production-structured REST API with MySQL, Flyway, and Swagger.

Introduction

30 Second Summary

Every app you use daily has a backend quietly managing data. When you add an item to a shopping list, mark a task complete, or update your profile, a REST API processes that request and talks to a database.

In this project, you will build a Todo REST API using Spring Boot 3.5 and MySQL. You'll set up a production-ready backend with database migrations, input validation, structured error handling, and auto-generated API documentation.

What You'll Build

A fully functional Todo REST API with CRUD operations, paginated list endpoints with filtering and search, multi-environment configuration, and interactive Swagger documentation.

By the end of this project, you'll have:

  • A Spring Boot 3.5 Maven project with layered architecture including controllers, services, repositories, entities, and DTOs.
  • Flyway migration scripts managing your database schema with version control.
  • Docker Compose configuration spinning up MySQL for a dev environment, alongside profile-specific settings for local, dev, and prod.
  • Global exception handling returning structured error JSON, plus paginated and filterable endpoints with auto-generated Swagger UI.
  • Secret Mission: Add a Category entity with a one-to-many relationship to Todo for category-based filtering.

Are there any prerequisites?

This project assumes you have Java 21 (JDK), Docker Desktop, MySQL installed locally, and an IDE (IntelliJ IDEA or VS Code) ready to go. You do not need prior Spring Boot experience, but basic Java familiarity helps.

Scaffold the Project and Configure Profiles

Every production API starts with a solid foundation. Before writing any business logic, you need a project structure that separates concerns, manages dependencies, and handles different environments cleanly.

In this step, you will scaffold a Spring Boot project, wire up the dependencies your API needs, and configure profiles so your app knows how to connect to MySQL in local development, in a Docker container, and in production.

In this step, get ready to:
  • Generate and configure the Spring Boot project from Spring Initializr.
  • Create environment-specific configuration profiles for local, dev, and prod.
  • Set up Docker Compose for the dev MySQL database.
Generate the project from Spring Initializr

Spring Initializr is a web tool that generates a ready-to-build project skeleton with your chosen dependencies already wired in.

  • Open your browser and navigate to start.spring.io.
  • Set **Project** to Maven.
  • Set Language to Java.
  • Set Spring Boot to 3.5.15.
  • Under Project Metadata, set Group to com.nextwork and Artifact to todo-api.
  • Set Java version to 21.
  • Click ADD DEPENDENCIES and add: Spring Web, Spring Data JPA, Validation, Flyway Migration, MySQL Driver, and Lombok.
  • Click GENERATE to download the project zip.
  • Extract the zip to your Desktop.
  • Open the extracted todo-api folder in your IDE.

Why these dependencies?

Spring Web gives you REST controllers. Spring Data JPA handles database operations. Validation lets you enforce rules on incoming requests. Flyway Migration manages your database schema versioning. MySQL Driver connects to your database. Lombok reduces boilerplate code.

Spring Initializr adds flyway-core but does not automatically include the MySQL-specific Flyway module. You need to add flyway-mysql and springdoc-openapi manually.

  • Open pom.xml in your IDE.
  • Find the <dependencies> section and add the following two dependencies after the existing flyway-core dependency:
    <dependency>
      <groupId>org.flywaydb</groupId>
      <artifactId>flyway-mysql</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springdoc</groupId>
      <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
      <version>2.8.17</version>
    </dependency>

What do these additions do?

The flyway-mysql module tells Flyway how to work with MySQL specifically. Without it, Flyway throws an "Unsupported Database" error at runtime.

The springdoc-openapi-starter-webmvc-ui dependency auto-generates interactive Swagger documentation for your API. Version 2.8.17 is the latest release compatible with Spring Boot 3.5.x.

You do not need to specify a version for flyway-mysql because Spring Boot's parent POM manages Flyway versions automatically.

  • Save pom.xml.
  • Verify the project compiles by running this command in your terminal from the todo-api folder:
./mvnw clean compile
  • You should see BUILD SUCCESS in the terminal output.

Seeing BUILD FAILURE?

  • Make sure you saved pom.xml before running the command.
  • Check that the springdoc-openapi-starter-webmvc-ui dependency includes <version>2.8.17</version>. Unlike the other dependencies, this one is not managed by Spring Boot's parent POM.
  • Confirm that flyway-mysql is inside the <dependencies> block, not outside it.

Help me debug my Maven build failure.

✔️ Awesome, I've got everything!

Great. Double check you have saved pom.xml and move on to configuring profiles.

ⓧ I'd like to double check the full code

Your full pom.xml should look like this:

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.15</version>
    <relativePath/>
  </parent>
  <groupId>com.nextwork</groupId>
  <artifactId>todo-api</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <name>todo-api</name>
  <description>Todo REST API with Spring Boot 3.5, MySQL, and Docker</description>
  <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>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
      <groupId>org.flywaydb</groupId>
      <artifactId>flyway-core</artifactId>
    </dependency>
    <dependency>
      <groupId>org.flywaydb</groupId>
      <artifactId>flyway-mysql</artifactId>
    </dependency>
    <dependency>
      <groupId>com.mysql</groupId>
      <artifactId>mysql-connector-j</artifactId>
      <scope>runtime</scope>
    </dependency>
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>
    <dependency>
      <groupId>org.springdoc</groupId>
      <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
      <version>2.8.17</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <configuration>
          <excludes>
            <exclude>
              <groupId>org.projectlombok</groupId>
              <artifactId>lombok</artifactId>
            </exclude>
          </excludes>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
Create the profile configuration files

Spring profiles let you swap configuration based on the environment. You will create a shared config and three profile-specific files so the same codebase connects to different databases depending on where it runs.

  • In your IDE, navigate to src/main/resources.
  • Delete the existing application.properties file (Spring Initializr generates it empty by default).
  • Create a new file called application.yml in src/main/resources and paste the following:
spring:
  application:
    name: todo-api
  flyway:
    enabled: true
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false

springdoc:
  swagger-ui:
    path: /swagger-ui.html

What does this shared config do?

This file applies to all profiles. It enables Flyway for database migrations and sets Hibernate's ddl-auto to validate. That means Hibernate will verify your entity classes match the database schema but never modify it. Flyway handles all schema changes instead.

The springdoc section sets the Swagger UI path so you can browse your API docs interactively.

  • Create application-local.yml in the same src/main/resources folder with this content:
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/tododb
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    show-sql: true

What is the local profile for?

The local profile connects to your host MySQL on port 3306. This is the standalone MySQL already running on your machine. The show-sql: true setting prints every SQL query to the console for debugging.

  • Create application-dev.yml in src/main/resources:
spring:
  datasource:
    url: jdbc:mysql://localhost:3307/tododb
    username: todo_user
    password: todo_pass
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    show-sql: true

Why port 3307 for dev?

The dev profile connects to a Dockerized MySQL on port 3307 instead of 3306. This avoids conflicts with your local MySQL installation. Both databases can run at the same time without fighting over the same port.

  • Create application-prod.yml in src/main/resources:
spring:
  datasource:
    url: ${DB_URL}
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    show-sql: false

Why environment variables in prod?

In production you never hardcode credentials. The ${DB_URL}, ${DB_USERNAME}, and ${DB_PASSWORD} placeholders are resolved from environment variables at runtime. This keeps secrets out of your source code.

  • Confirm the files exist by running:
ls src/main/resources/
  • You should see application.yml, application-local.yml, application-dev.yml, and application-prod.yml in the output.

Missing a file?

  • Double-check the filenames start with application- (with a hyphen, not an underscore or dot).
  • Make sure the files are in src/main/resources and not nested inside a subfolder.

Help me find my missing config files.

Create Docker Compose and verify the setup

Docker Compose lets you define and run multi-container applications. You will use it to spin up a MySQL container for the dev profile so you have an isolated database that is easy to create and destroy.

  • Create a file called docker-compose.yml in the project root (the todo-api folder, same level as pom.xml) with this content:
version: '3.8'
services:
  mysql-dev:
    image: mysql:8.4
    container_name: todo-mysql-dev
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: tododb
      MYSQL_USER: todo_user
      MYSQL_PASSWORD: todo_pass
    ports:
      - "3307:3306"
    volumes:
      - todo-mysql-dev-data:/var/lib/mysql
volumes:
  todo-mysql-dev-data:

What does this Docker Compose file do?

It starts a MySQL 8.4 container named todo-mysql-dev. The environment variables automatically create a tododb database with a todo_user user on startup.

Port 3307 on your host maps to port 3306 inside the container. This matches the application-dev.yml datasource URL. The volume ensures data persists even if you restart the container.

  • Save docker-compose.yml.
  • Start the dev MySQL container by running:
docker compose up -d
  • You should see output showing the container starting. Confirm it is running:
docker ps
  • You should see todo-mysql-dev listed with status Up.

Container not starting?

  • Confirm Docker Desktop is running. You can check by looking for the Docker icon in your system tray.
  • If port 3307 is already in use, run docker compose down and then docker compose up -d again.
  • Make sure you are running the command from the todo-api folder where docker-compose.yml lives.

Help me troubleshoot my Docker container.

Now create the tododb database in your local MySQL so the local profile can connect to it.

  • Connect to your local MySQL by running:
mysql -u root -p
  • Enter your MySQL root password when prompted.
  • Create the database by running:
CREATE DATABASE tododb;
exit;
  • You should see Query OK, 1 row affected confirming the database was created.

Why create the database manually?

The Docker container auto-creates tododb for the dev profile. But your local MySQL does not have this database by default. Creating it ensures the local profile can connect successfully.

Finally, run the app with the local profile to confirm everything connects.

  • Start the app with the local profile by running:
./mvnw spring-boot:run -Dspring-boot.run.profiles=local
  • The app will fail to start. This is expected. You should see an error mentioning that Flyway cannot find migrations or cannot resolve the migration location.

Why does it fail?

Flyway is enabled and looks for migration scripts in src/main/resources/db/migration by default. That directory does not exist yet. You will create it in the next step when you write your first migration file.

The failure confirms three things: your datasource connects to MySQL, Flyway is active, and your profile configuration is working. The only missing piece is the migration scripts.

  • Press Ctrl+C in the terminal to stop the process.

Seeing a connection refused error instead?

  • Make sure your local MySQL is running on port 3306.
  • Verify the tododb database exists by running mysql -u root -p -e "SHOW DATABASES;" and checking for tododb in the list.
  • Check that the username and password in application-local.yml match your local MySQL credentials.

Help me debug my Spring Boot connection error.

Your project skeleton is ready. You have a compiling Spring Boot app with profiles for three environments and a Docker-based dev database. Next up, you will write your first Flyway migration to create the todos table and bring the app to life.

Set Up the Database Schema and Todo Entity

Your Spring Boot project compiles and the profile configuration is ready. The next goal is to give your app a real database schema and a Java class that maps to it.

Flyway manages database changes as versioned SQL scripts that run exactly once, in order. Right now your app fails on startup because the todos table doesn't exist yet. You'll fix that by writing a migration script, then mapping the table to a JPA entity with automatic timestamps.

In this step, get ready to:
  • Create a Flyway migration that builds the todos table.
  • Define a Todo JPA entity with auditing timestamps.
  • Create a TodoRepository for database access and verify the migration applies.
Create the Flyway migration script

Flyway looks for SQL scripts in a specific folder and applies them in version order. Each file runs exactly once against the database, giving you a reliable, version-controlled history of every schema change.

  • In your project's src/main/resources folder, create the directory path db/migration.
  • Inside db/migration, create a new file named V1__create_todos_table.sql.
  • Paste the following SQL into V1__create_todos_table.sql:
CREATE TABLE todos (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    completed BOOLEAN DEFAULT FALSE,
    priority VARCHAR(20) DEFAULT 'MEDIUM',
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

What does this migration do?

This SQL script creates the todos table that will store all your Todo items.

  • id is the primary key. AUTO_INCREMENT tells MySQL to assign the next available number for each new row.
  • title is required (NOT NULL) because a Todo without a title is meaningless.
  • completed defaults to FALSE and priority defaults to 'MEDIUM', so new Todos start in a sensible state.
  • created_at and updated_at will be populated automatically by JPA auditing (set up in the next substep).

Why is the file named V1__create_todos_table.sql?

Flyway uses a strict naming convention: the V prefix marks it as a versioned migration, 1 is the version number, __ (double underscore) separates version from description, and the description is human-readable. Flyway applies scripts in version order and records each one in a flyway_schema_history table so it never re-runs a migration.

  • Save V1__create_todos_table.sql.
  • Confirm the file appears in your IDE's file tree at src/main/resources/db/migration/V1__create_todos_table.sql.

Don't see the db/migration folder?

Make sure you created the folders inside src/main/resources, not in the project root. The full path from your project root is src/main/resources/db/migration/.

Also confirm the filename starts with a capital V and uses two underscores (__), not one. A single underscore causes Flyway to skip the file silently.

Help me troubleshoot my Flyway migration file location.

✔️ Awesome, I've got everything!

Great. Make sure the file is saved before moving on.

ⓧ I'd like to double check the full code

CREATE TABLE todos (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    completed BOOLEAN DEFAULT FALSE,
    priority VARCHAR(20) DEFAULT 'MEDIUM',
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);
Define the Todo entity and enable JPA auditing

The migration creates the table in MySQL, but Spring Boot also needs a Java class that maps to that table. This is your JPA entity. You'll also enable JPA auditing so that createdAt and updatedAt timestamps populate automatically whenever a Todo is created or modified.

  • Create a new package entity inside src/main/java/com/nextwork/todoapi/.
  • Inside the entity package, create a file named Todo.java.
  • Paste the following code into Todo.java:
package com.nextwork.todoapi.entity;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Table(name = "todos")
@EntityListeners(AuditingEntityListener.class)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Todo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Column(nullable = false)
    private Boolean completed = false;

    @Column(length = 20)
    private String priority = "MEDIUM";

    @CreatedDate
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}

What does this entity do?

  • @Entity and @Table(name = "todos") tell JPA this class maps to the todos table you just created with Flyway.
  • @EntityListeners(AuditingEntityListener.class) hooks into JPA's lifecycle events to auto-populate timestamp fields.
  • @CreatedDate sets createdAt when a row is first persisted. @LastModifiedDate updates updatedAt on every save.
  • Lombok annotations (@Data, @Builder, etc.) generate getters, setters, constructors, and a builder pattern at compile time so you don't write boilerplate.
  • Save Todo.java.

JPA auditing also needs to be enabled at the application level. You'll add a single annotation to your main class.

  • Open TodoApiApplication.java at src/main/java/com/nextwork/todoapi/TodoApiApplication.java.
  • Replace the entire file contents with the following:
package com.nextwork.todoapi;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class TodoApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(TodoApiApplication.class, args);
    }
}

What does @EnableJpaAuditing do?

This annotation activates Spring Data's auditing infrastructure. Without it, the @CreatedDate and @LastModifiedDate annotations on your entity are ignored and the timestamp fields stay null.

  • Save TodoApiApplication.java.

Seeing a compile error on the imports?

If your IDE shows red underlines on the jakarta.persistence or lombok imports, make sure your Maven dependencies loaded correctly. In IntelliJ, click the Maven refresh icon (or right-click pom.xml and select Maven then Reload Project). In VS Code, the Java extension reloads automatically on save.

Help me fix import errors in my Todo entity.

✔️ Awesome, I've got everything!

Great. Double check both files are saved before moving on.

ⓧ I'd like to double check the full code

Todo.java:

package com.nextwork.todoapi.entity;

import jakarta.persistence.*;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Table(name = "todos")
@EntityListeners(AuditingEntityListener.class)
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Todo {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Column(nullable = false)
    private Boolean completed = false;

    @Column(length = 20)
    private String priority = "MEDIUM";

    @CreatedDate
    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}

TodoApiApplication.java:

package com.nextwork.todoapi;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class TodoApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(TodoApiApplication.class, args);
    }
}
Create the TodoRepository and verify the migration

The final piece is a repository interface that gives your service layer database access. Spring Data JPA generates the implementation at runtime based on the interface methods you declare.

  • Create a new package repository inside src/main/java/com/nextwork/todoapi/.
  • Inside the repository package, create a file named TodoRepository.java.
  • Paste the following code into TodoRepository.java:
package com.nextwork.todoapi.repository;

import com.nextwork.todoapi.entity.Todo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface TodoRepository extends JpaRepository<Todo, Long>, JpaSpecificationExecutor<Todo> {
    List<Todo> findByTitleContainingIgnoreCase(String title);
}

What does this repository do?

  • JpaRepository<Todo, Long> gives you built-in methods like save(), findById(), findAll(), and deleteById() without writing any SQL.
  • JpaSpecificationExecutor<Todo> enables dynamic queries for filtering and pagination (you'll use this in a later step).
  • findByTitleContainingIgnoreCase is a derived query method. Spring Data parses the method name and generates a LIKE query that searches for titles matching the input, ignoring case.
  • Save TodoRepository.java.
  • Run the application with the local profile by executing this command in your terminal:
./mvnw spring-boot:run -Dspring-boot.run.profiles=local
  • Watch the console output. You should see a log line containing Successfully applied 1 migration (or similar Flyway success message), followed by the application starting on port 8080.

What should I see?

In the console, Flyway logs that it found and applied V1__create_todos_table.sql. After that, Hibernate validates the entity mapping against the database schema. If both succeed, you'll see the familiar Spring Boot startup banner with Started TodoApiApplication at the end.

App fails with a migration error?

If you see an error about Flyway not finding migrations, check that your file is at exactly src/main/resources/db/migration/V1__create_todos_table.sql. The folder must be db/migration (not db/migrations with an 's').

If you see a Hibernate validation error, it means the entity fields don't match the table columns. Double check the column names in your SQL match the @Column(name = "...") annotations in your entity.

Also confirm your local MySQL server is running and the tododb database exists. You can check by running mysql -u root -p -e "SHOW DATABASES;" in a separate terminal.

Help me debug my Flyway migration or Hibernate validation failure.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three new files.

ⓧ I'd like to double check the full code

TodoRepository.java:

package com.nextwork.todoapi.repository;

import com.nextwork.todoapi.entity.Todo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface TodoRepository extends JpaRepository<Todo, Long>, JpaSpecificationExecutor<Todo> {
    List<Todo> findByTitleContainingIgnoreCase(String title);
}

Your database schema is now version-controlled and your entity maps cleanly to it. Next up, you'll build the service and controller layers that expose CRUD endpoints for your Todos.

Build CRUD Endpoints with DTOs and Validation

Your database is ready and the Todo entity maps to it. Now you need an API layer that clients can actually talk to.

In a production Spring Boot app, you never expose your entity directly to the outside world. Instead, you create DTOs (Data Transfer Objects) that define exactly what data goes in and out. You also add a service layer that contains your business logic and a controller that handles HTTP requests.

In this step, get ready to:
  • Create request and response DTOs with Bean Validation constraints.
  • Build a service layer with full CRUD operations and pagination.
  • Wire up a REST controller with validated endpoints accessible via Swagger UI.
Create the request and response DTOs

DTOs act as a contract between your API and its clients. The request DTO defines what fields a client must send (and validates them). The response DTO defines what your API sends back.

  • Create a new package called dto inside src/main/java/com/nextwork/todoapi/.
  • Create a new file called TodoRequest.java inside the dto package, then paste the following code:
package com.nextwork.todoapi.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
public class TodoRequest {

    // Title is required and must be between 1 and 255 characters
    @NotBlank(message = "Title is required")
    @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters")
    private String title;

    // Description is optional but capped at 5000 characters
    @Size(max = 5000, message = "Description must not exceed 5000 characters")
    private String description;

    // Defaults to false if not provided
    private Boolean completed = false;

    // Must be one of LOW, MEDIUM, or HIGH
    @Pattern(regexp = "LOW|MEDIUM|HIGH", message = "Priority must be LOW, MEDIUM, or HIGH")
    private String priority = "MEDIUM";
}

What does this code do?

This class defines what a client must send when creating or updating a todo. Bean Validation annotations enforce the rules automatically.

  • @NotBlank rejects empty or whitespace-only titles.
  • @Size limits string length. The title must be 1-255 characters. The description must not exceed 5000.
  • @Pattern uses a regex to restrict priority to exactly LOW, MEDIUM, or HIGH.
  • Save TodoRequest.java. You won't see a visible change yet. The controller will validate incoming JSON against these constraints.
  • Now create a second file called TodoResponse.java inside the same dto package, then paste the following code:
package com.nextwork.todoapi.dto;

import lombok.Data;

import java.time.LocalDateTime;

@Data
public class TodoResponse {
    // Mirrors the entity fields that clients need to see
    private Long id;
    private String title;
    private String description;
    private Boolean completed;
    private String priority;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

What does this code do?

This class defines the shape of JSON your API returns. It mirrors the Todo entity fields but keeps your entity decoupled from the HTTP layer. If you later add internal-only fields to the entity, they won't accidentally leak into API responses.

  • Save TodoResponse.java.

Next, you need a mapper to convert between the entity and these DTOs.

  • Create a new package called mapper inside src/main/java/com/nextwork/todoapi/.
  • Create TodoMapper.java inside the mapper package, then paste the following code:
package com.nextwork.todoapi.mapper;

import com.nextwork.todoapi.dto.TodoRequest;
import com.nextwork.todoapi.dto.TodoResponse;
import com.nextwork.todoapi.entity.Todo;
import org.springframework.stereotype.Component;

@Component
public class TodoMapper {

    // Convert a request DTO into a new Todo entity
    public Todo toEntity(TodoRequest request) {
        return Todo.builder()
                .title(request.getTitle())
                .description(request.getDescription())
                .completed(request.getCompleted() != null ? request.getCompleted() : false)
                .priority(request.getPriority() != null ? request.getPriority() : "MEDIUM")
                .build();
    }

    // Convert a Todo entity into a response DTO
    public TodoResponse toResponse(Todo todo) {
        TodoResponse response = new TodoResponse();
        response.setId(todo.getId());
        response.setTitle(todo.getTitle());
        response.setDescription(todo.getDescription());
        response.setCompleted(todo.getCompleted());
        response.setPriority(todo.getPriority());
        response.setCreatedAt(todo.getCreatedAt());
        response.setUpdatedAt(todo.getUpdatedAt());
        return response;
    }
}

What does this code do?

The mapper is a Spring @Component with two conversion methods.

  • toEntity() uses the Lombok builder to create a Todo from request fields. It applies defaults if completed or priority are null.
  • toResponse() copies entity fields into a response DTO, including the audit timestamps populated by JPA Auditing.
  • Save TodoMapper.java.
  • Confirm all three files compile by running:
./mvnw compile
  • You should see BUILD SUCCESS in the terminal output.

Seeing a compilation error?

  • Check that your package declarations match the folder structure. TodoRequest.java must be at src/main/java/com/nextwork/todoapi/dto/.
  • Make sure the imports in TodoMapper.java reference com.nextwork.todoapi.dto and com.nextwork.todoapi.entity. A typo in the package name causes unresolved symbol errors.

Help me fix a compile error in my DTOs or mapper.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is TodoRequest.java:

package com.nextwork.todoapi.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
public class TodoRequest {

    @NotBlank(message = "Title is required")
    @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters")
    private String title;

    @Size(max = 5000, message = "Description must not exceed 5000 characters")
    private String description;

    private Boolean completed = false;

    @Pattern(regexp = "LOW|MEDIUM|HIGH", message = "Priority must be LOW, MEDIUM, or HIGH")
    private String priority = "MEDIUM";
}

Here is TodoResponse.java:

package com.nextwork.todoapi.dto;

import lombok.Data;

import java.time.LocalDateTime;

@Data
public class TodoResponse {
    private Long id;
    private String title;
    private String description;
    private Boolean completed;
    private String priority;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

Here is TodoMapper.java:

package com.nextwork.todoapi.mapper;

import com.nextwork.todoapi.dto.TodoRequest;
import com.nextwork.todoapi.dto.TodoResponse;
import com.nextwork.todoapi.entity.Todo;
import org.springframework.stereotype.Component;

@Component
public class TodoMapper {

    public Todo toEntity(TodoRequest request) {
        return Todo.builder()
                .title(request.getTitle())
                .description(request.getDescription())
                .completed(request.getCompleted() != null ? request.getCompleted() : false)
                .priority(request.getPriority() != null ? request.getPriority() : "MEDIUM")
                .build();
    }

    public TodoResponse toResponse(Todo todo) {
        TodoResponse response = new TodoResponse();
        response.setId(todo.getId());
        response.setTitle(todo.getTitle());
        response.setDescription(todo.getDescription());
        response.setCompleted(todo.getCompleted());
        response.setPriority(todo.getPriority());
        response.setCreatedAt(todo.getCreatedAt());
        response.setUpdatedAt(todo.getUpdatedAt());
        return response;
    }
}
Create the service layer with CRUD operations

The service layer sits between the controller and the repository. It contains business logic like looking up a todo by ID and throwing an error if it doesn't exist.

  • Create a new package called exception inside src/main/java/com/nextwork/todoapi/.
  • Create ResourceNotFoundException.java inside the exception package, then paste the following code:
package com.nextwork.todoapi.exception;

// Custom exception for when a requested resource doesn't exist
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

What does this code do?

This is a custom unchecked exception. When the service layer can't find a todo by its ID, it throws this exception. In the next step, you'll add a global exception handler that catches it and returns a structured 404 JSON response.

  • Save ResourceNotFoundException.java. You won't see a change yet. The service you create next will use this exception.
  • Create a new package called service inside src/main/java/com/nextwork/todoapi/.
  • Create TodoService.java inside the service package, then paste the following code:
package com.nextwork.todoapi.service;

import com.nextwork.todoapi.dto.TodoRequest;
import com.nextwork.todoapi.dto.TodoResponse;
import com.nextwork.todoapi.entity.Todo;
import com.nextwork.todoapi.exception.ResourceNotFoundException;
import com.nextwork.todoapi.mapper.TodoMapper;
import com.nextwork.todoapi.repository.TodoRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class TodoService {

    private final TodoRepository todoRepository;
    private final TodoMapper todoMapper;

    // Create a new todo from the request DTO
    public TodoResponse createTodo(TodoRequest request) {
        Todo todo = todoMapper.toEntity(request);
        return todoMapper.toResponse(todoRepository.save(todo));
    }

    // Look up a single todo by ID, throw 404 if missing
    public TodoResponse getTodoById(Long id) {
        Todo todo = todoRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Todo not found with id: " + id));
        return todoMapper.toResponse(todo);
    }

    // Return a paginated list of all todos
    public Page<TodoResponse> getAllTodos(Pageable pageable) {
        return todoRepository.findAll(pageable).map(todoMapper::toResponse);
    }

    // Update an existing todo's fields
    public TodoResponse updateTodo(Long id, TodoRequest request) {
        Todo todo = todoRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Todo not found with id: " + id));
        todo.setTitle(request.getTitle());
        todo.setDescription(request.getDescription());
        todo.setCompleted(request.getCompleted());
        todo.setPriority(request.getPriority());
        return todoMapper.toResponse(todoRepository.save(todo));
    }

    // Delete a todo by ID, throw 404 if it doesn't exist
    public void deleteTodo(Long id) {
        if (!todoRepository.existsById(id)) {
            throw new ResourceNotFoundException("Todo not found with id: " + id);
        }
        todoRepository.deleteById(id);
    }

    // Search todos by title (case-insensitive partial match)
    public List<TodoResponse> searchTodos(String query) {
        return todoRepository.findByTitleContainingIgnoreCase(query)
                .stream()
                .map(todoMapper::toResponse)
                .collect(Collectors.toList());
    }
}

What does this code do?

This is the heart of your business logic. Each method handles one CRUD operation.

  • createTodo() converts the request DTO to an entity, saves it, and returns the response DTO.
  • getTodoById() and updateTodo() throw ResourceNotFoundException if the ID doesn't exist in the database.
  • getAllTodos() uses Spring Data's Pageable to return results in pages instead of loading everything at once.
  • searchTodos() uses the custom repository method for case-insensitive title search.
  • Save TodoService.java.
  • Confirm the service compiles by running:
./mvnw compile
  • You should see BUILD SUCCESS again.

Compile fails on TodoService?

  • Verify your import for Pageable is org.springframework.data.domain.Pageable. Your IDE may suggest a different Pageable class.
  • Make sure ResourceNotFoundException.java is in the exception package. The service imports it from com.nextwork.todoapi.exception.

Help me debug my TodoService compilation error.

✔️ Awesome, I've got everything!

Great. Both new files are saved and compiling. Time to wire them up with a controller.

ⓧ I'd like to double check the full code

Here is ResourceNotFoundException.java:

package com.nextwork.todoapi.exception;

public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}

Here is TodoService.java:

package com.nextwork.todoapi.service;

import com.nextwork.todoapi.dto.TodoRequest;
import com.nextwork.todoapi.dto.TodoResponse;
import com.nextwork.todoapi.entity.Todo;
import com.nextwork.todoapi.exception.ResourceNotFoundException;
import com.nextwork.todoapi.mapper.TodoMapper;
import com.nextwork.todoapi.repository.TodoRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class TodoService {

    private final TodoRepository todoRepository;
    private final TodoMapper todoMapper;

    public TodoResponse createTodo(TodoRequest request) {
        Todo todo = todoMapper.toEntity(request);
        return todoMapper.toResponse(todoRepository.save(todo));
    }

    public TodoResponse getTodoById(Long id) {
        Todo todo = todoRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Todo not found with id: " + id));
        return todoMapper.toResponse(todo);
    }

    public Page<TodoResponse> getAllTodos(Pageable pageable) {
        return todoRepository.findAll(pageable).map(todoMapper::toResponse);
    }

    public TodoResponse updateTodo(Long id, TodoRequest request) {
        Todo todo = todoRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Todo not found with id: " + id));
        todo.setTitle(request.getTitle());
        todo.setDescription(request.getDescription());
        todo.setCompleted(request.getCompleted());
        todo.setPriority(request.getPriority());
        return todoMapper.toResponse(todoRepository.save(todo));
    }

    public void deleteTodo(Long id) {
        if (!todoRepository.existsById(id)) {
            throw new ResourceNotFoundException("Todo not found with id: " + id);
        }
        todoRepository.deleteById(id);
    }

    public List<TodoResponse> searchTodos(String query) {
        return todoRepository.findByTitleContainingIgnoreCase(query)
                .stream()
                .map(todoMapper::toResponse)
                .collect(Collectors.toList());
    }
}
Create the REST controller and test in Swagger

The controller is the HTTP entry point. It maps URLs to service methods and tells Spring to validate incoming request bodies before they reach your business logic.

  • Create a new package called controller inside src/main/java/com/nextwork/todoapi/.
  • Create TodoController.java inside the controller package, then paste the following code:
package com.nextwork.todoapi.controller;

import com.nextwork.todoapi.dto.TodoRequest;
import com.nextwork.todoapi.dto.TodoResponse;
import com.nextwork.todoapi.service.TodoService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/todos")
@RequiredArgsConstructor
public class TodoController {

    private final TodoService todoService;

    // POST /api/v1/todos - create a new todo
    @PostMapping
    public ResponseEntity<TodoResponse> createTodo(@Valid @RequestBody TodoRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(todoService.createTodo(request));
    }

    // GET /api/v1/todos/{id} - fetch a single todo
    @GetMapping("/{id}")
    public ResponseEntity<TodoResponse> getTodoById(@PathVariable Long id) {
        return ResponseEntity.ok(todoService.getTodoById(id));
    }

    // GET /api/v1/todos - list all todos with pagination
    @GetMapping
    public ResponseEntity<Page<TodoResponse>> getAllTodos(Pageable pageable) {
        return ResponseEntity.ok(todoService.getAllTodos(pageable));
    }

    // PUT /api/v1/todos/{id} - update an existing todo
    @PutMapping("/{id}")
    public ResponseEntity<TodoResponse> updateTodo(@PathVariable Long id, @Valid @RequestBody TodoRequest request) {
        return ResponseEntity.ok(todoService.updateTodo(id, request));
    }

    // DELETE /api/v1/todos/{id} - remove a todo
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTodo(@PathVariable Long id) {
        todoService.deleteTodo(id);
        return ResponseEntity.noContent().build();
    }

    // GET /api/v1/todos/search?query= - search by title
    @GetMapping("/search")
    public ResponseEntity<List<TodoResponse>> searchTodos(@RequestParam String query) {
        return ResponseEntity.ok(todoService.searchTodos(query));
    }
}

What does this code do?

This controller exposes six endpoints under /api/v1/todos.

  • @Valid on the @RequestBody triggers Bean Validation before the method runs. Invalid requests get rejected automatically.
  • Pageable in getAllTodos lets clients pass ?page=0&size=10&sort=createdAt,desc as query parameters. Spring resolves these into a Pageable object automatically.
  • ResponseEntity gives you fine-grained control over HTTP status codes. POST returns 201 Created. DELETE returns 204 No Content.
  • Save TodoController.java.
  • Start the app with the local profile by running:
./mvnw spring-boot:run -Dspring-boot.run.profiles=local
  • Wait for the log line showing Started TodoApiApplication.
  • Open your browser and navigate to http://localhost:8080/swagger-ui.html.

You should see the Swagger UI page listing your todo-controller with all six endpoints (POST, GET, PUT, DELETE, search).

  • Expand the POST /api/v1/todos endpoint in Swagger.
  • Click Try it out.
  • Replace the request body with:
{
  "title": "Learn Spring Boot",
  "description": "Build a REST API project",
  "completed": false,
  "priority": "HIGH"
}
  • Click Execute.

You should see a 201 response with the created todo, including the auto-generated id, createdAt, and updatedAt timestamps.

Swagger UI not loading or endpoint returns an error?

  • If the page is blank, make sure you're using http://localhost:8080/swagger-ui.html (not HTTPS).
  • If you see a 500 error on POST, check your terminal for the stack trace. A common cause is the tododb database not existing in your local MySQL. Create it with CREATE DATABASE tododb; in the MySQL CLI.
  • If the app fails to start entirely, confirm MySQL is running on port 3306 for the local profile.

Help me troubleshoot my Swagger UI or controller errors.

✔️ Awesome, I've got everything!

Great. Your full CRUD API is live and testable through Swagger.

ⓧ I'd like to double check the full code

Here is the full TodoController.java:

package com.nextwork.todoapi.controller;

import com.nextwork.todoapi.dto.TodoRequest;
import com.nextwork.todoapi.dto.TodoResponse;
import com.nextwork.todoapi.service.TodoService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/todos")
@RequiredArgsConstructor
public class TodoController {

    private final TodoService todoService;

    @PostMapping
    public ResponseEntity<TodoResponse> createTodo(@Valid @RequestBody TodoRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(todoService.createTodo(request));
    }

    @GetMapping("/{id}")
    public ResponseEntity<TodoResponse> getTodoById(@PathVariable Long id) {
        return ResponseEntity.ok(todoService.getTodoById(id));
    }

    @GetMapping
    public ResponseEntity<Page<TodoResponse>> getAllTodos(Pageable pageable) {
        return ResponseEntity.ok(todoService.getAllTodos(pageable));
    }

    @PutMapping("/{id}")
    public ResponseEntity<TodoResponse> updateTodo(@PathVariable Long id, @Valid @RequestBody TodoRequest request) {
        return ResponseEntity.ok(todoService.updateTodo(id, request));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTodo(@PathVariable Long id) {
        todoService.deleteTodo(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/search")
    public ResponseEntity<List<TodoResponse>> searchTodos(@RequestParam String query) {
        return ResponseEntity.ok(todoService.searchTodos(query));
    }
}

Why separate DTOs from the entity?

Exposing your JPA entity directly means any internal field change (like adding a password hash column) accidentally leaks into your API. DTOs give you an explicit contract: the request DTO controls what goes in, the response DTO controls what goes out.

This also lets you version your API independently of your database schema. You can add new response fields for v2 clients without touching the entity at all.

Your API now handles creating, reading, updating, deleting, and searching todos with validated inputs. Next up, you'll add a global exception handler so validation failures and missing resources return clean, structured JSON error responses instead of raw stack traces.

Add Global Exception Handling and Swagger Configuration

Your CRUD endpoints work, but try requesting a todo that does not exist. Spring Boot returns a default error response that leaks internal details and does not match a consistent structure.

In this step, you will build a global exception handler that intercepts errors and returns clean, structured JSON. You will also customize your Swagger documentation and add filtering to your list endpoint so consumers can query todos by status and priority.

In this step, get ready to:
  • Build a global exception handler that returns structured error JSON for all failure scenarios.
  • Configure Swagger/OpenAPI with custom API metadata.
  • Add pagination defaults and filtering by completed status and priority to your list endpoint.
Create the global exception handler

Every REST API needs a predictable error format. Right now, a missing todo or invalid request body triggers Spring Boot's default error page. You will replace that with a structured ErrorResponse DTO and a centralized handler class.

  • Create a new file ErrorResponse.java in src/main/java/com/nextwork/todoapi/dto/ and add the following code:
package com.nextwork.todoapi.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.Map;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
    // Timestamp of when the error occurred
    private LocalDateTime timestamp;

    // HTTP status code (e.g. 404, 400, 500)
    private int status;

    // Human-readable error message
    private String message;

    // Field-level validation errors (null when not a validation error)
    private Map<String, String> fieldErrors;
}

What does this code do?

This is a simple DTO that defines the shape of every error response your API returns. The @Builder annotation lets you construct instances cleanly, while fieldErrors holds a map of field names to validation messages when a request body fails validation.

  • Save ErrorResponse.java.

Now you will create the handler class that catches exceptions and builds the error response.

  • Create a new file GlobalExceptionHandler.java in src/main/java/com/nextwork/todoapi/exception/ and add the following code:
package com.nextwork.todoapi.exception;

import com.nextwork.todoapi.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handles requests for resources that don't exist (404)
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.NOT_FOUND.value())
                .message(ex.getMessage())
                .build();
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // Handles @Valid failures on request bodies (400)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = new HashMap<>();
        for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
            fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage());
        }
        ErrorResponse error = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.BAD_REQUEST.value())
                .message("Validation failed")
                .fieldErrors(fieldErrors)
                .build();
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    // Catches anything else that slips through (500)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        ErrorResponse error = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
                .message("An unexpected error occurred")
                .build();
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

What does this code do?

The @RestControllerAdvice annotation tells Spring to intercept exceptions thrown from any controller and route them to the matching @ExceptionHandler method in this class.

  • handleResourceNotFound catches your custom ResourceNotFoundException and returns a 404 with the exception message.
  • handleValidationErrors catches MethodArgumentNotValidException (thrown when @Valid fails) and maps each field error into the fieldErrors map.
  • handleGenericException is the safety net that catches any unhandled exception and returns a 500 without leaking stack traces.
  • Save GlobalExceptionHandler.java.
  • Restart your application, then open Swagger UI at http://localhost:8080/swagger-ui.html.
  • Use the GET /api/v1/todos/{id} endpoint and enter an ID that does not exist (e.g. 999). You should see a structured JSON error with status: 404 and your "Todo not found with id: 999" message.

Still seeing the default Spring error page?

Make sure GlobalExceptionHandler is in the com.nextwork.todoapi.exception package. Spring component scanning only picks up classes under the base package. Also confirm you restarted the app after saving.

help me debug my GlobalExceptionHandler not being picked up by Spring

Configure Swagger/OpenAPI metadata

Your Swagger UI already renders endpoints, but it shows generic metadata. A custom OpenAPI configuration class lets you set the API title, description, version, and contact info that appears at the top of the documentation page.

  • Create a new file OpenApiConfig.java in src/main/java/com/nextwork/todoapi/config/ (create the config package if it does not exist) and add the following code:
package com.nextwork.todoapi.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenApiConfig {

    // Defines the metadata shown at the top of Swagger UI
    @Bean
    public OpenAPI todoOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Todo REST API")
                        .description("A production-structured Todo REST API built with Spring Boot 3.5")
                        .version("1.0")
                        .contact(new Contact()
                                .name("NextWork")
                                .url("https://nextwork.org")));
    }
}

What does this code do?

The @Configuration class exposes a @Bean of type OpenAPI that springdoc picks up automatically. The Info object sets the title, description, and version displayed at the top of the Swagger UI page. The Contact object adds author details.

  • Save OpenApiConfig.java and restart your application.
  • Open http://localhost:8080/swagger-ui.html in your browser. You should see Todo REST API as the title at the top of the page, with your description and version below it.

Swagger UI still showing the default title?

Confirm that OpenApiConfig.java is inside the com.nextwork.todoapi.config package and has both @Configuration on the class and @Bean on the method. A missing annotation means Spring never registers the bean.

help me fix my OpenAPI configuration not loading in Swagger UI

Add pagination defaults and filtering

Your GET /api/v1/todos endpoint returns all todos in whatever order the database provides. Production APIs need sensible defaults (page size, sort order) and the ability to filter results.

You will update the service to build a dynamic query using JpaSpecificationExecutor, then update the controller to accept filter parameters with pagination defaults.

  • In TodoService.java, find the existing getAllTodos method and replace it with the updated version below. Also add the new imports at the top of the file:
// Add these imports at the top of TodoService.java
import jakarta.persistence.criteria.Predicate;
import org.springframework.data.jpa.domain.Specification;

import java.util.ArrayList;
  • Find the existing getAllTodos method:
public Page<TodoResponse> getAllTodos(Pageable pageable) {
    return todoRepository.findAll(pageable).map(todoMapper::toResponse);
}
  • Replace it with this updated version that accepts filter parameters and builds a dynamic specification:
public Page<TodoResponse> getAllTodos(Boolean completed, String priority, Pageable pageable) {
    // Build a dynamic query based on which filters are provided
    Specification<Todo> spec = (root, query, cb) -> {
        List<Predicate> predicates = new ArrayList<>();
        if (completed != null) predicates.add(cb.equal(root.get("completed"), completed));
        if (priority != null && !priority.isBlank()) predicates.add(cb.equal(root.get("priority"), priority));
        return cb.and(predicates.toArray(new Predicate[0]));
    };
    return todoRepository.findAll(spec, pageable).map(todoMapper::toResponse);
}

What does this code do?

A Specification builds a WHERE clause dynamically at runtime. If completed is provided, it adds a predicate filtering by that value. If priority is provided, it adds another predicate. When both are null, no predicates are added and all todos are returned.

This works because TodoRepository already extends JpaSpecificationExecutor, which provides the findAll(Specification, Pageable) method.

  • Save TodoService.java.

Now update the controller to pass these parameters through and set pagination defaults.

  • In TodoController.java, add these imports at the top of the file:
// Add these imports at the top of TodoController.java
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
  • Find the existing getAllTodos method:
@GetMapping
public ResponseEntity<Page<TodoResponse>> getAllTodos(Pageable pageable) {
    return ResponseEntity.ok(todoService.getAllTodos(pageable));
}
  • Replace it with this updated version that adds request parameters and pagination defaults:
@GetMapping
public ResponseEntity<Page<TodoResponse>> getAllTodos(
        @RequestParam(required = false) Boolean completed,
        @RequestParam(required = false) String priority,
        @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
    return ResponseEntity.ok(todoService.getAllTodos(completed, priority, pageable));
}

What does this code do?

The @PageableDefault annotation sets sensible defaults when the caller does not specify pagination params: 10 items per page, sorted by createdAt in descending order (newest first).

The two @RequestParam(required = false) parameters are optional query params. Callers can append ?completed=true or ?priority=HIGH to filter results.

  • Save TodoController.java and restart your application.
  • Open Swagger UI at http://localhost:8080/swagger-ui.html.
  • Expand the GET /api/v1/todos endpoint. You should see completed, priority, page, size, and sort as query parameters.
  • Try calling the endpoint with priority set to HIGH. Only high-priority todos should appear in the response.

Getting a compilation error?

The most common cause is a mismatched method signature. The controller now passes completed and priority to the service, so both files must be saved with the updated signatures before the app compiles.

help me fix a compilation error in my TodoController or TodoService after adding filtering parameters

✔️ Awesome, I've got everything!

Great. Double check that all four new or modified files are saved.

ⓧ I'd like to double check the full code

Here are the complete files you created or modified in this step.

package com.nextwork.todoapi.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;
import java.util.Map;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
    private LocalDateTime timestamp;
    private int status;
    private String message;
    private Map<String, String> fieldErrors;
}
package com.nextwork.todoapi.exception;

import com.nextwork.todoapi.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.NOT_FOUND.value())
                .message(ex.getMessage())
                .build();
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, String> fieldErrors = new HashMap<>();
        for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
            fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage());
        }
        ErrorResponse error = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.BAD_REQUEST.value())
                .message("Validation failed")
                .fieldErrors(fieldErrors)
                .build();
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        ErrorResponse error = ErrorResponse.builder()
                .timestamp(LocalDateTime.now())
                .status(HttpStatus.INTERNAL_SERVER_ERROR.value())
                .message("An unexpected error occurred")
                .build();
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}
package com.nextwork.todoapi.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI todoOpenAPI() {
        return new OpenAPI()
                .info(new Info()
                        .title("Todo REST API")
                        .description("A production-structured Todo REST API built with Spring Boot 3.5")
                        .version("1.0")
                        .contact(new Contact()
                                .name("NextWork")
                                .url("https://nextwork.org")));
    }
}
package com.nextwork.todoapi.controller;

import com.nextwork.todoapi.dto.TodoRequest;
import com.nextwork.todoapi.dto.TodoResponse;
import com.nextwork.todoapi.service.TodoService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/todos")
@RequiredArgsConstructor
public class TodoController {

    private final TodoService todoService;

    @PostMapping
    public ResponseEntity<TodoResponse> createTodo(@Valid @RequestBody TodoRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(todoService.createTodo(request));
    }

    @GetMapping("/{id}")
    public ResponseEntity<TodoResponse> getTodoById(@PathVariable Long id) {
        return ResponseEntity.ok(todoService.getTodoById(id));
    }

    @GetMapping
    public ResponseEntity<Page<TodoResponse>> getAllTodos(
            @RequestParam(required = false) Boolean completed,
            @RequestParam(required = false) String priority,
            @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) {
        return ResponseEntity.ok(todoService.getAllTodos(completed, priority, pageable));
    }

    @PutMapping("/{id}")
    public ResponseEntity<TodoResponse> updateTodo(@PathVariable Long id, @Valid @RequestBody TodoRequest request) {
        return ResponseEntity.ok(todoService.updateTodo(id, request));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTodo(@PathVariable Long id) {
        todoService.deleteTodo(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/search")
    public ResponseEntity<List<TodoResponse>> searchTodos(@RequestParam String query) {
        return ResponseEntity.ok(todoService.searchTodos(query));
    }
}
package com.nextwork.todoapi.service;

import com.nextwork.todoapi.dto.TodoRequest;
import com.nextwork.todoapi.dto.TodoResponse;
import com.nextwork.todoapi.entity.Todo;
import com.nextwork.todoapi.exception.ResourceNotFoundException;
import com.nextwork.todoapi.mapper.TodoMapper;
import com.nextwork.todoapi.repository.TodoRepository;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class TodoService {

    private final TodoRepository todoRepository;
    private final TodoMapper todoMapper;

    public TodoResponse createTodo(TodoRequest request) {
        Todo todo = todoMapper.toEntity(request);
        return todoMapper.toResponse(todoRepository.save(todo));
    }

    public TodoResponse getTodoById(Long id) {
        Todo todo = todoRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Todo not found with id: " + id));
        return todoMapper.toResponse(todo);
    }

    public Page<TodoResponse> getAllTodos(Boolean completed, String priority, Pageable pageable) {
        Specification<Todo> spec = (root, query, cb) -> {
            List<Predicate> predicates = new ArrayList<>();
            if (completed != null) predicates.add(cb.equal(root.get("completed"), completed));
            if (priority != null && !priority.isBlank()) predicates.add(cb.equal(root.get("priority"), priority));
            return cb.and(predicates.toArray(new Predicate[0]));
        };
        return todoRepository.findAll(spec, pageable).map(todoMapper::toResponse);
    }

    public TodoResponse updateTodo(Long id, TodoRequest request) {
        Todo todo = todoRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Todo not found with id: " + id));
        todo.setTitle(request.getTitle());
        todo.setDescription(request.getDescription());
        todo.setCompleted(request.getCompleted());
        todo.setPriority(request.getPriority());
        return todoMapper.toResponse(todoRepository.save(todo));
    }

    public void deleteTodo(Long id) {
        if (!todoRepository.existsById(id)) {
            throw new ResourceNotFoundException("Todo not found with id: " + id);
        }
        todoRepository.deleteById(id);
    }

    public List<TodoResponse> searchTodos(String query) {
        return todoRepository.findByTitleContainingIgnoreCase(query)
                .stream()
                .map(todoMapper::toResponse)
                .collect(Collectors.toList());
    }
}

Your API now handles errors gracefully, documents itself automatically, and lets consumers filter and paginate results. Next up, you will tackle the Secret Mission and extend your schema with a new entity relationship.

Secret mission

Add Category Entity with One-to-Many Relationship

Your Todo API works, but every todo floats in isolation. In the real world, tasks belong to categories like 'Work', 'Personal', or 'Shopping'. Can you extend your API with a second entity, a JPA relationship, and filtering by category?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • MySQL Docker container (todo-mysql-dev) and its data volume (dev profile).
  • Local MySQL database tododb on your host (local profile).
  • Project files in the todo-api folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building on your Todo API or plan to continue development soon.

  • Your Docker container stays running and available on port 3307.
  • Your local MySQL database stays intact with all your migration history and test data.
  • Your project files remain on your Desktop, ready to open in your IDE.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping your files and data intact for later.

  • Stop the Spring Boot application if it is still running by pressing Ctrl+C in its terminal.
  • Stop the Docker container (preserves data) by running:
docker compose stop
  • When you come back, start the container again with docker compose start.
  • Your project files and local MySQL database stay as they are.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes your container, data volume, database, and project files permanently.

  • Stop and remove the Docker container and its data volume by running:
docker compose down -v
  • Drop the local MySQL database by opening your MySQL client and running:
mysql -u root -p -e "DROP DATABASE tododb;"
  • Delete the project folder from your Desktop by running:

macOS

rm -rf ~/Desktop/todo-api

Windows

rmdir /s /q %USERPROFILE%\Desktop\todo-api

Nice Work!

Nice Work!

You just built a production-structured REST API from scratch using Spring Boot 3.5, MySQL, and Docker. This is the kind of backend foundation that real applications are built on.

You've learned how to:

  • Build a Spring Boot 3.5 REST API with full CRUD, pagination, filtering by status and priority, and text search.
  • Configure multi-environment profiles (local, dev, prod) with Docker Compose for the dev database and Flyway for version-controlled schema migrations.
  • Implement a global exception handler returning structured JSON error payloads, Bean Validation on request DTOs, JPA Auditing for automatic timestamps, and auto-generated Swagger UI documentation with springdoc-openapi.
  • Secret Mission: Added a Category entity with a one-to-many relationship to Todo, including cascading queries and filtering by category.

Ready to quiz yourself?