Spring BootBuilding REST APIs
Spring Boot

Building REST APIs

Spring Boot is the most widely used framework for building REST APIs in Java. It provides everything needed out of the box — HTTP routing, JSON serialization, input validation, error handling, and content negotiation — with minimal configuration. This guide covers the complete picture: designing endpoints, handling requests and responses, validation, error handling, pagination, and the patterns that make REST APIs production-ready.

REST API Foundations in Spring Boot

A REST API in Spring Boot is built around three core annotations: @RestController marks the class as a REST endpoint handler, @RequestMapping defines the base URL path for the controller, and the HTTP method annotations (@GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping) map specific HTTP methods and paths to handler methods. Spring Boot's embedded Tomcat handles all HTTP concerns — parsing request bodies, serializing response objects to JSON (via Jackson), setting response headers, and managing HTTP status codes. You write plain Java methods that return Java objects — Spring Boot handles the HTTP layer automatically. Jackson is auto-configured by Spring Boot and handles JSON serialization transparently. A Java object returned from a @RestController method is automatically serialized to JSON. A JSON request body annotated with @RequestBody is automatically deserialized to a Java object.

Controller Structure and HTTP Method Mapping

A complete REST controller covering all five HTTP methods with proper URL design, status codes, and response types.
Java
@RestController
@RequestMapping("/api/v1/products")
@RequiredArgsConstructor
@Slf4j
public class ProductController {

    private final ProductService productService;

