Build a Product Store REST API

Build a Java Spring Boot REST API with full CRUD and category search.

Introduction

30 Second Summary

Every time you browse an online store, search for a product, or add something to your cart, a REST API is working behind the scenes. It receives your request, talks to a database, and sends back exactly the data you need.

In this project, you will build a Product Store REST API using Java and Spring Boot. Your API will serve product data as clean JSON responses.

What You'll Build

Imagine adding "Wireless Headphones" to your store's catalog with a single API call, then pulling up every product in "Electronics" just as easily.

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

  • A running product catalog API where you can add items like "Wireless Headphones" under "Electronics" and fetch them back as JSON.
  • An interactive H2 database console in your browser where you can run queries and see your product data stored in real tables.
  • The ability to create, update, and delete products through proper HTTP methods, and search by category to pull filtered results from a single endpoint.
  • Secret Mission: Add a shopping cart endpoint that tracks quantities and calculates the total price.

Are there any prerequisites?

No prerequisites are needed. This project includes setup instructions for all required tools, including Java and the VS Code extensions.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Java Development Environment

To build a REST API with Spring Boot, you need three things in place: the Java Development Kit (JDK) that compiles your code, VS Code extensions that understand Java syntax, and a generated project skeleton to build on.

Without the JDK, your code won't compile. Without the extensions, VS Code won't highlight errors or offer completions. In this step, you'll install all three so you're ready to write your first line of Spring Boot code.

In this step, get ready to:
  • Install JDK 21 so your Java code can compile and run.
  • Install the Java and Spring Boot extensions in VS Code.
  • Generate a Spring Boot project from Spring Initializr and open it in VS Code.
Install JDK 21

The JDK (Java Development Kit) is the engine that compiles and runs Java code. Spring Boot requires at least Java 17, but this project targets JDK 21, the current long-term support (LTS) release.

  • Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check if Java is already installed by running this command:
java -version

✔️ I see version 21 or higher

JDK 21 is already installed. Skip ahead to the next substep to install the VS Code extensions.

ⓧ I see an older version

You have Java installed, but Spring Boot works best with JDK 21. You can install JDK 21 alongside your current version.

macOS

This step uses Homebrew, a package manager for macOS. If you don't have Homebrew yet, install it first by following the instructions at brew.sh.

  • Install JDK 21 by running this command:
brew install openjdk@21
  • Create a system symlink so your terminal can find JDK 21 by running this command:
sudo ln -sfn $HOMEBREW_PREFIX/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk
What does the symlink command do?

Homebrew installs JDK 21 in its own directory but doesn't register it with macOS by default. The symlink tells macOS where to find JDK 21 so that java -version returns the correct version.

  • Close and reopen your terminal, then verify the install by running:
java -version

You should see output containing openjdk 21.

Windows

  • Install the Microsoft Build of OpenJDK 21 by running this command in PowerShell:
winget install Microsoft.OpenJDK.21
  • Close and reopen PowerShell after the install completes.
  • Verify the install by running:
java -version

You should see output containing openjdk 21.

ⓧ Command not found

Java isn't installed yet. Let's install JDK 21 from scratch.

macOS

This step uses Homebrew, a package manager for macOS. If you don't have Homebrew yet, install it first by following the instructions at brew.sh.

  • Install JDK 21 by running this command:
brew install openjdk@21
  • Create a system symlink so your terminal can find JDK 21 by running this command:
sudo ln -sfn $HOMEBREW_PREFIX/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk
What does the symlink command do?

Homebrew installs JDK 21 in its own directory but doesn't register it with macOS by default. The symlink tells macOS where to find JDK 21 so that java -version returns the correct version.

  • Close and reopen your terminal, then verify the install by running:
java -version

You should see output containing openjdk 21.

Windows

  • Install the Microsoft Build of OpenJDK 21 by running this command in PowerShell:
winget install Microsoft.OpenJDK.21
  • Close and reopen PowerShell after the install completes.
  • Verify the install by running:
java -version

You should see output containing openjdk 21.

Still not seeing version 21?

  • Make sure you closed and reopened your terminal after the install. The terminal needs to refresh its path to pick up the new JDK.
  • On macOS, confirm the symlink command ran successfully. If it asked for a password, that's normal. sudo requires your Mac login password.
  • Help me troubleshoot my JDK 21 installation
Install the Java and Spring Boot extensions in VS Code

VS Code doesn't understand Java out of the box. You need two extension packs that add language support, debugging, and Spring-specific tooling.

  • Switch back to VS Code.
  • Open the Extensions view by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
  • Type Extension Pack for Java in the search bar.
  • Find the result by Microsoft and click Install.
  • Wait for the install to finish. You'll see a progress indicator next to the extension name.
  • Clear the search bar and type Spring Boot Extension Pack.
  • Find the result by VMware and click Install.

After both installs finish, you should see Extension Pack for Java and Spring Boot Extension Pack listed under your installed extensions in the sidebar.

What do these extensions give you?

The Extension Pack for Java bundles six extensions: Java language support, a debugger, test runner, and Maven integration. It turns VS Code into a full Java IDE.

