Build a Product Catalog REST API

Build a Spring Boot REST API with layered architecture and validation.

Introduction

30 Second Summary

Every online store, food delivery app, and booking platform has one thing in common. Behind the scenes, a REST API handles the work of creating, reading, updating, and deleting data.

In this project, you will build a Product Catalog REST API using Spring Boot. You will create endpoints that manage products and follow the same layered architecture pattern used in enterprise Java applications.

What You'll Build

A fully functional REST API with CRUD endpoints for managing products, built with Spring Boot's layered architecture pattern.

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

  • A Spring Boot application with REST endpoints for creating, reading, updating, and deleting products.
  • A layered architecture with separate Controller, Service, and Repository layers following enterprise conventions.
  • Request validation and global exception handling that return structured error responses to API consumers.
  • Secret Mission: Add filtering and search capabilities so consumers can query products by category and price range.

Are there any prerequisites?

You should be comfortable with Java fundamentals like classes, interfaces, annotations, and generics. Basic HTTP knowledge (understanding GET, POST, PUT, and DELETE requests) is also helpful.

Create Your First REST Controller

Your development environment is ready and your Spring Boot project is scaffolded. Now it's time to write the actual code that handles HTTP requests.

Every REST API needs two things: a data model that defines the shape of your information, and a controller that maps HTTP requests to Java methods. In this step, you'll create both and see your API respond to its first request.

In this step, get ready to:
  • Create a Product model class that defines the shape of your data.
  • Build a REST controller with endpoints for all CRUD operations.
  • Run your application and test endpoints with curl.
Create the Product model class

Before you can send product data through your API, you need a Java class that defines what a "product" looks like. This class holds the fields and gives Spring Boot a blueprint for converting between Java objects and JSON.

  • In the VS Code Explorer sidebar, right-click the com.example.productcatalog package folder inside src/main/java.
  • Select New Folder and type model.
  • Right-click the new model folder and select New File.
  • Name the file Product.java and paste the following code:
package com.example.productcatalog.model;

public class Product {

    private Long id;
    private String name;
    private String description;
    private Double price;
    private String category;

    // No-argument constructor (required for JSON deserialization)
    public Product() {
    }

    // All-argument constructor for creating products with all fields
    public Product(Long id, String name, String description, Double price, String category) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
    }

    // Getters and setters for each field
    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 Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
}

What does this code do?

This is a plain Java class (often called a POJO) that represents a product in your catalog. Each field maps to a piece of data your API will send and receive as JSON.

  • The no-argument constructor lets Jackson (Spring Boot's JSON library) create Product objects from incoming JSON.
  • The getters and setters let Jackson read and write each field when converting between Java and JSON.
  • The all-argument constructor is a convenience for creating products in your own code.
  • Save Product.java.
  • Check the Problems tab at the bottom of VS Code. You should see no errors for this file.

Seeing red underlines in Product.java?

  • Make sure the package declaration at the top matches the folder structure exactly: com.example.productcatalog.model.
  • Confirm the file is saved inside src/main/java/com/example/productcatalog/model/. A misplaced file causes package resolution errors.

Why is my Product.java showing compilation errors?

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and there are no errors in the Problems tab.

ⓧ I'd like to double check the full code

package com.example.productcatalog.model;

public class Product {

    private Long id;
    private String name;
    private String description;
    private Double price;
    private String category;

    public Product() {
    }

    public Product(Long id, String name, String description, Double price, String category) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
    }

    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 Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
}
Create the ProductController with a GET endpoint

A REST controller is the class that receives HTTP requests and returns responses. Spring Boot uses annotations to map URL paths and HTTP methods to Java methods.

  • In the Explorer sidebar, right-click the com.example.productcatalog package folder and create a new folder called controller.
  • Inside the controller folder, create a new file called ProductController.java and paste the following code:
package com.example.productcatalog.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

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

    // Returns a hardcoded list to verify the endpoint works
    @GetMapping
    public ResponseEntity<List<String>> getAllProducts() {
        return ResponseEntity.ok(List.of("product1", "product2"));
    }
}

What does this code do?

  • `@RestController` tells Spring this class handles HTTP requests and returns JSON directly (no HTML templates).
  • `@RequestMapping("/api/products")` sets the base URL path for all endpoints in this class.
  • `@GetMapping` maps HTTP GET requests to the getAllProducts() method. When someone visits /api/products, this method runs.
  • `ResponseEntity` wraps the response and lets you control the HTTP status code.
  • Save ProductController.java.

Now let's start the application and verify the endpoint responds.

  • Start your Spring Boot application by running this command in the VS Code terminal:
./mvnw.cmd spring-boot:run

What does this command do?

The Maven Wrapper (mvnw.cmd) downloads the correct Maven version automatically on the first run. The spring-boot:run goal compiles your code and starts the embedded Tomcat server.

The first run may take a minute or two as Maven downloads project dependencies. Subsequent runs are much faster.

  • Wait until you see a log line that says Started ProductcatalogApplication in the terminal output. This confirms your app is running on port 8080.
  • Open a second terminal by clicking the + icon in the terminal panel. Test your endpoint by running:
curl http://localhost:8080/api/products
  • You should see ["product1","product2"] printed in the terminal.

Not seeing the expected output?

  • If the terminal says the port is already in use, another process is running on port 8080. Stop it or change the port in application.properties.
  • If you see a compilation error, check that the package line says com.example.productcatalog.controller and the file is in the matching folder.
  • If curl returns a 404, make sure the URL is exactly http://localhost:8080/api/products with no trailing slash issues.

