Build a Product Catalog REST API

Build a full CRUD REST API with Spring Boot, JPA, and MySQL.

Introduction

30 Second Summary

Every online store, marketplace, and booking app has the same thing at its core: a product catalog. It is the engine that serves product names, prices, and availability to every screen a customer touches.

In this project, you will build a Product Catalog REST API from scratch using Spring Boot and MySQL. You will create endpoints that let any client application create, read, update, and delete products, with built-in validation that rejects bad data before it reaches your database.

What You'll Build

A fully functional REST API that manages product data for a marketplace, built with Spring Boot and backed by a MySQL database.

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

  • A Spring Boot application with a layered architecture following the Controller, Service, and Repository pattern.
  • Five REST endpoints supporting full CRUD operations with proper HTTP status codes.
  • Built-in input validation that rejects bad data with structured error responses.
  • A saved Postman collection proving every endpoint works correctly.
  • Secret Mission: Add filtering and search to your product catalog so clients can find products by category, price range, and name keyword.

Are there any prerequisites?

You will need JDK 17 or higher, MySQL, and VS Code installed on your machine. Step 1 walks you through verifying each of these before you write any code.

Before We Start

This project uses Java with Spring Boot to build a REST API that manages product data for a marketplace. Before you write any code, you need to confirm the right tools are in place.

In this step, you will verify your Java version meets the minimum requirement, add Spring Boot support to VS Code, and install Postman for testing your API endpoints.

In this step, get ready to:
  • Confirm your JDK version is 17 or higher.
  • Install the Spring Boot Extension Pack in VS Code.
  • Install Postman for API testing.
Verify your JDK version

Spring Boot 4.1.0 requires Java 17 or higher. You need to confirm your installed JDK meets this minimum before generating the project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Cmd+` (macOS) or Ctrl+` (Windows).
  • Check your Java version by running this command:
java -version

Check your output and select the tab that matches what you see:

✔️ I see version 17 or higher

Your output looks something like this:

openjdk version "17.0.x" 2024-xx-xx

The first number after version is what matters. As long as it starts with 17, 21, or any number 17 or above, you are good to go.

ⓧ I see an older version

If your version starts with a number lower than 17 (such as 11 or 8), you need to install a newer JDK. Your system may have an older Java installed by default.

  • Open your browser and go to adoptium.net.
  • Download the latest JDK 17+ installer for your operating system.
  • Run the installer and follow the prompts.
  • Close and reopen your VS Code terminal, then run java -version again to confirm it now shows 17 or higher.

Older Java still showing up?

On macOS, the system may still point to an older Java even after installing a new one. If java -version still shows the old version after installing, restart VS Code completely and try again.

ⓧ Command not found

This means Java is not installed on your machine yet.

  • Open your browser and go to adoptium.net.
  • Download the JDK 17+ installer for your operating system.
  • Run the installer and follow the prompts.
  • Close and reopen your VS Code terminal, then run java -version again to confirm it shows version 17 or higher.
Install the Spring Boot Extension Pack in VS Code

The Spring Boot Extension Pack adds project generation, code navigation, and a Boot Dashboard to VS Code. This saves you from switching between your editor and a browser for common Spring Boot tasks.

  • Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Type Spring Boot Extension Pack in the search bar.
  • Find the result published by VMware and click Install.

What does this extension pack include?

The pack bundles several extensions together: Spring Initializr integration for generating projects, Spring Boot Tools for code navigation, and the Boot Dashboard for running your app directly from VS Code.

  • Confirm the extension shows as installed in your Extensions panel. You should see a green checkmark or Disable and Uninstall buttons where the Install button used to be.

Extension not appearing in search?

Make sure you are searching the Marketplace tab (not the installed tab). If nothing appears, check your internet connection and try again.

Still stuck? Help me find and install the Spring Boot Extension Pack in VS Code.

Install Postman

Postman is a tool for sending HTTP requests to your API and inspecting responses. You will use it in a later step to test every endpoint you build.

  • Open your browser and go to postman.com/downloads.
  • Download the installer for your operating system.
  • Run the installer and follow the prompts to complete the installation.
  • Open Postman once the installation finishes. You can create a free account or skip sign-in for now.

Do I need a Postman account?

Postman works without an account for basic requests. Signing in lets you save collections across devices, but it is completely optional for this project.

  • Confirm Postman opens and you can see the main workspace screen.