    // GET /api/v1/products — list all (with pagination):
    @GetMapping
    public Page<ProductResponse> getAllProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "name") String sortBy,
            @RequestParam(defaultValue = "asc") String direction) {

        Sort sort = direction.equalsIgnoreCase("desc")
            ? Sort.by(sortBy).descending()
            : Sort.by(sortBy).ascending();

        return productService.findAll(PageRequest.of(page, size, sort));
    }

    // GET /api/v1/products/42 — get one by ID:
    @GetMapping("/{id}")
    public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
        return productService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // GET /api/v1/products/search?query=laptop&category=electronics — search:
    @GetMapping("/search")
    public List<ProductResponse> searchProducts(
            @RequestParam String query,
            @RequestParam(required = false) String category,
            @RequestParam(defaultValue = "10") int limit) {
        return productService.search(query, category, limit);
    }

    // POST /api/v1/products — create new:
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ProductResponse createProduct(
            @RequestBody @Valid CreateProductRequest request) {
        log.info("Creating product: {}", request.name());
        return productService.create(request);
    }

    // PUT /api/v1/products/42 — full replacement:
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @RequestBody @Valid UpdateProductRequest request) {
        return productService.update(id, request)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // PATCH /api/v1/products/42 — partial update:
    @PatchMapping("/{id}")
    public ResponseEntity<ProductResponse> patchProduct(
            @PathVariable Long id,
            @RequestBody @Valid PatchProductRequest request) {
        return productService.patch(id, request)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // DELETE /api/v1/products/42 — delete:
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteProduct(@PathVariable Long id) {
        productService.delete(id);
    }
}

Request DTOs — Input Validation

Request DTOs define what the API accepts. They carry validation annotations enforced by @Valid — invalid input is rejected with HTTP 400 before reaching any business logic.
Java
// Create request — all required fields:
public record CreateProductRequest(

    @NotBlank(message = "Product name is required")
    @Size(min = 2, max = 200, message = "Name must be 2-200 characters")
    String name,

    @NotBlank(message = "Description is required")
    @Size(max = 2000, message = "Description cannot exceed 2000 characters")
    String description,

    @NotNull(message = "Price is required")
    @DecimalMin(value = "0.01", message = "Price must be greater than 0")
    @Digits(integer = 8, fraction = 2, message = "Invalid price format")
    BigDecimal price,

    @NotNull(message = "Category is required")
    Long categoryId,

    @Min(value = 0, message = "Stock cannot be negative")
    int stockQuantity,

    @Size(max = 10, message = "Maximum 10 tags allowed")
    @NotNull
    List<@NotBlank @Size(max = 50) String> tags

) { }

// Update request — all fields required (PUT = full replacement):
public record UpdateProductRequest(

    @NotBlank
    @Size(min = 2, max = 200)
    String name,

    @NotBlank
    @Size(max = 2000)
    String description,

    @NotNull
    @DecimalMin("0.01")
    BigDecimal price,

    @NotNull
    Long categoryId,

    @Min(0)
    int stockQuantity

) { }

// Patch request — all fields optional (PATCH = partial update):
public record PatchProductRequest(

    @Size(min = 2, max = 200)
    String name,                   // null = don't update

    @Size(max = 2000)
    String description,            // null = don't update

    @DecimalMin("0.01")
    BigDecimal price,              // null = don't update

    Long categoryId,               // null = don't update

    @Min(0)
    Integer stockQuantity          // null = don't update — Integer not int

) { }

// Custom cross-field validation:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PriceRangeValidator.class)
public @interface ValidPriceRange {
    String message() default "Sale price must be less than regular price";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

@ValidPriceRange
public record CreateSaleRequest(
    @NotNull BigDecimal regularPrice,
    @NotNull BigDecimal salePrice
) { }

public class PriceRangeValidator
        implements ConstraintValidator<ValidPriceRange, CreateSaleRequest> {
    @Override
    public boolean isValid(CreateSaleRequest req, ConstraintValidatorContext ctx) {
        if (req.regularPrice() == null || req.salePrice() == null) return true;
        return req.salePrice().compareTo(req.regularPrice()) < 0;
    }
}

Response DTOs and ResponseEntity

Response DTOs define what the API returns. They expose exactly the fields clients need — no more, no less. ResponseEntity gives you control over HTTP status codes and response headers.
Java
// Response DTO — never expose JPA entities directly:
public record ProductResponse(
    Long id,
    String name,
    String description,
    BigDecimal price,
    String category,
    int stockQuantity,
    boolean inStock,
    List<String> tags,
    LocalDateTime createdAt,
    LocalDateTime updatedAt
) {
    // Factory method — converts entity to DTO:
    public static ProductResponse from(Product product) {
        return new ProductResponse(
            product.getId(),
            product.getName(),
            product.getDescription(),
            product.getPrice(),
            product.getCategory().getName(),
            product.getStockQuantity(),
            product.getStockQuantity() > 0,
            product.getTags().stream().map(Tag::getName).toList(),
            product.getCreatedAt(),
            product.getUpdatedAt()
        );
    }
}

// Paginated response — wraps Page<T> for consistent API shape:
public record PagedResponse<T>(
    List<T> content,
    int pageNumber,
    int pageSize,
    long totalElements,
    int totalPages,
    boolean first,
    boolean last
) {
    public static <T> PagedResponse<T> from(Page<T> page) {
        return new PagedResponse<>(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements(),
            page.getTotalPages(),
            page.isFirst(),
            page.isLast()
        );
    }
}

// ResponseEntity — full control over response:
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {

    // 200 OK with body:
    return productService.findById(id)
        .map(ResponseEntity::ok)
        .orElse(ResponseEntity.notFound().build());  // 404

    // Custom status:
    // return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT).body(dto);

    // With headers:
    // return ResponseEntity.ok()
    //     .header("X-Product-Version", "2")
    //     .header("Cache-Control", "max-age=3600")
    //     .body(dto);

    // Created with Location header:
    // URI location = URI.create("/api/v1/products/" + saved.getId());
    // return ResponseEntity.created(location).body(saved);
}

@PostMapping
public ResponseEntity<ProductResponse> createProduct(
        @RequestBody @Valid CreateProductRequest request,
        UriComponentsBuilder uriBuilder) {

    ProductResponse created = productService.create(request);

    // 201 Created with Location header pointing to the new resource:
    URI location = uriBuilder
        .path("/api/v1/products/{id}")
        .buildAndExpand(created.id())
        .toUri();

    return ResponseEntity.created(location).body(created);
}

Global Exception Handling

@RestControllerAdvice centralizes exception handling. Every exception thrown anywhere in the application is caught here and converted to a consistent error response — no try-catch blocks in controllers.
Java
// Standard error response structure:
public record ErrorResponse(
    String code,
    String message,
    List<FieldError> errors,
    LocalDateTime timestamp,
    String path
) {
    public record FieldError(String field, String message) { }

    // Constructor for simple errors without field details:
    public static ErrorResponse of(String code, String message, String path) {
        return new ErrorResponse(code, message, List.of(),
            LocalDateTime.now(), path);
    }
}

// Global exception handler:
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 404 — resource not found:
    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorResponse handleNotFound(ResourceNotFoundException ex,
                                         HttpServletRequest request) {
        return ErrorResponse.of("RESOURCE_NOT_FOUND", ex.getMessage(),
            request.getRequestURI());
    }

    // 409 — duplicate/conflict:
    @ExceptionHandler(DuplicateResourceException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorResponse handleDuplicate(DuplicateResourceException ex,
                                          HttpServletRequest request) {
        return ErrorResponse.of("DUPLICATE_RESOURCE", ex.getMessage(),
            request.getRequestURI());
    }

    // 400 — validation failure (@Valid on @RequestBody):
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleValidation(MethodArgumentNotValidException ex,
                                           HttpServletRequest request) {
        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(e -> new ErrorResponse.FieldError(e.getField(), e.getDefaultMessage()))
            .toList();

        return new ErrorResponse(
            "VALIDATION_FAILED",
            "Request validation failed",
            fieldErrors,
            LocalDateTime.now(),
            request.getRequestURI()
        );
    }

    // 400 — malformed JSON or type mismatch:
    @ExceptionHandler(HttpMessageNotReadableException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleMalformedJson(HttpMessageNotReadableException ex,
                                              HttpServletRequest request) {
        return ErrorResponse.of("MALFORMED_REQUEST",
            "Request body is malformed or contains invalid values",
            request.getRequestURI());
    }

    // 400 — path variable or query param type mismatch:
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ErrorResponse handleTypeMismatch(MethodArgumentTypeMismatchException ex,
                                             HttpServletRequest request) {
        String message = String.format("Parameter '%s' must be of type %s",
            ex.getName(), ex.getRequiredType().getSimpleName());
        return ErrorResponse.of("INVALID_PARAMETER", message,
            request.getRequestURI());
    }

    // 405 — wrong HTTP method:
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
    public ErrorResponse handleMethodNotAllowed(
            HttpRequestMethodNotSupportedException ex,
            HttpServletRequest request) {
        return ErrorResponse.of("METHOD_NOT_ALLOWED",
            ex.getMessage(), request.getRequestURI());
    }

    // 403 — access denied:
    @ExceptionHandler(AccessDeniedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public ErrorResponse handleAccessDenied(AccessDeniedException ex,
                                             HttpServletRequest request) {
        return ErrorResponse.of("ACCESS_DENIED",
            "You do not have permission to access this resource",
            request.getRequestURI());
    }

    // 500catch-all for unexpected exceptions:
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorResponse handleGeneral(Exception ex, HttpServletRequest request) {
        log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
        return ErrorResponse.of("INTERNAL_ERROR",
            "An unexpected error occurred", request.getRequestURI());
    }
}

// Custom exception classes:
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String resource, Object id) {
        super(resource + " not found with id: " + id);
    }
}

public class DuplicateResourceException extends RuntimeException {
    public DuplicateResourceException(String message) {
        super(message);
    }
}

Service Layer

The service layer contains all business logic. Controllers delegate to services — services never deal with HTTP. This separation keeps controllers thin and business logic testable.
Java
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class ProductService {

    private final ProductRepository productRepository;
    private final CategoryRepository categoryRepository;

    @Transactional(readOnly = true)
    public Page<ProductResponse> findAll(Pageable pageable) {
        return productRepository.findAll(pageable)
            .map(ProductResponse::from);
    }

    @Transactional(readOnly = true)
    public Optional<ProductResponse> findById(Long id) {
        return productRepository.findById(id)
            .map(ProductResponse::from);
    }

    @Transactional(readOnly = true)
    public List<ProductResponse> search(String query, String category, int limit) {
        return productRepository
            .searchByNameAndCategory(query, category, PageRequest.of(0, limit))
            .stream()
            .map(ProductResponse::from)
            .toList();
    }

    public ProductResponse create(CreateProductRequest request) {
        // Validate business rules:
        if (productRepository.existsByName(request.name())) {
            throw new DuplicateResourceException(
                "Product already exists with name: " + request.name()
            );
        }

        Category category = categoryRepository.findById(request.categoryId())
            .orElseThrow(() -> new ResourceNotFoundException("Category", request.categoryId()));

        Product product = Product.builder()
            .name(request.name())
            .description(request.description())
            .price(request.price())
            .category(category)
            .stockQuantity(request.stockQuantity())
            .build();

        Product saved = productRepository.save(product);
        log.info("Product created: id={}, name={}", saved.getId(), saved.getName());
        return ProductResponse.from(saved);
    }

    public Optional<ProductResponse> update(Long id, UpdateProductRequest request) {
        return productRepository.findById(id)
            .map(product -> {
                product.setName(request.name());
                product.setDescription(request.description());
                product.setPrice(request.price());
                product.setStockQuantity(request.stockQuantity());
                return ProductResponse.from(productRepository.save(product));
            });
    }

    public Optional<ProductResponse> patch(Long id, PatchProductRequest request) {
        return productRepository.findById(id)
            .map(product -> {
                // Only update fields that are non-null in the request:
                if (request.name() != null) product.setName(request.name());
                if (request.description() != null) product.setDescription(request.description());
                if (request.price() != null) product.setPrice(request.price());
                if (request.stockQuantity() != null) product.setStockQuantity(request.stockQuantity());
                return ProductResponse.from(productRepository.save(product));
            });
    }

    public void delete(Long id) {
        if (!productRepository.existsById(id)) {
            throw new ResourceNotFoundException("Product", id);
        }
        productRepository.deleteById(id);
        log.info("Product deleted: id={}", id);
    }
}

Content Negotiation and Headers

Spring Boot's REST controllers support content negotiation — serving different response formats based on the Accept header — and give full control over request and response headers.
Java
@RestController
@RequestMapping("/api/v1/reports")
public class ReportController {

    // Content negotiation — serve JSON or XML based on Accept header:
    @GetMapping(
        value = "/{id}",
        produces = {
            MediaType.APPLICATION_JSON_VALUE,
            MediaType.APPLICATION_XML_VALUE
        }
    )
    public ReportResponse getReport(@PathVariable Long id) {
        return reportService.findById(id);
        // Jackson serializes to JSON or XML based on Accept header:
        // Accept: application/json → JSON response
        // Accept: application/xml → XML response (add jackson-dataformat-xml)
    }

    // Restrict to specific content type:
    @PostMapping(
        consumes = MediaType.APPLICATION_JSON_VALUE,
        produces = MediaType.APPLICATION_JSON_VALUE
    )
    public ReportResponse createReport(@RequestBody @Valid CreateReportRequest request) {
        return reportService.create(request);
    }

    // Read request headers:
    @GetMapping("/download")
    public ResponseEntity<byte[]> downloadReport(
            @RequestHeader("Accept-Language") String language,
            @RequestHeader(value = "X-Report-Format", defaultValue = "PDF") String format,
            @PathVariable Long id) {

        byte[] data = reportService.generateReport(id, language, format);

        return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,
                "attachment; filename="report-" + id + "." + format.toLowerCase() + """)
            .header(HttpHeaders.CONTENT_TYPE, "application/pdf")
            .header("X-Report-Id", id.toString())
            .header("X-Generated-At", LocalDateTime.now().toString())
            .contentLength(data.length)
            .body(data);
    }

    // Read cookies:
    @GetMapping("/preferences")
    public UserPreferences getPreferences(
            @CookieValue(value = "user_prefs", defaultValue = "{}") String prefsJson) {
        return parsePreferences(prefsJson);
    }
}

// CORS configuration — allow cross-origin requests:
@RestController
@CrossOrigin(
    origins = {"https://myapp.com", "http://localhost:3000"},
    methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE},
    allowedHeaders = {"Content-Type", "Authorization"},
    maxAge = 3600
)
@RequestMapping("/api/v1/products")
public class ProductController { }