The Spring Boot Extension Pack adds Spring-specific features like Spring Boot Dashboard, project initialization, and code navigation for Spring annotations.

Extensions not appearing in search?

  • Make sure you're searching in the Extensions view (the puzzle piece icon in the left sidebar), not the file search.
  • Check your internet connection. VS Code needs to reach the extension marketplace to download them.
  • Help me find and install the Java extensions in VS Code
Generate your Spring Boot project

Spring Initializr is a web tool that generates a ready-to-run Spring Boot project with the exact dependencies you pick. Instead of creating folders and config files by hand, you fill in a form and download a ZIP.

You'll see a form with project settings on the left and metadata on the right. Configure the left side first.

  • Set Project to Maven.
  • Set Language to Java.
  • Set Spring Boot to the latest 4.1.x version available.

Now fill in the project metadata on the right side of the form.

  • Set Group to com.productstore.
  • Set Artifact to productstore.
  • Set Packaging to Jar.
  • Set Java to 21.

What are Group and Artifact?

Group is your organization's reverse domain name. It keeps your code namespaced so it doesn't collide with other Java libraries.

Artifact is the name of your project. Together, com.productstore.productstore becomes the base package for all your Java classes.

Now add the three dependencies your project needs.

  • Click the ADD DEPENDENCIES button (or press Ctrl+B).
  • Search for Spring Web and select it.
  • Open the dependency search again and search for Spring Data JPA. Select it.
  • Open the dependency search one more time and search for H2 Database. Select it.

You should see all three dependencies listed on the right side of the form: Spring Web, Spring Data JPA, and H2 Database.

What are these dependencies for?

  • Spring Web lets your app serve HTTP requests and return JSON responses. This is what makes it a REST API.
  • Spring Data JPA handles database operations. You define Java classes and it auto-generates SQL queries for you.
  • H2 Database is a lightweight in-memory database that runs inside your app. No external database server to install.
  • Click the GENERATE button at the bottom of the page. A productstore.zip file will download.
  • Find the downloaded productstore.zip in your Downloads folder and extract it.
  • Move the extracted productstore folder to your Desktop.

Now open the project in VS Code.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the productstore folder, and click Open.

VS Code may ask if you trust the authors of the files in this folder. Click Yes, I trust the authors.

In the Explorer sidebar on the left, you should see the project structure: a src folder, a pom.xml file, and the Maven wrapper files (mvnw and mvnw.cmd).

What is the Maven wrapper?

The mvnw file is a Maven wrapper. It lets you run Maven commands without installing Maven globally on your machine. Spring Initializr includes it automatically so you can build and run your project right away.

Don't see the project files in the sidebar?

  • Make sure you opened the productstore folder itself, not a parent folder. You should see pom.xml at the top level of the Explorer, not nested inside another folder.
  • If your ZIP extraction created a nested folder (like productstore/productstore), open the inner folder that contains pom.xml.
  • Help me open my Spring Boot project in VS Code

Before you move on, do you think the app is ready to run, or does it need more configuration first?

  • Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, below Esc).
  • Verify your Java version one more time from inside the project by running:
java -version

You should see output containing openjdk 21. This confirms JDK 21 is available inside your project's terminal, which is where you'll run all your Spring Boot commands.

Your development environment is ready. You have JDK 21 compiling your code, VS Code understanding your Java, and a Spring Boot project skeleton waiting for its first class. Next up, you'll create your first data model and see Spring Boot turn it into a real database table.

Create Your First Product Entity and See Data in the Database

Your development environment is set up and ready to go. Now it's time to define the core data your API will manage: products.

Before you can build API endpoints, you need a data model. In Spring Boot, a Product entity is a plain Java class that maps directly to a database table. You write the class, and Spring Boot auto-creates the table for you.

In this step, get ready to:
  • Configure the H2 in-memory database so you can browse your data in a web console.
  • Create a Product entity that Spring Boot auto-converts into a database table.
  • Run the app and verify the PRODUCT table appears in the H2 console.
Configure H2 console and database settings

Your project already includes H2 as a dependency from Spring Initializr. Spring Boot needs a few configuration properties to know which database to connect to and whether to enable the web-based database console.

  • In VS Code, open the file src/main/resources/application.properties from the Explorer sidebar.
  • Replace the file's contents with the following configuration:
spring.application.name=productstore
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update

What do these properties do?

  • spring.datasource.url=jdbc:h2:mem:testdb tells Spring Boot to use an in-memory H2 database called testdb. The data lives only while the app is running.
  • spring.h2.console.enabled=true turns on a web-based database explorer you can open in your browser.
  • spring.jpa.show-sql=true prints every SQL statement Spring runs to your terminal, so you can see exactly what's happening under the hood.
  • spring.jpa.hibernate.ddl-auto=update tells JPA to auto-create and update database tables from your Java classes.
  • Save application.properties.
  • Confirm the app starts with this new configuration by running this command in your terminal:
./mvnw spring-boot:run

What should I see?

