Back to blog

REST API Development Best Practices in Spring Boot

javaspring-bootrest-apibackendbest-practices
REST API Development Best Practices in Spring Boot

Introduction

Building a REST API that works is one thing—building one that's maintainable, scalable, and follows industry standards is another. In this post, we'll explore production-ready best practices that separate hobby projects from enterprise-grade APIs.

What You'll Learn

✅ RESTful design principles and resource naming conventions
✅ HTTP status codes and semantic method usage
✅ Consistent response structures and error handling patterns
✅ Performance optimization with caching, compression, and rate limiting
✅ Security best practices for production APIs
✅ Health checks, logging, and monitoring strategies
✅ Real-world patterns: filtering, sorting, pagination, soft deletes
✅ Build an E-Commerce Product API with all best practices applied

Prerequisites

  • Java 17+ installed
  • Spring Boot basics (see Getting Started guide)
  • Familiarity with REST API concepts
  • Understanding of HTTP protocol

Note: This post focuses on conventions and standards. For implementation details on exception handling, validation, and testing, see our REST API Advanced Patterns post.


1. RESTful Design Principles

Resource-Oriented Architecture

REST APIs should model resources, not actions. Use nouns, not verbs.

✅ Good:

GET    /products              # List products
GET    /products/{id}         # Get single product
POST   /products              # Create product
PUT    /products/{id}         # Update product
DELETE /products/{id}         # Delete product

❌ Bad:

GET    /getProducts
POST   /createProduct
POST   /updateProduct
DELETE /deleteProduct

Resource Naming Conventions

Rules:

  1. Use plural nouns for collections: /users, /orders, /products
  2. Use lowercase and hyphens for readability: /order-items, /user-profiles
  3. Avoid file extensions: /products not /products.json
  4. Keep URLs shallow (max 2-3 levels): /users/{id}/orders/{orderId}
  5. Use query parameters for filtering: /products?category=electronics&sort=price

Resource Relationships:

@RestController
@RequestMapping("/api/v1")
public class OrderController {
    
    // ✅ Good: Clear hierarchy
    @GetMapping("/users/{userId}/orders")
    public List<Order> getUserOrders(@PathVariable Long userId) {
        return orderService.findByUserId(userId);
    }
    
    // ✅ Good: Specific resource access
    @GetMapping("/orders/{orderId}/items")
    public List<OrderItem> getOrderItems(@PathVariable Long orderId) {
        return orderItemService.findByOrderId(orderId);
    }
    
    // ❌ Bad: Too deep nesting
    @GetMapping("/users/{userId}/orders/{orderId}/items/{itemId}/reviews")
    public List<Review> getReviews() { /* ... */ }
    
    // ✅ Better: Flatten with query params
    @GetMapping("/reviews?itemId={itemId}")
    public List<Review> getReviews(@RequestParam Long itemId) { /* ... */ }
}

HTTP Methods Semantics

Each HTTP method has specific meaning and expected behavior:

MethodPurposeIdempotentSafeRequest BodyResponse Body
GETRetrieve resource(s)
POSTCreate resource
PUTReplace entire resource
PATCHUpdate partial resource
DELETERemove resourceOptional

Idempotent: Multiple identical requests have the same effect as a single request. Safe: Does not modify server state.

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    // GET: Safe and idempotent
    @GetMapping("/{id}")
    public ResponseEntity<Product> getProduct(@PathVariable Long id) {
        return productService.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
    
    // POST: Not idempotent (creates new resource each time)
    @PostMapping
    public ResponseEntity<Product> createProduct(@RequestBody @Valid ProductRequest request) {
        Product product = productService.create(request);
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .header("Location", "/api/v1/products/" + product.getId())
            .body(product);
    }
    
    // PUT: Idempotent (same request = same result)
    @PutMapping("/{id}")
    public ResponseEntity<Product> updateProduct(
            @PathVariable Long id,
            @RequestBody @Valid ProductRequest request) {
        Product product = productService.update(id, request);
        return ResponseEntity.ok(product);
    }
    
    // PATCH: Partial update (only provided fields)
    @PatchMapping("/{id}")
    public ResponseEntity<Product> patchProduct(
            @PathVariable Long id,
            @RequestBody Map<String, Object> updates) {
        Product product = productService.patch(id, updates);
        return ResponseEntity.ok(product);
    }
    
    // DELETE: Idempotent (deleting twice = same state)
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

Stateless Design

Each request must contain all information needed to process it. No server-side session state.

✅ Good (Stateless):

@GetMapping("/products")
public Page<Product> getProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestHeader("Authorization") String token) {
    // All context in request (page, size, auth token)
    return productService.findAll(page, size);
}

