Build a Product Catalog Search API

Build a Spring Boot API with PostgreSQL and dynamic product search.

Introduction

30 Second Summary

A product list feels manageable when it only holds a few items. As it grows, finding the right match by name, category, price, or stock becomes a real search problem.

In this project, you will build a REST API with Spring Boot that stores a product catalog in PostgreSQL. You'll use Spring Data JPA to move from basic saving to searches that combine name, category, price, or stock filters.

What You'll Build

You'll demo a local catalog where a search request returns exactly the stored products matching your chosen filters.

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

  • A create-and-read flow that turns each submitted product into a persistent row with a generated id.
  • Useful category and low-stock reports that make derived methods plus JPQL visible through returned product lists.
  • A dynamic search endpoint powered by composable Specifications for any subset of name, category, price range, or minimum stock filters.
  • Secret Mission: Add pagination plus price sorting so you can compare bounded result pages from highest to lowest or lowest to highest.

Are there any prerequisites?

You should be comfortable with basic Java classes and methods. Basic SQL SELECT and WHERE clauses are enough for the database work.

Before We Start

Before any hands-on work, commit to the searchable product catalog you are about to build. Your one-sentence goal connects that API to a progression from inherited CRUD operations through derived queries, JPQL, and Specifications.

Connect Spring Boot to PostgreSQL

A repository becomes useful after its runtime, build, database, plus connection settings line up. Your Product Catalog Search API currently has none of those layers.

In this step, you verify Java plus Maven. You also connect Spring Boot to PostgreSQL through a local database.

In this step, get ready to:
  • Verify the versions of your local development tools.
  • Create the local product catalog database.
  • Build the Maven application and start it against PostgreSQL.
Verify your local tools

Version checks expose environment problems before they become application errors. You need Java 17 or newer plus Maven 3.6.3 or newer for this project.

  • Press the Windows key to open search.
  • Type Windows PowerShell.
  • Press Enter to open PowerShell.
  • Check all three tools by running these commands:
java -version
mvn -version
$PSVersionTable.PSVersion

What do these checks show?

  • The java -version command reports the Java runtime available to applications.
  • The mvn -version command reports Maven plus the Java runtime it uses.
  • The $PSVersionTable.PSVersion value reports the current Windows PowerShell version.

✔️ All three tools meet the minimums

Your results show Java 17 or newer. They also show Maven 3.6.3 or newer plus Windows PowerShell 5.1 or newer.

That is your local toolchain confirmed. The application can now use the runtime plus build system expected by Spring Boot.

ⓧ One version is too old

An older Java or Maven version can fail before the application reaches PostgreSQL. Update the tool that falls below the required minimum.

  • Install Java 17 or newer from the Java downloads page if Java is below the minimum.
  • Install Maven 3.6.3 or newer by following the Apache Maven installation guide if Maven is below the minimum.
  • Close the current PowerShell window after updating the tool.
  • Open a new PowerShell window through Windows search.
  • Run the three version checks again.

ⓧ A command is not recognized

A command that PowerShell cannot find is missing from your installed tools or system path. Install the missing tool before continuing.

  • Install Java 17 or newer from the Java downloads page if the Java check fails.
  • Install Maven 3.6.3 or newer by following the Apache Maven installation guide if the Maven check fails.
  • Close the current PowerShell window after the installation finishes.
  • Open a new PowerShell window through Windows search.
  • Run the three version checks again.

Tool check still failing?

A newly installed tool may remain unavailable until you open a fresh PowerShell session. The new session reloads your system path.

Share the output from all three checks with help me diagnose my local tool versions.

Create the PostgreSQL database

The application needs a persistent database before its datasource can connect. You will use pgAdmin 4 to create product_catalog plus confirm that the PostgreSQL server accepts SQL.

pgAdmin may ask for the password you chose during PostgreSQL setup. This password stays on your computer during the local connection.

  • Press the Windows key to open search.
  • Type pgAdmin 4.
  • Press Enter to open pgAdmin 4.
  • Enter your local PostgreSQL password if pgAdmin asks you to unlock the server connection.
  • Expand your local PostgreSQL server in the Browser pane.
  • Right-click Databases beneath the server.
  • Select Create.
  • Select Database.
  • Enter product_catalog in the Database field.
  • Click Save.

You should now see product_catalog beneath Databases in the Browser pane.

  • Select product_catalog in the Browser pane.
  • Open the Tools menu.
  • Select Query Tool.
  • Confirm the server accepts queries by running this SQL:
SELECT version();

What does this query prove?

The version() function asks the connected PostgreSQL server to describe its version. A returned row proves that pgAdmin can execute SQL through the active database connection.

You should see one result row containing information about your PostgreSQL server version. Your product_catalog database is ready to receive application data.

Query not returning a result?

Confirm that your local PostgreSQL server is connected in pgAdmin. A disconnected server cannot execute the query.

Check that the Query Tool tab belongs to product_catalog. Ask why is my pgAdmin query not running if the problem continues.

Your database is live plus accepting queries. That removes the first connection risk from the application setup.

Build and start the application

The pom.xml file defines the application build plus dependencies. The application class provides the Spring Boot entry point.

The application.properties file supplies the JDBC connection details that point Spring Boot at product_catalog.

  • Press the Windows key to open search.
  • Type File Explorer.
  • Press Enter to open File Explorer.
  • Select Desktop in the left navigation pane.
  • Click New.
  • Select Folder.
  • Type product-catalog.
  • Press Enter to create the folder.

You should see the new product-catalog folder on your Desktop.

  • Press the Windows key to open search.
  • Type IntelliJ IDEA.
  • Press Enter to open IntelliJ IDEA.
  • Click Open on the welcome screen.
  • Select the product-catalog folder on your Desktop.
  • Click Select Folder.
  • Confirm that you trust the local folder if IntelliJ asks for project trust.

IntelliJ should show product-catalog at the top of the Project pane. This named folder is where every application file belongs.

  • Right-click product-catalog in the Project pane.
  • Select New.
  • Select File.
  • Enter pom.xml as the file name.
  • Create the Maven project foundation by pasting this into pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project>
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
    </parent>

    <groupId>com.nextwork</groupId>
    <artifactId>product-catalog</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>product-catalog</name>

    <properties>
        <java.version>17</java.version>
    </properties>
</project>

What does this project foundation do?

  • The parent selects Spring Boot 4.1.1 for dependency plus plugin management.
  • The project coordinates identify this application as com.nextwork:product-catalog.
  • The Java property tells Maven to compile the project for Java 17.
  • Save pom.xml.
  • Confirm pom.xml appears directly beneath product-catalog in the Project pane.

POM file not recognized?