Postman not opening?

On macOS, you may need to allow the app in System Settings > Privacy & Security if you see a security warning. On Windows, try running the installer as an administrator.

Still stuck? Help me get Postman installed and running on my machine.

Your development environment is ready. Next up, you will generate a Spring Boot project and connect it to your MySQL database.

Generate and Configure the Spring Boot Project

Your development tools are ready. Now it's time to create the actual Spring Boot project and connect it to a database.

Spring Initializr is a web tool that generates a pre-configured project skeleton for you. You pick your build tool, language, and dependencies, then download a ready-to-run ZIP file. In this step, you will generate the project, create a MySQL database, and confirm the application starts.

In this step, get ready to:
  • Generate a Spring Boot project from Spring Initializr with the required dependencies.
  • Create the MySQL database and configure the connection properties.
  • Start the application and verify it runs on port 8080.
Generate the project using Spring Initializr

Spring Initializr lets you configure a project from a web interface, select all the libraries you need, and download a ready-to-go ZIP. You will use it to set up the project structure, build tool, and dependencies in one shot.

  • Open your browser and navigate to https://start.spring.io.
  • Under Project, select Maven.
  • Under Language, select Java.
  • Under Spring Boot, select 4.1.0.
  • In the Project Metadata section, set Group to com.marketplace.
  • Set Artifact to product-catalog.
  • Click ADD DEPENDENCIES and search for Spring Web MVC. Select it.
  • Search for Spring Data JPA and select it.
  • Search for MySQL Driver and select it.
  • Search for Validation and select it.
  • Click GENERATE to download the ZIP file.
  • Extract the ZIP file to a location you can find easily (such as your Desktop).
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the extracted product-catalog folder, then click Open.

What is Spring Initializr?

Spring Initializr is the official project generator for Spring Boot. It creates a complete Maven project structure with a pom.xml (dependency file), a main application class, and default configuration files. This saves you from manually wiring together dozens of library versions.

The four dependencies you added give you web request handling (Spring Web MVC), database access (Spring Data JPA), a MySQL connection driver, and input validation.

What is Maven?

Maven is a build tool that manages your project's dependencies and compiles your code. The generated project includes a Maven Wrapper (mvnw), so you do not need to install Maven separately. The wrapper downloads the correct Maven version automatically.

You should now see the product-catalog project loaded in VS Code's Explorer sidebar with folders like src and files like pom.xml.

Don't see the project files in VS Code?

Make sure you opened the product-catalog folder itself, not the parent folder that contains it. You should see pom.xml at the top level of the Explorer sidebar.

help me open the correct project folder in VS Code

Create the MySQL database and configure the connection

Your Spring Boot application needs a database to store product data. You will create an empty MySQL database and then tell Spring Boot how to connect to it using a properties file.

  • Open a terminal window and log in to the MySQL command-line client by running:
mysql -u root -p

You will be prompted to enter your MySQL root password. Type it and press Enter. The password will not appear on screen as you type.

  • Create the database by running this SQL command:
CREATE DATABASE product_catalog;
  • Exit the MySQL client by typing:
EXIT;

What does this command do?

CREATE DATABASE product_catalog; creates a new empty database named product_catalog on your local MySQL server. Spring Boot will later create the products table inside this database automatically when you define an entity class.

Now you need to tell Spring Boot where this database lives and how to authenticate. You do this in the application.properties file.

  • In VS Code's Explorer sidebar, expand src > main > resources.
  • Open application.properties.
  • Replace the contents of the file with the following configuration:
spring.datasource.url=jdbc:mysql://localhost:3306/product_catalog
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

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 tells Spring Boot the address of your MySQL database on port 3306.
  • spring.datasource.username and spring.datasource.password are your MySQL login credentials.
  • spring.datasource.driver-class-name specifies the MySQL Connector/J driver class that handles the connection.
  • spring.jpa.hibernate.ddl-auto=update tells Hibernate to automatically create or update database tables based on your Java classes.
  • spring.jpa.show-sql=true prints every SQL query to the console so you can see what's happening behind the scenes.
  • Replace your-mysql-password-here with your actual MySQL root password.
  • Save application.properties.

Using a different MySQL username?

If your MySQL setup uses a username other than root, update the spring.datasource.username value to match your username.