Why isn't my Spring Boot app starting or responding to requests?

Add the remaining CRUD endpoints

A full CRUD API needs endpoints for creating, reading, updating, and deleting resources. You already have the "read all" endpoint. Now you'll add the remaining four.

  • Stop the running application by pressing Ctrl+C in the terminal where it's running.
  • Replace the entire contents of ProductController.java with the following code:
package com.example.productcatalog.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    // GET /api/products - returns all products
    @GetMapping
    public ResponseEntity<List<String>> getAllProducts() {
        return ResponseEntity.ok(List.of("product1", "product2"));
    }

    // GET /api/products/{id} - returns a single product by ID
    @GetMapping("/{id}")
    public ResponseEntity<String> getProductById(@PathVariable Long id) {
        return ResponseEntity.ok("product-" + id);
    }

    // POST /api/products - creates a new product
    @PostMapping
    public ResponseEntity<String> createProduct() {
        return ResponseEntity.status(HttpStatus.CREATED).body("created");
    }

    // PUT /api/products/{id} - updates an existing product
    @PutMapping("/{id}")
    public ResponseEntity<String> updateProduct(@PathVariable Long id) {
        return ResponseEntity.ok("updated-" + id);
    }

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

What does this code do?

  • `@GetMapping("/{id}")` captures the ID from the URL (e.g., /api/products/5) and passes it to the method via @PathVariable.
  • `@PostMapping` handles POST requests. It returns a 201 Created status, which is the standard response for resource creation.
  • `@PutMapping("/{id}")` handles PUT requests for updating an existing resource identified by its ID.
  • `@DeleteMapping("/{id}")` handles DELETE requests and returns 204 No Content, meaning the deletion succeeded but there's no response body.

These are placeholder responses for now. In the next step, you'll replace them with real logic backed by a service and repository layer.

  • Save ProductController.java and restart the application by running:
./mvnw.cmd spring-boot:run
  • Once you see Started ProductcatalogApplication again, open your second terminal and test each endpoint:
curl http://localhost:8080/api/products
curl http://localhost:8080/api/products/1
curl -X POST http://localhost:8080/api/products
curl -X PUT http://localhost:8080/api/products/1
curl -X DELETE -v http://localhost:8080/api/products/1
  • You should see ["product1","product2"] for the first command, product-1 for the second, created for the third, updated-1 for the fourth, and a 204 No Content status in the verbose output of the fifth.

Some endpoints returning 404 or 405?

  • A 405 Method Not Allowed means the HTTP method doesn't match. Double check you used -X POST or -X PUT in your curl command.
  • A 404 on /api/products/1 usually means @GetMapping("/{id}") is missing or the controller didn't compile. Restart the app and check the terminal for errors.
  • Make sure you stopped the old server before starting the new one. Two servers can't share port 8080.

Why are my REST endpoints returning unexpected status codes?

✔️ Awesome, I've got everything!

Great. Double check you've saved ProductController.java and all five endpoints respond.

ⓧ I'd like to double check the full code

package com.example.productcatalog.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    @GetMapping
    public ResponseEntity<List<String>> getAllProducts() {
        return ResponseEntity.ok(List.of("product1", "product2"));
    }

    @GetMapping("/{id}")
    public ResponseEntity<String> getProductById(@PathVariable Long id) {
        return ResponseEntity.ok("product-" + id);
    }

    @PostMapping
    public ResponseEntity<String> createProduct() {
        return ResponseEntity.status(HttpStatus.CREATED).body("created");
    }

    @PutMapping("/{id}")
    public ResponseEntity<String> updateProduct(@PathVariable Long id) {
        return ResponseEntity.ok("updated-" + id);
    }

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

Your controller skeleton is live and responding to all five HTTP methods. Next up, you'll build the service and repository layers that replace these placeholder responses with real product management logic.

Build the Service and Repository Layers

Right now, your controller handles HTTP requests and returns hardcoded placeholder responses. In a real application, you need a clear separation between the code that handles HTTP (the controller), the code that contains business logic (the service), and the code that stores data (the repository).

This layered architecture is the pattern used in every enterprise Spring Boot application. In this step, you will build the service and repository layers, then wire them into your controller so your API actually stores and retrieves products.

In this step, get ready to:
  • Create a repository class that stores products in memory.
  • Create a service class that handles business logic.
  • Refactor your controller to use the service layer and return real HTTP status codes.
Create the ProductRepository class

The repository layer is responsible for data storage and retrieval. You will use a HashMap as an in-memory data store and an AtomicLong counter to generate unique IDs for each product.

  • In the VS Code Explorer sidebar, right-click the com.example.productcatalog package folder (inside src/main/java/com/example/productcatalog).
  • Select New Folder and name it repository.
  • Inside the repository folder, create a new file called ProductRepository.java.
  • Paste the following code into ProductRepository.java:
package com.example.productcatalog.repository;

import com.example.productcatalog.model.Product;
import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;

@Repository
public class ProductRepository {

    // In-memory storage using a HashMap
    private final Map<Long, Product> products = new HashMap<>();
    // Thread-safe counter for generating unique IDs
    private final AtomicLong idCounter = new AtomicLong(1);

    // Return all products as a list
    public List<Product> findAll() {
        return new ArrayList<>(products.values());
    }

    // Find a single product by ID, wrapped in Optional
    public Optional<Product> findById(Long id) {
        return Optional.ofNullable(products.get(id));
    }