❌ Bad (Stateful):

// Don't store user context in session
@GetMapping("/products")
public Page<Product> getProducts() {
    User user = session.getAttribute("currentUser"); // ❌ Bad
    return productService.findAllForUser(user);
}

2. Response Standards

HTTP Status Codes Best Practices

Use appropriate status codes to communicate intent clearly.

Success Codes (2xx):

  • 200 OK: Successful GET, PUT, PATCH, DELETE with response body
  • 201 Created: Successful POST (resource created)
  • 204 No Content: Successful DELETE or update with no response body
  • 206 Partial Content: Successful partial GET (range requests)

Client Error Codes (4xx):

  • 400 Bad Request: Invalid request body or parameters
  • 401 Unauthorized: Authentication required or invalid
  • 403 Forbidden: Authenticated but not authorized
  • 404 Not Found: Resource doesn't exist
  • 409 Conflict: Request conflicts with current state (e.g., duplicate email)
  • 422 Unprocessable Entity: Validation errors
  • 429 Too Many Requests: Rate limit exceeded

Server Error Codes (5xx):

  • 500 Internal Server Error: Unexpected server error
  • 502 Bad Gateway: Upstream service error
  • 503 Service Unavailable: Temporary outage
  • 504 Gateway Timeout: Upstream timeout

Implementation:

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(
            @RequestBody @Valid ProductRequest request) {
        
        ProductResponse product = productService.create(request);
        
        return ResponseEntity
            .status(HttpStatus.CREATED)  // 201 Created
            .header("Location", "/api/v1/products/" + product.getId())
            .body(product);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @RequestBody @Valid ProductRequest request) {
        
        try {
            ProductResponse product = productService.update(id, request);
            return ResponseEntity.ok(product);  // 200 OK
        } catch (ResourceNotFoundException e) {
            return ResponseEntity.notFound().build();  // 404 Not Found
        } catch (ConflictException e) {
            return ResponseEntity.status(HttpStatus.CONFLICT).build();  // 409 Conflict
        }
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.delete(id);
        return ResponseEntity.noContent().build();  // 204 No Content
    }
}

Consistent Response Structure

Option 1: Direct Response (Simple APIs)

// GET /api/v1/products/123
{
  "id": 123,
  "name": "Laptop",
  "price": 999.99,
  "category": "electronics"
}

Option 2: Envelope Pattern (Complex APIs)

// GET /api/v1/products?page=0&size=20
{
  "success": true,
  "data": {
    "content": [
      {"id": 1, "name": "Laptop", "price": 999.99},
      {"id": 2, "name": "Mouse", "price": 29.99}
    ],
    "pagination": {
      "page": 0,
      "size": 20,
      "totalElements": 150,
      "totalPages": 8
    }
  },
  "timestamp": "2026-02-04T10:30:00Z"
}

Error Response Structure:

// 400 Bad Request
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request parameters",
    "details": [
      {
        "field": "price",
        "message": "Price must be positive",
        "rejectedValue": -10
      }
    ]
  },
  "timestamp": "2026-02-04T10:30:00Z",
  "path": "/api/v1/products"
}

Implementation:

// Response wrapper
public record ApiResponse<T>(
    boolean success,
    T data,
    ErrorDetails error,
    LocalDateTime timestamp,
    String path
) {
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, data, null, LocalDateTime.now(), null);
    }
    
    public static ApiResponse<Void> error(ErrorDetails error, String path) {
        return new ApiResponse<>(false, null, error, LocalDateTime.now(), path);
    }
}
 
// Error details
public record ErrorDetails(
    String code,
    String message,
    List<FieldError> details
) {}
 
public record FieldError(
    String field,
    String message,
    Object rejectedValue
) {}
 