The first run downloads dependencies, so it may take a minute or two. When it finishes, you should see a line ending with something like Started ProductstoreApplication in your terminal output. That means the app is running.

  • Stop the app by pressing Ctrl+C in the terminal. You'll restart it after creating the Product entity.

Permission denied when running ./mvnw?

On macOS, the Maven wrapper script may not have execute permission. Fix this by running:

chmod +x ./mvnw

Then try ./mvnw spring-boot:run again.

help me fix a permission denied error when running the Maven wrapper in my Spring Boot project

Create the Product entity class

An entity in JPA is a Java class that represents a database table. Each instance of the class becomes a row in that table. You annotate the class, and Spring Boot handles the rest.

  • In the Explorer sidebar, navigate to src/main/java/com/productstore/productstore/.
  • Right-click the productstore folder and select New File.
  • Name the file Product.java.
  • Add the class structure, fields, and constructors by copying this code into the file:
package com.productstore.productstore;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity // Marks this class as a database table
public class Product {

    @Id // Primary key
    @GeneratedValue(strategy = GenerationType.IDENTITY) // Auto-incremented
    private Long id;
    private String name;
    private String description;
    private Double price;
    private String category;

    // Required by JPA to create instances from database rows
    public Product() {
    }

    // Used when creating new products in code
    public Product(String name, String description, Double price, String category) {
        this.name = name;
        this.description = description;
        this.price = price;
        this.category = category;
    }
}

What does this code do?

  • @Entity tells JPA this class maps to a database table. Spring Boot will create a PRODUCT table automatically.
  • @Id and @GeneratedValue mark the id field as the primary key, auto-incremented by the database.
  • The no-args constructor (public Product() {}) is required by JPA. It uses this constructor to create Java objects from database rows.
  • The imports use jakarta.persistence (not javax.persistence). Spring Boot 4.x uses the Jakarta namespace.
  • Save Product.java.
  • Verify the entity is picked up by running the app again:
./mvnw spring-boot:run

Because you set show-sql=true earlier, your terminal now shows the SQL statements Spring Boot ran. Look for a line containing create table. That proves your Java class was converted into a real database table.

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

Don't see any SQL output?

Make sure spring.jpa.show-sql=true is in your application.properties and that you saved the file before running.

Check that Product.java is saved in the correct folder: src/main/java/com/productstore/productstore/. If it's in the wrong directory, Spring Boot won't detect the entity.

help me figure out why Spring Boot isn't creating my Product table

Your entity needs getters and setters so that Spring Boot and JPA can read and write the field values. Without them, the framework cannot serialize your products to JSON or populate fields from the database.

  • In Product.java, find the closing } brace at the end of the file.
  • Add the following getters and setters just above that closing brace:
    // Getters and setters for JPA and JSON serialization
    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 do getters and setters do here?

Each getter (like getName()) lets other code read the field. Each setter (like setName()) lets other code write to it.

JPA uses setters to populate a Product from a database row. When you return a Product from an API endpoint later, Spring Boot uses the getters to build the JSON response.

  • Save Product.java.

✔️ Awesome, I've got everything!

Great. Double check you've saved Product.java before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your Product.java has all of these sections, in order:

  • The package declaration and four jakarta.persistence imports.
  • The @Entity annotation and class declaration with five fields (id, name, description, price, category).
  • A no-args constructor and a parameterized constructor.
  • Getters and setters for all five fields (10 one-liner methods).
Run the application and explore the H2 console

Your entity is complete. Time to start the app and see the database table that Spring Boot creates from your Product class.

  • Start the app by running this command in your terminal:
./mvnw spring-boot:run
  • Wait until you see Started ProductstoreApplication in the terminal output.
  • Open your browser and go to http://localhost:8080/h2-console.

You should see the H2 login page. You need to connect to the same in-memory database you configured in application.properties.

  • In the JDBC URL field, make sure the value is jdbc:h2:mem:testdb.
  • Leave User Name as sa and Password empty.
  • Click Connect.

You should see a PRODUCT table listed in the left sidebar. Spring Boot created it from your Product.java class without you writing a single line of SQL.

Before you run the next query, what do you think the table contains right now?

  • Type the following query into the SQL text box and click Run:
SELECT * FROM PRODUCT;

You should see an empty result set with columns matching your entity fields: ID, NAME, DESCRIPTION, PRICE, and CATEGORY. The table is empty because you haven't added any products yet. That's exactly right.

Can't connect to the H2 console?

Make sure the JDBC URL in the console login is exactly jdbc:h2:mem:testdb. The default value in the form may be different from what your app is configured to use.

If the page doesn't load at all, confirm the app is still running in your terminal. You should see the Started ProductstoreApplication message and the cursor should not have returned to the prompt.

help me connect to the H2 database console in my Spring Boot app

Your data model is alive. A plain Java class turned into a real database table. Next up, you'll build the API endpoints that let the outside world create, read, update, and delete products in that table.

Build the Product API and Hit a Wall

In the last step, you created a Product entity and watched Spring Boot auto-create a database table from it. Your data has a home, but no one can reach it from outside the application.