Check that the file is named exactly pom.xml. A hidden text extension can prevent IntelliJ from recognizing it as a Maven project.

Ask why is IntelliJ not recognizing my pom.xml if the Maven project does not appear.

  • Find the closing </project> line in pom.xml.
  • Insert these dependencies immediately above that line:
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

What do these dependencies add?

  • The web starter supplies the Spring MVC components used by the REST API.
  • The JPA starter supplies Spring Data JPA plus Hibernate for relational persistence.
  • The PostgreSQL dependency supplies the database driver at runtime.
  • Save pom.xml.
  • Confirm the three dependency elements remain inside the outer project element.

Dependencies showing XML errors?

Check that the entire dependencies section sits above the final </project> tag. Placing it after that tag makes the XML structure invalid.

Ask help me check my Maven dependency placement if the editor still reports a structure problem.

  • Find the closing </project> line again.
  • Insert this build configuration immediately above that line:
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

What does the build configuration do?

The Spring Boot Maven plugin gives Maven the application-specific run goal used later in this step. It connects the build to the Spring Boot startup process.

  • Save pom.xml.
  • Confirm the build section sits below dependencies inside the outer project element.

Build plugin showing an XML error?

Confirm that each opening tag has a matching closing tag. The final </project> tag must remain the last line.

Use help me compare my pom.xml structure if IntelliJ still marks the file as invalid.

✔️ Awesome, I've got everything!

  • Save pom.xml before continuing.

ⓧ I'd like to double check the full code

<?xml version="1.0" encoding="UTF-8"?>
<project>
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.1</version>
    </parent>

    <groupId>com.nextwork</groupId>
    <artifactId>product-catalog</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>product-catalog</name>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

How to use this reference

Compare the structure plus values against your saved pom.xml. The complete file should match this reference exactly.

  • Right-click product-catalog in the Project pane.
  • Select New.
  • Select Directory.
  • Enter src/main/java/com/nextwork/catalog.
  • Press Enter to create the Java package path.

The Project pane should now show a path ending in com.nextwork.catalog. This package holds the application entry point.

  • Right-click product-catalog in the Project pane.
  • Select New.
  • Select Directory.
  • Enter src/main/resources.
  • Press Enter to create the resources path.

You should now see both source paths beneath src/main in the Project pane.

  • Right-click the com.nextwork.catalog package in the Project pane.
  • Select New.
  • Select Java Class.
  • Enter ProductCatalogApplication as the class name.
  • Replace the generated contents of ProductCatalogApplication.java with this code:
package com.nextwork.catalog;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ProductCatalogApplication {

    public static void main(String[] args) {
        SpringApplication.run(ProductCatalogApplication.class, args);
    }
}

What does this application class do?

  • The package declaration places the class in com.nextwork.catalog.
  • The @SpringBootApplication annotation enables configuration, auto-configuration, plus component scanning.
  • The main() method starts the application through SpringApplication.run().
  • Save ProductCatalogApplication.java.
  • Confirm the class appears inside the com.nextwork.catalog package in the Project pane.

Java imports showing errors?

Confirm that pom.xml is saved in the top-level product-catalog folder. IntelliJ needs that Maven file to resolve the Spring Boot imports.

Ask why are my Spring Boot imports unresolved if the imports remain unresolved.

✔️ Awesome, I've got everything!

  • Save ProductCatalogApplication.java before continuing.

ⓧ I'd like to double check the full code

package com.nextwork.catalog;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class ProductCatalogApplication {

    public static void main(String[] args) {
        SpringApplication.run(ProductCatalogApplication.class, args);
    }
}

How to use this reference

Compare your package, imports, annotation, class name, plus main() method against this complete file.

  • Right-click src/main/resources in the Project pane.
  • Select New.
  • Select File.
  • Enter application.properties as the file name.
  • Add the datasource template by pasting these properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/product_catalog
spring.datasource.username=your-postgresql-username
spring.datasource.password=your-postgresql-password
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false

What do these properties configure?

  • The spring.datasource.url property points the JDBC connection at the local product_catalog database on port 5432.
  • The username plus password properties provide the credentials PostgreSQL uses for authentication.
  • The spring.jpa.hibernate.ddl-auto=update setting allows Hibernate to update the learning database schema as entities are added.
  • The spring.jpa.open-in-view=false setting disables the open EntityManager pattern for web requests.

Database credentials are sensitive even in a local project. Keep your real password in this local file plus out of screenshots.

  • Replace your-postgresql-username with your local PostgreSQL username.
  • Replace your-postgresql-password with your local PostgreSQL password.
  • Save application.properties.

Unsure which credentials to use?

Use the PostgreSQL account that successfully connects to your local server in pgAdmin. The password is the one you configured for that account.

Ask which PostgreSQL credentials should my Spring Boot datasource use if you need help identifying the account.

✔️ Awesome, I've got everything!

Your local file should contain your real PostgreSQL username plus password. Keep those values private.

ⓧ I'd like to double check the full code

spring.datasource.url=jdbc:postgresql://localhost:5432/product_catalog
spring.datasource.username=your-postgresql-username
spring.datasource.password=your-postgresql-password
spring.jpa.hibernate.ddl-auto=update
spring.jpa.open-in-view=false

How to use this reference

Compare the property names plus datasource URL against this reference. Keep your private username plus password in place of the two safe placeholders.

  • Click Terminal in the bottom toolbar of IntelliJ IDEA.
  • Confirm the terminal prompt points to the product-catalog folder.

Before you start the application, do you expect the database connection to succeed with the credentials you just entered?

  • Start the Spring Boot application from the project folder by running this command:
mvn spring-boot:run

What does this command do?

Maven resolves the dependencies from pom.xml plus launches the application through the Spring Boot Maven plugin.

The first run may take a few minutes while Maven downloads dependencies. The terminal remains occupied because the application keeps running for incoming requests.

You should see the console report that the application started. You should not see a datasource connection failure or PostgreSQL authentication failure.

That is the setup complete. Spring Boot can now reach the persistent product_catalog database through your local credentials.

Application not starting?

Confirm that the PostgreSQL server remains connected in pgAdmin. Check that the database name in application.properties is exactly product_catalog.

Recheck your local username plus password if the console mentions datasource authentication. Ask help me diagnose my Spring Boot startup logs for help without sharing your password.

Your application now starts with a working PostgreSQL connection. Next, you will map products into persistent rows plus expose the first create and list endpoints.

Persist and Retrieve Your First Products

Your Spring Boot application can reach PostgreSQL. It cannot store product details yet.

This step maps Java objects to database rows with a JPA entity. A REST API then proves the complete create-and-read cycle.

In this step, get ready to:
  • Map the Product class to a generated database table.
  • Build the repository and controller layer.
  • Prove persistence with a create-and-read cycle.