// Usage in controller
@GetMapping
public ResponseEntity<ApiResponse<Page<Product>>> getProducts(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {
    
    Page<Product> products = productService.findAll(page, size);
    return ResponseEntity.ok(ApiResponse.success(products));
}

NULL Handling Strategies

Strategy 1: Omit null fields (Recommended)

@JsonInclude(JsonInclude.Include.NON_NULL)
public class ProductResponse {
    private Long id;
    private String name;
    private String description;  // Omitted if null
    private BigDecimal price;
}

Strategy 2: Use Optional for nullable fields

public class ProductResponse {
    private Long id;
    private String name;
    private Optional<String> description;  // Explicit optionality
    private BigDecimal price;
}

Strategy 3: Explicit null (Less common)

{
  "id": 123,
  "name": "Laptop",
  "description": null,
  "price": 999.99
}

3. Request/Response Best Practices

Content Negotiation

Support multiple response formats (JSON, XML) using Accept header.

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    // Supports both JSON and XML based on Accept header
    @GetMapping(value = "/{id}", produces = {
        MediaType.APPLICATION_JSON_VALUE,
        MediaType.APPLICATION_XML_VALUE
    })
    public ResponseEntity<Product> getProduct(
            @PathVariable Long id,
            @RequestHeader("Accept") String acceptHeader) {
        
        Product product = productService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
        
        return ResponseEntity.ok(product);
    }
    
    // Request: Accept: application/json
    // Response: {"id": 123, "name": "Laptop"}
    
    // Request: Accept: application/xml
    // Response: <product><id>123</id><name>Laptop</name></product>
}

Response Headers

Essential Headers:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
            .allowedOrigins("https://myapp.com")
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
            .allowedHeaders("*")
            .exposedHeaders("Location", "X-Total-Count")
            .allowCredentials(true)
            .maxAge(3600);
    }
}
 
@RestController
public class ProductController {
    
    @GetMapping("/api/v1/products")
    public ResponseEntity<List<Product>> getProducts() {
        List<Product> products = productService.findAll();
        
        return ResponseEntity.ok()
            .header("X-Total-Count", String.valueOf(products.size()))
            .header("Cache-Control", "max-age=3600, public")
            .header("ETag", generateETag(products))
            .body(products);
    }
}

Compression

Enable gzip compression for responses > 1KB:

# application.yml
server:
  compression:
    enabled: true
    min-response-size: 1024  # 1KB
    mime-types:
      - application/json
      - application/xml
      - text/html
      - text/xml
      - text/plain

Benefits:

  • Reduces bandwidth usage by 70-90%
  • Faster response times
  • Lower hosting costs

4. API Design Patterns

Filtering, Sorting, and Searching

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    @GetMapping
    public ResponseEntity<Page<Product>> getProducts(
            // Filtering
            @RequestParam(required = false) String category,
            @RequestParam(required = false) BigDecimal minPrice,
            @RequestParam(required = false) BigDecimal maxPrice,
            @RequestParam(required = false) Boolean inStock,
            
            // Searching
            @RequestParam(required = false) String search,
            
            // Sorting
            @RequestParam(defaultValue = "name") String sortBy,
            @RequestParam(defaultValue = "asc") String sortDirection,
            
            // Pagination
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        
        ProductFilter filter = ProductFilter.builder()
            .category(category)
            .minPrice(minPrice)
            .maxPrice(maxPrice)
            .inStock(inStock)
            .search(search)
            .build();
        
        Sort sort = Sort.by(
            sortDirection.equalsIgnoreCase("desc") ? Sort.Direction.DESC : Sort.Direction.ASC,
            sortBy
        );
        
        PageRequest pageRequest = PageRequest.of(page, size, sort);
        
        Page<Product> products = productService.findAll(filter, pageRequest);
        
        return ResponseEntity.ok(products);
    }
}
 
// Example requests:
// GET /api/v1/products?category=electronics&minPrice=100&maxPrice=1000
// GET /api/v1/products?search=laptop&sortBy=price&sortDirection=desc
// GET /api/v1/products?inStock=true&page=0&size=10

Service Implementation:

@Service
public class ProductService {
    
    public Page<Product> findAll(ProductFilter filter, Pageable pageable) {
        return productRepository.findAll(
            ProductSpecification.withFilter(filter),
            pageable
        );
    }
}
 
// Using Spring Data JPA Specification
public class ProductSpecification {
    