// Or globally via WebMvcConfigurer:
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://myapp.com", "http://localhost:3000")
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
            .allowedHeaders("*")
            .allowCredentials(true)
            .maxAge(3600);
    }
}

Pagination and Sorting

Spring Data provides Pageable for automatic pagination and sorting. The PagedResponse wrapper standardizes the paginated response structure across all list endpoints.
Java
// Controller — Pageable auto-resolved from request parameters:
@GetMapping
public ResponseEntity<PagedResponse<ProductResponse>> getProducts(
        @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
        Pageable pageable) {

    Page<ProductResponse> page = productService.findAll(pageable);
    return ResponseEntity.ok(PagedResponse.from(page));
}

// Client usage:
// GET /api/v1/products                           → page 0, size 20, default sort
// GET /api/v1/products?page=2&size=10            → page 2, 10 per page
// GET /api/v1/products?sort=price,desc           → sorted by price descending
// GET /api/v1/products?sort=name,asc&sort=price,desc → multi-sort

// PagedResponse:
public record PagedResponse<T>(
    List<T> content,
    int pageNumber,
    int pageSize,
    long totalElements,
    int totalPages,
    boolean first,
    boolean last,
    boolean empty
) {
    public static <T> PagedResponse<T> from(Page<T> page) {
        return new PagedResponse<>(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements(),
            page.getTotalPages(),
            page.isFirst(),
            page.isLast(),
            page.isEmpty()
        );
    }
}