✔️ Awesome, I've got everything!

Great. Double check you have saved application.properties with your real MySQL password filled in.

ⓧ I'd like to double check the full code

Here is the complete application.properties file for comparison:

spring.datasource.url=jdbc:mysql://localhost:3306/product_catalog
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
Verify the application starts

With the database created and the connection configured, you can now start the Spring Boot application to make sure everything is wired together correctly.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key).
  • Start the application by running:

macOS

./mvnw spring-boot:run

Windows

mvnw.cmd spring-boot:run

The first run downloads all dependencies, so it may take a minute or two. When the application is ready, you will see a log line containing Started ProductCatalogApplication in the terminal output.

What should I see?

Look for a line near the end of the output that says Started ProductCatalogApplication followed by a time in seconds. This confirms your app is running on port 8080 and successfully connected to MySQL.

The products table does not exist yet. That is expected. It will be auto-created in the next step when you define a JPA entity class.

Seeing a connection refused error?

  • Make sure your MySQL server is running. On macOS, check System Settings > MySQL. On Windows, open Services and confirm MySQL shows as Running.
  • Verify the password in application.properties matches the password you used when logging into the MySQL CLI earlier.
  • Confirm the database name is exactly product_catalog (underscores, no hyphens).

help me fix a MySQL connection error in my Spring Boot application

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

Your Spring Boot application is alive and connected to MySQL. Next up, you will define a Product entity class that tells JPA how to create the database table automatically.

Create the Product Entity and Repository

Your Spring Boot application starts successfully, but it has no idea what a "product" is yet. The database is empty and waiting for a structure that defines what data to store.

In this step, you will create a JPA entity class that maps Java fields directly to database columns. When you restart the application, Hibernate reads this class and auto-generates the products table in MySQL for you.

In this step, get ready to:
  • Create the Product entity class with validation annotations.
  • Create the ProductRepository interface for database access.
  • Verify the products table was auto-created in MySQL.
Create the Product entity class

A JPA entity is a Java class annotated with @Entity that maps directly to a database table. Each field in the class becomes a column, and each object instance becomes a row.

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

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;

import java.math.BigDecimal;

// Marks this class as a JPA entity mapped to the "products" table
@Entity
@Table(name = "products")
public class Product {

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

    // Product 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;

    private String category;

    // Stock cannot be negative
    @PositiveOrZero(message = "Stock quantity cannot be negative")
    private Integer stockQuantity;

    // JPA requires a no-argument constructor
    public Product() {}

    // 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 BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
}

What does this code do?

@Entity tells Hibernate this class represents a database table. @Table(name = "products") sets the exact table name in MySQL.

@Id and @GeneratedValue(strategy = GenerationType.IDENTITY) mark id as the primary key and tell the database to auto-increment it for each new row.

The validation annotations (@NotBlank, @NotNull, @Positive, @PositiveOrZero) enforce data quality rules. These come from Jakarta Bean Validation and will reject bad input before it ever reaches the database.

  • Save Product.java.

You will not see a visible change yet. The next substep creates the repository, and then you will restart the app to confirm the table is generated.

Seeing red underlines in Product.java?

Make sure the package declaration at the top matches the folder structure exactly: package com.marketplace.productcatalog.model;.

If imports show errors, confirm spring-boot-starter-data-jpa and spring-boot-starter-validation are in your pom.xml. The Spring Boot Extension Pack resolves dependencies automatically after saving.

help me fix red underlines in my Product.java entity class

✔️ Awesome, I've got everything!

Great. Make sure you have saved Product.java before moving on.

ⓧ I'd like to double check the full code

package com.marketplace.productcatalog.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;

import java.math.BigDecimal;

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

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

    @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;

    private String category;

    @PositiveOrZero(message = "Stock quantity cannot be negative")
    private Integer stockQuantity;

    public Product() {}

    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 String getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
}
Create the ProductRepository interface

Spring Data JPA repositories give you complete database access from a single interface declaration. You do not need to write any implementation code. Spring generates the SQL queries at runtime.

  • Right-click the src/main/java/com/marketplace/productcatalog folder in the Explorer sidebar.
  • Select New Folder and name it repository.
  • Right-click the new repository folder and select New File.
  • Name the file ProductRepository.java.
  • Add the following code to ProductRepository.java:
package com.marketplace.productcatalog.repository;