    public static Specification<Product> withFilter(ProductFilter filter) {
        return (root, query, cb) -> {
            List<Predicate> predicates = new ArrayList<>();
            
            if (filter.getCategory() != null) {
                predicates.add(cb.equal(root.get("category"), filter.getCategory()));
            }
            
            if (filter.getMinPrice() != null) {
                predicates.add(cb.greaterThanOrEqualTo(root.get("price"), filter.getMinPrice()));
            }
            
            if (filter.getMaxPrice() != null) {
                predicates.add(cb.lessThanOrEqualTo(root.get("price"), filter.getMaxPrice()));
            }
            
            if (filter.getInStock() != null) {
                predicates.add(cb.equal(root.get("inStock"), filter.getInStock()));
            }
            
            if (filter.getSearch() != null) {
                String searchPattern = "%" + filter.getSearch().toLowerCase() + "%";
                predicates.add(cb.or(
                    cb.like(cb.lower(root.get("name")), searchPattern),
                    cb.like(cb.lower(root.get("description")), searchPattern)
                ));
            }
            
            return cb.and(predicates.toArray(new Predicate[0]));
        };
    }
}

Bulk Operations

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    // Bulk create
    @PostMapping("/bulk")
    public ResponseEntity<BulkResponse<Product>> bulkCreate(
            @RequestBody @Valid List<ProductRequest> requests) {
        
        BulkResponse<Product> response = productService.bulkCreate(requests);
        
        return ResponseEntity
            .status(response.hasErrors() ? HttpStatus.MULTI_STATUS : HttpStatus.CREATED)
            .body(response);
    }
    
    // Bulk update
    @PatchMapping("/bulk")
    public ResponseEntity<BulkResponse<Product>> bulkUpdate(
            @RequestBody @Valid List<ProductUpdateRequest> requests) {
        
        BulkResponse<Product> response = productService.bulkUpdate(requests);
        
        return ResponseEntity.ok(response);
    }
    
    // Bulk delete
    @DeleteMapping("/bulk")
    public ResponseEntity<BulkDeleteResponse> bulkDelete(
            @RequestBody List<Long> ids) {
        
        BulkDeleteResponse response = productService.bulkDelete(ids);
        
        return ResponseEntity.ok(response);
    }
}
 
// Response structure for bulk operations
public record BulkResponse<T>(
    int totalRequests,
    int successCount,
    int errorCount,
    List<T> successResults,
    List<BulkError> errors
) {
    public boolean hasErrors() {
        return errorCount > 0;
    }
}
 
public record BulkError(
    int index,
    String message,
    Object request
) {}

PATCH vs PUT

PUT: Replace entire resource (all fields required)

@PutMapping("/{id}")
public ResponseEntity<Product> updateProduct(
        @PathVariable Long id,
        @RequestBody @Valid ProductRequest request) {
    
    // All fields in ProductRequest are required
    Product product = productService.update(id, request);
    return ResponseEntity.ok(product);
}
 
// Request must include ALL fields
// PUT /api/v1/products/123
// {"name": "New Name", "price": 999.99, "category": "electronics", "inStock": true}

PATCH: Update only provided fields (partial update)

@PatchMapping("/{id}")
public ResponseEntity<Product> patchProduct(
        @PathVariable Long id,
        @RequestBody Map<String, Object> updates) {
    
    // Only update fields present in the request
    Product product = productService.patch(id, updates);
    return ResponseEntity.ok(product);
}
 
// Request includes only fields to update
// PATCH /api/v1/products/123
// {"price": 899.99}  // Only updates price, keeps other fields unchanged

Service Implementation:

@Service
public class ProductService {
    
    public Product patch(Long id, Map<String, Object> updates) {
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
        
        updates.forEach((key, value) -> {
            switch (key) {
                case "name" -> product.setName((String) value);
                case "price" -> product.setPrice(new BigDecimal(value.toString()));
                case "category" -> product.setCategory((String) value);
                case "inStock" -> product.setInStock((Boolean) value);
                default -> throw new IllegalArgumentException("Invalid field: " + key);
            }
        });
        
        return productRepository.save(product);
    }
}

Soft Delete Pattern

@Entity
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String name;
    private BigDecimal price;
    
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    @Column(nullable = false)
    private Boolean deleted = false;
    
    private LocalDateTime deletedAt;
    
    // Soft delete method
    public void softDelete() {
        this.deleted = true;
        this.deletedAt = LocalDateTime.now();
    }
}
 
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    // Soft delete (default behavior)
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.softDelete(id);
        return ResponseEntity.noContent().build();
    }
    
    // Hard delete (admin only)
    @DeleteMapping("/{id}/permanent")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Void> permanentlyDeleteProduct(@PathVariable Long id) {
        productService.hardDelete(id);
        return ResponseEntity.noContent().build();
    }
    
    // Restore soft-deleted product
    @PostMapping("/{id}/restore")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Product> restoreProduct(@PathVariable Long id) {
        Product product = productService.restore(id);
        return ResponseEntity.ok(product);
    }
    
    // List deleted products (admin only)
    @GetMapping("/deleted")
    @PreAuthorize("hasRole('ADMIN')")
    public ResponseEntity<Page<Product>> getDeletedProducts(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size) {
        
        Page<Product> products = productService.findDeleted(PageRequest.of(page, size));
        return ResponseEntity.ok(products);
    }
}
 