// Enable Pageable parameter resolution in application.properties:
// spring.data.web.pageable.one-indexed-parameters=false  (default: 0-indexed)
// spring.data.web.pageable.max-page-size=100             (max allowed page size)
// spring.data.web.pageable.default-page-size=20          (default page size)

// Repository with Pageable:
public interface ProductRepository extends JpaRepository<Product, Long> {

    Page<Product> findByCategoryId(Long categoryId, Pageable pageable);

    @Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
    Page<Product> findByPriceRange(
        @Param("min") BigDecimal min,
        @Param("max") BigDecimal max,
        Pageable pageable
    );
}

// JSON response shape:
// {
//   "content": [...],
//   "pageNumber": 0,
//   "pageSize": 20,
//   "totalElements": 150,
//   "totalPages": 8,
//   "first": true,
//   "last": false,
//   "empty": false
// }

API Versioning

REST API versioning allows evolving an API without breaking existing clients. Spring Boot supports three versioning strategies — URL path versioning, header versioning, and parameter versioning.
Java
// ── STRATEGY 1: URL path versioning (most common, most visible) ──────

@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
    @GetMapping("/{id}")
    public UserResponseV1 getUser(@PathVariable Long id) {
        return userService.findByIdV1(id);
    }
}

@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
    @GetMapping("/{id}")
    public UserResponseV2 getUser(@PathVariable Long id) {
        return userService.findByIdV2(id);  // new response shape
    }
}

