Build a Product Inventory REST API

Build a Spring Boot REST API with CRUD, validation, and error handling.

Introduction

30 Second Summary

Every online store, mobile app, and dashboard you use relies on a backend API to manage its data. The quality of that API determines whether the product feels reliable or breaks under pressure.

In this project, you will build a Product Inventory REST API using Spring Boot and PostgreSQL. You will implement full CRUD operations, input validation, centralized error handling, and paginated endpoints.

What You'll Build

A REST API that manages a catalog of products with proper layered architecture, validation, and structured error responses.

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

  • A Spring Boot application with full CRUD endpoints connected to a real PostgreSQL database.
  • Proper layered architecture separating controllers, services, repositories, DTOs, and exceptions.
  • Centralized error handling and input validation returning consistent, meaningful JSON error responses.
  • Secret Mission: Add filtering and search so users can find products by name or price range.

Are there any prerequisites?

You should have basic Java knowledge and familiarity with HTTP methods (GET, POST, PUT, DELETE). You will also need IntelliJ IDEA, PostgreSQL, and JDK 17 or higher installed on your machine.

Generate and Configure the Spring Boot Project

Every Spring Boot application starts with a project scaffold that wires together your dependencies, build tool, and configuration files.

In this step, you'll generate that scaffold using Spring Initializr, create a PostgreSQL database, and confirm the application starts cleanly.

In this step, get ready to:
  • Generate a Spring Boot project with the right dependencies.
  • Create and configure a PostgreSQL database connection.
  • Verify the application starts successfully.
Create a new Spring Boot project in IntelliJ IDEA

Spring Initializr is a web-based tool (also built into IntelliJ) that generates a ready-to-run project with your chosen dependencies and build configuration.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ IDEA and press Enter to open it.
  • Click File in the top menu bar.
  • Click New, then Project.
  • In the left panel, select Spring Initializr.

Now configure the project metadata on the right side of the wizard.

  • Set Group to com.example.
  • Set Artifact to productapi.
  • Select Maven as the build system.
  • Select Java 17 as the JDK version.
  • Click Next.

On the dependencies screen, select the Spring Boot version and add your dependencies.

  • Select Spring Boot 3.5.x from the version dropdown (the latest 3.5 option available).
  • Search for and add Spring Web.
  • Search for and add Spring Data JPA.
  • Search for and add PostgreSQL Driver.
  • Search for and add Validation.
  • Click Create.

IntelliJ downloads the project scaffold and opens it. Wait for the indexing bar at the bottom to finish before proceeding.

What did Spring Initializr just generate?

The scaffold includes a Maven wrapper (mvnw), a pom.xml with your four dependencies, a main application class, and a application.properties file for configuration.

The Maven wrapper means you do not need Maven installed separately. The project carries its own build tool.

Don't see Spring Initializr in the New Project wizard?

Spring Initializr is only available in IntelliJ IDEA Ultimate Edition. If you have the Community Edition, go to start.spring.io in your browser, configure the same settings, click Generate, unzip the download, and open the folder in IntelliJ.

Help me set up my Spring Boot project

Create the PostgreSQL database and configure the connection

Your Spring Boot application needs a database to store product data. You will create a fresh PostgreSQL database and then tell the application how to connect to it.

  • Open a terminal (outside IntelliJ is fine) and create the database by running this command:
psql -U postgres -c "CREATE DATABASE productdb;"

What does this command do?

psql -U postgres connects to PostgreSQL as the postgres superuser. The -c flag runs the SQL command and exits automatically.

  • Verify the database was created by running:
psql -U postgres -c "\l"

You should see productdb in the list of databases.

Getting a connection refused or role does not exist error?

Make sure PostgreSQL is running. On macOS, check with brew services list. On Windows, search for Services and confirm postgresql shows as Running.

If you see "role postgres does not exist", your PostgreSQL installation may use a different default username. Try your system username instead.

Help me connect to my PostgreSQL database

Now you need to tell the application where to find this database. Spring Boot reads connection details from a properties file.

  • In IntelliJ's Project panel on the left, expand src > main > resources.
  • Double-click application.properties to open it.
  • Replace the empty file contents with the following configuration:
spring.datasource.url=jdbc:postgresql://localhost:5432/productdb
spring.datasource.username=postgres
spring.datasource.password=your-password-here
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

What does this configuration do?

  • spring.datasource.url points to your local PostgreSQL instance on port 5432 and the productdb database you just created.
  • spring.jpa.hibernate.ddl-auto=update tells Hibernate to automatically create or update database tables based on your Java entities.
  • spring.jpa.show-sql=true prints every SQL query to the console so you can see what the app is doing behind the scenes.
  • Replace your-password-here with the actual password for your postgres database user.
  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).

Not sure what your PostgreSQL password is?

If you set a password during PostgreSQL installation, use that one. On some macOS installations via Homebrew, the default postgres user has no password. In that case, leave the password line as an empty value: spring.datasource.password=.

Help me figure out my PostgreSQL password

Run the application to verify the setup