Map the Product entity

Object-relational mapping connects a Java class to a relational table. Each Product object becomes one persistent row.

  • Switch back to the PowerShell session from earlier.
  • Stop the running Spring Boot application by pressing Ctrl+C.
  • Return to IntelliJ IDEA.
  • Select src/main/java/com/nextwork/catalog in the Project tool window.
  • Press Alt+Insert.
  • Select Java Class.
  • Enter Product.
  • Press Enter.

You now see Product.java inside the existing com.nextwork.catalog package.

  • Replace the generated contents of Product.java with this entity:
package com.nextwork.catalog;

import java.math.BigDecimal;

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

@Entity
public class Product {
    @Id @GeneratedValue private Long id;
    @Column(nullable = false) private String name;
    @Column(nullable = false) private String category;
    @Column(nullable = false, precision = 10, scale = 2) private BigDecimal price;
    @Column(nullable = false) 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 getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
}

What Does This Entity Map?

  • The @Entity annotation marks Product as a class that JPA persists in a table.
  • The id field becomes the primary-key column. The @GeneratedValue annotation lets the persistence provider generate each identifier.
  • The name field maps to a required text column. The category field maps to another required text column.
  • The price field stores an exact decimal value. The stockQuantity field stores the required stock count.
  • The no-argument constructor gives JPA a supported way to create Product objects from database rows.
  • Save Product.java by pressing Ctrl+S.
  • Switch back to the PowerShell session from earlier.
  • Start the application from the product-catalog folder by running:
mvn spring-boot:run

What Does This Check Prove?

Maven compiles the new entity before Spring Boot starts. Hibernate reads the mapping and creates the corresponding table in product_catalog.

The console reports that the application started without a mapping or datasource error.

  • Return to pgAdmin 4.
  • Refresh the product_catalog database in the object tree.
  • Expand Schemas.
  • Expand public.
  • Expand Tables.

You should see the generated table for Product. That table is the database-side result of your entity mapping.

Table Missing or Application Failing?

Check that Product.java starts with the com.nextwork.catalog package. Confirm every import matches the code above.

If startup mentions the datasource or authentication, confirm the PostgreSQL credentials in application.properties still match your local account.

Help me diagnose this startup problem

✔️ Awesome, I've got everything!

Your Product entity is saved. The generated PostgreSQL table confirms that the mapping works.

ⓧ I'd like to double check the full code

package com.nextwork.catalog;

import java.math.BigDecimal;

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

@Entity
public class Product {
    @Id @GeneratedValue private Long id;
    @Column(nullable = false) private String name;
    @Column(nullable = false) private String category;
    @Column(nullable = false, precision = 10, scale = 2) private BigDecimal price;
    @Column(nullable = false) 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 getCategory() { return category; }
    public void setCategory(String category) { this.category = category; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
}

How to Use This Reference

Compare this complete file with your saved Product.java. Pay close attention to the package name and field types.

Expose inherited CRUD operations

Spring Data JPA can generate repository behavior from an interface. Extending JpaRepository gives your application operations such as save and findAll.

  • Switch back to the PowerShell session running the application.
  • Stop the application by pressing Ctrl+C.
  • Return to IntelliJ IDEA.
  • Select src/main/java/com/nextwork/catalog in the Project tool window.
  • Press Alt+Insert.
  • Select Java Class.
  • Select Interface.
  • Enter ProductRepository.

IntelliJ IDEA creates ProductRepository.java in the same package as your entity.

  • Replace the generated contents of ProductRepository.java with this repository interface:
package com.nextwork.catalog;

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

public interface ProductRepository extends JpaRepository<Product, Long> {
}

What Does This Repository Do?

The first generic type tells the repository to manage Product entities. The second identifies Long as the type of each primary key.

Spring Data creates the concrete repository implementation when the application starts. Your interface inherits its CRUD operations without implementing them manually.

  • Save ProductRepository.java by pressing Ctrl+S.
  • Switch back to the PowerShell session from earlier.
  • Check that Spring Data accepts the repository by running:
mvn spring-boot:run

What Does This Check Prove?

A successful startup confirms that Spring Data created a repository for the Product entity. The interface can now participate in application requests.

The console reports that the application started without a repository creation error.

Repository Preventing Startup?

Confirm that ProductRepository and Product use the same package. Check that the imported repository type is org.springframework.data.jpa.repository.JpaRepository.

Help me fix this repository error

✔️ Awesome, I've got everything!

Your repository compiles. Spring Data can now save and retrieve Product entities.

ⓧ I'd like to double check the full code

package com.nextwork.catalog;

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

public interface ProductRepository extends JpaRepository<Product, Long> {
}

How to Use This Reference

Compare this complete interface with your saved ProductRepository.java. The entity type and identifier type must match Product.java.

The repository handles persistence. The controller now connects that behavior to HTTP requests.

  • Stop the running application by pressing Ctrl+C.
  • Return to IntelliJ IDEA.
  • Select src/main/java/com/nextwork/catalog in the Project tool window.
  • Press Alt+Insert.
  • Select Java Class.
  • Enter ProductController.
  • Press Enter.

You now have ProductController.java beside the entity and repository.

  • Replace the generated contents of ProductController.java with this controller:
package com.nextwork.catalog;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/products")
public class ProductController {
    private final ProductRepository repository;
    public ProductController(ProductRepository repository) { this.repository = repository; }
    @PostMapping
    public Product create(@RequestBody Product product) { product.setId(null); return repository.save(product); }
    @GetMapping
    public List<Product> findAll() { return repository.findAll(); }
}

How Do the Endpoints Work?

Constructor injection gives the controller its repository. Spring Boot supplies the generated repository implementation when it creates the controller.

The POST handler clears the incoming id before calling save. This keeps the endpoint focused on creating new rows with generated identifiers.

The GET handler calls findAll to return every stored Product. Both handlers share the base path /products.

  • Save ProductController.java by pressing Ctrl+S.
  • Switch back to the PowerShell session from earlier.
  • Start the completed create-and-read API by running:
mvn spring-boot:run

What Does This Startup Confirm?

Maven compiles the controller against the repository. A successful startup confirms that Spring Boot accepted both endpoint mappings.

The application stays running in this PowerShell window so it can receive your requests.

Controller Preventing Startup?

Confirm that the controller constructor uses ProductRepository. Check that both mapping methods are inside the ProductController class.

Help me fix this controller error

✔️ Awesome, I've got everything!

Your controller is saved. The running application can now accept create and list requests.

ⓧ I'd like to double check the full code

package com.nextwork.catalog;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/products")
public class ProductController {
    private final ProductRepository repository;
    public ProductController(ProductRepository repository) { this.repository = repository; }
    @PostMapping
    public Product create(@RequestBody Product product) { product.setId(null); return repository.save(product); }
    @GetMapping
    public List<Product> findAll() { return repository.findAll(); }
}

How to Use This Reference

Compare this complete file with your saved ProductController.java. Check the shared request path and both repository calls.

Create and retrieve the sample products

Each request sends one product as JSON. The controller converts that request body into a Product before the repository saves it.

The first PowerShell window is hosting the application. A second window lets you make requests while the server remains available.