    // Save a product - assigns an ID if one doesn't exist
    public Product save(Product product) {
        if (product.getId() == null) {
            product.setId(idCounter.getAndIncrement());
        }
        products.put(product.getId(), product);
        return product;
    }

    // Remove a product by its ID
    public void deleteById(Long id) {
        products.remove(id);
    }

    // Check if a product exists
    public boolean existsById(Long id) {
        return products.containsKey(id);
    }
}

What does this code do?

The @Repository annotation registers this class as a Spring bean so other classes can inject it. It is a stereotype annotation that signals this class handles data access.

The HashMap<Long, Product> stores products keyed by their ID. The AtomicLong generates unique IDs safely, even if multiple requests arrive at the same time.

The save() method checks whether the product already has an ID. If it does not, it assigns the next available number. This lets the same method handle both creating new products and updating existing ones.

Why an in-memory HashMap instead of a database?

Using a HashMap isolates the REST patterns you are learning from database configuration complexity. The @Repository interface stays the same whether data lives in a HashMap or a real database. When you add a database later, you only swap out the internals of this class.

  • Save ProductRepository.java.

You will not see any visible change yet because the repository is not wired to anything. The next chunk connects it to a service layer.

Create the ProductService class

The service layer sits between the controller and the repository. It holds your business logic and keeps the controller focused only on HTTP concerns.

  • In the VS Code Explorer sidebar, right-click the com.example.productcatalog package folder again.
  • Select New Folder and name it service.
  • Inside the service folder, create a new file called ProductService.java.
  • Paste the following code into ProductService.java:
package com.example.productcatalog.service;

import com.example.productcatalog.model.Product;
import com.example.productcatalog.repository.ProductRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ProductService {

    // Inject the repository via constructor injection
    private final ProductRepository productRepository;

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

    // Delegate to repository to get all products
    public List<Product> getAllProducts() {
        return productRepository.findAll();
    }

    // Return null if the product does not exist
    public Product getProductById(Long id) {
        return productRepository.findById(id).orElse(null);
    }

    // Save a new product (ID is auto-generated by the repository)
    public Product createProduct(Product product) {
        return productRepository.save(product);
    }

    // Set the ID on the product and save to overwrite the existing entry
    public Product updateProduct(Long id, Product product) {
        product.setId(id);
        return productRepository.save(product);
    }

    // Remove a product by ID
    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
    }
}

What does this code do?

The @Service annotation marks this class as a Spring bean that holds business logic. Spring automatically detects it during component scanning.

Constructor injection declares ProductRepository as a final field and receives it through the constructor. Spring Boot recommends this pattern because it makes dependencies explicit and guarantees the field is set before any method runs.

Each method in the service maps to a CRUD operation and delegates the actual data access to the repository. Right now the logic is simple delegation, but this is where you would add things like validation, authorization checks, or transformation in a real application.

What is dependency injection?

Dependency injection means that a class declares what it needs (the repository) and Spring provides it automatically. The class never creates its own dependency with new ProductRepository(). This makes testing and swapping implementations much easier.

  • Save ProductService.java.

You will not see a change yet because the controller still uses its old placeholder responses. The next substep wires everything together.

Refactor ProductController to use the service layer

Now that the service and repository exist, you can replace the placeholder responses in your controller with real calls to the service layer. This also adds proper HTTP status codes to each response.

  • Open ProductController.java in the controller package.
  • Select all the code in the file and replace it with the following:
package com.example.productcatalog.controller;

import com.example.productcatalog.model.Product;
import com.example.productcatalog.service.ProductService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    // Inject ProductService via constructor injection
    private final ProductService productService;

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

    // GET /api/products - returns 200 OK with product list
    @GetMapping
    public ResponseEntity<List<Product>> getAllProducts() {
        return ResponseEntity.ok(productService.getAllProducts());
    }

    // GET /api/products/{id} - returns 200 OK or 404 Not Found
    @GetMapping("/{id}")
    public ResponseEntity<Product> getProductById(@PathVariable Long id) {
        Product product = productService.getProductById(id);
        if (product == null) return ResponseEntity.notFound().build();
        return ResponseEntity.ok(product);
    }

    // POST /api/products - returns 201 Created with the new product
    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody Product product) {
        return ResponseEntity.status(HttpStatus.CREATED).body(productService.createProduct(product));
    }

    // PUT /api/products/{id} - returns 200 OK with the updated product
    @PutMapping("/{id}")
    public ResponseEntity<Product> updateProduct(@PathVariable Long id, @RequestBody Product product) {
        return ResponseEntity.ok(productService.updateProduct(id, product));
    }

    // DELETE /api/products/{id} - returns 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}

What changed in the controller?

The controller no longer creates or stores products itself. It injects ProductService through its constructor and delegates every operation.

Each endpoint now returns a ResponseEntity with a proper HTTP status code. POST returns 201 Created, DELETE returns 204 No Content, and GET by ID returns 404 Not Found when the product does not exist.

  • Save ProductController.java.
  • In your VS Code terminal, stop the running application by pressing Ctrl+C.
  • Restart the application by running:
mvnw.cmd spring-boot:run
  • Once you see the startup log showing Started ProductcatalogApplication, test the full CRUD flow by creating a product:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d "{\"name\":\"Keyboard\",\"description\":\"Mechanical keyboard\",\"price\":79.99,\"category\":\"electronics\"}"
  • You should see a JSON response with an auto-generated id field and the product data you sent.
  • Retrieve all products by running:
curl http://localhost:8080/api/products
  • You should see a JSON array containing the product you just created.
  • Test the 404 response by requesting a product that does not exist:
