๐Ÿ‡ฎ๐Ÿ‡ณ
๐Ÿ‡ฎ๐Ÿ‡ณ
Limited-Time Offer!Get 20% OFF on all live courses
Enroll Now
PrakalpanaLive online tech training
Java Ecosystemโฑ๏ธ 14 min read๐Ÿ“… Oct 1

Spring Boot REST API Tutorial (2026): Build a CRUD API with JPA, Validation & Tests

PM
Priya Menonโ€ขBackend Tech Lead
๐Ÿ“‘ Contents (20 sections)

๐Ÿ“ŒWhat We Will Build

A REST API for managing products, the way it is built in real teams:

  • Spring Boot 3 on Java 21
  • Spring Data JPA with PostgreSQL (H2 for tests)
  • Records as request and response DTOs
  • Bean Validation on input
  • Global error handling with RFC 9457 ProblemDetail
  • Pagination and sorting
  • Slice and integration tests
  • OpenAPI and Swagger UI documentation
  • New 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/shop
    spring.datasource.username=shop
    spring.datasource.password=shop
    spring.jpa.hibernate.ddl-auto=validate
    spring.jpa.open-in-view=false
    spring.threads.virtual.enabled=true

    Use 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
    @Transactional
    public 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");
    }
    }
    @RestControllerAdvice
    public 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

  • Secure the API โ€” see our Spring Security JWT tutorial
  • Add Spring Boot Actuator for health checks and metrics
  • Use database migrations (Flyway or Liquibase)
  • Containerise with a multi-stage Docker build (Docker course)
  • Split into services when the domain grows โ€” Spring Boot microservices tutorial
  • ๐Ÿ“Œ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?

    Use BigDecimal 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.

    PM

    Written by

    Priya Menon

    Backend Tech Lead

    ๐Ÿš€ Master Java Ecosystem

    Live online + 1-on-1 Spring Boot training ยท Join 5000+ developers