  • Press the Windows key to open the search bar.
  • Type Windows PowerShell.
  • Press Enter to open a second PowerShell window.
  • Submit the four sample products by running these requests:
@{ name = "Mechanical Keyboard"; category = "Electronics"; price = 89.99; stockQuantity = 12 } | ConvertTo-Json | Invoke-RestMethod -Method Post -Uri "http://localhost:8080/products" -ContentType "application/json"

@{ name = "USB-C Hub"; category = "Electronics"; price = 39.99; stockQuantity = 3 } | ConvertTo-Json | Invoke-RestMethod -Method Post -Uri "http://localhost:8080/products" -ContentType "application/json"

@{ name = "Standing Desk"; category = "Furniture"; price = 299.99; stockQuantity = 2 } | ConvertTo-Json | Invoke-RestMethod -Method Post -Uri "http://localhost:8080/products" -ContentType "application/json"

@{ name = "Notebook"; category = "Stationery"; price = 4.99; stockQuantity = 50 } | ConvertTo-Json | Invoke-RestMethod -Method Post -Uri "http://localhost:8080/products" -ContentType "application/json"

What Do These Requests Do?

Each hash table holds the fields for one Product. ConvertTo-Json serializes those fields into the request body.

Invoke-RestMethod sends each body to the POST endpoint. The returned object includes the identifier generated during persistence.

You should see four responses. Each response shows the submitted product with a generated numeric id.

Requests Not Reaching the API?

Keep the first PowerShell window running the application. If that process stopped, start it again from the product-catalog folder.

If a request reaches the server but cannot be saved, compare every hash-table key with the fields in Product.java.

Help me debug this POST request

Before you retrieve the collection, predict whether all four saved rows will appear.

  • Retrieve every stored product by running:
Invoke-RestMethod -Method Get -Uri "http://localhost:8080/products"

What Does This Result Prove?

You should see Mechanical Keyboard, USB-C Hub, Standing Desk, and Notebook. Every returned product includes a generated id.

The result proves that the POST endpoint persisted four rows. It also proves that the GET endpoint retrieved those rows through findAll.

Missing Products or Identifiers?

Run the POST requests once if the returned list is empty. Check the first PowerShell window for a persistence error if any product is missing.

If the names appear without identifiers, confirm that id has both @Id and @GeneratedValue in Product.java.

Help me debug the returned products

That is the full persistence loop working: JSON enters through HTTP and returns from PostgreSQL with generated identifiers. Next, you will let a repository method create a category query from its name.

Add a Derived Category Query

Your catalog already turns Product objects into persistent rows in PostgreSQL. The next useful view is a product list filtered by category.

Spring Data JPA can create a fixed query from a repository method name. This derived query removes boilerplate for the category filter. Each new query shape still needs a matching method plus an HTTP endpoint.

In this step, get ready to:
  • Declare a case-insensitive category query in ProductRepository.java.
  • Expose the query through GET /products/by-category.
  • Test the boundary of the derived method with a low-stock request.
Declare the derived repository method

Spring Data JPA reads repository method names as query instructions. The new signature identifies the entity property plus the type of comparison to perform.

  • In IntelliJ IDEA, switch back to ProductRepository.java.
  • Replace the current file contents with this repository definition:
package com.nextwork.catalog;

import java.util.List;

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

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByCategoryIgnoreCase(String category);
}

How Does the Method Name Become a Query?

  • The List<Product> return type allows the query to return every matching product.
  • The findBy prefix tells Spring Data to create a lookup query.
  • The Category segment points to the category field on Product.
  • The IgnoreCase keyword makes the text comparison case-insensitive.
  • Save ProductRepository.java.
  • In the Windows PowerShell window running the application, press Ctrl+C to stop the process.
  • Restart the application by running this command:
mvn spring-boot:run

What Does This Command Prove?

The command launches the Spring Boot application through Maven. Spring Data checks the repository method name while the application starts.

A successful startup confirms that findByCategoryIgnoreCase maps to a valid property on Product.

That clean startup matters. Spring Data has accepted your method name as a valid query definition.

Does the Application Stop During Startup?

Compare the spelling of findByCategoryIgnoreCase with the category field in Product.java. Check that java.util.List is imported.

Ask for help with the repository startup failure.

✔️ My Repository Starts Successfully

Your repository method is ready. Keep the application running while you add the endpoint that calls it.

ⓧ I'd Like to Double Check the Full Code

package com.nextwork.catalog;

import java.util.List;

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

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByCategoryIgnoreCase(String category);
}
Expose the category endpoint

The repository can now answer a category question. The controller needs a route that receives the category value from the request and passes it to the repository.

  • Switch back to ProductController.java in IntelliJ IDEA.
  • Replace the current Spring Web import group with this updated group:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

Why Add RequestParam?

The RequestParam import makes @RequestParam available to the controller. That annotation binds a value from the request URL to a Java method parameter.

  • Find the closing brace of the ProductController class.
  • Paste this method immediately before that closing brace:
    @GetMapping("/by-category")
    public List<Product> findByCategory(
            @RequestParam(name = "category") String category) {
        return repository.findByCategoryIgnoreCase(category);
    }

How Does the Endpoint Work?

  • The @GetMapping("/by-category") annotation adds the category route below the controller's shared /products path.
  • The @RequestParam annotation binds the URL's category value to the method parameter.
  • The controller passes that value to findByCategoryIgnoreCase.
  • Save ProductController.java.
  • Return to the PowerShell window running the application.
  • Press Ctrl+C to stop the current process.
  • Restart the application with the updated controller by running:
mvn spring-boot:run

Why Restart the Application?

Restarting loads the new controller mapping into Spring Boot. The application can now route category requests to findByCategory.

Before you send the request, how many of the four products do you expect the Electronics category to return?

  • Return to the PowerShell window you used for API requests earlier.
  • Send the category request by running this command:
Invoke-RestMethod -Method Get -Uri "http://localhost:8080/products/by-category?category=Electronics"

How Did the Request Travel?

  • The request sends Electronics as the category query parameter.
  • The controller binds that value to its category method parameter.
  • The repository creates the case-insensitive database query from findByCategoryIgnoreCase.