With the database ready and the configuration saved, you can start the application to confirm everything connects properly.

  • Open the terminal inside IntelliJ by clicking Terminal at the bottom of the window.
  • Make sure you are in the productapi project root (where pom.xml lives).
  • Start the application by running this command:

macOS

./mvnw spring-boot:run

Windows

.\mvnw.cmd spring-boot:run

The first run downloads all the dependencies, so it may take a minute or two. When it finishes, look for this line in the console output:

Started ProductapiApplication in X.XXX seconds

You should also see Hibernate SQL output showing that the connection to PostgreSQL is active.

Seeing a database connection error on startup?

If you see Connection refused or password authentication failed, double-check three things:

  • PostgreSQL is running (not just installed).
  • The database name in your URL matches exactly: productdb.
  • The password in application.properties matches your actual PostgreSQL password.

Help me fix my Spring Boot database connection

  • Stop the application by pressing Ctrl+C in the terminal.

✔️ Awesome, I've got everything!

Great. Double check you have saved application.properties before moving on.

ⓧ I'd like to double check the full code

Here is the complete application.properties file you should have at this point:

spring.datasource.url=jdbc:postgresql://localhost:5432/productdb
spring.datasource.username=postgres
spring.datasource.password=your-password-here
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Your project is scaffolded and connected to a real database. Next up, you'll create the Product entity and repository layer that maps Java objects to database rows.

Create the Entity and Repository Layer

Your Spring Boot project is running and connected to PostgreSQL. Now you need to tell your application what data it manages.

In this step, you will create a Product entity that maps directly to a database table. You will also create a repository interface that gives your application built-in CRUD operations without writing SQL.

In this step, get ready to:
  • Create a Product JPA entity class with five fields.
  • Create a ProductRepository interface for database access.
  • Verify Hibernate automatically creates the products table in PostgreSQL.
Create the Product JPA entity

A JPA entity is a Java class that maps to a database table. Each instance of the class represents one row in that table.

  • In IntelliJ's Project panel on the left, right-click the com.example.productapi package inside src/main/java.
  • Select New, then Package.
  • Type com.example.productapi.entity and press Enter.
  • Right-click the new entity package, select New, then Java Class.
  • Name the class Product and press Enter.
  • Replace the entire file contents with the following code:
package com.example.productapi.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;

@Entity
@Table(name = "products")
public class Product {

    // Primary key, auto-generated by PostgreSQL
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Product name, required field
    @Column(nullable = false)
    private String name;

    // Optional description
    private String description;

    // Price stored as BigDecimal for precision
    @Column(nullable = false)
    private BigDecimal price;

    // Stock quantity, required field
    @Column(nullable = false)
    private Integer quantity;

    // No-arg constructor required by JPA
    public Product() {
    }

    // Convenience constructor for creating new products
    public Product(String name, String description, BigDecimal price, Integer quantity) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.quantity = quantity;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }

    public Integer getQuantity() {
        return quantity;
    }

    public void setQuantity(Integer quantity) {
        this.quantity = quantity;
    }
}

What does this code do?

The annotations at the top of the class tell Spring how this Java object maps to the database.

  • @Entity marks this class as a JPA entity, meaning Hibernate will manage it.
  • @Table(name = "products") maps the class to a table called products in your database.
  • @Id and @GeneratedValue(strategy = GenerationType.IDENTITY) tell PostgreSQL to auto-generate a unique ID for each new product.
  • @Column(nullable = false) enforces that certain fields cannot be empty in the database.
  • The no-arg constructor is required by JPA. The second constructor is a convenience for creating products in your service layer.

Why BigDecimal instead of double?

Money values should never use double or float because floating-point math introduces rounding errors. BigDecimal stores exact decimal values, which is the standard for financial data in Java applications.

  • Save Product.java by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Seeing red underlines on the imports?

If IntelliJ highlights the jakarta.persistence imports in red, your project dependencies may not have been resolved yet.

  • Click the Maven icon in the right sidebar, then click the refresh button (circular arrows) to reload the project.
  • Make sure your pom.xml contains the spring-boot-starter-data-jpa dependency. This was added by Spring Initializr in Step 1.

Still stuck? Help me resolve red imports in my Spring Boot entity class

Create the ProductRepository interface

Spring Data JPA can generate common database queries for you automatically. All you need is an interface that extends JpaRepository.

  • Right-click the com.example.productapi package, select New, then Package.
  • Type com.example.productapi.repository and press Enter.
  • Right-click the new repository package, select New, then Java Class.
  • Select Interface from the kind dropdown, name it ProductRepository, and press Enter.
  • Replace the entire file contents with the following code:
package com.example.productapi.repository;

import com.example.productapi.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

// Extends JpaRepository to get built-in CRUD and pagination methods
public interface ProductRepository extends JpaRepository<Product, Long> {
}

What does this code do?

By extending JpaRepository<Product, Long>, Spring automatically provides methods like save(), findById(), findAll(), and delete() at runtime. You write zero implementation code.

The two type parameters tell Spring which entity this repository manages (Product) and the type of its primary key (Long).

  • Save ProductRepository.java.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full Product.java file:

package com.example.productapi.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;