Now you need a way for the outside world to interact with your products. You'll build a REST API with endpoints that let anyone create, read, update, and delete products using standard HTTP requests. Along the way, you'll hit a real limitation that reveals how Spring's default tools only cover the basics.

In this step, get ready to:
  • Create a repository interface that gives you database operations for free.
  • Build a REST controller with full CRUD endpoints for products.
  • Test the API and discover a gap when trying to search by category.
Create the ProductRepository interface

Before your controller can save or fetch products, it needs a way to talk to the database. In Spring Boot, a repository is a Java interface that handles all the database operations for a specific entity.

  • Create a new file called ProductRepository.java in src/main/java/com/productstore/productstore/ and paste the following code:
package com.productstore.productstore;

import org.springframework.data.jpa.repository.JpaRepository;

// Spring Data JPA generates the implementation at runtime
public interface ProductRepository extends JpaRepository<Product, Long> {
}

What does this code do?

This is the entire file. By extending JpaRepository<Product, Long>, Spring Data JPA automatically generates a full implementation at startup with methods like save(), findById(), findAll(), and deleteById().

You wrote zero implementation code, yet you get a fully working data access layer. The Long type parameter tells JPA that your Product entity uses a Long for its primary key.

  • Save ProductRepository.java.
Create the ProductController with read endpoints

A controller is the class that receives incoming HTTP requests and sends back responses. It sits in front of your repository and decides what to do with each request.

  • Create a new file called ProductController.java in src/main/java/com/productstore/productstore/ and paste the following code:
package com.productstore.productstore;

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

// Marks this class as a REST API controller
@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final ProductRepository productRepository;

    // Spring injects the repository automatically
    public ProductController(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // Returns all products as a JSON array
    @GetMapping
    public List<Product> getAllProducts() {
        return productRepository.findAll();
    }

    // Returns a single product by its ID
    @GetMapping("/{id}")
    public Product getProductById(@PathVariable Long id) {
        return productRepository.findById(id).orElseThrow();
    }
}

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and returns JSON automatically.
  • @RequestMapping("/api/products") sets the base URL path. Every endpoint in this class starts with /api/products.
  • The constructor takes a ProductRepository parameter. Spring automatically injects the repository instance when the app starts.
  • @GetMapping on getAllProducts() handles GET requests to /api/products and returns every product in the database.
  • @GetMapping("/{id}") handles GET requests to /api/products/1 (or any ID) and returns a single product.
  • Save ProductController.java.
  • If your app is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
  • Restart the app by running this command:
./mvnw spring-boot:run
  • Once you see Started ProductstoreApplication in the terminal output, open a new terminal tab and test the GET endpoint by running:
curl http://localhost:8080/api/products

You should see an empty JSON array: []. That's correct. Your API is alive, but the database has no products in it yet.

Seeing a connection refused error?

  • Make sure the app finished starting. Look for the Started ProductstoreApplication line in the terminal running the app.
  • Run the curl command in a separate terminal tab, not the one running the app.
  • If the app failed to compile, check that ProductRepository.java is saved in the same package folder as Product.java.

Help me debug my Spring Boot app not starting.

Add the create, update, and delete endpoints

Reading products is just half the story. A real API also needs to create new products, update existing ones, and delete them.

  • Stop the app by pressing Ctrl+C in the terminal running it.
  • In ProductController.java, find the closing } brace of the class and paste the following methods above it:
    // Creates a new product and returns 201 Created
    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody Product product) {
        Product created = productRepository.save(product);
        return new ResponseEntity<>(created, HttpStatus.CREATED);
    }

    // Updates all fields of an existing product
    @PutMapping("/{id}")
    public Product updateProduct(@PathVariable Long id, @RequestBody Product productDetails) {
        Product product = productRepository.findById(id).orElseThrow();
        product.setName(productDetails.getName());
        product.setDescription(productDetails.getDescription());
        product.setPrice(productDetails.getPrice());
        product.setCategory(productDetails.getCategory());
        return productRepository.save(product);
    }

    // Deletes a product and returns 204 No Content
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productRepository.deleteById(id);
        return ResponseEntity.noContent().build();
    }

What does this code do?

  • @PostMapping handles POST requests. The @RequestBody annotation tells Spring to convert the incoming JSON into a Product object automatically.
  • @PutMapping("/{id}") handles PUT requests. It finds the existing product, copies over all the new field values, and saves it back.
  • @DeleteMapping("/{id}") handles DELETE requests. It removes the product and returns a 204 No Content status to confirm the deletion.
  • Save ProductController.java.

✔️ Awesome, I've got everything!

Great. Make sure you've saved ProductController.java. You should have six methods in the class: the constructor plus five endpoint methods.

ⓧ I'd like to double check the full code

Your ProductController.java should contain these sections in order. Scroll up and confirm each one is present:

  • Package declaration and imports (java.util.List, HttpStatus, ResponseEntity, and the web annotations wildcard import).
  • Class declaration with @RestController and @RequestMapping("/api/products").
  • Private productRepository field and constructor.
  • getAllProducts() with @GetMapping.
  • getProductById() with @GetMapping("/{id}").
  • createProduct() with @PostMapping.
  • updateProduct() with @PutMapping("/{id}").
  • deleteProduct() with @DeleteMapping("/{id}").