You'll see Mechanical Keyboard plus USB-C Hub in the response. You now have a working category filter without handwritten JPQL or SQL.

Do You See the Wrong Products?

Confirm that the request uses the category parameter. Check that both expected products were originally saved with the Electronics category.

Help me diagnose my category query result.

✔️ I See Two Electronics Products

Your controller now binds the category parameter and returns the matching products.

ⓧ I'd Like to Double Check the Full Code

package com.nextwork.catalog;

import java.util.List;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

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

    private final ProductRepository repository;

    public ProductController(ProductRepository repository) {
        this.repository = repository;
    }

    @PostMapping
    public Product create(@RequestBody Product product) {
        product.setId(null);
        return repository.save(product);
    }

    @GetMapping
    public List<Product> findAll() {
        return repository.findAll();
    }

    @GetMapping("/by-category")
    public List<Product> findByCategory(
            @RequestParam(name = "category") String category) {
        return repository.findByCategoryIgnoreCase(category);
    }
}
Test the fixed-query boundary

You have proved that the category route works. Now a low-stock request tests how far that new repository method reaches.

Before you send this request, do you think the category method can answer a stock threshold question?

  • Send the low-stock request by running this command:
Invoke-RestMethod -Method Get -Uri "http://localhost:8080/products/low-stock?threshold=5"

This 404 Is the Intended Result

You'll see a response with status code 404. The application has no mapping for /products/low-stock yet.

The category-derived method answers one fixed query shape. A stock threshold requires another repository query plus another controller endpoint.

Your derived category query works, and you have exposed its fixed boundary. Next, you'll express the low-stock rule with a custom JPQL query.

Create a JPQL Low-Stock Report

Your category endpoint proved that Spring Data JPA can derive a fixed query from a repository method name. The planned low-stock 404 exposed a different business question that still needs its own implementation.

The low-stock report needs an explicit stock rule. It also needs a stable result order.

A custom JPQL query keeps both rules readable inside the repository abstraction.

In this step, get ready to:
  • Declare a custom JPQL query with a named threshold parameter.
  • Expose the low-stock report through a GET endpoint.
  • Verify the ordered report and reveal the missing optional-filter search.
Declare the JPQL query

The repository needs two extra imports before it can declare an explicit query. One provides the query annotation. The other binds a Java parameter to the named value inside that query.

  • In IntelliJ IDEA, switch back to ProductRepository.java from earlier.
  • Find the existing JpaRepository import.
  • Replace that import with this group:
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

What Do These Imports Provide?

  • The Query annotation stores the JPQL statement beside the repository method that uses it.
  • The Param annotation connects the method parameter to :threshold inside the query.
  • Save ProductRepository.java.
  • Confirm IntelliJ IDEA shows no unresolved-import errors on Query or Param.

Seeing Unresolved Imports?

Check that both new imports start with org.springframework. Confirm the Maven dependencies from earlier have finished loading.

Help me resolve these repository imports

The query can now describe the report directly. It filters products whose stockQuantity is below the supplied threshold.

  • Find findByCategoryIgnoreCase inside ProductRepository.java.
  • Add the low-stock method directly below it by inserting this code:
    @Query("select p from Product p where p.stockQuantity < :threshold order by p.stockQuantity asc")
    List<Product> findLowStock(@Param("threshold") int threshold);

What Does This Query Do?

  • The query targets the Product entity through the alias p.
  • The stockQuantity < :threshold predicate keeps products below the requested stock level.
  • The ascending order places the lowest stock quantity first.
  • The @Param("threshold") annotation supplies the value used by :threshold.
  • Save ProductRepository.java.
  • Confirm IntelliJ IDEA shows no errors on findLowStock.

Seeing a Repository Error?

Check that :threshold includes its leading colon. Make sure the value inside @Param("threshold") uses the same spelling.

Help me fix my low-stock repository query

✔️ Awesome, I've got everything!

Your repository now supports the category query from earlier plus an explicit low-stock report. Keep ProductRepository.java saved.

ⓧ I'd like to double check the full code

package com.nextwork.catalog;

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

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByCategoryIgnoreCase(String category);
    @Query("select p from Product p where p.stockQuantity < :threshold order by p.stockQuantity asc")
    List<Product> findLowStock(@Param("threshold") int threshold);
}

This complete file preserves the derived category method. It adds the JPQL-backed low-stock method beneath it.

Expose the low-stock endpoint

The repository can answer the low-stock question now. The controller still needs a route that receives the HTTP parameter and passes it into findLowStock.

  • Switch back to ProductController.java from earlier.
  • Find the existing findByCategory method.
  • Add the low-stock endpoint directly below it by inserting this code:
    @GetMapping("/low-stock")
    public List<Product> findLowStock(
            @RequestParam(name = "threshold") int threshold) {
        return repository.findLowStock(threshold);
    }

How Does the Request Reach the Query?

  • The @GetMapping("/low-stock") annotation adds the low-stock path beneath /products.
  • The @RequestParam annotation binds the incoming threshold query parameter to the Java method argument.
  • The controller passes that threshold to the repository and returns the ordered products.
  • Save ProductController.java.
  • Switch back to the PowerShell window from earlier.
  • Stop the running application using the same method from the previous step.

Before you restart the application, do you expect the new controller mapping and repository query to compile together?

  • Restart the application from the project folder by running:
mvn spring-boot:run

You should see the console report that the application started without a mapping, datasource, or authentication error.

That successful restart proves the controller can call the new repository method. The low-stock route is ready for a real request.

Application Fails to Restart?

Check that the controller calls repository.findLowStock(threshold). Confirm the repository method uses the same name and parameter type.

Help me debug the application restart

✔️ Awesome, I've got everything!

Your application starts with the new low-stock route active. Leave it running while you test the report.

ⓧ I'd like to double check the full code

package com.nextwork.catalog;

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

@RestController
@RequestMapping("/products")
public class ProductController {
    private final ProductRepository repository;
    public ProductController(ProductRepository repository) { this.repository = repository; }
    @PostMapping public Product create(@RequestBody Product product) { product.setId(null); return repository.save(product); }
    @GetMapping public List<Product> findAll() { return repository.findAll(); }
    @GetMapping("/by-category") public List<Product> findByCategory(@RequestParam(name = "category") String category) { return repository.findByCategoryIgnoreCase(category); }
    @GetMapping("/low-stock") public List<Product> findLowStock(@RequestParam(name = "threshold") int threshold) { return repository.findLowStock(threshold); }
}

This complete controller preserves the create, list, and category routes. It adds the low-stock mapping backed by repository.findLowStock.

Verify the report and reveal the next gap