// Repository with soft delete support
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
    
    @Query("SELECT p FROM Product p WHERE p.deleted = false")
    Page<Product> findAllActive(Pageable pageable);
    
    @Query("SELECT p FROM Product p WHERE p.deleted = true")
    Page<Product> findAllDeleted(Pageable pageable);
}

5. Performance & Scalability

Rate Limiting with Bucket4j

Prevent API abuse and ensure fair usage.

Dependencies:

<dependency>
    <groupId>com.github.vladimir-bukhtoyarov</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>8.7.0</version>
</dependency>

Implementation:

@Component
public class RateLimitInterceptor implements HandlerInterceptor {
    
    private final Map<String, Bucket> cache = new ConcurrentHashMap<>();
    
    @Override
    public boolean preHandle(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler) throws Exception {
        
        String apiKey = request.getHeader("X-API-Key");
        if (apiKey == null) {
            apiKey = request.getRemoteAddr();  // Fallback to IP
        }
        
        Bucket bucket = resolveBucket(apiKey);
        
        if (bucket.tryConsume(1)) {
            return true;  // Allow request
        }
        
        // Rate limit exceeded
        response.setStatus(429);
        response.setHeader("X-Rate-Limit-Retry-After-Seconds", "60");
        response.getWriter().write("{\"error\": \"Rate limit exceeded\"}");
        return false;
    }
    
    private Bucket resolveBucket(String apiKey) {
        return cache.computeIfAbsent(apiKey, key -> {
            // 100 requests per minute
            Bandwidth limit = Bandwidth.simple(100, Duration.ofMinutes(1));
            return Bucket.builder()
                .addLimit(limit)
                .build();
        });
    }
}
 
@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Autowired
    private RateLimitInterceptor rateLimitInterceptor;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor)
            .addPathPatterns("/api/**");
    }
}

ETags and Conditional Requests

Reduce bandwidth by sending 304 Not Modified when content hasn't changed.

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    @GetMapping("/{id}")
    public ResponseEntity<Product> getProduct(
            @PathVariable Long id,
            @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {
        
        Product product = productService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product not found"));
        
        // Generate ETag from product data
        String etag = "\"" + product.hashCode() + "\"";
        
        // Check if client has latest version
        if (etag.equals(ifNoneMatch)) {
            return ResponseEntity
                .status(HttpStatus.NOT_MODIFIED)
                .eTag(etag)
                .build();
        }
        
        // Return full response with ETag
        return ResponseEntity.ok()
            .eTag(etag)
            .body(product);
    }
}

Request/Response Flow:

1. Client: GET /api/v1/products/123
2. Server: 200 OK, ETag: "12345", Body: {...}
3. Client caches response with ETag
 
4. Client: GET /api/v1/products/123, If-None-Match: "12345"
5. Server: 304 Not Modified, ETag: "12345", No Body (saves bandwidth!)
 
6. Product updated on server (ETag changes to "12346")
7. Client: GET /api/v1/products/123, If-None-Match: "12345"
8. Server: 200 OK, ETag: "12346", Body: {...} (new data)

Cache-Control Headers

@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
    
    // Public, cacheable for 1 hour
    @GetMapping
    public ResponseEntity<List<Product>> getProducts() {
        List<Product> products = productService.findAll();
        
        return ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(1, TimeUnit.HOURS).cachePublic())
            .body(products);
    }
    
    // Private, cacheable for 5 minutes
    @GetMapping("/my-cart")
    public ResponseEntity<Cart> getMyCart(@AuthenticationPrincipal User user) {
        Cart cart = cartService.findByUser(user);
        
        return ResponseEntity.ok()
            .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePrivate())
            .body(cart);
    }
    
    // No caching (sensitive data)
    @GetMapping("/orders/{id}/payment")
    public ResponseEntity<PaymentDetails> getPaymentDetails(@PathVariable Long id) {
        PaymentDetails payment = paymentService.findById(id);
        
        return ResponseEntity.ok()
            .cacheControl(CacheControl.noCache().noStore().mustRevalidate())
            .body(payment);
    }
}