Test CRUD and hit the category wall

Time to put all five endpoints through their paces. You'll create a product, fetch it back, and then try something the API can't handle yet.

  • Restart the app by running this command:
./mvnw spring-boot:run
  • Once the app has started, switch to your second terminal tab and create a product by running:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Wireless Headphones","description":"Noise cancelling","price":79.99,"category":"Electronics"}'

You should see your product echoed back as JSON, now with an id field assigned by the database.

  • Fetch all products to confirm it was saved by running:
curl http://localhost:8080/api/products

You should see a JSON array containing your Wireless Headphones product. Your CRUD API is working.

Now imagine you're building a storefront and a customer wants to browse only electronics. Before you run the next command, what do you think will happen if you try to fetch products by category?

  • Try browsing by category by running:
curl http://localhost:8080/api/products/category/Electronics

You get back a JSON error response with a 404 status. That's not a bug. Your controller has no endpoint mapped to /api/products/category/Electronics, so Spring Boot doesn't know what to do with that request.

Why did this fail?

JpaRepository gives you generic operations like findAll() and findById() for free. It does not know about your specific fields like category.

To filter products by category, you need to tell Spring Data JPA how to query that field. That's exactly what you'll fix in the next step.

Seeing something other than a JSON error?

  • If you see an HTML page instead of JSON, try adding -H "Accept: application/json" to your curl command.
  • If the POST command returned an error earlier, double-check that the JSON body uses double quotes around field names and string values.

Help me troubleshoot my Spring Boot API responses.

Your API handles creating, reading, updating, and deleting products. In the next step, you'll teach your repository how to search by category and close the gap you just discovered.

Add Category Search with Custom Repository Queries

Your product API can create, fetch, update, and delete products. But when you tried browsing by category in the last step, the API returned a 404.

Spring Data JPA has an elegant solution. You declare a method in your repository interface following a specific naming convention, and Spring auto-generates the SQL query at runtime. No SQL writing needed.

In this step, get ready to:
  • Add a custom query method to your repository interface.
  • Add a category endpoint to your controller.
  • Test category browsing with multiple product categories.
Add a custom query method to ProductRepository

Spring Data JPA can auto-generate database queries just from the name of a method. If you name a method findByCategory, Spring reads "findBy" + "Category" and generates a SELECT * FROM PRODUCT WHERE CATEGORY = ? query behind the scenes.

  • Switch back to the terminal where your app is running.
  • Press Ctrl+C to stop the app.
  • In VS Code, open ProductRepository.java from the Explorer sidebar.
  • Replace the entire contents of the file with this updated version:
package com.productstore.productstore;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
    // Find all products matching a specific category
    List<Product> findByCategory(String category);
}

What does this code do?

You added a single line to the interface: List<Product> findByCategory(String category).

Spring Data JPA parses this method name at startup. It splits "findByCategory" into the action ("findBy") and the field ("Category"), then auto-generates the SQL query to match products by their category column. You never write a single line of SQL.

  • Save ProductRepository.java.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

package com.productstore.productstore;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByCategory(String category);
}
Add the category endpoint to ProductController

The repository now knows how to filter by category. The controller needs a new endpoint that calls this method and exposes it over HTTP.

  • Open ProductController.java in VS Code.
  • Find the deleteProduct method near the bottom of the class.
  • After the closing brace of deleteProduct, but before the final closing brace of the class, add this new method:
    // Filter and return products by category name
    @GetMapping("/category/{category}")
    public List<Product> getProductsByCategory(@PathVariable String category) {
        return productRepository.findByCategory(category);
    }

What does this endpoint do?

  • @GetMapping("/category/{category}") maps GET requests like /api/products/category/Electronics to this method.
  • @PathVariable extracts the category name from the URL (e.g. "Electronics") and passes it to the method as a parameter.
  • The method calls findByCategory on the repository and returns the filtered list as JSON.
  • Save ProductController.java.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

Your ProductController.java should contain all six methods in this order:

  • getAllProducts (GET all products).
  • getProductById (GET one product by ID).
  • createProduct (POST a new product).
  • updateProduct (PUT to update a product).
  • deleteProduct (DELETE a product).
  • getProductsByCategory (GET products filtered by category). This is the new one you just added.

Scroll up and confirm each method is present in your file.

Test the category browsing

Both files are updated. Time to restart the app and see if the category endpoint works.

  • Start the app again by running this command in your terminal:
./mvnw spring-boot:run

Seeing a compilation error?

Make sure you saved both ProductRepository.java and ProductController.java before running the command. If the error mentions findByCategory, check that the method signature in ProductRepository.java matches the version above exactly.

Help me debug this Spring Boot compilation error.

Once the app starts, you need some test data. The H2 database is in-memory, so it starts empty every time.

  • Open a new terminal tab or window.
  • Create a product in the Electronics category by running:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Wireless Headphones","description":"Noise cancelling","price":79.99,"category":"Electronics"}'