import com.marketplace.productcatalog.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

// Extends JpaRepository to inherit CRUD methods (save, findAll, findById, delete)
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
}

What does this code do?

By extending JpaRepository<Product, Long>, this interface automatically inherits methods like save(), findAll(), findById(), and delete().

The two type parameters tell Spring which entity class to manage (Product) and the type of its primary key (Long). Spring generates the full implementation at runtime.

  • Save ProductRepository.java.

You will not see a change yet. The next substep restarts the app so Hibernate can read your entity and create the table.

Import error on JpaRepository?

Confirm that spring-boot-starter-data-jpa is listed as a dependency in your pom.xml. The Spring Boot Extension Pack downloads the JPA libraries when you save.

help me fix an import error on JpaRepository in my Spring Boot project

✔️ Awesome, I've got everything!

Great. Make sure you have saved ProductRepository.java before moving on.

ⓧ I'd like to double check the full code

package com.marketplace.productcatalog.repository;

import com.marketplace.productcatalog.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
}
Restart the application and verify the table

With both files saved, Hibernate will read your Product entity on startup and create the matching products table in MySQL.

  • If the application is still running in your terminal, press Ctrl+C to stop it.
  • Restart the application by running this command:
./mvnw spring-boot:run

On Windows?

Use mvnw.cmd spring-boot:run instead of ./mvnw spring-boot:run.

  • Wait until the logs show "Started ProductCatalogApplication" with no errors.
  • Open a new terminal tab or window and connect to MySQL by running:
mysql -u root -p
  • Enter your MySQL password when prompted.
  • Switch to the project database and check the table columns by running:
USE product_catalog;
SHOW COLUMNS FROM products;
  • You should see six columns listed: id, name, description, price, category, and stock_quantity.

Why is stockQuantity now stock_quantity?

Hibernate automatically converts Java camelCase field names (like stockQuantity) into snake_case column names (like stock_quantity) by default. This follows standard database naming conventions.

Table not showing up?

Check that spring.jpa.hibernate.ddl-auto=update is set in your application.properties. Without this property, Hibernate will not auto-create tables.

Also confirm your MySQL credentials in application.properties are correct. If the app cannot connect, it will not create the table.

help me figure out why Hibernate is not auto-creating my products table in MySQL

Your database now has a products table ready to store data. Next up, you will build the service and controller layers that let clients create, read, update, and delete products through HTTP requests.

Build the REST Controller and Service Layer

Your database layer is wired up. The Product entity maps to a products table, and ProductRepository gives you all the standard database operations for free.

Now you need the code that actually handles incoming HTTP requests. In Spring Boot, this follows a layered pattern: a Controller receives the request, delegates to a Service for business logic, and the Service calls the Repository to talk to the database.

In this step, get ready to:
  • Create a service class that contains your business logic for managing products.
  • Create a REST controller that maps HTTP endpoints to service methods.
  • Create a global exception handler that returns structured error responses.
Create the service layer

The service layer sits between the controller and the repository. It contains your business logic, like throwing an error when someone requests a product that does not exist.

Before creating the service, you need a custom exception class that the service will throw when a product is not found.

  • In the VS Code Explorer sidebar, right-click the src/main/java/com/marketplace/productcatalog folder.
  • Create a new folder called exception.
  • Inside the exception folder, create a new file called ResourceNotFoundException.java and paste the following code:
package com.marketplace.productcatalog.exception;

public class ResourceNotFoundException extends RuntimeException {

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

What does this code do?

This is a custom exception class that extends RuntimeException. When your service cannot find a product by ID, it will throw this exception with a descriptive message.

Later, your exception handler will catch this and convert it into a proper 404 HTTP response.

  • Save ResourceNotFoundException.java.

Now you can build the service class that uses this exception.