curl -i http://localhost:8080/api/products/999
  • You should see a 404 status code in the response headers, confirming the not-found handling works.

Application won't start?

If you see a compilation error mentioning an unresolved import, make sure you saved all three files: ProductRepository.java, ProductService.java, and ProductController.java.

Check that each file is in the correct package folder. ProductRepository.java must be inside repository/ and ProductService.java must be inside service/.

help me debug my Spring Boot application startup error

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

Here is the complete code for each file at this point in the project.

package com.example.productcatalog.repository;

import com.example.productcatalog.model.Product;
import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;

@Repository
public class ProductRepository {

    private final Map<Long, Product> products = new HashMap<>();
    private final AtomicLong idCounter = new AtomicLong(1);

    public List<Product> findAll() {
        return new ArrayList<>(products.values());
    }

    public Optional<Product> findById(Long id) {
        return Optional.ofNullable(products.get(id));
    }

    public Product save(Product product) {
        if (product.getId() == null) {
            product.setId(idCounter.getAndIncrement());
        }
        products.put(product.getId(), product);
        return product;
    }

    public void deleteById(Long id) {
        products.remove(id);
    }

    public boolean existsById(Long id) {
        return products.containsKey(id);
    }
}
package com.example.productcatalog.service;

import com.example.productcatalog.model.Product;
import com.example.productcatalog.repository.ProductRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ProductService {

    private final ProductRepository productRepository;

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

    public List<Product> getAllProducts() {
        return productRepository.findAll();
    }

    public Product getProductById(Long id) {
        return productRepository.findById(id).orElse(null);
    }

    public Product createProduct(Product product) {
        return productRepository.save(product);
    }

    public Product updateProduct(Long id, Product product) {
        product.setId(id);
        return productRepository.save(product);
    }

    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
    }
}
package com.example.productcatalog.controller;

import com.example.productcatalog.model.Product;
import com.example.productcatalog.service.ProductService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    private final ProductService productService;

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

    @GetMapping
    public ResponseEntity<List<Product>> getAllProducts() {
        return ResponseEntity.ok(productService.getAllProducts());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> getProductById(@PathVariable Long id) {
        Product product = productService.getProductById(id);
        if (product == null) return ResponseEntity.notFound().build();
        return ResponseEntity.ok(product);
    }

    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody Product product) {
        return ResponseEntity.status(HttpStatus.CREATED).body(productService.createProduct(product));
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> updateProduct(@PathVariable Long id, @RequestBody Product product) {
        return ResponseEntity.ok(productService.updateProduct(id, product));
    }

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

Your API now has a proper layered architecture with real data storage. Next up, you will add DTOs and request validation so your API rejects bad data before it ever reaches the service layer.

Add DTOs and Request Validation

Right now your API accepts any JSON body without checking it. A product with an empty name or a negative price gets stored without complaint.

In production, APIs need to validate incoming data before processing it. You also want to separate what the API accepts (the request shape) from what it returns (the response shape). This is the DTO (Data Transfer Object) pattern.

In this step, get ready to:
  • Create request and response DTOs to separate API contracts from internal models.
  • Add Bean Validation annotations for automatic input checking.
  • Update the service and controller layers to use DTOs instead of the raw model.
Create the ProductRequest DTO with validation annotations

The request DTO defines exactly what fields a client must send when creating or updating a product. Validation annotations on each field tell Spring to reject bad data before your code even runs.

  • In the VS Code Explorer sidebar, right-click the com.example.productcatalog package (inside src/main/java/com/example/productcatalog) and select New Folder.
  • Name the folder dto.
  • Right-click the new dto folder and select New File.
  • Name the file ProductRequest.java and paste the following code:
package com.example.productcatalog.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

public class ProductRequest {

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

    // Description is optional - no validation needed
    private String description;

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

    // Category is required - cannot be blank
    @NotBlank(message = "Category is required")
    private String category;

    public ProductRequest() {
    }

    public ProductRequest(String name, String description, Double price, String category) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
    }

    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 Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
}

What does this code do?

Each annotation tells Spring what to enforce on that field before your controller method even executes:

  • @NotBlank rejects null values and empty strings (including whitespace-only strings).
  • @NotNull rejects null but allows zero. Combined with @Positive, it ensures the price exists and is greater than zero.
  • The message attribute on each annotation controls what error text gets returned to the client.
  • Save ProductRequest.java.

Seeing red squiggles on the imports?

If VS Code shows errors on the jakarta.validation imports, the Validation dependency might not be resolved yet. Save all files and wait a moment for the Java language server to re-index. If the error persists, open the Command Palette and run Java: Clean Java Language Server Workspace.

help me fix red squiggles on jakarta.validation imports in my ProductRequest DTO

Create the ProductResponse DTO

The response DTO controls what fields get sent back to the client. It includes the generated id and a createdAt timestamp that the request DTO does not have.

  • Right-click the dto folder and select New File.
  • Name the file ProductResponse.java and paste the following code:
package com.example.productcatalog.dto;

import java.time.LocalDateTime;

public class ProductResponse {

    // Fields the API returns to the client
    private Long id;
    private String name;
    private String description;
    private Double price;
    private String category;
    private LocalDateTime createdAt;

    public ProductResponse() {
    }

    public ProductResponse(Long id, String name, String description, Double price, String category, LocalDateTime createdAt) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
        this.createdAt = createdAt;
    }

    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 Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

Why separate request and response DTOs?

The request DTO has no id or createdAt because the server generates those. The response DTO includes them because the client needs to see them. This separation means your API contract stays clean even as your internal model evolves.