You should see a JSON response with the created product and an id field.

  • Create a product in the Books category by running:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Java Programming","description":"Learn Java basics","price":29.99,"category":"Books"}'
  • Create a second Electronics product by running:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"USB-C Cable","description":"Fast charging cable","price":12.99,"category":"Electronics"}'

You now have three products across two categories. Before you run the next command, think about what you expect to see. Will the category endpoint return all three products, or just the ones in Electronics?

  • Query the Electronics category by running:
curl http://localhost:8080/api/products/category/Electronics

You should see a JSON array containing only the two Electronics products: Wireless Headphones and USB-C Cable. The Books product is filtered out.

  • Verify the Books category works too by running:
curl http://localhost:8080/api/products/category/Books

This time you should see a JSON array containing only the Java Programming book.

Getting an empty array or a 404?

  • If you see an empty array [], category names are case-sensitive. Make sure you used "Electronics" (capital E) when creating the product and in the URL.
  • If you see a 404, make sure the @GetMapping("/category/{category}") annotation in your controller matches exactly.
  • If the app won't start at all, check for typos in ProductRepository.java. The method name must be exactly findByCategory to match the category field on your Product entity.

Help me fix my category endpoint.

You can also verify the data directly in the database.

  • Open http://localhost:8080/h2-console in your browser.
  • Enter jdbc:h2:mem:testdb as the JDBC URL and click Connect.
  • Run this query to see all products:
SELECT * FROM PRODUCT;

You should see three rows matching the products you created through the API. The data the H2 console shows matches exactly what your category endpoints returned.

Why is this naming convention so powerful?

Spring Data JPA supports many naming patterns beyond findBy. You could add findByPriceGreaterThan(Double price) or findByNameContaining(String keyword) to your repository, and Spring would generate the matching queries automatically.

This means you can build complex search features without ever writing SQL. The method name IS the query.

Your product catalog now supports browsing by category. Customers can find all electronics, all books, or any other category with a single request. Next up, you'll add a service layer with business rules like price validation and proper error handling.

Add a Service Layer for Business Logic

Your product catalog API handles CRUD operations and category browsing. You can create products, fetch them, update prices, and filter by category.

There's a gap though. Your controller talks directly to the repository with zero guardrails. Anyone can create a product with a negative price or a blank name, and requesting a product that doesn't exist crashes with an ugly error instead of a clear message.

In this step, get ready to:
  • Create a ProductService class that validates products and enforces business rules.
  • Build custom exception handling that returns clear JSON error messages.
  • Refactor the controller to delegate all logic to the service layer.
Create the ProductService class

A service layer sits between the controller and the repository. The controller decides what the API exposes. The service owns the business rules like validation and error handling.

Why not keep logic in the controller?

Your controller currently does two jobs: receiving HTTP requests AND executing database operations. This works for small apps, but mixing these responsibilities makes code harder to test and maintain.

A service layer gives business rules a dedicated home. The controller stays thin, and validation logic lives in one place instead of being scattered across endpoints.

The service will throw a custom exception when a product isn't found. You need to create that exception class first.

  • Create a new file called ResourceNotFoundException.java in src/main/java/com/productstore/productstore/ and paste the following code:
package com.productstore.productstore;

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

What does this code do?

This is a custom exception that extends RuntimeException. When the service can't find a product, it throws this exception with a descriptive message. You'll wire up a handler to catch it and return a clean JSON error later.

  • Save ResourceNotFoundException.java.

Now you can build the service itself. The service needs a constructor that injects the repository, plus methods for reading products.

  • Create a new file called ProductService.java in src/main/java/com/productstore/productstore/ and paste the following code:
package com.productstore.productstore;

import java.util.List;
import org.springframework.stereotype.Service;

// Mark this class as a Spring service component
@Service
public class ProductService {

    // Inject the repository for database access
    private final ProductRepository productRepository;

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

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

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

What does this code do?

  • @Service tells Spring Boot to register this class as a component it manages. Spring automatically creates an instance and injects it wherever it's needed.
  • The constructor takes a ProductRepository parameter. Spring injects the repository automatically through constructor injection.
  • getProductById uses .orElseThrow() to throw your custom ResourceNotFoundException when no product matches the ID. This replaces the bare .orElseThrow() in your controller that crashes with a generic error.
  • Save ProductService.java.
  • Switch to your terminal and stop the running app by pressing Ctrl+C.
  • Restart the app by running:
./mvnw spring-boot:run
  • Confirm the app starts without errors. You should see the Spring Boot banner and a log line ending with Started ProductstoreApplication.

App won't start?

  • Check that ResourceNotFoundException.java is in the same package as ProductService.java. Both must have package com.productstore.productstore; at the top.
  • Make sure both files are saved. VS Code shows a dot on unsaved file tabs.
  • Confirm the class name in each file matches its filename exactly. ResourceNotFoundException must be in ResourceNotFoundException.java.

Why won't my Spring Boot app start after adding the service class?

The service compiles and Spring detects it. Now add the write methods that include validation logic.