  • In the Explorer sidebar, right-click the src/main/java/com/marketplace/productcatalog folder.
  • Create a new folder called service.
  • Inside the service folder, create a new file called ProductService.java and paste the following code:
package com.marketplace.productcatalog.service;

import com.marketplace.productcatalog.exception.ResourceNotFoundException;
import com.marketplace.productcatalog.model.Product;
import com.marketplace.productcatalog.repository.ProductRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class ProductService {

    private final ProductRepository productRepository;

    // Spring injects the repository automatically via constructor injection
    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // Return all products from the database
    public List<Product> getAllProducts() {
        return productRepository.findAll();
    }

    // Find a single product or throw an exception if it doesn't exist
    public Product getProductById(Long id) {
        return productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
    }

    // Save a new product to the database
    public Product createProduct(Product product) {
        return productRepository.save(product);
    }

    // Update all fields of an existing product
    public Product updateProduct(Long id, Product productDetails) {
        Product product = getProductById(id);
        product.setName(productDetails.getName());
        product.setDescription(productDetails.getDescription());
        product.setPrice(productDetails.getPrice());
        product.setCategory(productDetails.getCategory());
        product.setStockQuantity(productDetails.getStockQuantity());
        return productRepository.save(product);
    }

    // Delete a product after confirming it exists
    public void deleteProduct(Long id) {
        Product product = getProductById(id);
        productRepository.delete(product);
    }
}

What does this code do?

The @Service annotation tells Spring this class contains business logic. Spring automatically creates an instance and injects the ProductRepository through the constructor.

  • getAllProducts() delegates to the repository's built-in findAll() method.
  • getProductById() uses findById() and throws ResourceNotFoundException if the product does not exist.
  • updateProduct() first fetches the existing product, copies the new field values onto it, then saves the updated version.
  • deleteProduct() checks the product exists before deleting, so you get a clear 404 instead of a silent no-op.
  • Save ProductService.java.

✔️ Awesome, I've got everything!

Great. Make sure both ResourceNotFoundException.java and ProductService.java are saved.

ⓧ I'd like to double check the full code

Here is the complete ProductService.java:

package com.marketplace.productcatalog.service;

import com.marketplace.productcatalog.exception.ResourceNotFoundException;
import com.marketplace.productcatalog.model.Product;
import com.marketplace.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)
                .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
    }

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

    public Product updateProduct(Long id, Product productDetails) {
        Product product = getProductById(id);
        product.setName(productDetails.getName());
        product.setDescription(productDetails.getDescription());
        product.setPrice(productDetails.getPrice());
        product.setCategory(productDetails.getCategory());
        product.setStockQuantity(productDetails.getStockQuantity());
        return productRepository.save(product);
    }

    public void deleteProduct(Long id) {
        Product product = getProductById(id);
        productRepository.delete(product);
    }
}

And the complete ResourceNotFoundException.java:

package com.marketplace.productcatalog.exception;

public class ResourceNotFoundException extends RuntimeException {

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

Seeing red underlines in VS Code?

If ResourceNotFoundException shows an error in ProductService.java, make sure the package declaration in ResourceNotFoundException.java is exactly package com.marketplace.productcatalog.exception; and that the file is saved.

Also confirm the exception folder is inside src/main/java/com/marketplace/productcatalog/, not somewhere else in the project.

Help me fix import errors in my Spring Boot service class.

Create the REST controller

The controller is the entry point for all HTTP requests. It maps URL paths and HTTP methods to your service methods, converting incoming JSON into Java objects and Java objects back into JSON responses.

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

import com.marketplace.productcatalog.model.Product;
import com.marketplace.productcatalog.service.ProductService;
import jakarta.validation.Valid;
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.RestController;

import java.util.List;

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

    private final ProductService productService;

    // Spring injects the service automatically
    public ProductController(ProductService productService) {
        this.productService = productService;
    }

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

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

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

    // PUT /api/products/{id} - updates an existing product
    @PutMapping("/{id}")
    public ResponseEntity<Product> updateProduct(@PathVariable Long id, @Valid @RequestBody Product product) {
        return ResponseEntity.ok(productService.updateProduct(id, product));
    }

    // DELETE /api/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 @RestController annotation tells Spring this class handles HTTP requests and returns JSON responses directly. @RequestMapping("/api/products") sets the base URL path for all endpoints in this controller.