The application now has everything required to answer the low-stock request that returned a 404 earlier. A separate PowerShell window lets you send requests while the application keeps running.

  • Press the Windows key to open Windows Search.
  • Type Windows PowerShell into the search field.
  • Press Enter to open a second PowerShell window.
  • Test the low-stock endpoint with the request below. Before you run it, what order do you expect the qualifying products to use?
Invoke-RestMethod -Method Get -Uri "http://localhost:8080/products/low-stock?threshold=5"

You should see Standing Desk followed by USB-C Hub. Both products fall below the threshold of 5.

What Did the Report Prove?

The request parameter sets the stock boundary used by the JPQL predicate. Changing that threshold changes which products qualify.

The ascending order keeps the lowest stock quantity at the start of every report.

The low-stock route answers one explicit business question. A category plus minimum-price request introduces a new query shape.

  • Test the combined search request below. Before you run it, do you expect the current API to have a matching route?
Invoke-RestMethod -Method Get -Uri "http://localhost:8080/products/search?category=Electronics&minPrice=50"

You should see the planned 404 response because the optional-filter search mapping does not exist yet.

This 404 Is the Intended Result

The low-stock query handles one fixed report. The new request combines category and minimum price in a different shape.

Writing a separate JPQL string for every optional combination would become repetitive. The planned 404 makes that limitation visible before you solve it.

Your low-stock report now filters through JPQL and returns products in stock order. Next up, you will use composable Specifications to support optional search filters without declaring every combination separately.

Compose Optional Filters with Specifications

Your Spring Boot API now answers the fixed low-stock question with JPQL. The planned 404 showed that the optional-filter search still needs a flexible query.

Spring Data JPA provides composable Specifications for this problem. Each Specification contributes one filter when its value exists. Missing values contribute no restriction.

Why Specifications for This Search?

Separate repository methods work well for fixed questions. Optional filters create many possible combinations.

Specifications package each condition into a reusable predicate. The controller can combine those predicates for each request.

In this step, get ready to:
  • Enable Specification execution in the product repository.
  • Build reusable predicates for every optional product filter.
  • Expose the dynamic search endpoint and test changing filter combinations.
Build Specifications

The repository needs Specification support before it can execute composed predicates. The predicate class then describes how each request value maps to a Product property.

  • Switch back to ProductRepository.java in IntelliJ IDEA.
  • Replace the file contents with this repository definition:
package com.nextwork.catalog;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository
        extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {

    List<Product> findByCategoryIgnoreCase(String category);

    @Query("select p from Product p where p.stockQuantity < :threshold order by p.stockQuantity asc")
    List<Product> findLowStock(@Param("threshold") int threshold);
}

What Does This Repository Change?

  • JpaRepository keeps the inherited save and retrieval operations.
  • JpaSpecificationExecutor adds repository operations that accept a Specification.
  • The derived category method and custom low-stock query remain available.
  • Save ProductRepository.java.
  • Confirm IntelliJ IDEA resolves JpaSpecificationExecutor without red error highlighting.

JpaSpecificationExecutor Not Resolved?

Check that the import uses org.springframework.data.jpa.repository.JpaSpecificationExecutor. Confirm the interface lists it after JpaRepository<Product, Long>.

Help me fix the JpaSpecificationExecutor import in ProductRepository.java.

✔️ Awesome, I've got everything!

Your repository now supports inherited CRUD operations and Specification-based queries.

ⓧ I'd like to double check the full code

package com.nextwork.catalog;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository
        extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {

    List<Product> findByCategoryIgnoreCase(String category);

    @Query("select p from Product p where p.stockQuantity < :threshold order by p.stockQuantity asc")
    List<Product> findLowStock(@Param("threshold") int threshold);
}

How to Compare This File

This reference preserves the existing query methods. It adds Specification execution through the second inherited interface.

The new utility class keeps each filter independent. A missing value returns Specification.unrestricted(). This lets the other filters continue shaping the query.

  • Select the com.nextwork.catalog package in the Project tool window.
  • Right-click the selected package.
  • Select New.
  • Select Java Class.
  • Enter ProductSpecifications as the class name.
  • Press Enter.
  • Replace the generated contents with the first predicate below:
package com.nextwork.catalog;

import java.math.BigDecimal;
import java.util.Locale;

import org.springframework.data.jpa.domain.Specification;

public final class ProductSpecifications {

    private ProductSpecifications() {
    }

    public static Specification<Product> nameContains(String name) {
        if (name == null || name.isBlank()) {
            return Specification.unrestricted();
        }
        String pattern = "%" + name.toLowerCase(Locale.ROOT) + "%";
        return (root, query, builder) ->
                builder.like(builder.lower(root.<String>get("name")), pattern);
    }
}

How Does the Name Predicate Work?

  • nameContains() ignores a missing or blank name.
  • Locale.ROOT creates consistent lowercase text.
  • The percent characters allow the name to match text anywhere inside the stored value.
  • builder.like() compares the lowercase Product name with that pattern.
  • Save ProductSpecifications.java.
  • Confirm IntelliJ IDEA shows no red error highlighting inside nameContains().

Name Predicate Showing Errors?

Check that BigDecimal, Locale, and Specification use the imports shown above. Check that the final brace still closes the class.

Help me fix the nameContains method in ProductSpecifications.java.

  • Add the category predicate above the final closing brace by copying this method:
    public static Specification<Product> categoryEquals(String category) {
        if (category == null || category.isBlank()) {
            return Specification.unrestricted();
        }
        String normalizedCategory = category.toLowerCase(Locale.ROOT);
        return (root, query, builder) ->
                builder.equal(builder.lower(root.<String>get("category")), normalizedCategory);
    }

How Does the Category Predicate Work?

  • categoryEquals() ignores a missing or blank category.
  • The request value becomes lowercase before comparison.
  • builder.equal() performs a case-insensitive exact category match.
  • Save ProductSpecifications.java.
  • Confirm IntelliJ IDEA resolves the category Product property without red error highlighting.

Category Predicate Showing Errors?

Confirm the property string is exactly category. Check that normalizedCategory uses the same spelling in both lines.

Help me fix the categoryEquals method in ProductSpecifications.java.

  • Add the minimum-price predicate above the final closing brace by copying this method:
    public static Specification<Product> priceAtLeast(BigDecimal minPrice) {
        if (minPrice == null) {
            return Specification.unrestricted();
        }
        return (root, query, builder) ->
                builder.greaterThanOrEqualTo(root.<BigDecimal>get("price"), minPrice);
    }