// GET /api/v1/users/1  → old response shape
// GET /api/v2/users/1new response shape

// ── STRATEGY 2: Header versioning (clean URLs, less discoverable) ────

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping(value = "/{id}",
                headers = "X-API-Version=1")
    public UserResponseV1 getUserV1(@PathVariable Long id) {
        return userService.findByIdV1(id);
    }

    @GetMapping(value = "/{id}",
                headers = "X-API-Version=2")
    public UserResponseV2 getUserV2(@PathVariable Long id) {
        return userService.findByIdV2(id);
    }
}

// GET /api/users/1   X-API-Version: 1  → V1 response
// GET /api/users/1   X-API-Version: 2  → V2 response

// ── STRATEGY 3: Accept header versioning (content negotiation) ───────

@GetMapping(value = "/{id}",
            produces = "application/vnd.myapp.v1+json")
public UserResponseV1 getUserV1(@PathVariable Long id) { ... }

@GetMapping(value = "/{id}",
            produces = "application/vnd.myapp.v2+json")
public UserResponseV2 getUserV2(@PathVariable Long id) { ... }

// GET /api/users/1   Accept: application/vnd.myapp.v2+json  → V2

// ── STRATEGY 4: Request parameter versioning ─────────────────────────

@GetMapping(value = "/{id}", params = "version=1")
public UserResponseV1 getUserV1(@PathVariable Long id) { ... }