@Entity
@Table(name = "products")
public class Product {

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

    @Column(nullable = false)
    private String name;

    private String description;

    @Column(nullable = false)
    private BigDecimal price;

    @Column(nullable = false)
    private Integer quantity;

    public Product() {
    }

    public Product(String name, String description, BigDecimal price, Integer quantity) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.quantity = quantity;
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getDescription() {
        return description;
    }

    public void setDescription(String description) {
        this.description = description;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }

    public Integer getQuantity() {
        return quantity;
    }

    public void setQuantity(Integer quantity) {
        this.quantity = quantity;
    }
}

And the full ProductRepository.java file:

package com.example.productapi.repository;

import com.example.productapi.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}
Verify Hibernate creates the products table

Because application.properties has spring.jpa.hibernate.ddl-auto=update, Hibernate automatically creates or updates the database table when the application starts.

  • Stop the running application by pressing Ctrl+C in the terminal where it is running.
  • Restart the application by running this command from the project root:
.\mvnw.cmd spring-boot:run
  • Wait until you see Started ProductapiApplication in the console output.

You should also see Hibernate SQL output showing the table creation. Now confirm the table exists in PostgreSQL.

  • Open a new terminal tab or window.
  • Connect to your database by running:
psql -U postgres -d productdb
  • List the tables by running:
\dt
  • You should see a products table listed in the output.
  • Type \q and press Enter to exit the psql session.

Don't see the products table?

  • Check that your application started without errors. Look for any exceptions in the console output mentioning a database connection failure.
  • Verify that spring.jpa.hibernate.ddl-auto=update is in your application.properties file. Without this, Hibernate will not create tables automatically.
  • Make sure you are connecting to the correct database. The -d productdb flag connects to the database you created in Step 1.

Still stuck? Help me figure out why Hibernate didn't create my products table

Your data layer is in place. The Product entity defines the shape of your data, and ProductRepository provides all the database operations you need. Next up, you will build the DTO and service layer that sits between your API and this data layer.

Build the DTO and Service Layer

Your entity and repository are in place. The database knows what a product looks like, and Spring Data JPA gives you built-in methods to save and retrieve them.

Now you need the layer that sits between the outside world and your database. In a production API, you never expose your entity directly to clients. Instead, you use DTOs (Data Transfer Objects) to define what data goes in and what data comes out. A service class then orchestrates the business logic between those DTOs and your repository.

In this step, get ready to:
  • Create request and response DTOs with Jakarta Bean Validation annotations.
  • Create a custom exception class for missing resources.
  • Build a service class with full CRUD operations and DTO-to-entity mapping.
Create the request and response DTOs

DTOs let you control the shape of data entering and leaving your API independently of your database schema. Your ProductRequest defines what the client sends, and your ProductResponse defines what the client receives back.

  • In IntelliJ's Project panel on the left, right-click the com.example.productapi package.
  • Select New, then Package.
  • Name the new package dto and press Enter.
  • Right-click the dto package, select New > Java Class, and name it ProductRequest.
  • Replace the file contents with the following code:
package com.example.productapi.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;

public class ProductRequest {

    // Name is required and cannot be blank
    @NotBlank(message = "Product name is required")
    private String name;

    private String description;

    // Price must be provided and greater than zero
    @NotNull(message = "Price is required")
    @Positive(message = "Price must be greater than zero")
    private BigDecimal price;

    // Quantity must be provided and zero or greater
    @NotNull(message = "Quantity is required")
    @PositiveOrZero(message = "Quantity must be zero or greater")
    private Integer quantity;

    public ProductRequest() {
    }

    public ProductRequest(String name, String description, BigDecimal price, Integer quantity) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.quantity = quantity;
    }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

What do the validation annotations do?

These annotations come from Jakarta Bean Validation. They declare rules that Spring enforces automatically when a request arrives.

  • @NotBlank rejects null, empty strings, and whitespace-only strings.
  • @NotNull rejects null values but allows zero.
  • @Positive ensures a number is greater than zero.
  • @PositiveOrZero allows zero but rejects negative numbers.

The message attribute defines the error text returned to the client when validation fails.

  • Save ProductRequest.java with Ctrl+S (Windows) or Cmd+S (macOS).
  • You should see ProductRequest.java appear under the dto package in the Project panel.

Now create the response DTO. This class defines the data shape clients receive back from the API.

  • Right-click the dto package, select New > Java Class, and name it ProductResponse.
  • Replace the file contents with the following code:
package com.example.productapi.dto;

import java.math.BigDecimal;

public class ProductResponse {

    // Includes the generated id so the client can reference this product
    private Long id;
    private String name;
    private String description;
    private BigDecimal price;
    private Integer quantity;

    public ProductResponse() {
    }

    public ProductResponse(Long id, String name, String description, BigDecimal price, Integer quantity) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.quantity = quantity;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

Why separate request and response DTOs?

ProductRequest has no id field because the client doesn't choose the ID. The database generates it. ProductResponse includes the id so the client can reference that product in future requests.

This separation also means you can change your database schema without breaking the API contract your clients depend on.