How Does the Minimum Price Work?

  • priceAtLeast() contributes no restriction when the request omits minPrice.
  • greaterThanOrEqualTo() includes products priced exactly at the requested minimum.
  • BigDecimal keeps the comparison aligned with the Product price type.
  • Save ProductSpecifications.java.
  • Confirm IntelliJ IDEA resolves the price property as a BigDecimal value.

Minimum Price Predicate Showing Errors?

Confirm the generic type is BigDecimal. Check that the property string is exactly price.

Help me fix the priceAtLeast method in ProductSpecifications.java.

  • Add the maximum-price predicate above the final closing brace by copying this method:
    public static Specification<Product> priceAtMost(BigDecimal maxPrice) {
        if (maxPrice == null) {
            return Specification.unrestricted();
        }
        return (root, query, builder) ->
                builder.lessThanOrEqualTo(root.<BigDecimal>get("price"), maxPrice);
    }

How Does the Maximum Price Work?

  • priceAtMost() ignores an absent maximum.
  • lessThanOrEqualTo() keeps products priced at or below the supplied limit.
  • Save ProductSpecifications.java.
  • Confirm IntelliJ IDEA shows no red error highlighting inside priceAtMost().

Maximum Price Predicate Showing Errors?

Check that this method uses lessThanOrEqualTo(). Confirm the parameter name remains maxPrice throughout the method.

Help me fix the priceAtMost method in ProductSpecifications.java.

  • Add the minimum-stock predicate above the final closing brace by copying this method:
    public static Specification<Product> stockAtLeast(Integer minStock) {
        if (minStock == null) {
            return Specification.unrestricted();
        }
        return (root, query, builder) ->
                builder.greaterThanOrEqualTo(root.<Integer>get("stockQuantity"), minStock);
    }

How Does the Stock Predicate Work?

  • stockAtLeast() ignores an absent stock filter.
  • The predicate compares minStock with the Product property named stockQuantity.
  • Products at the requested minimum remain in the result.
  • Save ProductSpecifications.java.
  • Confirm IntelliJ IDEA resolves stockQuantity as an Integer property.

Stock Predicate Showing Errors?

Confirm the Product property string is exactly stockQuantity. Check that both generic values use Integer.

Help me fix the stockAtLeast method in ProductSpecifications.java.

✔️ Awesome, I've got everything!

Your predicate library now covers name text, category, price range, and minimum stock.

ⓧ I'd like to double check the full code

package com.nextwork.catalog;

import java.math.BigDecimal;
import java.util.Locale;

import org.springframework.data.jpa.domain.Specification;

public final class ProductSpecifications {

    private ProductSpecifications() {
    }

    public static Specification<Product> nameContains(String name) {
        if (name == null || name.isBlank()) {
            return Specification.unrestricted();
        }
        String pattern = "%" + name.toLowerCase(Locale.ROOT) + "%";
        return (root, query, builder) ->
                builder.like(builder.lower(root.<String>get("name")), pattern);
    }

    public static Specification<Product> categoryEquals(String category) {
        if (category == null || category.isBlank()) {
            return Specification.unrestricted();
        }
        String normalizedCategory = category.toLowerCase(Locale.ROOT);
        return (root, query, builder) ->
                builder.equal(builder.lower(root.<String>get("category")), normalizedCategory);
    }

    public static Specification<Product> priceAtLeast(BigDecimal minPrice) {
        if (minPrice == null) {
            return Specification.unrestricted();
        }
        return (root, query, builder) ->
                builder.greaterThanOrEqualTo(root.<BigDecimal>get("price"), minPrice);
    }

    public static Specification<Product> priceAtMost(BigDecimal maxPrice) {
        if (maxPrice == null) {
            return Specification.unrestricted();
        }
        return (root, query, builder) ->
                builder.lessThanOrEqualTo(root.<BigDecimal>get("price"), maxPrice);
    }

    public static Specification<Product> stockAtLeast(Integer minStock) {
        if (minStock == null) {
            return Specification.unrestricted();
        }
        return (root, query, builder) ->
                builder.greaterThanOrEqualTo(root.<Integer>get("stockQuantity"), minStock);
    }
}

How to Compare This File

Each method handles one request parameter. Every absent filter returns the same unrestricted Specification.

Expose Search

The controller now needs optional request parameters for all five filters. It passes each value to the matching predicate before combining the results with Specification.allOf().

  • Switch back to ProductController.java from earlier.
  • Replace the package and import section with this updated header:
package com.nextwork.catalog;

import java.math.BigDecimal;
import java.util.List;

import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

Why Add These Imports?

  • BigDecimal matches the Product price type.
  • Specification holds the combined optional predicates.
  • The web annotations keep the existing endpoint mappings explicit.
  • Save ProductController.java.
  • Confirm IntelliJ IDEA resolves every import without red error highlighting.

Controller Imports Showing Errors?

Confirm BigDecimal comes from java.math. Confirm Specification comes from the Spring Data JPA domain package.

Help me fix the imports in ProductController.java.

  • Add the dynamic search method below findLowStock() by copying this code:
    @GetMapping("/search")
    public List<Product> search(
            @RequestParam(name = "name", required = false) String name,
            @RequestParam(name = "category", required = false) String category,
            @RequestParam(name = "minPrice", required = false) BigDecimal minPrice,
            @RequestParam(name = "maxPrice", required = false) BigDecimal maxPrice,
            @RequestParam(name = "minStock", required = false) Integer minStock) {

        Specification<Product> specification = Specification.allOf(
                ProductSpecifications.nameContains(name),
                ProductSpecifications.categoryEquals(category),
                ProductSpecifications.priceAtLeast(minPrice),
                ProductSpecifications.priceAtMost(maxPrice),
                ProductSpecifications.stockAtLeast(minStock)
        );

        return repository.findAll(specification);
    }

What Does the Search Method Do?

  • required = false allows each query parameter to be omitted.
  • Specification.allOf() combines the active predicates with logical AND behavior.
  • repository.findAll(specification) executes the finished query.
  • An unrestricted predicate leaves the other filters unchanged.
  • Save ProductController.java.
  • Confirm IntelliJ IDEA shows no red error highlighting inside search().

Search Method Showing Errors?

Confirm every controller parameter matches its ProductSpecifications method argument. Check that repository.findAll() receives the combined specification variable.

Help me fix the dynamic search method in ProductController.java.

✔️ Awesome, I've got everything!

Your controller now exposes one endpoint that accepts any subset of the five filters.

ⓧ I'd like to double check the full code

package com.nextwork.catalog;

import java.math.BigDecimal;
import java.util.List;