6. Security Considerations

Input Validation and Sanitization

public class ProductRequest {
    
    @NotBlank(message = "Name is required")
    @Size(min = 3, max = 100, message = "Name must be between 3 and 100 characters")
    @Pattern(regexp = "^[a-zA-Z0-9\\s-]+$", message = "Name contains invalid characters")
    private String name;
    
    @NotNull(message = "Price is required")
    @DecimalMin(value = "0.01", message = "Price must be greater than 0")
    @DecimalMax(value = "999999.99", message = "Price is too high")
    private BigDecimal price;
    
    @NotBlank(message = "Category is required")
    @Pattern(regexp = "^(electronics|clothing|books|home|sports)$", 
             message = "Invalid category")
    private String category;
    
    @Size(max = 1000, message = "Description too long")
    private String description;
}
 
@RestController
public class ProductController {
    
    @PostMapping("/api/v1/products")
    public ResponseEntity<Product> createProduct(@RequestBody @Valid ProductRequest request) {
        // Spring validates automatically
        Product product = productService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(product);
    }
}

Security Headers

@Configuration
public class SecurityConfig {
    
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                // Prevent XSS attacks
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives("default-src 'self'; script-src 'self'"))
                
                // Prevent clickjacking
                .frameOptions(frame -> frame.deny())
                
                // Force HTTPS
                .httpStrictTransportSecurity(hsts -> hsts
                    .includeSubDomains(true)
                    .maxAgeInSeconds(31536000))
                
                // Prevent MIME sniffing
                .contentTypeOptions(Customizer.withDefaults())
                
                // XSS protection
                .xssProtection(xss -> xss.block(true))
            );
        
        return http.build();
    }
}

SQL Injection Prevention

✅ Always use parameterized queries (Spring Data JPA handles this):

// Safe: Uses parameterized query
@Query("SELECT p FROM Product p WHERE p.name = :name")
Optional<Product> findByName(@Param("name") String name);
 
// Safe: Spring Data method name
Optional<Product> findByNameAndCategory(String name, String category);

❌ Never concatenate user input:

// Dangerous! SQL injection vulnerability
@Query(value = "SELECT * FROM products WHERE name = '" + name + "'", nativeQuery = true)
List<Product> unsafeFind(String name);

7. Production Readiness

Health Checks with Actuator

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      show-details: when-authorized
      probes:
        enabled: true
  health:
    livenessState:
      enabled: true
    readinessState:
      enabled: true

Custom Health Indicator:

@Component
public class DatabaseHealthIndicator implements HealthIndicator {
    
    @Autowired
    private DataSource dataSource;
    
    @Override
    public Health health() {
        try (Connection conn = dataSource.getConnection()) {
            if (conn.isValid(1)) {
                return Health.up()
                    .withDetail("database", "PostgreSQL")
                    .withDetail("status", "reachable")
                    .build();
            }
        } catch (Exception e) {
            return Health.down()
                .withDetail("error", e.getMessage())
                .build();
        }
        return Health.down().build();
    }
}

Endpoints:

# Liveness probe (is the application running?)
GET /actuator/health/liveness
# Response: {"status": "UP"}
 
# Readiness probe (is the application ready to accept traffic?)
GET /actuator/health/readiness
# Response: {"status": "UP"}
 
# Detailed health
GET /actuator/health
# Response: {
#   "status": "UP",
#   "components": {
#     "database": {"status": "UP", "details": {...}},
#     "diskSpace": {"status": "UP", "details": {...}}
#   }
# }

Structured Logging

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.logstash.logback.argument.StructuredArguments;
 
@Service
public class ProductService {
    
    private static final Logger log = LoggerFactory.getLogger(ProductService.class);
    
    public Product create(ProductRequest request) {
        log.info("Creating product: {}",
            StructuredArguments.keyValue("productName", request.getName()));
        
        try {
            Product product = productRepository.save(Product.from(request));
            
            log.info("Product created successfully",
                StructuredArguments.keyValue("productId", product.getId()),
                StructuredArguments.keyValue("productName", product.getName()));
            
            return product;
            
        } catch (Exception e) {
            log.error("Failed to create product",
                StructuredArguments.keyValue("productName", request.getName()),
                StructuredArguments.keyValue("error", e.getMessage()),
                e);
            throw e;
        }
    }
}