  • @GetMapping, @PostMapping, @PutMapping, and @DeleteMapping map each method to a specific HTTP verb.
  • @Valid on @RequestBody triggers the validation annotations you added to the Product entity (like @NotBlank and @Positive).
  • ResponseEntity lets you control the HTTP status code. The create endpoint returns 201 Created, and the delete endpoint returns 204 No Content.
  • Save ProductController.java.

✔️ Awesome, I've got everything!

Great. Double check that ProductController.java is saved inside the controller folder.

ⓧ I'd like to double check the full code

Here is the complete ProductController.java:

package com.marketplace.productcatalog.controller;

import com.marketplace.productcatalog.model.Product;
import com.marketplace.productcatalog.service.ProductService;
import jakarta.validation.Valid;
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.RestController;

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) {
        return ResponseEntity.ok(productService.getProductById(id));
    }

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

    @PutMapping("/{id}")
    public ResponseEntity<Product> updateProduct(@PathVariable Long id, @Valid @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();
    }
}

Why the Controller > Service > Repository pattern?

This layered architecture keeps each class focused on one job. The controller only handles HTTP concerns (status codes, request parsing). The service only handles business rules. The repository only handles database access.

If you later need to add caching, logging, or a different database, you change one layer without touching the others. Every professional Spring Boot API follows this pattern.

Create the global exception handler

Right now, if someone requests a product that does not exist, your service throws a ResourceNotFoundException. Without an exception handler, Spring would return an ugly default error page instead of a clean JSON response.

  • Inside the exception folder (where ResourceNotFoundException.java lives), create a new file called GlobalExceptionHandler.java and paste the following code:
package com.marketplace.productcatalog.exception;

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.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handles requests for products that don't exist (returns 404)
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleResourceNotFound(ResourceNotFoundException ex) {
        Map<String, Object> error = new HashMap<>();
        error.put("timestamp", LocalDateTime.now().toString());
        error.put("status", 404);
        error.put("error", "Not Found");
        error.put("message", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // Handles validation failures like missing name or negative price (returns 400)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, Object> error = new HashMap<>();
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError ->
            fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage())
        );
        error.put("timestamp", LocalDateTime.now().toString());
        error.put("status", 400);
        error.put("error", "Validation Failed");
        error.put("messages", fieldErrors);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

What does this code do?

The @RestControllerAdvice annotation makes this class a global error handler. Spring automatically routes unhandled exceptions from any controller to the matching @ExceptionHandler method.

  • handleResourceNotFound() catches your custom 404 exception and returns a JSON body with the timestamp, status code, and error message.
  • handleValidationErrors() catches validation failures triggered by @Valid and returns a 400 response listing each invalid field and its error message.
  • Save GlobalExceptionHandler.java.

✔️ Awesome, I've got everything!

Great. Make sure all four new files are saved before restarting the application.

ⓧ I'd like to double check the full code

Here is the complete GlobalExceptionHandler.java:

package com.marketplace.productcatalog.exception;

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.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleResourceNotFound(ResourceNotFoundException ex) {
        Map<String, Object> error = new HashMap<>();
        error.put("timestamp", LocalDateTime.now().toString());
        error.put("status", 404);
        error.put("error", "Not Found");
        error.put("message", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
        Map<String, Object> error = new HashMap<>();
        Map<String, String> fieldErrors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError ->
            fieldErrors.put(fieldError.getField(), fieldError.getDefaultMessage())
        );
        error.put("timestamp", LocalDateTime.now().toString());
        error.put("status", 400);
        error.put("error", "Validation Failed");
        error.put("messages", fieldErrors);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

Time to verify everything works together.

  • If the application is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
  • Restart the application by running this command:
./mvnw spring-boot:run

On Windows?

Use mvnw.cmd spring-boot:run instead.

  • Wait for the startup to finish. You should see a line containing Started ProductCatalogApplication in your terminal output.

The application is now running with all five endpoints mapped and ready to receive requests.

Application fails to start?

Check that your package declarations match the folder structure exactly. Each file's package line must correspond to its location under src/main/java/.

Also verify your MySQL database is still running and accessible. The application needs a live database connection to start.

Help me fix my Spring Boot application startup error.

Your API structure is complete. Next up, you will test every endpoint using Postman to confirm they create, read, update, and delete products correctly.

Test Your API with Postman

You have a fully functional API with five endpoints, validation, and error handling. Now it is time to prove it works.

In this step, you will use Postman to send real HTTP requests to every endpoint and verify the responses. This is how professional developers confirm their APIs behave correctly before handing them off to frontend teams or other services.

In this step, get ready to:
  • Create a product via POST and confirm the 201 Created response.
  • Test read, update, and delete operations with correct status codes.
  • Verify that invalid data is rejected with structured error messages.
Create your first product with a POST request

The POST endpoint creates a new product in your database. Sending a valid JSON body should return the product back with a generated id and a 201 Created status code.

  • Switch to Postman.
  • Click the + icon (or the New button) in the left sidebar and select HTTP to open a new request tab.
  • Change the method dropdown from GET to POST.
  • Enter this URL in the request URL field:
http://localhost:8080/api/products
  • Click the Body tab below the URL field.
  • Select raw, then change the dropdown on the right from Text to JSON.
  • Paste this JSON body into the text area:
{
    "name": "Wireless Headphones",
    "description": "Noise-cancelling over-ear headphones",
    "price": 79.99,
    "category": "Electronics",
    "stockQuantity": 50
}
  • Click Send.

In the response pane at the bottom, you should see a status of 201 Created and a JSON body that matches what you sent, plus a new id field (likely 1).

What does this response tell you?

The 201 Created status code means the server successfully created a new resource. The response body includes the auto-generated id from MySQL, confirming the product was persisted to the database.

  • Save this request to a collection by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Click New Collection, name it Product Catalog API, and name the request Create Product.
  • Click Save.

Seeing a connection error?

Make sure your Spring Boot application is still running in the VS Code terminal. You should see the Started ProductCatalogApplication message in the logs. If the app stopped, restart it with ./mvnw spring-boot:run (macOS/Linux) or mvnw.cmd spring-boot:run (Windows).

Also confirm the URL is exactly http://localhost:8080/api/products with no trailing slash or typo.

Help me troubleshoot my Postman connection

Test read, update, and delete

Now that you have a product in the database, you can exercise the remaining four endpoints. Each one uses a different HTTP method and returns a specific status code.

  • Create a new request tab by clicking the + icon.
  • Leave the method as GET and enter this URL:
http://localhost:8080/api/products
  • Click Send.

You should see a 200 OK response containing a JSON array with your Wireless Headphones product inside it.

  • Save this request to your Product Catalog API collection as Get All Products.
  • Create another new request tab.
  • Keep the method as GET and enter this URL (using the ID from your create response):
http://localhost:8080/api/products/1
  • Click Send.

You should see a 200 OK response with just the single product object (not an array).

  • Save this request as Get Product By ID.
  • Create a new request tab.
  • Change the method to PUT and enter this URL:
http://localhost:8080/api/products/1
  • Click the Body tab, select raw, and set the type to JSON.
  • Paste this updated product body:
{
    "name": "Wireless Headphones Pro",
    "description": "Premium noise-cancelling over-ear headphones with 40-hour battery",
    "price": 129.99,
    "category": "Electronics",
    "stockQuantity": 35
}
  • Click Send.

You should see a 200 OK response with the updated product data. The name, description, price, and stock quantity should all reflect your changes.

  • Save this request as Update Product.
  • Create a new request tab.
  • Change the method to DELETE and enter this URL:
http://localhost:8080/api/products/1
  • Click Send.

You should see a 204 No Content response with an empty body. This confirms the product was deleted.

  • Verify the deletion by switching back to your Get All Products tab and clicking Send again.

The response should now be an empty array [], proving the product is gone from the database.

  • Save the delete request as Delete Product.

Why these status codes matter

Each HTTP status code communicates something specific to the API consumer. 200 OK means the request succeeded and data is returned. 201 Created means a new resource was successfully created. 204 No Content means the action succeeded but there is nothing to send back.

Frontend developers and other services rely on these codes to determine what happened without parsing the body. Using the correct codes is a hallmark of a well-designed REST API.

Getting a 404 Not Found on GET or DELETE?

If you already deleted the product with ID 1, that ID no longer exists. Create a new product first (re-send your POST request), then use the new ID in your GET, PUT, or DELETE requests.

Help me debug my 404 error

Test input validation with bad data

Your API should not blindly accept any data thrown at it. The validation annotations you added to the Product entity will reject invalid input and return structured error messages.

  • Create a new request tab.
  • Set the method to POST and enter the URL:
http://localhost:8080/api/products
  • Click the Body tab, select raw, and set the type to JSON.
  • Paste this body with an empty name:
{
    "name": "",
    "description": "A product with no name",
    "price": 29.99,
    "category": "Test",
    "stockQuantity": 10
}
  • Click Send.

You should see a 400 Bad Request response. The JSON body contains a messages object with a name field showing Product name is required.

  • Save this request as Validation - Empty Name.
  • Create one more request tab.
  • Set the method to POST and enter the same URL:
http://localhost:8080/api/products
  • Set up the body as raw / JSON and paste this body with a negative price:
{
    "name": "Bad Product",
    "description": "This product has a negative price",
    "price": -5.00,
    "category": "Test",
    "stockQuantity": 10
}
  • Click Send.

You should see another 400 Bad Request response. This time the messages object shows a price field with Price must be greater than zero.

  • Save this request as Validation - Negative Price.

What is happening behind the scenes?

When Postman sends a POST with invalid data, Spring Boot's @Valid annotation triggers Jakarta Bean Validation before the controller method even runs. If any constraint fails, Spring throws a MethodArgumentNotValidException.

Your GlobalExceptionHandler catches that exception and transforms it into the structured JSON error response you see in Postman. Without this handler, you would get a generic Spring error page instead.

Seeing a 200 OK instead of 400 Bad Request?

Make sure you have @Valid on the @RequestBody parameter in your createProduct method in ProductController.java. Without @Valid, Spring skips validation entirely and saves the bad data.

Also confirm the spring-boot-starter-validation dependency is present in your pom.xml. If you added it after the app started, restart with ./mvnw spring-boot:run.

Help me debug why validation is not working

Your Postman collection should now have 7 saved requests: Create Product, Get All Products, Get Product By ID, Update Product, Delete Product, Validation - Empty Name, and Validation - Negative Price.

You can confirm this by expanding your Product Catalog API collection in the left sidebar under Collections.

Every endpoint is confirmed working. Your API accepts valid products, rejects bad data with clear messages, and returns the correct HTTP status codes for every operation. You now have a fully tested Product Catalog API ready for production use.

Secret mission

Add Product Filtering and Search

Your API can create, read, update, and delete products. But what if a user wants to find all electronics under $50? Real marketplace APIs need filtering and search. Add query parameters that let callers filter by category, price range, and keyword.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. There are no cloud resources or ongoing costs to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Spring Boot application (running on port 8080).
  • MySQL product_catalog database.
  • Project folder (product-catalog on your Desktop).
  • Postman application and saved collection.
  • Spring Boot Extension Pack (VS Code extension).

Keep everything running

No action needed. Choose this if you are still actively building on this API or plan to extend it with more features (authentication, pagination, caching).

  • Your Spring Boot app will keep running on port 8080 until you stop it or close the terminal.
  • Your MySQL database will persist all product data between sessions.
  • Your Postman collection stays saved for future testing.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database stay intact for when you return.

  • Stop the Spring Boot application by pressing Ctrl+C in the terminal where it is running.
  • Your MySQL database and project files remain on disk. When you are ready to resume, navigate to the project folder and run ./mvnw spring-boot:run again.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each item below to fully clean up.

  • Stop the Spring Boot application by pressing Ctrl+C in the terminal where it is running.
  • Drop the MySQL database by opening your MySQL command line and running the command below:
mysql -u root -p -e "DROP DATABASE product_catalog;"
  • Delete the project folder. On macOS/Linux, run:
rm -rf ~/Desktop/product-catalog
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\product-catalog
  • Uninstall Postman. On macOS, drag Postman from your Applications folder to the Trash. On Windows, open Settings, then Apps, find Postman, and click Uninstall.
  • Uninstall the Spring Boot Extension Pack in VS Code. Open the Extensions sidebar (Cmd+Shift+X on macOS or Ctrl+Shift+X on Windows), find Spring Boot Extension Pack, and click Uninstall.

Nice Work!

Nice Work!

You just built a production-ready Product Catalog REST API from scratch using Spring Boot 4.1.0. Every marketplace feature that comes next (shopping carts, payments, seller dashboards) plugs directly into the pattern you now own.

You learned how to:

  • Build a layered REST API following the Controller > Service > Repository pattern with full CRUD operations backed by a MySQL database.
  • Validate incoming requests using Jakarta Bean Validation annotations and return structured JSON error responses with appropriate HTTP status codes.
  • Test and verify every endpoint using Postman with a saved collection covering happy paths and validation error cases.
  • Secret Mission: Added product filtering and search with multiple optional query parameters using Spring Data JPA custom query methods.

Ready to quiz yourself?