  • Save ProductResponse.java.
  • You should now see both ProductRequest.java and ProductResponse.java listed under the dto package in the Project panel.

Seeing red underlines on the imports?

If IntelliJ shows errors on the jakarta.validation.constraints imports, your project may not have loaded the validation dependency yet.

  • Click the Maven reload icon (circular arrows) in the top right corner of IntelliJ, or right-click pom.xml and select Maven > Reload Project.
  • Confirm that spring-boot-starter-validation is listed as a dependency in your pom.xml.

Why are my Jakarta validation imports showing errors in IntelliJ?

Create the custom exception class

When someone requests a product that does not exist, your API needs a clear way to signal that. A custom exception class gives you a named, meaningful error that your service can throw and your error handler can catch.

  • Right-click the com.example.productapi package in the Project panel.
  • Select New > Package and name it exception.
  • Right-click the exception package, select New > Java Class, and name it ResourceNotFoundException.
  • Replace the file contents with the following code:
package com.example.productapi.exception;

// Custom exception thrown when a requested product does not exist
public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

What does this class do?

ResourceNotFoundException extends RuntimeException, which means Spring does not force you to catch it with a try-catch block. It bubbles up automatically.

In the next step, you will build a global exception handler that intercepts this exception and returns a structured JSON error response to the client.

  • Save ResourceNotFoundException.java.
  • You should see it appear under the exception package in the Project panel.
Create the service layer

The service layer is where your business logic lives. It receives DTOs from the controller, interacts with the repository, and returns DTOs back. This keeps your controller thin and your database logic contained.

  • Right-click the com.example.productapi package.
  • Select New > Package and name it service.
  • Right-click the service package, select New > Java Class, and name it ProductService.
  • Replace the file contents with the following code:
package com.example.productapi.service;

import com.example.productapi.dto.ProductRequest;
import com.example.productapi.dto.ProductResponse;
import com.example.productapi.entity.Product;
import com.example.productapi.exception.ResourceNotFoundException;
import com.example.productapi.repository.ProductRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductService {

    private final ProductRepository productRepository;

    // Constructor injection - Spring automatically provides the repository
    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // Create a new product from the request DTO and return the saved response
    @Transactional
    public ProductResponse createProduct(ProductRequest request) {
        Product product = new Product(
                request.getName(),
                request.getDescription(),
                request.getPrice(),
                request.getQuantity()
        );
        Product saved = productRepository.save(product);
        return mapToResponse(saved);
    }

    // Find a product by ID or throw an exception if not found
    public ProductResponse getProductById(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
        return mapToResponse(product);
    }

    // Return a paginated list of all products
    public Page<ProductResponse> getAllProducts(int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        return productRepository.findAll(pageable).map(this::mapToResponse);
    }

    // Update all fields of an existing product
    @Transactional
    public ProductResponse updateProduct(Long id, ProductRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));

        product.setName(request.getName());
        product.setDescription(request.getDescription());
        product.setPrice(request.getPrice());
        product.setQuantity(request.getQuantity());

        Product updated = productRepository.save(product);
        return mapToResponse(updated);
    }

    // Delete a product by ID or throw if not found
    @Transactional
    public void deleteProduct(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
        productRepository.delete(product);
    }

    // Convert a Product entity to a ProductResponse DTO
    private ProductResponse mapToResponse(Product product) {
        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getDescription(),
                product.getPrice(),
                product.getQuantity()
        );
    }
}

What does this service do?

  • @Service tells Spring to manage this class as a bean and make it available for injection.
  • @Transactional on write methods ensures that if something fails mid-operation, all database changes roll back.
  • orElseThrow() converts an empty Optional into your custom ResourceNotFoundException.
  • PageRequest.of(page, size) creates pagination parameters that the repository uses to return a slice of results instead of everything.
  • mapToResponse() is a private helper that converts an entity to a DTO. This keeps the mapping logic in one place.
  • Save ProductService.java.
  • Restart the application by clicking the green Run button in IntelliJ's toolbar (or press Shift+F10 on Windows / Ctrl+R on macOS).
  • You should see Started ProductapiApplication in the console output. This confirms all your new classes compiled and loaded successfully.

Application fails to start?

  • Check for red underlines in your imports. If ResourceNotFoundException or the DTO imports show errors, confirm the package names match exactly: com.example.productapi.exception and com.example.productapi.dto.
  • Make sure each file's package declaration at the top matches the folder it lives in.
  • If you see a Cannot resolve symbol error, reload your Maven dependencies (click the Maven refresh icon in the top right).

My Spring Boot application fails to start after adding the service and DTO classes. Help me debug.

Why use @Transactional on the service layer?

The @Transactional annotation marks methods that modify data. If an exception occurs partway through updateProduct, Spring automatically rolls back all changes so your database never ends up in a half-updated state.

Read-only methods like getProductById don't need it because they only fetch data.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four new files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file created in this step.

package com.example.productapi.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import java.math.BigDecimal;

public class ProductRequest {

    @NotBlank(message = "Product name is required")
    private String name;

    private String description;

    @NotNull(message = "Price is required")
    @Positive(message = "Price must be greater than zero")
    private BigDecimal price;

