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 /deleteProductResource Naming Conventions
Rules:
- Use plural nouns for collections:
/users,/orders,/products - Use lowercase and hyphens for readability:
/order-items,/user-profiles - Avoid file extensions:
/productsnot/products.json - Keep URLs shallow (max 2-3 levels):
/users/{id}/orders/{orderId} - 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:
| Method | Purpose | Idempotent | Safe | Request Body | Response Body |
|---|---|---|---|---|---|
| GET | Retrieve resource(s) | ✅ | ✅ | ❌ | ✅ |
| POST | Create resource | ❌ | ❌ | ✅ | ✅ |
| PUT | Replace entire resource | ✅ | ❌ | ✅ | ✅ |
| PATCH | Update partial resource | ❌ | ❌ | ✅ | ✅ |
| DELETE | Remove resource | ✅ | ❌ | ❌ | Optional |
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/plainBenefits:
- 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=10Service 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 unchangedService 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: trueCustom 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.javaComplete 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/1Summary 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:
- Advanced JPA: Query Optimization - Database performance
- API Documentation with OpenAPI - Auto-generate docs
- Caching with Redis - Improve performance
Related Posts:
- REST API Advanced Patterns - Exception handling, validation, testing
- Spring Boot Learning Roadmap - Complete learning path
- Getting Started with Spring Boot - Foundation
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.