logback-spring.xml:

<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
    </appender>
    
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

Output (JSON):

{
  "timestamp": "2026-02-04T10:30:00.123Z",
  "level": "INFO",
  "thread": "http-nio-8080-exec-1",
  "logger": "com.example.service.ProductService",
  "message": "Product created successfully",
  "productId": 123,
  "productName": "Laptop",
  "trace_id": "abc123",
  "span_id": "xyz789"
}

Monitoring and Metrics

@Service
public class ProductService {
    
    private final MeterRegistry meterRegistry;
    
    public ProductService(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }
    
    public Product create(ProductRequest request) {
        Timer.Sample sample = Timer.start(meterRegistry);
        
        try {
            Product product = productRepository.save(Product.from(request));
            
            // Increment success counter
            meterRegistry.counter("products.created.success",
                "category", product.getCategory()
            ).increment();
            
            return product;
            
        } catch (Exception e) {
            // Increment error counter
            meterRegistry.counter("products.created.error",
                "error_type", e.getClass().getSimpleName()
            ).increment();
            
            throw e;
            
        } finally {
            // Record duration
            sample.stop(meterRegistry.timer("products.create.duration",
                "category", request.getCategory()
            ));
        }
    }
}

Metrics endpoint:

GET /actuator/metrics/products.created.success
# Response: {
#   "name": "products.created.success",
#   "measurements": [{"statistic": "COUNT", "value": 1543}],
#   "availableTags": [{"tag": "category", "values": ["electronics", "books"]}]
# }

8. Hands-On Project: E-Commerce Product API

Let's build a complete API applying all best practices.

Project Structure

src/main/java/com/example/ecommerce/
├── config/
│   ├── SecurityConfig.java
│   ├── WebConfig.java
│   └── CacheConfig.java
├── controller/
│   └── ProductController.java
├── service/
│   └── ProductService.java
├── repository/
│   └── ProductRepository.java
├── model/
│   ├── Product.java
│   └── ProductFilter.java
├── dto/
│   ├── ProductRequest.java
│   └── ProductResponse.java
└── exception/
    ├── GlobalExceptionHandler.java
    └── ResourceNotFoundException.java

Complete Implementation

Product Entity:

@Entity
@Table(name = "products")
public class Product {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, length = 100)
    private String name;
    
    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal price;
    
    @Column(nullable = false, length = 50)
    private String category;
    
    @Column(length = 1000)
    private String description;
    
    @Column(nullable = false)
    private Integer stockQuantity = 0;
    
    @Column(nullable = false)
    private Boolean active = true;
    
    @Column(nullable = false)
    private Boolean deleted = false;
    
    private LocalDateTime deletedAt;
    
    @CreationTimestamp
    private LocalDateTime createdAt;
    
    @UpdateTimestamp
    private LocalDateTime updatedAt;
    
    // Transient field for search ranking
    @Transient
    private Double relevanceScore;
    
    // Getters, setters, equals, hashCode
}

ProductController with All Best Practices:

@RestController
@RequestMapping("/api/v1/products")
@Tag(name = "Products", description = "Product management APIs")
public class ProductController {
    
    private final ProductService productService;
    private final MeterRegistry meterRegistry;
    
    public ProductController(ProductService productService, MeterRegistry meterRegistry) {
        this.productService = productService;
        this.meterRegistry = meterRegistry;
    }
    