In a real production app, your internal model might eventually have JPA annotations, audit fields, or relationships that clients should never see. DTOs keep those hidden.

  • Save ProductResponse.java.

✔️ Awesome, I've got everything!

Great. Double check you have saved both DTO files.

ⓧ I'd like to double check the full code

Here is the complete ProductRequest.java:

package com.example.productcatalog.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

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 positive")
    private Double price;

    @NotBlank(message = "Category is required")
    private String category;

    public ProductRequest() {
    }

    public ProductRequest(String name, String description, Double price, String category) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
    }

    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 Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
}

And here is the complete ProductResponse.java:

package com.example.productcatalog.dto;

import java.time.LocalDateTime;

public class ProductResponse {

    private Long id;
    private String name;
    private String description;
    private Double price;
    private String category;
    private LocalDateTime createdAt;

    public ProductResponse() {
    }

    public ProductResponse(Long id, String name, String description, Double price, String category, LocalDateTime createdAt) {
        this.id = id;
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
        this.createdAt = createdAt;
    }

    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 Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
Update the service layer to use DTOs

The service layer sits between the controller and the repository. It needs to convert between the DTO objects (what the API speaks) and the Product model (what the repository stores).

  • Open ProductService.java in the service package.
  • Replace the entire file contents with the following code:
package com.example.productcatalog.service;

import com.example.productcatalog.dto.ProductRequest;
import com.example.productcatalog.dto.ProductResponse;
import com.example.productcatalog.model.Product;
import com.example.productcatalog.repository.ProductRepository;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;

@Service
public class ProductService {

    private final ProductRepository productRepository;

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

    // Return all products as response DTOs
    public List<ProductResponse> getAllProducts() {
        return productRepository.findAll().stream()
                .map(this::mapToResponse)
                .toList();
    }

    // Find a single product and return as response DTO
    public ProductResponse getProductById(Long id) {
        Product product = productRepository.findById(id).orElse(null);
        if (product == null) return null;
        return mapToResponse(product);
    }

    // Accept a request DTO, convert to model, save, return response DTO
    public ProductResponse createProduct(ProductRequest request) {
        Product product = mapToModel(request);
        Product saved = productRepository.save(product);
        return mapToResponse(saved);
    }

    // Accept a request DTO for updates
    public ProductResponse updateProduct(Long id, ProductRequest request) {
        Product product = mapToModel(request);
        product.setId(id);
        Product updated = productRepository.save(product);
        return mapToResponse(updated);
    }

    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
    }

    // Convert request DTO to internal model
    private Product mapToModel(ProductRequest request) {
        Product product = new Product();
        product.setName(request.getName());
        product.setDescription(request.getDescription());
        product.setPrice(request.getPrice());
        product.setCategory(request.getCategory());
        return product;
    }

    // Convert internal model to response DTO
    private ProductResponse mapToResponse(Product product) {
        ProductResponse response = new ProductResponse();
        response.setId(product.getId());
        response.setName(product.getName());
        response.setDescription(product.getDescription());
        response.setPrice(product.getPrice());
        response.setCategory(product.getCategory());
        response.setCreatedAt(LocalDateTime.now());
        return response;
    }
}

What does this code do?

Two private helper methods handle all the conversion:

  • mapToModel() takes a ProductRequest from the client and creates a Product that the repository can store.
  • mapToResponse() takes a stored Product and builds a ProductResponse that gets sent back to the client with a timestamp.

The public methods now accept ProductRequest and return ProductResponse instead of raw Product objects.

  • Save ProductService.java.

Now update the controller to match the new service signatures and add validation.

  • Open ProductController.java in the controller package.
  • Replace the entire file contents with the following code:
package com.example.productcatalog.controller;

import com.example.productcatalog.dto.ProductRequest;
import com.example.productcatalog.dto.ProductResponse;
import com.example.productcatalog.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    private final ProductService productService;

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

    @GetMapping
    public ResponseEntity<List<ProductResponse>> getAllProducts() {
        return ResponseEntity.ok(productService.getAllProducts());
    }

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

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

    // @Valid also applied to update requests
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> updateProduct(@PathVariable Long id, @Valid @RequestBody ProductRequest request) {
        return ResponseEntity.ok(productService.updateProduct(id, request));
    }

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

What does @Valid do?

The @Valid annotation on a @RequestBody parameter tells Spring to run all Bean Validation annotations on that object before your method executes. If any constraint fails, Spring automatically returns a 400 Bad Request response. Your controller method never runs.

  • Save ProductController.java.

Seeing compile errors in the controller?

Make sure you saved ProductService.java first. The controller depends on the new service method signatures. If the service still has old signatures, the controller cannot compile.

help me fix compile errors after updating my ProductController and ProductService to use DTOs

✔️ Awesome, I've got everything!

Great. Double check you have saved both the service and controller files.

ⓧ I'd like to double check the full code

Here is the complete ProductService.java:

package com.example.productcatalog.service;

import com.example.productcatalog.dto.ProductRequest;
import com.example.productcatalog.dto.ProductResponse;
import com.example.productcatalog.model.Product;
import com.example.productcatalog.repository.ProductRepository;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;

@Service
public class ProductService {

    private final ProductRepository productRepository;

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

    public List<ProductResponse> getAllProducts() {
        return productRepository.findAll().stream()
                .map(this::mapToResponse)
                .toList();
    }

    public ProductResponse getProductById(Long id) {
        Product product = productRepository.findById(id).orElse(null);
        if (product == null) return null;
        return mapToResponse(product);
    }