  • In ProductService.java, add the following code below the getProductById method:
    // Validate and create a new product
    public Product createProduct(Product product) {
        if (product.getName() == null || product.getName().isBlank()) {
            throw new IllegalArgumentException("Product name is required");
        }
        if (product.getPrice() == null || product.getPrice() < 0) {
            throw new IllegalArgumentException("Product price must be zero or positive");
        }
        return productRepository.save(product);
    }

    // Update an existing product by ID
    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());
        return productRepository.save(product);
    }

    // Delete a product by ID
    public void deleteProduct(Long id) {
        Product product = getProductById(id);
        productRepository.delete(product);
    }

    // Find products filtered by category
    public List<Product> getProductsByCategory(String category) {
        return productRepository.findByCategory(category);
    }
}

What does this code do?

  • createProduct checks that the product name isn't blank and the price isn't negative before saving. If either check fails, it throws an IllegalArgumentException with a clear message.
  • updateProduct first fetches the existing product using getProductById. If the product doesn't exist, the lookup throws ResourceNotFoundException before any update happens.
  • deleteProduct also looks up the product first. This ensures you get a clear "not found" error instead of a silent no-op when deleting a product that doesn't exist.
  • Save ProductService.java.

✔️ Awesome, I've got everything!

Great. Make sure you've saved ProductService.java.

ⓧ I'd like to double check the full code

Scroll up and confirm you have both code blocks for ProductService.java in order:

  • The first block starts with the package declaration, the @Service annotation, the constructor, getAllProducts, and getProductById.
  • The second block adds createProduct (with validation), updateProduct, deleteProduct, and getProductsByCategory, then closes the class with }.
Create a custom exception handler

Your service now throws exceptions, but Spring Boot doesn't know how to turn those exceptions into clean JSON responses yet. A global exception handler intercepts exceptions from any controller and returns structured error messages.

  • Create a new file called GlobalExceptionHandler.java in src/main/java/com/productstore/productstore/ and paste the following code:
package com.productstore.productstore;

import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

// Catch exceptions thrown across all controllers
@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handle missing resource errors with a 404 response
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<Map<String, String>> handleResourceNotFound(ResourceNotFoundException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("error", ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

    // Handle validation errors with a 400 response
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException ex) {
        Map<String, String> error = new HashMap<>();
        error.put("error", ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }
}

What does this code do?

  • @RestControllerAdvice makes this class a global interceptor. Any exception thrown by any controller in your app passes through here before reaching the client.
  • The handleResourceNotFound method catches ResourceNotFoundException and returns a 404 status with a JSON body like {"error": "Product not found with id: 99"}.
  • The handleBadRequest method catches IllegalArgumentException and returns a 400 status. This is where your validation errors from createProduct land.
  • Save GlobalExceptionHandler.java.
  • Switch to your terminal and stop the app with Ctrl+C.
  • Restart the app by running:
./mvnw spring-boot:run
  • Confirm the app starts successfully. You should see Started ProductstoreApplication in the terminal output.

Seeing a compilation error?

  • Make sure GlobalExceptionHandler.java has the correct import for ResourceNotFoundException. Since both classes are in the same package, no explicit import is needed.
  • Check that the package line at the top of GlobalExceptionHandler.java says com.productstore.productstore and the file lives in the matching folder path.

Why is my GlobalExceptionHandler causing a compilation error?

Refactor the controller to use the service

Your service and error handler are ready, but the controller still talks directly to the repository. You need to rewire it to delegate everything to ProductService instead.

  • Open ProductController.java in the Explorer sidebar.
  • Find the field declaration and constructor at the top of the class:
    private final ProductRepository productRepository;

    public ProductController(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }
  • Replace those lines with the following, so the controller injects ProductService instead:
    private final ProductService productService;

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

Now every method in the controller needs to call productService instead of productRepository. The methods become much thinner because the service handles all the logic.

  • Replace all the methods below the constructor with the following code:
    // Return all products from the service
    @GetMapping
    public List<Product> getAllProducts() {
        return productService.getAllProducts();
    }

    // Find a single product by ID through the service
    @GetMapping("/{id}")
    public Product getProductById(@PathVariable Long id) {
        return productService.getProductById(id);
    }

    // Create a product - the service validates before saving
    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody Product product) {
        Product created = productService.createProduct(product);
        return new ResponseEntity<>(created, HttpStatus.CREATED);
    }

    // Update a product by ID through the service
    @PutMapping("/{id}")
    public Product updateProduct(@PathVariable Long id, @RequestBody Product product) {
        return productService.updateProduct(id, product);
    }
  • Right below those methods, add the final two methods and the closing brace:
    // Delete a product by ID through the service
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.deleteProduct(id);
        return ResponseEntity.noContent().build();
    }

    // Filter products by category through the service
    @GetMapping("/category/{category}")
    public List<Product> getProductsByCategory(@PathVariable String category) {
        return productService.getProductsByCategory(category);
    }
}

What changed in the controller?

