๐What We Will Build
A REST API for managing products, the way it is built in real teams:
ProblemDetailNew to Spring Boot? Read Spring vs Spring Boot first, or join our live Spring Boot course.
๐1. Create the Project
Generate a project at start.spring.io with Java 21, Maven, and these dependencies: Spring Web, Spring Data JPA, Validation, PostgreSQL Driver, H2 Database and Spring Boot DevTools. Add springdoc for API docs:
<dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.8.9</version></dependency>๐2. Configure the Database
spring.datasource.url=jdbc:postgresql://localhost:5432/shopspring.datasource.username=shopspring.datasource.password=shopspring.jpa.hibernate.ddl-auto=validatespring.jpa.open-in-view=falsespring.threads.virtual.enabled=trueUse ddl-auto=validate with Flyway or Liquibase migrations in real projects โ never update in production. Disabling open-in-view avoids lazy-loading surprises in the web layer.
๐3. The Entity
@Entity@Table(name = "products")public class Product { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false) private String name; private String description; @Column(nullable = false, precision = 12, scale = 2) private BigDecimal price; private int stock; protected Product() {} public Product(String name, String description, BigDecimal price, int stock) { this.name = name; this.description = description; this.price = price; this.stock = stock; } // getters and setters omitted}๐4. The Repository
public interface ProductRepository extends JpaRepository<Product, Long> { Page<Product> findByNameContainingIgnoreCase(String name, Pageable pageable);}Spring Data generates the implementation, including the derived query.
๐5. DTOs With Records and Validation
Never expose entities directly โ use DTOs so your API contract is independent of your database schema.
public record ProductRequest( @NotBlank @Size(max = 120) String name, @Size(max = 1000) String description, @NotNull @DecimalMin("0.01") BigDecimal price, @PositiveOrZero int stock) {}public record ProductResponse(Long id, String name, String description, BigDecimal price, int stock) { static ProductResponse from(Product p) { return new ProductResponse(p.getId(), p.getName(), p.getDescription(), p.getPrice(), p.getStock()); }}๐6. The Service Layer
@Service@Transactionalpublic class ProductService { private final ProductRepository repository; public ProductService(ProductRepository repository) { this.repository = repository; } @Transactional(readOnly = true) public Page<ProductResponse> list(String q, Pageable pageable) { Page<Product> page = (q == null || q.isBlank()) ? repository.findAll(pageable) : repository.findByNameContainingIgnoreCase(q, pageable); return page.map(ProductResponse::from); } @Transactional(readOnly = true) public ProductResponse get(Long id) { return ProductResponse.from(find(id)); } public ProductResponse create(ProductRequest req) { Product saved = repository.save(new Product(req.name(), req.description(), req.price(), req.stock())); return ProductResponse.from(saved); } public ProductResponse update(Long id, ProductRequest req) { Product p = find(id); p.setName(req.name()); p.setDescription(req.description()); p.setPrice(req.price()); p.setStock(req.stock()); return ProductResponse.from(p); // dirty checking saves the changes } public void delete(Long id) { repository.delete(find(id)); } private Product find(Long id) { return repository.findById(id).orElseThrow(() -> new ProductNotFoundException(id)); }}๐7. The Controller
@RestController@RequestMapping("/api/v1/products")public class ProductController { private final ProductService service; public ProductController(ProductService service) { this.service = service; } @GetMapping public Page<ProductResponse> list(@RequestParam(required = false) String q, @PageableDefault(size = 20, sort = "name") Pageable pageable) { return service.list(q, pageable); } @GetMapping("/{id}") public ProductResponse get(@PathVariable Long id) { return service.get(id); } @PostMapping public ResponseEntity<ProductResponse> create(@Valid @RequestBody ProductRequest req) { ProductResponse created = service.create(req); URI location = URI.create("/api/v1/products/" + created.id()); return ResponseEntity.created(location).body(created); } @PutMapping("/{id}") public ProductResponse update(@PathVariable Long id, @Valid @RequestBody ProductRequest req) { return service.update(id, req); } @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete(@PathVariable Long id) { service.delete(id); }}Notice the REST conventions: plural resource names, a version in the path, 201 Created with a Location header for POST and 204 No Content for DELETE. Pagination works out of the box: GET /api/v1/products?page=0&size=10&sort=price,desc.
๐8. Global Error Handling With ProblemDetail
public class ProductNotFoundException extends RuntimeException { public ProductNotFoundException(Long id) { super("Product " + id + " not found"); }}@RestControllerAdvicepublic class GlobalExceptionHandler { @ExceptionHandler(ProductNotFoundException.class) public ProblemDetail handleNotFound(ProductNotFoundException ex) { ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); pd.setTitle("Product not found"); return pd; } @ExceptionHandler(MethodArgumentNotValidException.class) public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); pd.setTitle("Validation failed"); Map<String, String> errors = new HashMap<>(); ex.getBindingResult().getFieldErrors() .forEach(fe -> errors.put(fe.getField(), fe.getDefaultMessage())); pd.setProperty("errors", errors); return pd; }}Clients now get consistent, standard JSON errors (application/problem+json) instead of stack traces.
๐9. Testing
A fast web-layer slice test with MockMvc:
@WebMvcTest(ProductController.class)class ProductControllerTest { @Autowired MockMvc mvc; @MockitoBean ProductService service; @Test void returns404WhenProductMissing() throws Exception { when(service.get(99L)).thenThrow(new ProductNotFoundException(99L)); mvc.perform(get("/api/v1/products/99")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.title").value("Product not found")); } @Test void rejectsInvalidProduct() throws Exception { mvc.perform(post("/api/v1/products") .contentType(MediaType.APPLICATION_JSON) .content("{ \"name\": \"\", \"price\": 0 }")) .andExpect(status().isBadRequest()); }}@MockitoBean replaces the deprecated @MockBean from Spring Boot 3.4. For repository and full integration tests, use @DataJpaTest and @SpringBootTest with Testcontainers running a real PostgreSQL.
๐10. API Documentation
With springdoc on the classpath, open /swagger-ui.html to explore and try every endpoint, and /v3/api-docs for the OpenAPI JSON your front-end team or API gateway can consume.
๐Production Checklist
๐Frequently Asked Questions
Should I return entities or DTOs from controllers?
DTOs. Returning JPA entities couples your API to your schema, risks lazy-loading exceptions and infinite recursion on bidirectional relationships, and can leak fields you never meant to expose. Records make DTOs nearly free to write.PUT vs PATCH?
PUT replaces the whole resource with the representation you send, so it is idempotent and every field is required. PATCH applies a partial update. Many APIs support both: PUT for full edits from forms and PATCH for small changes such as updating stock.Where should validation live?
Validate request shape at the edge with Bean Validation (@Valid on the request body), and enforce business rules โ such as "price cannot drop below cost" โ in the service layer, where they apply no matter which controller or message listener calls it.How do I version a REST API?
A version in the URL path (/api/v1) is the simplest and most common choice. Header- or media-type-based versioning is cleaner in theory but harder for clients and tooling. Whatever you choose, only bump the version for breaking changes.Is Spring WebFlux better for REST APIs?
Not by default. With virtual threads in Java 21 and Spring Boot 3.2+, the traditional blocking Spring MVC stack scales well for I/O-bound APIs and is far simpler to write, debug and test. Choose WebFlux when you need streaming or an end-to-end reactive pipeline.How should I handle money?
UseBigDecimal in Java and a fixed-precision NUMERIC column in the database, as in the entity above. Never use double or float for currency โ rounding errors will appear in totals.๐Learn It Live
Expect questions on exactly this in interviews โ revise with Spring Boot interview questions. Prakalpana's Spring Boot course builds several production-style APIs like this one live online, with an optional 1-on-1 track and placement support. WhatsApp or call +91 9243078181 for a free demo.