@GetMapping(value = "/{id}", params = "version=2")
public UserResponseV2 getUserV2(@PathVariable Long id) { ... }

// GET /api/users/1?version=2  → V2 response

// ── Recommended approach: URL versioning ─────────────────────────────
// Pros: visible in URL, bookmarkable, easy to test in browser
// Cons: URL "pollution", changes resource identity
// Most teams use URL versioning for its simplicity and discoverability

Testing REST APIs

Spring Boot provides @WebMvcTest for fast controller-layer tests and @SpringBootTest with TestRestTemplate or MockMvc for full integration tests.
Java
// Controller slice test — only loads MVC layer, no DB:
@WebMvcTest(ProductController.class)
@AutoConfigureMockMvc
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getProduct_whenExists_returns200() throws Exception {
        ProductResponse product = new ProductResponse(
            1L, "Laptop Pro", "High-performance laptop",
            new BigDecimal("1299.99"), "Electronics",
            10, true, List.of("laptop", "premium"),
            LocalDateTime.now(), LocalDateTime.now()
        );
        when(productService.findById(1L)).thenReturn(Optional.of(product));

        mockMvc.perform(get("/api/v1/products/1")
                .accept(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("Laptop Pro"))
            .andExpect(jsonPath("$.price").value(1299.99))
            .andExpect(jsonPath("$.inStock").value(true));
    }

    @Test
    void getProduct_whenNotFound_returns404() throws Exception {
        when(productService.findById(99L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/v1/products/99"))
            .andExpect(status().isNotFound());
    }

    @Test
    void createProduct_withValidRequest_returns201() throws Exception {
        CreateProductRequest request = new CreateProductRequest(
            "New Laptop", "Description", new BigDecimal("999.99"),
            1L, 5, List.of("laptop")
        );
        ProductResponse created = new ProductResponse(
            2L, "New Laptop", "Description",
            new BigDecimal("999.99"), "Electronics",
            5, true, List.of("laptop"),
            LocalDateTime.now(), LocalDateTime.now()
        );
        when(productService.create(any())).thenReturn(created);

        mockMvc.perform(post("/api/v1/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").value(2))
            .andExpect(jsonPath("$.name").value("New Laptop"))
            .andExpect(header().exists("Location"));
    }

    @Test
    void createProduct_withInvalidRequest_returns400() throws Exception {
        CreateProductRequest invalid = new CreateProductRequest(
            "",                         // blank name — @NotBlank violation
            "Description",
            new BigDecimal("-10.00"),   // negative price — @DecimalMin violation
            1L, 0, List.of()
        );

        mockMvc.perform(post("/api/v1/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalid)))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("VALIDATION_FAILED"))
            .andExpect(jsonPath("$.errors").isArray())
            .andExpect(jsonPath("$.errors.length()").value(2));
    }
}

// Full integration test:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class ProductApiIntegrationTest {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private ProductRepository productRepository;

    @Test
    @Transactional
    void fullCrudFlow() {
        // Create:
        CreateProductRequest createReq = new CreateProductRequest(
            "Test Product", "Description", new BigDecimal("49.99"),
            1L, 100, List.of("test")
        );
        ResponseEntity<ProductResponse> createRes = restTemplate
            .postForEntity("/api/v1/products", createReq, ProductResponse.class);
        assertThat(createRes.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long id = createRes.getBody().id();

        // Read:
        ResponseEntity<ProductResponse> getRes = restTemplate
            .getForEntity("/api/v1/products/" + id, ProductResponse.class);
        assertThat(getRes.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getRes.getBody().name()).isEqualTo("Test Product");

        // Delete:
        restTemplate.delete("/api/v1/products/" + id);
        ResponseEntity<ProductResponse> deletedRes = restTemplate
            .getForEntity("/api/v1/products/" + id, ProductResponse.class);
        assertThat(deletedRes.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
    }
}