The controller is now a thin pass-through. Each method receives the HTTP request, calls the corresponding service method, and returns the response. No database logic, no validation, no error handling.

Compare the old updateProduct (which manually fetched, set each field, and saved) to the new version (a single line that calls productService.updateProduct(id, product)). That's the power of separating concerns.

  • Save ProductController.java.

✔️ Awesome, I've got everything!

Great. Make sure you've saved ProductController.java before restarting the app.

ⓧ I'd like to double check the full code

Scroll up and confirm your controller has three sections:

  • The field and constructor now reference ProductService instead of ProductRepository.
  • The first batch of methods (getAllProducts through updateProduct) all call productService.
  • The second batch (deleteProduct and getProductsByCategory) also calls productService, and the file ends with a closing }.

Time to test the full service layer. Before you run the app, think about what should happen when you try to create a product with a negative price.

  • Stop the app with Ctrl+C and restart it by running:
./mvnw spring-boot:run
  • Confirm the app starts with Started ProductstoreApplication in the output.

What do you think will happen if you POST a product with a price of -5.00? Will you get the product back, or an error?

  • Open a new terminal tab and test the validation by running:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Bad Product","description":"Negative price","price":-5.00,"category":"Test"}'

You should see a 400 Bad Request response with a clear error message:

{"error":"Product price must be zero or positive"}

That error message came from your ProductService.createProduct validation, and the 400 status came from your GlobalExceptionHandler. The whole chain works together.

  • Now test that a valid product still works by running:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Wireless Headphones","description":"Noise cancelling","price":79.99,"category":"Electronics"}'

You should see a 201 Created response with the full product JSON, including an auto-generated id.

  • Finally, test the not-found handling. Try fetching a product with an ID that doesn't exist by running:
curl http://localhost:8080/api/products/999

You should see a 404 Not Found response with a meaningful message:

{"error":"Product not found with id: 999"}

Getting a 500 error instead of 400 or 404?

  • Make sure GlobalExceptionHandler.java is saved and the app was fully restarted after creating it.
  • Verify the controller now injects ProductService and not ProductRepository. If the controller still uses the repository, exceptions bypass the service validation entirely.
  • Check the terminal logs for the stack trace. The exception class name tells you whether GlobalExceptionHandler is catching it or not.

Why am I getting a 500 error instead of a 400 or 404 from my Spring Boot API?

Your API now enforces real business rules. Invalid data gets rejected with clear error messages, and missing resources return proper 404 responses. Next up, you'll take on the Secret Mission and add a shopping cart to your product store.

Secret mission

Add a Shopping Cart Endpoint

A product catalog without a cart is just a menu. Can you build a shopping cart that tracks products, manages quantities, and calculates the total price?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. The H2 database runs in-memory and disappears the moment you stop the application.

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

Resources you used:

  • The productstore project folder with all your Java source files.
  • JDK 21 installed via Homebrew (macOS).
  • Extension Pack for Java and Spring Boot Extension Pack VS Code extensions.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with your API.

  • Your project lives in the productstore folder you extracted earlier.
  • Start the app again anytime by opening a terminal in your project folder and running:
./mvnw spring-boot:run
  • The H2 database is in-memory, so any products or cart items you created in a previous session will not carry over to the next run.

Pause - I'll come back to this later

Stop the running application to free up your terminal. Your project files stay on disk for when you want to pick things back up.

  • Press Ctrl+C in the terminal where your Spring Boot app is running.
  • The H2 database is in-memory, so it cleans itself up automatically when the app stops.
  • Your source code and project configuration remain in the productstore folder, ready to run again.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • Press Ctrl+C in the terminal to stop the running application (if it is still running).
  • Navigate to the folder that contains your productstore project folder.
  • Delete the project folder by running the command for your operating system:

macOS

rm -rf productstore

Windows

rmdir /s /q productstore
  • Uninstall JDK 21 and remove the system symlink by running these commands (macOS with Homebrew):
brew uninstall openjdk@21
sudo rm /Library/Java/JavaVirtualMachines/openjdk-21.jdk

What does the second command do?

The sudo rm command removes the symlink you created during setup so that system Java tools could find your JDK. Your terminal will ask for your computer password before running it.

  • Open VS Code and press Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows) to open the Extensions view.
  • Search for Extension Pack for Java, click it, and click Uninstall.
  • Search for Spring Boot Extension Pack, click it, and click Uninstall.

Nice Work!

Nice Work!

Nice work! You just built a production-pattern REST API from scratch using Spring Boot, complete with a product catalog, category search, validation, error handling, and a layered architecture that mirrors how real ecommerce backends are built.

You've learned how to:

  • Built a REST API with full CRUD operations (Create, Read, Update, Delete) for a product catalog, powered by Spring Boot controllers that return JSON.
  • Created JPA entities that auto-generate database tables without writing any SQL, and used Spring Data JPA repositories with custom query methods to search products by category.
  • Implemented a service layer with input validation and global error handling, following the controller-service-repository architecture pattern used in production Java backends.
  • Added a shopping cart with quantity management and total price calculation (Secret Mission).

Ready to quiz yourself?