    @NotNull(message = "Quantity is required")
    @PositiveOrZero(message = "Quantity must be zero or greater")
    private Integer quantity;

    public ProductRequest() {
    }

    public ProductRequest(String name, String description, BigDecimal price, Integer quantity) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.quantity = quantity;
    }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
}
package com.example.productapi.dto;

import java.math.BigDecimal;

public class ProductResponse {

    private Long id;
    private String name;
    private String description;
    private BigDecimal price;
    private Integer quantity;

    public ProductResponse() {
    }

    public ProductResponse(Long id, String name, String description, BigDecimal price, Integer quantity) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.quantity = quantity;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
}
package com.example.productapi.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package com.example.productapi.service;

import com.example.productapi.dto.ProductRequest;
import com.example.productapi.dto.ProductResponse;
import com.example.productapi.entity.Product;
import com.example.productapi.exception.ResourceNotFoundException;
import com.example.productapi.repository.ProductRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public ProductResponse createProduct(ProductRequest request) {
        Product product = new Product(
                request.getName(),
                request.getDescription(),
                request.getPrice(),
                request.getQuantity()
        );
        Product saved = productRepository.save(product);
        return mapToResponse(saved);
    }

    public ProductResponse getProductById(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
        return mapToResponse(product);
    }

    public Page<ProductResponse> getAllProducts(int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        return productRepository.findAll(pageable).map(this::mapToResponse);
    }

    @Transactional
    public ProductResponse updateProduct(Long id, ProductRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));

        product.setName(request.getName());
        product.setDescription(request.getDescription());
        product.setPrice(request.getPrice());
        product.setQuantity(request.getQuantity());

        Product updated = productRepository.save(product);
        return mapToResponse(updated);
    }

    @Transactional
    public void deleteProduct(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
        productRepository.delete(product);
    }

    private ProductResponse mapToResponse(Product product) {
        return new ProductResponse(
                product.getId(),
                product.getName(),
                product.getDescription(),
                product.getPrice(),
                product.getQuantity()
        );
    }
}

Your DTO and service layer is complete. You have a clear contract for incoming and outgoing data, a service that handles all CRUD operations, and a custom exception ready for when things go wrong. Next up, you will wire all of this to HTTP endpoints by building the REST controller.

Implement the REST Controller

Your service layer knows how to create, read, update, and delete products. The missing piece is an HTTP entry point that lets clients send requests to trigger those operations.

In this step, you will build a REST controller that maps HTTP methods (GET, POST, PUT, DELETE) to your service methods. Each endpoint returns a proper status code and JSON response body.

In this step, get ready to:
  • Create the ProductController class with Spring annotations and constructor injection.
  • Implement five CRUD endpoints with proper HTTP status codes.
  • Test all endpoints using curl to verify the full request-response cycle.
Create the ProductController class

The controller is the outermost layer in your architecture. It receives HTTP requests, delegates work to the service layer, and returns HTTP responses.

  • In the IntelliJ Project panel, right-click the com.example.productapi package.
  • Select New, then Package.
  • Name it controller and press Enter.
  • Right-click the new controller package and select New, then Java Class.
  • Name it ProductController and press Enter.
  • Replace the generated contents with this class shell:
package com.example.productapi.controller;

import com.example.productapi.dto.ProductRequest;
import com.example.productapi.dto.ProductResponse;
import com.example.productapi.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

// Marks this class as a REST controller that returns JSON responses
@RestController
// All endpoints in this controller start with /api/v1/products
@RequestMapping("/api/v1/products")
public class ProductController {

    // Inject the service layer via constructor injection
    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }
}

What does this code do?

@RestController tells Spring this class handles HTTP requests and returns JSON (not HTML views). @RequestMapping("/api/v1/products") sets the base URL path for every endpoint in this controller.

Constructor injection gives the controller access to ProductService without making it responsible for creating the service. Spring handles wiring them together automatically.

Why /api/v1/ in the URL?

URI versioning (like /api/v1/) is a common best practice for REST APIs. If you need to make breaking changes later, you can introduce /api/v2/ without disrupting existing clients that still use v1.

You won't see a change yet. The next chunk adds the endpoint methods that handle actual HTTP requests.

Add the create and read endpoints

Each endpoint method maps one HTTP verb to a service call. Spring uses annotations like @PostMapping and @GetMapping to route incoming requests to the correct method.

  • In ProductController.java, add the following three methods inside the class body, below the constructor:
    // POST /api/v1/products - creates a new product
    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody ProductRequest request) {
        ProductResponse response = productService.createProduct(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    // GET /api/v1/products/{id} - retrieves a single product by ID
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
        ProductResponse response = productService.getProductById(id);
        return ResponseEntity.ok(response);
    }

    // GET /api/v1/products - retrieves all products with pagination
    @GetMapping
    public ResponseEntity<Page<ProductResponse>> getAllProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        Page<ProductResponse> products = productService.getAllProducts(page, size);
        return ResponseEntity.ok(products);
    }

