Build a Product Catalog REST API
Build a Spring Boot REST API with CRUD, validation, and clean architecture.
Introduction
30 Second Summary
Every time you browse products on an online store, add items to your cart, or filter by category, a backend API is doing the heavy lifting. These APIs receive requests, talk to a database, and send back structured data.
In this project, you will build a Product Catalog REST API using Spring Boot. You will create endpoints that support creating, reading, updating, and deleting products, structured with the same layered architecture that professional Java teams use in production.
What You'll Build
A Product Catalog REST API that manages products for an e-commerce backend, complete with input validation and consistent error handling.
By the end of this project, you'll have:
- A running Spring Boot application with full CRUD endpoints for products at /api/products.
- A properly layered architecture separating your Controller, Service, and Repository concerns with DTOs for clean API contracts.
- Input validation using Jakarta Bean Validation and a global exception handler that returns proper HTTP status codes.
- Secret Mission: Add search by name, filter by category, and pagination to your API.
Are there any prerequisites?
This project assumes you have Java programming experience and are comfortable with classes, methods, and basic object-oriented concepts.
You will also need IntelliJ IDEA and JDK 24 installed on your machine before starting.
Generate and Configure the Spring Boot Project
Every backend API starts with a foundation. For your product catalog, that foundation is a Spring Boot project configured with the right dependencies and a database connection.
Spring Initializr is an official tool that generates pre-configured Spring Boot projects. You will use it to create a Maven project with all the dependencies your API needs, then configure an H2 in-memory database to store product data during development.
In this step, get ready to:
- Generate a Spring Boot project with the correct dependencies.
- Configure the H2 in-memory database connection.
- Verify the application starts and the database console is accessible.
Generate the project with Spring Initializr
Spring Initializr generates a ready-to-run project structure with all your chosen dependencies already wired up. You just pick your settings and download a zip file.
- Open https://start.spring.io in your browser.
- Set Project to Maven.
- Set Language to Java.
- Set Spring Boot to 4.1.0.
- Set Group to com.example.
- Set Artifact to productcatalog.
- Set Name to productcatalog.
- Set Package name to com.example.productcatalog.
- Set Packaging to Jar.
- Set Java to 24.
Now add the four dependencies your project needs.
- Click ADD DEPENDENCIES and search for Spring Web. Select it.
- Search for Spring Data JPA and select it.
- Search for H2 Database and select it.
- Search for Validation and select it.
- Click GENERATE to download the project as a zip file.
- Extract the downloaded zip file to a location you can find easily (such as your Desktop).
What are these dependencies?
Spring Web provides the tools to build REST endpoints. Spring Data JPA handles database operations so you do not write raw SQL. H2 Database is a lightweight in-memory database that requires no installation. Validation adds input checking with annotations like @NotBlank and @Positive.
Now open the project in IntelliJ IDEA.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type IntelliJ IDEA and press Enter to open it.
- In IntelliJ, click Open on the welcome screen.
- Navigate to the extracted productcatalog folder and click Open.
- If prompted to trust the project, click Trust Project.
Why Maven over Gradle?
Maven is the most widely used build tool in enterprise Java. Its XML-based configuration is explicit and easier to follow step by step. Spring Initializr supports both, but Maven is the standard starting point for teams building production APIs.
IntelliJ not recognizing the project?
Make sure you opened the productcatalog folder itself (the one containing pom.xml), not the parent folder. IntelliJ needs to see pom.xml at the root to detect it as a Maven project.
If you see a notification asking to import the project as Maven, click Load Maven Project.
help me fix IntelliJ not detecting my Maven Spring Boot project
Configure the H2 database
Spring Boot needs to know where your database lives and how to connect. The application.properties file is where all of this configuration goes.
- In the Project tool window on the left, expand src > main > resources.
- Open application.properties.
- You will see a single line: spring.application.name=productcatalog. Add the following configuration below it:
spring.datasource.url=jdbc:h2:mem:productdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
What does this configuration do?
- spring.datasource.url points to an in-memory H2 database called productdb. The DB_CLOSE_DELAY=-1 flag keeps the database alive as long as the JVM runs.
- spring.jpa.hibernate.ddl-auto=update tells Hibernate to create or update database tables automatically based on your Java entity classes.
- spring.jpa.show-sql=true prints every SQL query to the console so you can see what JPA is doing behind the scenes.
- spring.h2.console.enabled=true turns on a browser-based database viewer at /h2-console so you can inspect your data.
- Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
You will not see a visible change yet. The next substep runs the application and confirms the configuration works.
✔️ Awesome, I've got everything!
Great. Double check you have saved application.properties.
ⓧ I'd like to double check the full code
Here is the complete application.properties file:
spring.application.name=productcatalog
spring.datasource.url=jdbc:h2:mem:productdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
Verify the application runs
With the configuration in place, you can start the application and confirm everything is wired up correctly.
- In the Project tool window, expand src > main > java > com.example.productcatalog.
- Open ProductcatalogApplication.java.
- Click the green Run icon in the gutter next to the main() method, then select Run 'ProductcatalogApplication'.
What should I see?
The Run tool window opens at the bottom of IntelliJ. After a few seconds, you should see log output ending with a line like Started ProductcatalogApplication. This confirms your application is running on port 8080.
Now verify the H2 database console is accessible.
- Open your web browser and navigate to http://localhost:8080/h2-console.
- In the JDBC URL field, enter jdbc:h2:mem:productdb.
- Leave User Name as sa and Password empty.
- Click Connect.
You should see the H2 console interface load. There are no tables yet because you have not created any entities. You will add those in the next step.
Cannot connect to the H2 console?
Make sure the application is still running in IntelliJ. If the Run tool window shows a stopped process, click the green Run icon again to restart.
Double check that the JDBC URL in the H2 console login form matches exactly: jdbc:h2:mem:productdb. A typo here means you will connect to a different (empty) database instance.
help me troubleshoot connecting to the H2 console in my Spring Boot application
Your Spring Boot project is running and your database is ready. Next up, you will create the Product entity and repository layer to start storing data.
Create the Entity and Repository Layer
Your application can start and serve requests, but it has no concept of what a "product" is yet. Before you can build API endpoints, you need to define how a product is stored in the database.
In this step, you will create a JPA entity that maps a Java class to a database table. You will also create a repository interface that gives your application ready-made database operations without writing SQL.
In this step, get ready to:
- Create the Product entity with JPA annotations.
- Create the ProductRepository interface for database access.
- Verify the PRODUCTS table appears in the H2 console.
Create the Product entity
A JPA entity is a plain Java class that maps directly to a database table. Each field in the class becomes a column in the table, and JPA handles the translation between Java objects and database rows automatically.
- In IntelliJ's Project tool window (left sidebar), right-click on the com.example.productcatalog package inside src/main/java.
- Select New then Package.
- Type com.example.productcatalog.entity and press Enter.
- Right-click on the new entity package, select New then Java Class.
- Name the class Product and press Enter.
- Replace the entire contents of the new file with the following code:
package com.example.productcatalog.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
private BigDecimal price;
private String category;
private Boolean inStock;
// No-args constructor required by JPA
public Product() {
}
// Convenience constructor for creating new products
public Product(String name, String description, BigDecimal price, String category, Boolean inStock) {
this.name = name;
this.description = description;
this.price = price;
this.category = category;
this.inStock = inStock;
}
// Getters and setters for each field
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Boolean getInStock() { return inStock; }
public void setInStock(Boolean inStock) { this.inStock = inStock; }
}
What does this code do?
The @Entity annotation tells JPA this class represents a database table. @Table(name = "products") sets the table name explicitly.
The @Id annotation marks id as the primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to auto-increment the ID for each new product.
The remaining fields (name, description, price, category, inStock) each become a column in the PRODUCTS table automatically.
- Save Product.java by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- You won't see a visible change yet. The database table is created when the application restarts in substep 3.
Seeing red underlines on the imports?
If IntelliJ shows errors on the jakarta.persistence imports, Maven may not have downloaded the dependencies yet.
- Click the Maven refresh icon in the right sidebar (or press Ctrl+Shift+O on Windows, Cmd+Shift+I on macOS) to reload the project.
- Wait for the download to complete, then check that the red underlines disappear.
Ask for help: Why are my jakarta.persistence imports unresolved in IntelliJ?
✔️ Awesome, I've got everything!
Great. Make sure you have saved Product.java before moving on.
ⓧ I'd like to double check the full code
package com.example.productcatalog.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
private BigDecimal price;
private String category;
private Boolean inStock;
public Product() {
}
public Product(String name, String description, BigDecimal price, String category, Boolean inStock) {
this.name = name;
this.description = description;
this.price = price;
this.category = category;
this.inStock = inStock;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Boolean getInStock() { return inStock; }
public void setInStock(Boolean inStock) { this.inStock = inStock; }
}
Create the Product repository
Spring Data JPA can generate common database operations (find, save, delete) automatically from an interface. You do not need to write any SQL or implementation code.
- Right-click on the com.example.productcatalog package in the Project tool window.
- Select New then Package.
- Type com.example.productcatalog.repository and press Enter.
- Right-click on the new repository package, select New then Java Class.
- Select Interface from the kind dropdown, name it ProductRepository, and press Enter.
- Replace the entire contents of the new file with the following code:
package com.example.productcatalog.repository;
import com.example.productcatalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
// Extending JpaRepository gives us CRUD methods for free
public interface ProductRepository extends JpaRepository<Product, Long> {
}
What does this code do?
By extending JpaRepository<Product, Long>, Spring Data JPA automatically provides methods like findAll(), findById(), save(), and delete().
The two type parameters tell Spring which entity this repository manages (Product) and what type its primary key is (Long). No implementation class is needed.
- Save ProductRepository.java.
- You won't see a change yet. The next substep restarts the app and confirms everything works together.
✔️ Awesome, I've got everything!
Great. Make sure you have saved ProductRepository.java before moving on.
ⓧ I'd like to double check the full code
package com.example.productcatalog.repository;
import com.example.productcatalog.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductRepository extends JpaRepository<Product, Long> {
}
Test the database setup
When the application starts, JPA reads your entity class and creates (or updates) the matching table in the database. Restarting the app triggers this process.
- In IntelliJ, stop the running application by clicking the red Stop button in the top toolbar (or press Cmd+F2 on macOS, Ctrl+F2 on Windows).
- Run the application again by clicking the green Run button (or press Shift+F10).
- Wait for the console to show Started ProductcatalogApplication in the output.
- Open your browser and go to http://localhost:8080/h2-console.
- In the JDBC URL field, enter jdbc:h2:mem:productdb and click Connect.
- In the left panel, you should see the PRODUCTS table listed.
- Click on PRODUCTS to see its columns: ID, NAME, DESCRIPTION, PRICE, CATEGORY, and IN_STOCK.
Why does the console output show SQL?
You configured spring.jpa.show-sql=true in the previous step. This prints every SQL statement JPA executes to the console. You should see a CREATE TABLE statement in the log, confirming JPA built the PRODUCTS table from your entity.
Don't see the PRODUCTS table?
- Make sure the JDBC URL in the H2 console is exactly jdbc:h2:mem:productdb. A typo connects you to a different (empty) database.
- Check the IntelliJ console for errors. If you see a compilation error, verify that Product.java has the correct package declaration: package com.example.productcatalog.entity;.
- Confirm the @Entity annotation is present on the Product class. Without it, JPA does not scan the class.
Help me troubleshoot: Why isn't my PRODUCTS table showing up in the H2 console?
Your data layer is ready. The Product entity defines the shape of your data, and the repository gives you instant database operations. Next up, you will build the service layer that adds business logic on top of this foundation.
Create the Service Layer with DTOs
Your database layer is working. The Product entity maps directly to the PRODUCTS table, and the repository gives you free CRUD operations.
Professional APIs never expose database entities directly to clients. Instead, they use DTOs (Data Transfer Objects) to define exactly what data goes in and out of the API. In this step, you will build the service layer that sits between the controller and the repository, translating between DTOs and entities.
In this step, get ready to:
- Create request and response DTOs with Jakarta validation annotations.
- Build a service interface and implementation that maps between DTOs and entities.
- Create a custom exception for handling missing resources.
Create DTOs for request and response
A DTO defines the shape of data your API accepts and returns. You will create two: one for incoming requests (with validation rules) and one for outgoing responses (with the generated ID).
- In IntelliJ's Project sidebar, right-click the com.example.productcatalog package.
- Select New > Package.
- Enter com.example.productcatalog.dto as the package name and press Enter.
- Right-click the new dto package and select New > Java Class.
- Name it ProductRequestDto and press Enter.
- Replace the generated content with the following code:
package com.example.productcatalog.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
public class ProductRequestDto {
// Name must not be blank (null or empty string)
@NotBlank(message = "Product name is required")
private String name;
@NotBlank(message = "Description is required")
private String description;
// Price must exist and be greater than zero
@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
private BigDecimal price;
@NotBlank(message = "Category is required")
private String category;
@NotNull(message = "Stock status is required")
private Boolean inStock;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Boolean getInStock() { return inStock; }
public void setInStock(Boolean inStock) { this.inStock = inStock; }
}
What does this code do?
This DTO defines the contract for creating or updating a product. The annotations enforce validation rules automatically:
- @NotBlank rejects null values AND empty strings. Perfect for text fields like name and category.
- @NotNull ensures the field is present. Used for non-string types like BigDecimal and Boolean.
- @Positive ensures the price is greater than zero.
Why use DTOs instead of the Product entity directly?
Exposing JPA entities directly causes problems. If you add a database column later, your API contract changes without warning. DTOs decouple the API contract from the database model so each can evolve independently.
Now create the response DTO that defines what the API sends back to clients.
- Right-click the dto package and select New > Java Class.
- Name it ProductResponseDto and press Enter.
- Replace the generated content with the following code:
package com.example.productcatalog.dto;
import java.math.BigDecimal;
public class ProductResponseDto {
private Long id;
private String name;
private String description;
private BigDecimal price;
private String category;
private Boolean inStock;
// No-arg constructor for Jackson serialization
public ProductResponseDto() {
}
// All-args constructor for convenient mapping
public ProductResponseDto(Long id, String name, String description, BigDecimal price, String category, Boolean inStock) {
this.id = id;
this.name = name;
this.description = description;
this.price = price;
this.category = category;
this.inStock = inStock;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Boolean getInStock() { return inStock; }
public void setInStock(Boolean inStock) { this.inStock = inStock; }
}
What does this code do?
The response DTO includes the id field that the database generates. It has no validation annotations because validation only matters on incoming data. The all-args constructor makes it easy to build response objects from entity fields.
- Save both files with Cmd+S (macOS) or Ctrl+S (Windows).
- Confirm there are no red underlines or error markers in either file.
Seeing red underlines on the import statements?
If jakarta.validation.constraints is unresolved, IntelliJ may not have loaded your Maven dependencies yet. Right-click the pom.xml file in the Project sidebar and select Maven > Reload Project.
Help me fix Jakarta import errors in IntelliJ
Create the custom exception class
Before building the service, you need an exception to throw when a product is not found. This gives the service a clear way to signal "that ID does not exist" without returning null.
- Right-click the com.example.productcatalog package in the Project sidebar.
- Select New > Package and enter com.example.productcatalog.exception.
- Right-click the new exception package and select New > Java Class.
- Name it ResourceNotFoundException and press Enter.
- Replace the generated content with the following code:
package com.example.productcatalog.exception;
// Custom exception thrown when a requested product does not exist
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
What does this code do?
This is a simple custom exception that extends RuntimeException. When the service cannot find a product by ID, it throws this exception with a descriptive message. In a later step, you will wire this to return a proper 404 HTTP response.
- Save the file.
Create the service interface and implementation
The service layer contains your business logic. It sits between the controller (which handles HTTP) and the repository (which handles the database). Professional Java teams define a service interface first, then provide the implementation separately.
- Right-click the com.example.productcatalog package.
- Select New > Package and enter com.example.productcatalog.service.
- Right-click the new service package and select New > Java Class.
- In the dialog, select Interface as the kind.
- Name it ProductService and press Enter.
- Replace the generated content with the following code:
package com.example.productcatalog.service;
import com.example.productcatalog.dto.ProductRequestDto;
import com.example.productcatalog.dto.ProductResponseDto;
import java.util.List;
// Defines the contract for product operations
public interface ProductService {
List<ProductResponseDto> getAllProducts();
ProductResponseDto getProductById(Long id);
ProductResponseDto createProduct(ProductRequestDto dto);
ProductResponseDto updateProduct(Long id, ProductRequestDto dto);
void deleteProduct(Long id);
}
What does this code do?
This interface defines the five CRUD operations your API will support. Notice it uses DTOs in the method signatures, not the Product entity. The controller will talk to this interface, never touching the repository or entity directly.
Now create the implementation class that contains the actual business logic.
- Right-click the service package and select New > Java Class.
- Name it ProductServiceImpl and press Enter.
- Replace the generated content with the following code:
package com.example.productcatalog.service;
import com.example.productcatalog.dto.ProductRequestDto;
import com.example.productcatalog.dto.ProductResponseDto;
import com.example.productcatalog.entity.Product;
import com.example.productcatalog.exception.ResourceNotFoundException;
import com.example.productcatalog.repository.ProductRepository;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
@Service
public class ProductServiceImpl implements ProductService {
// Injected via constructor - Spring resolves this automatically
private final ProductRepository productRepository;
public ProductServiceImpl(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override
public List<ProductResponseDto> getAllProducts() {
// Fetch all entities, convert each to a response DTO
return productRepository.findAll()
.stream()
.map(this::mapToResponseDto)
.collect(Collectors.toList());
}
@Override
public ProductResponseDto getProductById(Long id) {
// Throw custom exception if product not found
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
return mapToResponseDto(product);
}
@Override
public ProductResponseDto createProduct(ProductRequestDto dto) {
// Convert DTO to entity, save, then convert back to response DTO
Product product = mapToEntity(dto);
Product saved = productRepository.save(product);
return mapToResponseDto(saved);
}
@Override
public ProductResponseDto updateProduct(Long id, ProductRequestDto dto) {
// Find existing product or throw exception
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
// Update entity fields from DTO
product.setName(dto.getName());
product.setDescription(dto.getDescription());
product.setPrice(dto.getPrice());
product.setCategory(dto.getCategory());
product.setInStock(dto.getInStock());
Product updated = productRepository.save(product);
return mapToResponseDto(updated);
}
@Override
public void deleteProduct(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
productRepository.delete(product);
}
// Converts a Product entity to a response DTO
private ProductResponseDto mapToResponseDto(Product product) {
return new ProductResponseDto(
product.getId(),
product.getName(),
product.getDescription(),
product.getPrice(),
product.getCategory(),
product.getInStock()
);
}
// Converts a request DTO to a Product entity
private Product mapToEntity(ProductRequestDto dto) {
return new Product(
dto.getName(),
dto.getDescription(),
dto.getPrice(),
dto.getCategory(),
dto.getInStock()
);
}
}
What does this code do?
This is where the real work happens. Here are the key patterns:
- @Service tells Spring to manage this class as a bean and make it available for injection.
- Constructor injection gives the service access to ProductRepository without any manual wiring.
- mapToResponseDto() and mapToEntity() handle the translation between DTOs and entities in one place.
- orElseThrow() with ResourceNotFoundException ensures a clear error when a product ID does not exist.
- Save both files.
- Check that IntelliJ shows no red error markers in the service package or anywhere else in the project.
Seeing errors in ProductServiceImpl?
If ResourceNotFoundException is unresolved, make sure you created it in the com.example.productcatalog.exception package (not inside service or dto). If ProductRepository is unresolved, check that your repository is in the com.example.productcatalog.repository package.
Help me fix unresolved imports in my Spring Boot service class
- Restart the application by clicking the green Run button in IntelliJ.
- Confirm the console shows the application started successfully on port 8080 with no errors.
✔️ Awesome, I've got everything!
Great. Make sure all your files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each new file. Compare yours against these:
package com.example.productcatalog.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
public class ProductRequestDto {
@NotBlank(message = "Product name is required")
private String name;
@NotBlank(message = "Description is required")
private String description;
@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
private BigDecimal price;
@NotBlank(message = "Category is required")
private String category;
@NotNull(message = "Stock status is required")
private Boolean inStock;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Boolean getInStock() { return inStock; }
public void setInStock(Boolean inStock) { this.inStock = inStock; }
}
package com.example.productcatalog.dto;
import java.math.BigDecimal;
public class ProductResponseDto {
private Long id;
private String name;
private String description;
private BigDecimal price;
private String category;
private Boolean inStock;
public ProductResponseDto() {
}
public ProductResponseDto(Long id, String name, String description, BigDecimal price, String category, Boolean inStock) {
this.id = id;
this.name = name;
this.description = description;
this.price = price;
this.category = category;
this.inStock = inStock;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getPrice() { return price; }
public void setPrice(BigDecimal price) { this.price = price; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Boolean getInStock() { return inStock; }
public void setInStock(Boolean inStock) { this.inStock = inStock; }
}
package com.example.productcatalog.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
package com.example.productcatalog.service;
import com.example.productcatalog.dto.ProductRequestDto;
import com.example.productcatalog.dto.ProductResponseDto;
import java.util.List;
public interface ProductService {
List<ProductResponseDto> getAllProducts();
ProductResponseDto getProductById(Long id);
ProductResponseDto createProduct(ProductRequestDto dto);
ProductResponseDto updateProduct(Long id, ProductRequestDto dto);
void deleteProduct(Long id);
}
package com.example.productcatalog.service;
import com.example.productcatalog.dto.ProductRequestDto;
import com.example.productcatalog.dto.ProductResponseDto;
import com.example.productcatalog.entity.Product;
import com.example.productcatalog.exception.ResourceNotFoundException;
import com.example.productcatalog.repository.ProductRepository;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
@Service
public class ProductServiceImpl implements ProductService {
private final ProductRepository productRepository;
public ProductServiceImpl(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override
public List<ProductResponseDto> getAllProducts() {
return productRepository.findAll()
.stream()
.map(this::mapToResponseDto)
.collect(Collectors.toList());
}
@Override
public ProductResponseDto getProductById(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
return mapToResponseDto(product);
}
@Override
public ProductResponseDto createProduct(ProductRequestDto dto) {
Product product = mapToEntity(dto);
Product saved = productRepository.save(product);
return mapToResponseDto(saved);
}
@Override
public ProductResponseDto updateProduct(Long id, ProductRequestDto dto) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
product.setName(dto.getName());
product.setDescription(dto.getDescription());
product.setPrice(dto.getPrice());
product.setCategory(dto.getCategory());
product.setInStock(dto.getInStock());
Product updated = productRepository.save(product);
return mapToResponseDto(updated);
}
@Override
public void deleteProduct(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
productRepository.delete(product);
}
private ProductResponseDto mapToResponseDto(Product product) {
return new ProductResponseDto(
product.getId(),
product.getName(),
product.getDescription(),
product.getPrice(),
product.getCategory(),
product.getInStock()
);
}
private Product mapToEntity(ProductRequestDto dto) {
return new Product(
dto.getName(),
dto.getDescription(),
dto.getPrice(),
dto.getCategory(),
dto.getInStock()
);
}
}
Your service layer is complete. The DTOs define a clean API contract, the service handles all the business logic, and the exception gives you a clear error path. Next up, you will wire everything to HTTP endpoints by creating the REST controller.
Create the REST Controller
Your entity, repository, service, and DTOs are all in place. The last piece of the puzzle is the REST controller. This is the class that receives HTTP requests from the outside world and routes them to your service layer.
In this step, you will create the controller class and test every endpoint to confirm your full CRUD pipeline works end to end.
In this step, get ready to:
- Create a ProductController with five REST endpoints.
- Test all CRUD operations using curl or Postman.
Create the ProductController
The controller is the entry point for all HTTP traffic. It maps URL paths and HTTP methods to your service methods, and it decides what HTTP status code to return.
- In the IntelliJ Project sidebar, right-click the com.example.productcatalog package.
- Select New then Package.
- Enter controller as the package name and press Enter.
- Right-click the new controller package, select New then Java Class.
- Name it ProductController and press Enter.
Replace the generated file contents with the class declaration, constructor injection, and the two GET endpoints by pasting the following code:
package com.example.productcatalog.controller;
import com.example.productcatalog.dto.ProductRequestDto;
import com.example.productcatalog.dto.ProductResponseDto;
import com.example.productcatalog.service.ProductService;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
// Marks this class as a REST controller that handles HTTP requests
@RestController
// All endpoints in this controller start with /api/products
@RequestMapping("/api/products")
public class ProductController {
// Inject the service layer via constructor
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
// GET /api/products - returns all products
@GetMapping
public ResponseEntity<List<ProductResponseDto>> getAllProducts() {
return ResponseEntity.ok(productService.getAllProducts());
}
// GET /api/products/{id} - returns a single product by ID
@GetMapping("/{id}")
public ResponseEntity<ProductResponseDto> getProductById(@PathVariable Long id) {
return ResponseEntity.ok(productService.getProductById(id));
}
}
What does this code do?
@RestController tells Spring this class handles HTTP requests and automatically serializes return values to JSON.
@RequestMapping("/api/products") sets the base URL path for every endpoint in this controller. All methods inherit this prefix.
The constructor accepts a ProductService parameter. Spring automatically injects the ProductServiceImpl bean here because it is the only implementation.
ResponseEntity.ok() wraps the response body with a 200 OK status code.
Now add the write endpoints (POST, PUT, DELETE) below the getProductById method, inside the class closing brace:
// POST /api/products - creates a new product
@PostMapping
public ResponseEntity<ProductResponseDto> createProduct(@Valid @RequestBody ProductRequestDto dto) {
ProductResponseDto created = productService.createProduct(dto);
return new ResponseEntity<>(created, HttpStatus.CREATED);
}
// PUT /api/products/{id} - updates an existing product
@PutMapping("/{id}")
public ResponseEntity<ProductResponseDto> updateProduct(@PathVariable Long id, @Valid @RequestBody ProductRequestDto dto) {
return ResponseEntity.ok(productService.updateProduct(id, dto));
}
// DELETE /api/products/{id} - removes a product
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build();
}
What does this code do?
@Valid triggers the Jakarta Bean Validation annotations you defined on ProductRequestDto. If validation fails, Spring automatically returns a 400 Bad Request.
@RequestBody tells Spring to deserialize the incoming JSON body into a ProductRequestDto object.
The POST endpoint returns HttpStatus.CREATED (201) instead of 200, which is the correct status code for resource creation. The DELETE endpoint returns ResponseEntity.noContent().build() which sends a 204 No Content.
- Save ProductController.java.
- Confirm IntelliJ shows no red underlines in the file.
Seeing red underlines on imports?
Make sure you added all the imports at the top of the file. If IntelliJ suggests auto-importing, accept its suggestions. If you see an error on jakarta.validation.Valid, confirm the Validation dependency is in your pom.xml.
help me fix import errors in my Spring Boot controller
✔️ Awesome, I've got everything!
Great. Double check you have saved ProductController.java before moving on.
ⓧ I'd like to double check the full code
Here is the complete ProductController.java file:
package com.example.productcatalog.controller;
import com.example.productcatalog.dto.ProductRequestDto;
import com.example.productcatalog.dto.ProductResponseDto;
import com.example.productcatalog.service.ProductService;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public ResponseEntity<List<ProductResponseDto>> getAllProducts() {
return ResponseEntity.ok(productService.getAllProducts());
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponseDto> getProductById(@PathVariable Long id) {
return ResponseEntity.ok(productService.getProductById(id));
}
@PostMapping
public ResponseEntity<ProductResponseDto> createProduct(@Valid @RequestBody ProductRequestDto dto) {
ProductResponseDto created = productService.createProduct(dto);
return new ResponseEntity<>(created, HttpStatus.CREATED);
}
@PutMapping("/{id}")
public ResponseEntity<ProductResponseDto> updateProduct(@PathVariable Long id, @Valid @RequestBody ProductRequestDto dto) {
return ResponseEntity.ok(productService.updateProduct(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build();
}
}
Test the API with curl
Your controller is wired up. Time to run the application and hit every endpoint to confirm the full request cycle works.
- Stop the currently running application in IntelliJ by clicking the red Stop button in the top toolbar.
- Run ProductcatalogApplication.java again by clicking the green Run button.
- Wait for the console to show Started ProductcatalogApplication before testing.
Open a terminal (the built-in IntelliJ terminal works fine) and create a new product by running this command:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d "{\"name\":\"Wireless Mouse\",\"description\":\"Ergonomic wireless mouse\",\"price\":29.99,\"category\":\"Electronics\",\"inStock\":true}"
What does this command do?
-X POST sets the HTTP method. -H adds the Content-Type header so Spring knows the body is JSON. -d provides the JSON request body with your product data.
You should see a JSON response containing the product with an auto-generated id field and a 201 status.
- Retrieve all products by running:
curl http://localhost:8080/api/products
You should see a JSON array containing the product you just created.
- Retrieve a single product by ID by running:
curl http://localhost:8080/api/products/1
You should see the single product object with "id":1.
- Update the product by running:
curl -X PUT http://localhost:8080/api/products/1 -H "Content-Type: application/json" -d "{\"name\":\"Wireless Mouse Pro\",\"description\":\"Ergonomic wireless mouse with RGB\",\"price\":39.99,\"category\":\"Electronics\",\"inStock\":true}"
You should see the updated product with the new name and price.
- Delete the product by running:
curl -X DELETE http://localhost:8080/api/products/1 -v
The -v flag shows verbose output. Look for 204 No Content in the response status line to confirm the delete worked.
- Try requesting a product that does not exist by running:
curl http://localhost:8080/api/products/999 -v
You should see a 500 Internal Server Error. This is expected for now. Your service throws a ResourceNotFoundException, but Spring does not know how to translate it into a proper 404 response yet. You will fix this in the next step with a global exception handler.
Getting connection refused?
Make sure your application is still running. Check the IntelliJ console for the Started ProductcatalogApplication message. If the application crashed on startup, look for error messages about port 8080 already being in use or missing beans.
help me debug why my Spring Boot app won't start or curl cannot connect
Prefer a GUI for testing?
You can use Postman instead of curl. Create a new request, set the method and URL, add a JSON body under the Body tab (select raw and JSON), and click Send.
Your API is fully functional. Every CRUD operation works end to end. Next up, you will add proper error handling so that invalid requests return clean, consistent error responses instead of a 500 stacktrace.
Add Validation and Global Exception Handling
Your CRUD endpoints work, but right now they don't communicate errors clearly. If a client requests a product that doesn't exist, they get a raw 500 error with a stack trace. If they send invalid data, Spring returns a generic 400 with no helpful details.
Professional APIs need consistent, predictable error responses. In this step, you will build a global exception handler that catches errors across your entire application and returns structured JSON with the right HTTP status codes.
In this step, get ready to:
- Verify that validation already rejects invalid input with a default 400 response.
- Create a global exception handler that returns structured error JSON for 404 and 400 errors.
- Test the complete error handling to confirm consistent responses.
Verify validation works on the controller
Before building custom error handling, confirm that the validation annotations you added to ProductRequestDto are already working. Spring automatically rejects invalid request bodies with a 400 status.
- Make sure your application is running in IntelliJ.
- Send a POST request to /api/products with an empty JSON body by running this in your terminal:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d "{}"
- You should see a 400 Bad Request response. The response body contains Spring's default error format, but it doesn't tell the client which fields failed or why.
Why isn't the default 400 response good enough?
Spring's default error response includes a status code and a generic message, but it doesn't list which fields failed validation or what the specific error was. API clients need field-level details to show meaningful form errors to users.
- Now try fetching a product that doesn't exist by running:
curl http://localhost:8080/api/products/999
- You should see a 500 Internal Server Error. This is because the ResourceNotFoundException you created earlier is thrown, but nothing catches it. Spring treats it as an unhandled exception and returns a 500.
What's wrong with a 500 for a missing product?
A 500 means something broke on the server. A missing product isn't a server error. It's a "not found" situation that should return a 404. Returning the correct status code helps clients handle errors programmatically.
Create the global exception handler
A global exception handler intercepts exceptions thrown anywhere in your application and converts them into structured JSON responses with appropriate HTTP status codes. You will create two files: an ErrorResponse class that defines the shape of error JSON, and a GlobalExceptionHandler class that catches exceptions.
- In IntelliJ, right-click the com.example.productcatalog.exception package in the Project sidebar.
- Select New then Java Class.
- Name it ErrorResponse and press Enter.
- Replace the file contents with this code:
package com.example.productcatalog.exception;
import java.time.LocalDateTime;
public class ErrorResponse {
private int status;
private String message;
private LocalDateTime timestamp;
public ErrorResponse(int status, String message, LocalDateTime timestamp) {
this.status = status;
this.message = message;
this.timestamp = timestamp;
}
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public LocalDateTime getTimestamp() { return timestamp; }
public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
}
What does this code do?
ErrorResponse is a simple data class that defines the JSON shape of every error your API returns. It includes three fields:
- status holds the HTTP status code as an integer (e.g. 404).
- message holds a human-readable error description.
- timestamp records when the error occurred.
- Save ErrorResponse.java.
Now create the handler class that catches exceptions and builds these error responses.
- Right-click the com.example.productcatalog.exception package again.
- Select New then Java Class.
- Name it GlobalExceptionHandler and press Enter.
- Replace the file contents with this code:
package com.example.productcatalog.exception;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
// Catches ResourceNotFoundException and returns a 404 response
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
// Catches validation failures and returns a 400 response with field-level errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
Map<String, Object> response = new HashMap<>();
response.put("status", HttpStatus.BAD_REQUEST.value());
response.put("errors", fieldErrors);
response.put("timestamp", LocalDateTime.now().toString());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
}
What does this code do?
@RestControllerAdvice tells Spring that this class handles exceptions across all controllers. You don't need to add error handling to each controller individually.
- handleResourceNotFound catches your custom ResourceNotFoundException and returns a 404 with a structured ErrorResponse JSON body.
- handleValidationErrors catches MethodArgumentNotValidException (thrown when @Valid fails) and returns a 400 with a map of field names to their specific error messages.
Why @RestControllerAdvice instead of try-catch?
Without a global handler, you would need try-catch blocks in every controller method. @RestControllerAdvice centralizes all error handling in one place. Every controller in your application automatically benefits from it.
- Save GlobalExceptionHandler.java.
- Restart your application by clicking the green Run button in IntelliJ (or press Shift+F10 on Windows / Ctrl+R on macOS).
- Confirm the application starts without errors in the console.
Application won't start?
Check that GlobalExceptionHandler.java is inside the com.example.productcatalog.exception package. If it's in the wrong package, Spring won't scan it. Also confirm there are no red underlines in your imports.
help me fix my Spring Boot application startup error
Test the complete error handling
With the global exception handler in place, your API now returns proper status codes and structured error bodies. Time to verify all three scenarios work correctly.
- Test the 404 response by requesting a product that does not exist:
curl http://localhost:8080/api/products/999
- You should now see a JSON response with status set to 404, a message of Product not found with id: 999, and a timestamp. No more 500 errors for missing products.
- Test the validation error response by sending a POST with invalid data:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d "{\"price\": -5}"
- You should see a 400 response with an errors object listing each invalid field and its specific message (e.g. Product name is required, Price must be positive).
- Confirm your success responses still work by creating a valid product:
curl -X POST http://localhost:8080/api/products -H "Content-Type: application/json" -d "{\"name\": \"Keyboard\", \"description\": \"Mechanical keyboard\", \"price\": 79.99, \"category\": \"Electronics\", \"inStock\": true}"
- You should see a 201 Created response with the full product JSON, including a generated id.
Still seeing a 500 for missing products?
Make sure you restarted the application after saving GlobalExceptionHandler.java. Spring only picks up new classes on startup. Also verify that @RestControllerAdvice is present on the class (not just @ControllerAdvice).
help me debug why my global exception handler isn't catching ResourceNotFoundException
✔️ Awesome, I've got everything!
Great. Double check you have saved both ErrorResponse.java and GlobalExceptionHandler.java in the exception package.
ⓧ I'd like to double check the full code
package com.example.productcatalog.exception;
import java.time.LocalDateTime;
public class ErrorResponse {
private int status;
private String message;
private LocalDateTime timestamp;
public ErrorResponse(int status, String message, LocalDateTime timestamp) {
this.status = status;
this.message = message;
this.timestamp = timestamp;
}
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public LocalDateTime getTimestamp() { return timestamp; }
public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
}
package com.example.productcatalog.exception;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
Map<String, Object> response = new HashMap<>();
response.put("status", HttpStatus.BAD_REQUEST.value());
response.put("errors", fieldErrors);
response.put("timestamp", LocalDateTime.now().toString());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
}
Your API now communicates errors like a professional backend. Clients get the exact status code and details they need to handle failures gracefully. Next up, you will tackle the Secret Mission and add search, filtering, and pagination.
Secret mission
Add Search, Filter, and Pagination
Your API handles basic CRUD, but real product catalogs have thousands of items. Can you add search by name, filter by category, and paginated results so clients only fetch what they need?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The productcatalog project folder on your Desktop.
- The Spring Boot application process running on port 8080.
- H2 in-memory database (ephemeral, lost when the application stops).
Keep everything running
No action needed. Choose this if you are still actively building or testing your API.
- Leave IntelliJ IDEA open with the application running.
- Your H2 database keeps your test data in memory as long as the app stays running.
- You can continue making API requests to http://localhost:8080/api/products.
Pause - I'll come back to this later
Stop the application to free up memory. You can restart anytime with no concerns about data loss, because H2 is ephemeral by design.
- In IntelliJ IDEA, click the red square Stop button in the top toolbar to stop the application.
- Your project files remain on disk. To resume, reopen the project in IntelliJ and run ProductcatalogApplication.java again.
Delete - I don't want to use this again
Remove the project folder from your machine entirely. There are no cloud resources or databases to tear down separately.
- In IntelliJ IDEA, click the red square Stop button in the top toolbar to stop the application.
- Close IntelliJ IDEA.
- Delete the productcatalog folder from your Desktop.
On macOS or Linux, run this command in your terminal:
rm -rf ~/Desktop/productcatalog
On Windows, run this command in PowerShell:
Remove-Item -Recurse -Force ~\Desktop\productcatalog
Nice Work!
Nice Work!
Nice work. You just built a production-style REST API from scratch using Spring Boot, complete with layered architecture, validation, and error handling.
You've learned how to:
- Build a complete REST API with full CRUD operations (Create, Read, Update, Delete) using Spring Boot and Spring Data JPA.
- Structure a layered architecture (Controller > Service > Repository) with DTOs to separate your API contract from your database model.
- Implement Jakarta Bean Validation for input validation and a global exception handler that returns consistent error responses with proper HTTP status codes.
- Secret Mission: Added search by name, filter by category, and pagination using Spring Data JPA query methods and Pageable.
Ready to quiz yourself?