    public ProductResponse createProduct(ProductRequest request) {
        Product product = mapToModel(request);
        Product saved = productRepository.save(product);
        return mapToResponse(saved);
    }

    public ProductResponse updateProduct(Long id, ProductRequest request) {
        Product product = mapToModel(request);
        product.setId(id);
        Product updated = productRepository.save(product);
        return mapToResponse(updated);
    }

    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
    }

    private Product mapToModel(ProductRequest request) {
        Product product = new Product();
        product.setName(request.getName());
        product.setDescription(request.getDescription());
        product.setPrice(request.getPrice());
        product.setCategory(request.getCategory());
        return product;
    }

    private ProductResponse mapToResponse(Product product) {
        ProductResponse response = new ProductResponse();
        response.setId(product.getId());
        response.setName(product.getName());
        response.setDescription(product.getDescription());
        response.setPrice(product.getPrice());
        response.setCategory(product.getCategory());
        response.setCreatedAt(LocalDateTime.now());
        return response;
    }
}

And here is the complete ProductController.java:

package com.example.productcatalog.controller;

import com.example.productcatalog.dto.ProductRequest;
import com.example.productcatalog.dto.ProductResponse;
import com.example.productcatalog.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    private final ProductService productService;

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

    @GetMapping
    public ResponseEntity<List<ProductResponse>> getAllProducts() {
        return ResponseEntity.ok(productService.getAllProducts());
    }

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

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

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

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }
}
Test validation with invalid and valid payloads

Time to see validation in action. You will send a bad request first, then a good one, to confirm Spring rejects invalid data automatically.

  • If your app is still running from the previous step, stop it with Ctrl+C in the terminal.
  • Restart the app by running:
mvnw.cmd spring-boot:run
  • Once you see the startup log indicating the app is running on port 8080, open a second terminal in VS Code by clicking the + icon in the terminal panel.
  • Send an invalid request (empty name, negative price) by running:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d "{\"name\":\"\", \"price\":-5, \"category\":\"\"}"
  • You should see a 400 Bad Request response with error details mentioning the validation failures.

What should I see?

Spring returns a 400 status code along with a JSON body that includes information about which fields failed validation and why. The exact format includes the field names and your custom messages like "Product name is required" and "Price must be positive".

  • Now send a valid request to confirm normal operation still works:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d "{\"name\":\"Mechanical Keyboard\", \"description\":\"Cherry MX switches\", \"price\":89.99, \"category\":\"electronics\"}"
  • You should see a 201 response with the product data including a generated id and a createdAt timestamp.

Still getting 200 instead of 400 on bad data?

Make sure you added @Valid before @RequestBody on the createProduct method. Without @Valid, Spring skips validation entirely. Also check that the import is jakarta.validation.Valid (not javax.validation.Valid).

help me figure out why Spring is not validating my request body

Your API now rejects bad data before it reaches your business logic. In the next step, you will build a custom exception handler so that error responses follow a consistent, production-friendly format.

Implement Global Exception Handling

Your API currently has a problem. If someone requests a product that does not exist, the service returns null and the controller returns an empty 404 with no body. Validation failures produce Spring's default error response, which is verbose and leaks internal class names.

In this step, you will build a centralized exception handling layer. You will create a custom exception, a structured error response format, and a global handler that intercepts exceptions across all controllers and returns clean, consistent JSON error bodies.

In this step, get ready to:
  • Create a custom ProductNotFoundException that the service layer throws when a product does not exist.
  • Build a global exception handler with @RestControllerAdvice that returns structured error JSON for all error types.
  • Test the full error handling flow by requesting non-existent products and sending invalid data.
Create the custom exception and update the service

Right now, ProductService returns null when a product is not found. That pushes error handling into every controller method. A better pattern is to throw a domain-specific exception from the service layer and let a centralized handler convert it to the right HTTP response.

  • Create the file ProductNotFoundException.java inside a new exception package at src/main/java/com/example/productcatalog/exception/.
  • Add the following code to define the exception:
package com.example.productcatalog.exception;

public class ProductNotFoundException extends RuntimeException {

    public ProductNotFoundException(Long id) {
        super("Product not found with id: " + id);
    }
}

What does this code do?

ProductNotFoundException extends RuntimeException, which means Spring does not require you to declare it with throws in every method signature. The constructor takes a product ID and builds a human-readable error message that the handler will include in the response body.

  • Save ProductNotFoundException.java.

Now update ProductService.java to throw this exception instead of returning null or silently proceeding when a product does not exist.

  • Open src/main/java/com/example/productcatalog/service/ProductService.java.
  • Add this import near the top of the file, below the existing imports:
import com.example.productcatalog.exception.ProductNotFoundException;
  • Find the getProductById method. Replace it with the following:
    public ProductResponse getProductById(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
        return mapToResponse(product);
    }
  • Find the updateProduct method. Replace it with the following:
    public ProductResponse updateProduct(Long id, ProductRequest request) {
        if (!productRepository.existsById(id)) {
            throw new ProductNotFoundException(id);
        }
        Product product = mapToModel(request);
        product.setId(id);
        Product updated = productRepository.save(product);
        return mapToResponse(updated);
    }
  • Find the deleteProduct method. Replace it with the following:
    public void deleteProduct(Long id) {
        if (!productRepository.existsById(id)) {
            throw new ProductNotFoundException(id);
        }
        productRepository.deleteById(id);
    }

What changed in the service?

The getProductById method now uses .orElseThrow() instead of .orElse(null). This throws ProductNotFoundException immediately when the product is missing.