What does this code do?

  • @Valid on the request body triggers Jakarta Bean Validation. If the incoming JSON fails any constraint (like @NotBlank on name), Spring rejects the request before it reaches your service.
  • @RequestBody tells Spring to deserialize the incoming JSON into a ProductRequest object.
  • ResponseEntity.status(HttpStatus.CREATED) returns a 201 status code, which is the correct HTTP response for successful resource creation.
  • @PathVariable extracts the {id} from the URL path. @RequestParam reads query string parameters like ?page=0&size=10, with defaults so callers can omit them.
  • Now add the update and delete endpoints below the getAllProducts method:
    // PUT /api/v1/products/{id} - updates an existing product
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @Valid @RequestBody ProductRequest request) {
        ProductResponse response = productService.updateProduct(id, request);
        return ResponseEntity.ok(response);
    }

    // DELETE /api/v1/products/{id} - deletes a product
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }

What does this code do?

  • The PUT endpoint takes both a path variable (which product to update) and a validated request body (the new data).
  • ResponseEntity.noContent().build() returns a 204 No Content status. This is the standard response for successful deletions where there is nothing to return in the body.
  • ResponseEntity<Void> signals that the delete endpoint intentionally returns no body.
  • Save ProductController.java.
  • Restart the application by running this from your project root terminal:
.\mvnw.cmd spring-boot:run

You should see "Started ProductapiApplication" in the console with no errors.

Seeing a compilation error?

  • Check that your import statements match exactly. Missing imports for @Valid, ResponseEntity, or HttpStatus cause compile failures.
  • Make sure the endpoint methods are inside the class body (between the class braces), not outside.
  • Verify that ProductService has all five methods the controller calls: createProduct, getProductById, getAllProducts, updateProduct, deleteProduct.

Still stuck? Help me fix my ProductController compilation error.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

package com.example.productapi.controller;

import com.example.productapi.dto.ProductRequest;
import com.example.productapi.dto.ProductResponse;
import com.example.productapi.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody ProductRequest request) {
        ProductResponse response = productService.createProduct(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
        ProductResponse response = productService.getProductById(id);
        return ResponseEntity.ok(response);
    }

    @GetMapping
    public ResponseEntity<Page<ProductResponse>> getAllProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "10") int size) {
        Page<ProductResponse> products = productService.getAllProducts(page, size);
        return ResponseEntity.ok(products);
    }

    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @Valid @RequestBody ProductRequest request) {
        ProductResponse response = productService.updateProduct(id, request);
        return ResponseEntity.ok(response);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}
Test the API endpoints with curl

Your controller is wired up, but you have not confirmed that requests flow correctly from HTTP through the controller to the service and database. Testing each endpoint now catches issues before you build more on top.

  • Open a new terminal window (keep the application running in the original).
  • Create a product by running this POST request:
curl -X POST http://localhost:8080/api/v1/products -H "Content-Type: application/json" -d "{\"name\": \"Wireless Mouse\", \"description\": \"Ergonomic wireless mouse\", \"price\": 29.99, \"quantity\": 100}"

You should see a JSON response with the product data and an auto-generated id field (likely 1).

  • Retrieve the product you just created by running:
curl http://localhost:8080/api/v1/products/1

You should see the same product JSON returned with a 200 status.

  • Update the product with new data by running:
curl -X PUT http://localhost:8080/api/v1/products/1 -H "Content-Type: application/json" -d "{\"name\": \"Wireless Mouse Pro\", \"description\": \"Updated ergonomic wireless mouse\", \"price\": 39.99, \"quantity\": 85}"

The response shows the updated product with the new name, price, and quantity.

  • Delete the product by running:
curl -X DELETE http://localhost:8080/api/v1/products/1

This returns no body (204 No Content). The product is gone from the database.

  • Now test pagination. Create a few products first by running these commands:
curl -X POST http://localhost:8080/api/v1/products -H "Content-Type: application/json" -d "{\"name\": \"Keyboard\", \"description\": \"Mechanical keyboard\", \"price\": 79.99, \"quantity\": 50}"
curl -X POST http://localhost:8080/api/v1/products -H "Content-Type: application/json" -d "{\"name\": \"Monitor\", \"description\": \"27 inch 4K monitor\", \"price\": 399.99, \"quantity\": 25}"
curl -X POST http://localhost:8080/api/v1/products -H "Content-Type: application/json" -d "{\"name\": \"Webcam\", \"description\": \"HD webcam with mic\", \"price\": 59.99, \"quantity\": 200}"
  • Fetch the first page with a size of 2 by running:
curl "http://localhost:8080/api/v1/products?page=0&size=2"

The response includes a content array with only 2 products, plus pagination metadata like totalElements, totalPages, and number (current page).

What is pagination metadata?

Spring Data's Page wrapper automatically includes metadata about the result set. Clients use totalPages and totalElements to build "Page 1 of 3" UI components. The number field is zero-indexed, so page 0 is the first page.

Getting a 404 or connection refused?

  • Make sure the application is still running in your other terminal. Look for "Started ProductapiApplication" in the console output.
  • Check that the URL is exactly http://localhost:8080/api/v1/products. A typo in the path returns 404.
  • On Windows, if curl gives a "not recognized" error, use PowerShell instead of Command Prompt.