    @GetMapping
    @Operation(summary = "List products with filtering and pagination")
    public ResponseEntity<Page<ProductResponse>> getProducts(
            @Parameter(description = "Category filter") 
            @RequestParam(required = false) String category,
            
            @Parameter(description = "Minimum price")
            @RequestParam(required = false) BigDecimal minPrice,
            
            @Parameter(description = "Maximum price")
            @RequestParam(required = false) BigDecimal maxPrice,
            
            @Parameter(description = "Search query")
            @RequestParam(required = false) String search,
            
            @Parameter(description = "Sort field")
            @RequestParam(defaultValue = "name") String sortBy,
            
            @Parameter(description = "Sort direction")
            @RequestParam(defaultValue = "asc") String sortDirection,
            
            @Parameter(description = "Page number")
            @RequestParam(defaultValue = "0") @Min(0) int page,
            
            @Parameter(description = "Page size")
            @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
            
            @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {
        
        Timer.Sample sample = Timer.start(meterRegistry);
        
        try {
            ProductFilter filter = ProductFilter.builder()
                .category(category)
                .minPrice(minPrice)
                .maxPrice(maxPrice)
                .search(search)
                .build();
            
            Sort sort = Sort.by(
                sortDirection.equalsIgnoreCase("desc") ? Sort.Direction.DESC : Sort.Direction.ASC,
                sortBy
            );
            
            PageRequest pageRequest = PageRequest.of(page, size, sort);
            Page<ProductResponse> products = productService.findAll(filter, pageRequest);
            
            // Generate ETag
            String etag = "\"" + products.getContent().hashCode() + "\"";
            
            if (etag.equals(ifNoneMatch)) {
                return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                    .eTag(etag)
                    .build();
            }
            
            return ResponseEntity.ok()
                .eTag(etag)
                .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
                .header("X-Total-Count", String.valueOf(products.getTotalElements()))
                .body(products);
                
        } finally {
            sample.stop(meterRegistry.timer("products.list.duration"));
        }
    }
    
    @GetMapping("/{id}")
    @Operation(summary = "Get product by ID")
    public ResponseEntity<ProductResponse> getProduct(
            @PathVariable Long id,
            @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch) {
        
        ProductResponse product = productService.findById(id);
        String etag = "\"" + product.hashCode() + "\"";
        
        if (etag.equals(ifNoneMatch)) {
            return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                .eTag(etag)
                .build();
        }
        
        return ResponseEntity.ok()
            .eTag(etag)
            .cacheControl(CacheControl.maxAge(10, TimeUnit.MINUTES))
            .body(product);
    }
    
    @PostMapping
    @Operation(summary = "Create new product")
    public ResponseEntity<ProductResponse> createProduct(
            @RequestBody @Valid ProductRequest request) {
        
        ProductResponse product = productService.create(request);
        
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .header("Location", "/api/v1/products/" + product.id())
            .body(product);
    }
    
    @PutMapping("/{id}")
    @Operation(summary = "Update product (full replacement)")
    public ResponseEntity<ProductResponse> updateProduct(
            @PathVariable Long id,
            @RequestBody @Valid ProductRequest request) {
        
        ProductResponse product = productService.update(id, request);
        return ResponseEntity.ok(product);
    }
    
    @PatchMapping("/{id}")
    @Operation(summary = "Partially update product")
    public ResponseEntity<ProductResponse> patchProduct(
            @PathVariable Long id,
            @RequestBody Map<String, Object> updates) {
        
        ProductResponse product = productService.patch(id, updates);
        return ResponseEntity.ok(product);
    }
    
    @DeleteMapping("/{id}")
    @Operation(summary = "Soft delete product")
    public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
        productService.softDelete(id);
        return ResponseEntity.noContent().build();
    }
}

Testing:

# List products with filtering
curl "http://localhost:8080/api/v1/products?category=electronics&minPrice=100&sortBy=price&sortDirection=desc&page=0&size=10"
 
# Get single product with ETag
curl -H "If-None-Match: \"12345\"" http://localhost:8080/api/v1/products/1
 
# Create product
curl -X POST http://localhost:8080/api/v1/products \
  -H "Content-Type: application/json" \
  -d '{"name": "Laptop", "price": 999.99, "category": "electronics"}'
 
# Partial update
curl -X PATCH http://localhost:8080/api/v1/products/1 \
  -H "Content-Type: application/json" \
  -d '{"price": 899.99}'
 
# Soft delete
curl -X DELETE http://localhost:8080/api/v1/products/1

Summary and Key Takeaways

RESTful design: Use nouns, plural resources, proper HTTP methods
HTTP status codes: 2xx success, 4xx client errors, 5xx server errors
Consistent responses: Envelope pattern for complex APIs, direct for simple ones
Filtering & sorting: Use query parameters, support pagination
PUT vs PATCH: PUT replaces, PATCH updates partially
Soft deletes: Mark as deleted, keep data for recovery
Rate limiting: Protect against abuse with Bucket4j
ETags: Reduce bandwidth with conditional requests
Caching: Use Cache-Control headers appropriately
Security: Validate input, use security headers, prevent SQL injection
Monitoring: Health checks, structured logging, metrics
Production-ready: Handle errors gracefully, log properly, monitor actively

What's Next?

Now that you understand REST API best practices, explore related topics:

Continue the Spring Boot series:

Related Posts:


Part of the Spring Boot Learning Roadmap series

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.