The updateProduct and deleteProduct methods now check existsById before proceeding, preventing silent overwrites or no-op deletes on products that do not exist.

  • Save ProductService.java.

Since the service now throws instead of returning null, you can simplify the controller's getProductById method.

  • Open src/main/java/com/example/productcatalog/controller/ProductController.java.
  • Find the getProductById method that currently looks like this:
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProductById(@PathVariable Long id) {
        ProductResponse response = productService.getProductById(id);
        if (response == null) return ResponseEntity.notFound().build();
        return ResponseEntity.ok(response);
    }
  • Replace it with this simplified version:
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProductById(@PathVariable Long id) {
        return ResponseEntity.ok(productService.getProductById(id));
    }
  • Save ProductController.java.

Why is the controller simpler now?

The controller no longer checks for null. If the product does not exist, the service throws ProductNotFoundException before the method returns. The global exception handler (which you will build next) catches it and returns the 404 response automatically.

Seeing red squiggles on the import?

If VS Code underlines the ProductNotFoundException import in ProductService.java, make sure you saved ProductNotFoundException.java first. The Java Language Server needs a moment to index new files.

help me fix import errors in my Spring Boot project

Build the global exception handler

With the exception in place, you need something to catch it and turn it into a proper HTTP response. Spring's @RestControllerAdvice annotation creates a class that intercepts exceptions from all controllers and formats the response. You will also need a structured response object to give every error a consistent shape.

  • Create the file ErrorResponse.java in the exception package at src/main/java/com/example/productcatalog/exception/.
  • Add the following code:
package com.example.productcatalog.exception;

import java.time.LocalDateTime;

public record ErrorResponse(
        LocalDateTime timestamp,
        int status,
        String error,
        String message,
        String path
) {
}

What is a Java record?

A record is a compact class that automatically generates a constructor, getters, equals(), hashCode(), and toString(). Records are ideal for immutable data carriers like error responses. Jackson serializes them to JSON automatically.

  • Save ErrorResponse.java.
  • Create the file GlobalExceptionHandler.java in the same exception package.
  • Add the following code:
package com.example.productcatalog.exception;

import jakarta.servlet.http.HttpServletRequest;
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;