Need more help? Help me debug my Spring Boot API curl requests.

Your API is fully functional. Every CRUD operation works, and pagination keeps large result sets manageable. Next up, you will add centralized error handling so that invalid requests get clean, consistent error responses instead of raw stack traces.

Add Global Exception Handling

Your API endpoints are working, but what happens when something goes wrong? Right now, if a client requests a product that does not exist, Spring Boot returns its default error page with a generic JSON body.

In this step, you will build a centralized error handling layer that catches exceptions and returns clean, structured JSON responses. This means your API clients always get consistent, useful error information regardless of what went wrong.

In this step, get ready to:
  • Create a global exception handler with @RestControllerAdvice that catches ResourceNotFoundException and returns a 404 response.
  • Add a validation error handler that returns 400 responses with field-level error details.
  • Test error handling by sending invalid requests and verifying the structured error responses.
Create the ErrorResponse class

Before you can return structured error JSON, you need a class that defines the shape of that response. This class acts as a template for every error your API returns.

  • In IntelliJ, right-click the com.example.productapi.exception package in the Project sidebar.
  • Select New, then Java Class.
  • Name the class ErrorResponse and press Enter.
  • Replace the file contents with the following code:
package com.example.productapi.exception;

import java.time.LocalDateTime;

public class ErrorResponse {

    // Fields that make up the structured error JSON
    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;

    // All-args constructor used by the exception handler
    public ErrorResponse(LocalDateTime timestamp, int status, String error, String message, String path) {
        this.timestamp = timestamp;
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
    }

    // Getters for Jackson serialization to JSON
    public LocalDateTime getTimestamp() { return timestamp; }
    public int getStatus() { return status; }
    public String getError() { return error; }
    public String getMessage() { return message; }
    public String getPath() { return path; }
}

What does this code do?

This class defines the JSON structure that clients receive when an error occurs. Every error response includes a timestamp, HTTP status code, an error label, a human-readable message, and the request path that caused it.

Jackson (Spring Boot's JSON library) uses the getters to automatically serialize this object into JSON when Spring sends the response.

  • Save ErrorResponse.java.
Create the GlobalExceptionHandler class

Spring Boot provides @RestControllerAdvice as a way to intercept exceptions thrown anywhere in your controllers. Instead of scattering try-catch blocks across every endpoint, you write one centralized handler class.

  • Right-click the com.example.productapi.exception package again.
  • Select New, then Java Class.
  • Name it GlobalExceptionHandler and press Enter.
  • Replace the file contents with the following code:
package com.example.productapi.exception;

import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Catches ResourceNotFoundException thrown by the service layer
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex, HttpServletRequest request) {

        ErrorResponse error = new ErrorResponse(
                LocalDateTime.now(),
                HttpStatus.NOT_FOUND.value(),
                "Not Found",
                ex.getMessage(),
                request.getRequestURI()
        );

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // Catches validation failures triggered by @Valid on request bodies
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(
            MethodArgumentNotValidException ex, HttpServletRequest request) {

        // Collect each invalid field and its error message
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError ->
                fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage())
        );

        // Build the response body with all error details
        Map<String, Object> response = new HashMap<>();
        response.put("timestamp", LocalDateTime.now().toString());
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("error", "Validation Failed");
        response.put("fieldErrors", fieldErrors);
        response.put("path", request.getRequestURI());

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }
}

What does this code do?

  • @RestControllerAdvice tells Spring to apply this class globally to all controllers. Any exception thrown during request processing gets routed here.
  • handleResourceNotFound catches the ResourceNotFoundException you created earlier. It builds an ErrorResponse object and returns it as a 404 JSON response.
  • handleValidationErrors catches MethodArgumentNotValidException, which Spring throws when @Valid annotations fail. It extracts each invalid field name and its message into a map, then returns a 400 response.
  • HttpServletRequest gives access to the original request URI so you can include the path that caused the error.

Why centralized error handling instead of try-catch?

Without @RestControllerAdvice, you would need to wrap every controller method in a try-catch and manually build error responses. That duplicates logic across every endpoint.

A centralized handler guarantees that every error from every endpoint follows the same structure. This is the pattern production Spring Boot applications use.

  • Save GlobalExceptionHandler.java.
  • Restart your application by stopping the running process and running the Maven wrapper again:
.\mvnw.cmd spring-boot:run
  • You should see Started ProductapiApplication in the console output.

Application won't start?

  • Check that the import statements at the top of GlobalExceptionHandler.java match exactly. A missing import causes a compile error.
  • Make sure ErrorResponse.java is in the same exception package. If it is in a different package, the handler cannot find it.
  • help me debug my GlobalExceptionHandler startup error

✔️ Awesome, I've got everything!

Great. Make sure both ErrorResponse.java and GlobalExceptionHandler.java are saved.

ⓧ I'd like to double check the full code

package com.example.productapi.exception;

import java.time.LocalDateTime;

public class ErrorResponse {

    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;