import org.springframework.data.jpa.domain.Specification;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

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

    private final ProductRepository repository;

    public ProductController(ProductRepository repository) {
        this.repository = repository;
    }

    @PostMapping
    public Product create(@RequestBody Product product) {
        product.setId(null);
        return repository.save(product);
    }

    @GetMapping
    public List<Product> findAll() {
        return repository.findAll();
    }

    @GetMapping("/by-category")
    public List<Product> findByCategory(
            @RequestParam(name = "category") String category) {
        return repository.findByCategoryIgnoreCase(category);
    }

    @GetMapping("/low-stock")
    public List<Product> findLowStock(
            @RequestParam(name = "threshold") int threshold) {
        return repository.findLowStock(threshold);
    }

    @GetMapping("/search")
    public List<Product> search(
            @RequestParam(name = "name", required = false) String name,
            @RequestParam(name = "category", required = false) String category,
            @RequestParam(name = "minPrice", required = false) BigDecimal minPrice,
            @RequestParam(name = "maxPrice", required = false) BigDecimal maxPrice,
            @RequestParam(name = "minStock", required = false) Integer minStock) {

        Specification<Product> specification = Specification.allOf(
                ProductSpecifications.nameContains(name),
                ProductSpecifications.categoryEquals(category),
                ProductSpecifications.priceAtLeast(minPrice),
                ProductSpecifications.priceAtMost(maxPrice),
                ProductSpecifications.stockAtLeast(minStock)
        );

        return repository.findAll(specification);
    }
}

How to Compare This File

The existing create, list, category, and low-stock endpoints remain intact. The new search method appears after findLowStock().

Test Filter Combinations

The planned search URL previously returned 404. Restarting the application loads the new route and proves that every Java file compiles together.

  • Return to PowerShell from earlier.
  • Stop the running application by pressing Ctrl+C.
  • Restart the application from the project folder by running:
mvn spring-boot:run

What Does This Restart Prove?

The command recompiles the repository, predicate class, and controller. A successful startup confirms that the new Specification types fit together.

You should see the application start without a compilation, datasource, or authentication error.

Application Failing to Start?

Use the first filename and line number in the error output to locate the failing edit. Check for a missing closing brace in ProductSpecifications.java or ProductController.java.

Help me diagnose this Spring Boot startup failure.

  • Return to a second PowerShell window while the application keeps running.
  • Predict which product will match before you send this request:
Invoke-RestMethod -Method Get -Uri "http://localhost:8080/products/search?category=Electronics&minPrice=50&minStock=5"

How Is This Request Filtered?

  • category=Electronics activates the category predicate.
  • minPrice=50 activates the minimum-price predicate.
  • minStock=5 activates the minimum-stock predicate.
  • The missing name and maximum-price values contribute unrestricted predicates.

You will see only Mechanical Keyboard. The combined predicates exclude every other saved product.

Search Still Returning 404?

Confirm the restarted process loaded the saved controller. Check that the method mapping is exactly /search beneath the shared /products path.

Help me fix a 404 from the product search endpoint.

  • Press the up arrow key in PowerShell to recall the request.
  • Delete &minStock=5 from the URI.
  • Press Enter to send the updated request.

You will receive a valid response without a 404. The remaining category and minimum-price predicates still control the result.

  • Press the up arrow key to recall the latest request.
  • Delete &minPrice=50 from the URI.
  • Press Enter to run the category-only search.

You will see Mechanical Keyboard and USB-C Hub. Removing the price restriction expands the Electronics result set.

  • Press the up arrow key to recall the category-only request.
  • Delete ?category=Electronics from the URI.
  • Press Enter to run the search without filters.

You will see all four saved products. Every predicate is unrestricted when the request contains no filter parameters.

That completes the dynamic search path. Your API now adjusts one query to match the exact filter combination supplied by each request.

Secret mission

Paginate and Sort Dynamic Results

Your dynamic search currently returns every match in one response. Extend it with bounded pages that preserve the same optional filters. Order each page by price.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your Windows computer, so there are no cloud charges. Choose whether to keep its resources, pause your local tools, or delete the work.

Resources you used:

  • Local PostgreSQL product_catalog database.
  • Hibernate-generated Product table with four sample rows inside product_catalog.
  • Local Maven project folder containing the Spring Boot source files.

Keep everything running

No deletion is needed. Choose this option if you want to keep the database plus project files for more practice.

  • Return to the PowerShell window from earlier.
  • Press Ctrl+C to stop the application.
  • Keep the product_catalog database for future queries.
  • Retain the local Maven project folder for future changes.

Your database remains available for later searches. Your project files remain ready for more changes.

Pause - I'll come back to this later

Shut down the running application to free up memory. Keep the database plus project folder on disk for your return.

  • Return to the PowerShell window from earlier.
  • Press Ctrl+C to stop the application.
  • Close pgAdmin 4.
  • Close IntelliJ IDEA.

The database stays available in PostgreSQL. The Maven project remains on your computer.

Delete - I don't want to use this again

This option permanently removes the rows plus code you created. The next actions target only this local project.

  • Return to the PowerShell window from earlier.
  • Press Ctrl+C to stop the application.
  • In pgAdmin 4, select the postgres database under your existing server.
  • Open Query Tool from the Tools menu.
  • Delete the local product database by running this SQL:
DROP DATABASE IF EXISTS product_catalog;

Database Deletion Is Permanent

This command removes product_catalog. It also removes the generated Product table plus all four rows.

Run it from postgres because PostgreSQL cannot drop a database while you are connected to it. The deletion cannot be undone.

Database Still in Use?

  • Close any pgAdmin 4 Query Tool tabs still connected to product_catalog.
  • Confirm that the Spring Boot process has stopped in PowerShell.

Help me troubleshoot a PostgreSQL database that cannot be dropped because it is still in use.

  • Confirm that product_catalog no longer appears in the pgAdmin 4 database list.
  • Close pgAdmin 4.
  • Close IntelliJ IDEA.
  • Use the Windows file browser to locate the Maven project folder containing pom.xml plus src.
  • Delete that Maven project folder.
  • Confirm that the folder no longer appears in the Windows file browser.

Nice Work!

Nice Work!

You made it! Your local Spring Boot 4.1.1 Product Catalog Search API now turns Java objects into persistent PostgreSQL rows. Its REST endpoints expose each repository query style through results you can inspect.

What you learned:

  • Persistent CRUD: Mapped the Product JPA entity to relational data. Used inherited JpaRepository operations to save products. Retrieved each row with a generated identifier.
  • Query styles: Used a derived query to search by category. Wrote a JPQL query for the low-stock report. Bound its threshold through a named parameter.
  • Dynamic search: Composed reusable Specifications for five optional product filters. The /products/search endpoint handles any supported combination without extra repository methods.
  • Secret Mission: Reused the dynamic Specification in /products/search-page. Added zero-based pagination with a configurable page size. Applied price sorting in either direction.

Ready to quiz yourself?