import java.time.LocalDateTime;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handles requests for products that don't exist
    @ExceptionHandler(ProductNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleProductNotFound(ProductNotFoundException 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);
    }

    // Handles validation failures from @Valid annotations
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex, HttpServletRequest request) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
                .collect(Collectors.joining(", "));

        ErrorResponse error = new ErrorResponse(
                LocalDateTime.now(),
                HttpStatus.BAD_REQUEST.value(),
                "Bad Request",
                message,
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    // Catch-all for any unhandled exceptions
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex, HttpServletRequest request) {
        ErrorResponse error = new ErrorResponse(
                LocalDateTime.now(),
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal Server Error",
                "An unexpected error occurred",
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}

What does each handler do?

  • handleProductNotFound catches ProductNotFoundException and returns a 404 response with the exception's message (e.g. "Product not found with id: 99").
  • handleValidationErrors catches MethodArgumentNotValidException (thrown when @Valid fails) and builds a human-readable list of which fields failed and why.
  • handleGenericException is the safety net. It catches anything else and returns a generic 500 without leaking internal stack traces to the client.
  • Save GlobalExceptionHandler.java.

Why @RestControllerAdvice instead of try-catch in every controller?

@RestControllerAdvice combines @ControllerAdvice with @ResponseBody. It applies to every controller automatically. This means you write error formatting logic once and every endpoint in your application gets consistent error responses without any additional code.

Import errors on HttpServletRequest?

The jakarta.servlet.http.HttpServletRequest class comes from the spring-boot-starter-web dependency. If VS Code cannot resolve it, try reloading the window (Ctrl+Shift+P then type "Reload Window") to refresh the Java language server.

help me resolve Jakarta servlet import errors in my Spring Boot project

Test your exception handling

Time to verify that your exception handler returns structured JSON for both not-found errors and validation failures.

  • Start your application by running this command in the VS Code terminal:
mvnw.cmd spring-boot:run
  • Test a non-existent product by running this command in a second terminal:
curl http://localhost:8080/api/products/999

You should see a structured JSON error response like this:

{
  "timestamp": "2026-06-01T10:00:00.000",
  "status": 404,
  "error": "Not Found",
  "message": "Product not found with id: 999",
  "path": "/api/products/999"
}
  • Test validation by sending an invalid payload with this command:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d "{\"name\":\"\", \"price\":-5}"

You should see a 400 response with field-level error messages listing which fields failed validation and why.

What should the validation error look like?

The response will include a message field containing each invalid field and its error. For example: name: Product name is required, price: Price must be positive, category: Category is required. The exact order may vary.

Still seeing Spring's default error format?

Make sure GlobalExceptionHandler.java is in the com.example.productcatalog.exception package. Spring's component scan only picks up classes under the base package (com.example.productcatalog). If the file is in the wrong package, the handler will not be registered.

help me debug why my @RestControllerAdvice handler is not being picked up by Spring Boot

✔️ Awesome, I've got everything!

Great. Double check that all three files in the exception package are saved and that your service and controller changes are saved too.

ⓧ I'd like to double check the full code

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

package com.example.productcatalog.exception;

public class ProductNotFoundException extends RuntimeException {

    public ProductNotFoundException(Long id) {
        super("Product not found with id: " + id);
    }
}
package com.example.productcatalog.exception;

import java.time.LocalDateTime;

public record ErrorResponse(
        LocalDateTime timestamp,
        int status,
        String error,
        String message,
        String path
) {
}
package com.example.productcatalog.exception;

import jakarta.servlet.http.HttpServletRequest;
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;

import java.time.LocalDateTime;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ProductNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleProductNotFound(ProductNotFoundException 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<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex, HttpServletRequest request) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
                .collect(Collectors.joining(", "));

        ErrorResponse error = new ErrorResponse(
                LocalDateTime.now(),
                HttpStatus.BAD_REQUEST.value(),
                "Bad Request",
                message,
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex, HttpServletRequest request) {
        ErrorResponse error = new ErrorResponse(
                LocalDateTime.now(),
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal Server Error",
                "An unexpected error occurred",
                request.getRequestURI()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
    }
}
package com.example.productcatalog.service;

import com.example.productcatalog.dto.ProductRequest;
import com.example.productcatalog.dto.ProductResponse;
import com.example.productcatalog.exception.ProductNotFoundException;
import com.example.productcatalog.model.Product;
import com.example.productcatalog.repository.ProductRepository;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.List;

@Service
public class ProductService {

    private final ProductRepository productRepository;

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

    public List<ProductResponse> getAllProducts() {
        return productRepository.findAll().stream()
                .map(this::mapToResponse)
                .toList();
    }

    public ProductResponse getProductById(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
        return mapToResponse(product);
    }

    public ProductResponse createProduct(ProductRequest request) {
        Product product = mapToModel(request);
        Product saved = productRepository.save(product);
        return mapToResponse(saved);
    }

    public ProductResponse updateProduct(Long id, ProductRequest request) {
        if (!productRepository.existsById(id)) {
            throw new ProductNotFoundException(id);
        }
        Product product = mapToModel(request);
        product.setId(id);
        Product updated = productRepository.save(product);
        return mapToResponse(updated);
    }

    public void deleteProduct(Long id) {
        if (!productRepository.existsById(id)) {
            throw new ProductNotFoundException(id);
        }
        productRepository.deleteById(id);
    }

    private Product mapToModel(ProductRequest request) {
        Product product = new Product();
        product.setName(request.getName());
        product.setDescription(request.getDescription());
        product.setPrice(request.getPrice());
        product.setCategory(request.getCategory());
        return product;
    }

    private ProductResponse mapToResponse(Product product) {
        ProductResponse response = new ProductResponse();
        response.setId(product.getId());
        response.setName(product.getName());
        response.setDescription(product.getDescription());
        response.setPrice(product.getPrice());
        response.setCategory(product.getCategory());
        response.setCreatedAt(LocalDateTime.now());
        return response;
    }
}
package com.example.productcatalog.controller;

import com.example.productcatalog.dto.ProductRequest;
import com.example.productcatalog.dto.ProductResponse;
import com.example.productcatalog.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

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

    private final ProductService productService;

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

    @GetMapping
    public ResponseEntity<List<ProductResponse>> getAllProducts() {
        return ResponseEntity.ok(productService.getAllProducts());
    }

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

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

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

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

Secret mission

Add Filtering and Search

Your API returns all products every time. Real APIs let consumers filter by category, price range, and search by name. Can you add query parameter support to make your catalog actually usable?

Clean Up Your Resources

Clean Up Your Resources

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

Resources you used:

  • The productcatalog project folder on your Desktop.
  • Extension Pack for Java (VS Code extension).
  • Spring Boot Extension Pack (VS Code extension).

Keep everything running

No action needed. Choose this if you're still actively building or plan to continue with Phase 2 of the Spring Boot learning path.

  • Your productcatalog folder stays on your Desktop, ready to open in VS Code any time.
  • The Extension Pack for Java and Spring Boot Extension Pack remain installed in VS Code for future Java projects.
  • The Maven Wrapper files inside .mvn/wrapper/ stay bundled with the project. No system-wide Maven installation was added.

Pause - I'll come back to this later

Shut down the running Spring Boot process to free up your terminal and port 8080.

  • In the VS Code terminal where your app is running, press Ctrl+C to stop the Spring Boot server.
  • You can close VS Code. Your project files remain intact on your Desktop.
  • To resume later, open the productcatalog folder in VS Code and run mvnw.cmd spring-boot:run from the integrated terminal.

Delete - I don't want to use this again

Remove all project files and VS Code extensions installed during this project.

  • If your Spring Boot app is still running, press Ctrl+C in the terminal to stop it.
  • Delete the project folder by running this command on macOS/Linux:
rm -rf ~/Desktop/productcatalog
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\productcatalog
  • Uninstall the Extension Pack for Java by running:
code --uninstall-extension vscjava.vscode-java-pack
  • Uninstall the Spring Boot Extension Pack by running:
code --uninstall-extension vmware.vscode-boot-dev-pack
  • Restart VS Code to complete the extension removal.

Nice Work!

Nice Work!

You just built a production-style Spring Boot REST API from scratch. This is the exact architecture pattern that enterprise Java applications use, and the one interviewers expect you to explain and build.

Here's what you accomplished:

  • Built a fully functional CRUD REST API with Spring Boot 3.5.14, complete with proper HTTP status codes and ResponseEntity responses.
  • Implemented the layered architecture pattern (Controller → Service → Repository) with constructor injection for dependency management.
  • Added request validation with Bean Validation annotations and a global exception handler using @RestControllerAdvice for consistent, structured error responses.
  • Added filtering and search capabilities with optional query parameters, using Java streams to chain multiple filter predicates.

Ready to quiz yourself?