    public ErrorResponse(LocalDateTime timestamp, int status, String error, String message, String path) {
        this.timestamp = timestamp;
        this.status = status;
        this.error = error;
        this.message = message;
        this.path = path;
    }

    public LocalDateTime getTimestamp() { return timestamp; }
    public int getStatus() { return status; }
    public String getError() { return error; }
    public String getMessage() { return message; }
    public String getPath() { return path; }
}
package com.example.productapi.exception;

import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(
            ResourceNotFoundException ex, HttpServletRequest request) {

        ErrorResponse error = new ErrorResponse(
                LocalDateTime.now(),
                HttpStatus.NOT_FOUND.value(),
                "Not Found",
                ex.getMessage(),
                request.getRequestURI()
        );

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(
            MethodArgumentNotValidException ex, HttpServletRequest request) {

        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError ->
                fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage())
        );

        Map<String, Object> response = new HashMap<>();
        response.put("timestamp", LocalDateTime.now().toString());
        response.put("status", HttpStatus.BAD_REQUEST.value());
        response.put("error", "Validation Failed");
        response.put("fieldErrors", fieldErrors);
        response.put("path", request.getRequestURI());

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
    }
}
Test the 404 error response

Time to see your error handling in action. You will request a product ID that does not exist and confirm you get a structured 404 response instead of Spring Boot's default error page.

  • Open a new terminal window (keep the application running in the first one).
  • Send a GET request for a product ID that does not exist by running this command:
curl -s http://localhost:8080/api/v1/products/9999 | python -m json.tool

What should I see?

You should see a structured JSON response like this:

{
    "timestamp": "2026-05-05T10:30:00.123",
    "status": 404,
    "error": "Not Found",
    "message": "Product not found with id: 9999",
    "path": "/api/v1/products/9999"
}

The exact timestamp will differ, but the structure should match. This is your ErrorResponse class serialized to JSON.

Test the 400 validation error response

Next, test that validation errors also return structured JSON. You will send a POST request with a blank product name, which violates the @NotBlank constraint in your ProductRequest DTO.

  • Send a POST request with invalid data by running this command:
curl -s -X POST http://localhost:8080/api/v1/products -H "Content-Type: application/json" -d "{\"name\": \"\", \"price\": -5}" | python -m json.tool

What should I see?

You should see a 400 response with a fieldErrors map listing each invalid field and its validation message:

{
    "fieldErrors": {
        "name": "Product name is required",
        "price": "Price must be greater than zero",
        "quantity": "Quantity is required"
    },
    "path": "/api/v1/products",
    "error": "Validation Failed",
    "status": 400,
    "timestamp": "2026-05-05T10:31:00.456"
}

Each field that failed validation appears with its custom message from the @NotBlank, @Positive, and @NotNull annotations you defined in the previous step.

Seeing Spring's default error instead of your custom response?

  • Make sure you restarted the application after adding the new files. Spring only picks up new classes on startup.
  • Verify that GlobalExceptionHandler is in the com.example.productapi.exception package. It must be within the base package for Spring to scan it.
  • Check that the class has the @RestControllerAdvice annotation on the class declaration, not just on individual methods.
  • help me fix my exception handler

Your API now returns clean, consistent error responses for both missing resources and invalid input. This is the pattern that production Java applications use to communicate problems to API clients in a predictable format.

Secret mission

Add Filtering and Search

Your paginated endpoint returns all products, but real-world APIs need filtering. In this mission, you'll add search by name (partial, case-insensitive) and filtering by price range so users can find exactly what they need.

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • Spring Boot project folder (productapi).
  • PostgreSQL database (productdb).
  • IntelliJ IDEA project configuration.

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment with the API further.

  • Your PostgreSQL database will remain available whenever the PostgreSQL service is running.
  • Your project files stay in place, ready to open in IntelliJ IDEA at any time.

Pause - I'll come back to this later

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

  • Stop the Spring Boot application if it is still running by pressing Ctrl+C in the terminal.
  • Stop the PostgreSQL service if you do not need it for other projects. On Windows, open Services and stop PostgreSQL. On macOS, run brew services stop postgresql.
  • Your database data and project files will remain on disk for when you return.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Delete the productapi project folder from your Desktop.
  • Drop the PostgreSQL database by running psql -U postgres then DROP DATABASE productdb; and \q to exit.
  • Remove the IntelliJ IDEA project from your recent projects list by right-clicking it on the Welcome screen and selecting Remove from Recent Projects.

Nice Work!

Nice Work!

You just built a production-quality REST API from scratch using Spring Boot, complete with proper layered architecture, input validation, and centralized error handling.

Here's what you accomplished:

  • Built a full CRUD REST API with Spring Boot connected to a real PostgreSQL database, using proper layered architecture that separates controllers, services, repositories, DTOs, and exceptions.
  • Implemented Jakarta Bean Validation for input validation and @RestControllerAdvice for centralized exception handling that returns consistent, structured JSON error responses.
  • Added paginated endpoints using Spring Data JPA for scalable data access across your product catalog.
  • Extended the API with filtering and search capabilities so users can find products by name or price range.

Ready to quiz yourself?