Tired of those basic "Hello World" Spring Boot tutorials that skip all the real-world stuff? Yeah, me too. Today we're building a REST API that you could actually ship to production - complete with proper validation, error handling, documentation, and all those details that separate hobby projects from professional code.
I've built dozens of Spring Boot APIs over the years, and I'm going to share the patterns and practices that have saved me countless headaches. Let's build something solid together! 🚀
TL;DR: What We're Building
- Production-ready REST API with proper error handling and validation
- Clean architecture using DTOs, mappers, and service layers
- Auto-generated documentation with Swagger/OpenAPI
- HATEOAS support for discoverable APIs
- Pagination and sorting for scalable data access
- Real-world best practices you can apply to any Spring Boot project
By the end, you'll have a User management API that handles all the edge cases and follows industry standards.
Prerequisites (Don't Skip This!)
Before we dive in, make sure you have:
- Java 17+ installed (trust me, upgrade if you haven't)
- IDE like IntelliJ IDEA or Eclipse
- Basic understanding of Spring Boot and REST APIs
- A cup of coffee ☕ (optional but highly recommended)
Step 1: Project Setup (The Smart Way)
Let's start by creating a Spring Boot project using Spring Initializer. I'll show you the exact dependencies we need:
Core Dependencies
<!-- Essential Spring Boot starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
Enhanced Dependencies (The Good Stuff)
<!-- Model Mapper for DTO conversion -->
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>3.1.1</version>
</dependency>
<!-- HATEOAS for discoverable APIs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
<!-- Swagger/OpenAPI documentation -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Lombok (because who likes boilerplate?) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
Honestly, these dependencies transform a basic API into something you'd be proud to show in production. The validation alone will save you hours of debugging!
Step 2: The Entity Layer (Keep It Simple)
Here's our User
entity - clean and straightforward:
package com.example.api.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
Note: I added timestamps and JPA lifecycle callbacks. In real applications, you'll want to track when records are created and modified - your future self will thank you!
Step 3: Repository Layer (Nothing Fancy Needed)
The repository is straightforward thanks to Spring Data JPA:
package com.example.api.repository;
import com.example.api.model.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
@Query("SELECT u FROM User u WHERE u.name LIKE %:name%")
Page<User> findByNameContaining(@Param("name") String name, Pageable pageable);
boolean existsByEmail(String email);
}
These methods will come in handy for duplicate checking and search functionality.
Step 4: DTOs with Validation (The Professional Touch)
Here's where we separate what the outside world sees from our internal data structure:
package com.example.api.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class UserDTO {
private Long id;
@NotBlank(message = "Name is mandatory")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
@Pattern(regexp = "^[a-zA-Z\\s]+$", message = "Name should only contain letters and spaces")
private String name;
@NotBlank(message = "Email is mandatory")
@Email(message = "Please provide a valid email address")
@Size(max = 150, message = "Email must not exceed 150 characters")
private String email;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
// Create a separate DTO for user creation to avoid ID in requests
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateUserDTO {
@NotBlank(message = "Name is mandatory")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
@Pattern(regexp = "^[a-zA-Z\\s]+$", message = "Name should only contain letters and spaces")
private String name;
@NotBlank(message = "Email is mandatory")
@Email(message = "Please provide a valid email address")
@Size(max = 150, message = "Email must not exceed 150 characters")
private String email;
}
Note: I created two DTOs here. The
CreateUserDTO
doesn't include ID or timestamps since clients shouldn't be setting those. This prevents confusion and potential security issues.
Step 5: Custom Exceptions (Handle Failures Gracefully)
Let's create meaningful exceptions that help with debugging:
package com.example.api.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String message) {
super(message);
}
}
public class ValidationException extends RuntimeException {
public ValidationException(String message) {
super(message);
}
}
Global Exception Handler (Your Safety Net)
This is where we transform ugly stack traces into helpful error messages:
package com.example.api.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.context.request.WebRequest;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFoundException(
ResourceNotFoundException ex, WebRequest request) {
log.error("Resource not found: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.NOT_FOUND.value())
.error("Resource Not Found")
.message(ex.getMessage())
.path(request.getDescription(false).replace("uri=", ""))
.build();
return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleDuplicateResourceException(
DuplicateResourceException ex, WebRequest request) {
log.error("Duplicate resource: {}", ex.getMessage());
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.CONFLICT.value())
.error("Duplicate Resource")
.message(ex.getMessage())
.path(request.getDescription(false).replace("uri=", ""))
.build();
return new ResponseEntity<>(errorResponse, HttpStatus.CONFLICT);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationExceptions(
MethodArgumentNotValidException ex, WebRequest request) {
log.error("Validation failed: {}", ex.getMessage());
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
ValidationErrorResponse errorResponse = ValidationErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.BAD_REQUEST.value())
.error("Validation Failed")
.message("Invalid input data")
.path(request.getDescription(false).replace("uri=", ""))
.errors(errors)
.build();
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGlobalException(
Exception ex, WebRequest request) {
log.error("Unexpected error occurred: ", ex);
ErrorResponse errorResponse = ErrorResponse.builder()
.timestamp(LocalDateTime.now())
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.error("Internal Server Error")
.message("An unexpected error occurred")
.path(request.getDescription(false).replace("uri=", ""))
.build();
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Error Response DTOs
package com.example.api.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Map;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private String path;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ValidationErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private String path;
private Map<String, String> errors;
}
Step 6: The Mapper Layer (Keep It Clean)
Instead of manually converting between entities and DTOs everywhere, let's create a clean mapper:
package com.example.api.mapper;
import com.example.api.dto.CreateUserDTO;
import com.example.api.dto.UserDTO;
import com.example.api.model.User;
import org.modelmapper.ModelMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class UserMapper {
@Autowired
private ModelMapper modelMapper;
public UserDTO toDto(User user) {
return modelMapper.map(user, UserDTO.class);
}
public User toEntity(CreateUserDTO createUserDTO) {
return modelMapper.map(createUserDTO, User.class);
}
public User toEntity(UserDTO userDTO) {
return modelMapper.map(userDTO, User.class);
}
public List<UserDTO> toDtoList(List<User> users) {
return users.stream()
.map(this::toDto)
.collect(Collectors.toList());
}
}
Configuration Bean for ModelMapper
package com.example.api.config;
import org.modelmapper.ModelMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MapperConfig {
@Bean
public ModelMapper modelMapper() {
ModelMapper mapper = new ModelMapper();
// Configure strict matching to avoid mapping issues
mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
return mapper;
}
}
Step 7: Service Layer (Where the Magic Happens)
Here's the service layer with proper business logic and error handling:
package com.example.api.service;
import com.example.api.exception.DuplicateResourceException;
import com.example.api.exception.ResourceNotFoundException;
import com.example.api.model.User;
import com.example.api.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Slf4j
@Transactional
public class UserService {
@Autowired
private UserRepository userRepository;
public User createUser(User user) {
log.info("Creating new user with email: {}", user.getEmail());
// Check for duplicate email
if (userRepository.existsByEmail(user.getEmail())) {
throw new DuplicateResourceException("User with email " + user.getEmail() + " already exists");
}
User savedUser = userRepository.save(user);
log.info("User created successfully with ID: {}", savedUser.getId());
return savedUser;
}
@Transactional(readOnly = true)
public Page<User> getAllUsers(int page, int size, String sortBy, String sortDirection) {
log.info("Fetching users - page: {}, size: {}, sortBy: {}, direction: {}",
page, size, sortBy, sortDirection);
Sort.Direction direction = Sort.Direction.fromString(sortDirection);
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy));
return userRepository.findAll(pageable);
}
@Transactional(readOnly = true)
public User getUserById(Long id) {
log.info("Fetching user by ID: {}", id);
return userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with ID: " + id));
}
@Transactional(readOnly = true)
public User getUserByEmail(String email) {
log.info("Fetching user by email: {}", email);
return userRepository.findByEmail(email)
.orElseThrow(() -> new ResourceNotFoundException("User not found with email: " + email));
}
public User updateUser(Long id, User userDetails) {
log.info("Updating user with ID: {}", id);
User existingUser = getUserById(id);
// Check if email is being changed and if new email already exists
if (!existingUser.getEmail().equals(userDetails.getEmail()) &&
userRepository.existsByEmail(userDetails.getEmail())) {
throw new DuplicateResourceException("User with email " + userDetails.getEmail() + " already exists");
}
existingUser.setName(userDetails.getName());
existingUser.setEmail(userDetails.getEmail());
User updatedUser = userRepository.save(existingUser);
log.info("User updated successfully with ID: {}", updatedUser.getId());
return updatedUser;
}
public void deleteUser(Long id) {
log.info("Deleting user with ID: {}", id);
User user = getUserById(id); // This will throw exception if not found
userRepository.delete(user);
log.info("User deleted successfully with ID: {}", id);
}
@Transactional(readOnly = true)
public Page<User> searchUsersByName(String name, int page, int size) {
log.info("Searching users by name containing: {}", name);
Pageable pageable = PageRequest.of(page, size);
return userRepository.findByNameContaining(name, pageable);
}
}
Note: I added
@Transactional
annotations and proper logging. ThereadOnly = true
is a performance optimization for queries, and the logging will save you during debugging sessions!
Step 8: The Controller Layer (Where It All Comes Together)
This is the star of the show - a controller that handles everything properly:
package com.example.api.controller;
import com.example.api.dto.CreateUserDTO;
import com.example.api.dto.UserDTO;
import com.example.api.mapper.UserMapper;
import com.example.api.model.User;
import com.example.api.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.Link;
import org.springframework.hateoas.PagedModel;
import org.springframework.hateoas.server.mvc.WebMvcLinkBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/users")
@Tag(name = "User Management", description = "APIs for managing users")
@Validated
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@Autowired
private UserMapper userMapper;
@PostMapping
@Operation(summary = "Create a new user", description = "Creates a new user in the system")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "User created successfully"),
@ApiResponse(responseCode = "400", description = "Invalid input data"),
@ApiResponse(responseCode = "409", description = "User with email already exists")
})
public ResponseEntity<EntityModel<UserDTO>> createUser(
@Valid @RequestBody CreateUserDTO createUserDTO) {
log.info("Request to create user with email: {}", createUserDTO.getEmail());
User user = userMapper.toEntity(createUserDTO);
User createdUser = userService.createUser(user);
UserDTO userDTO = userMapper.toDto(createdUser);
EntityModel<UserDTO> userResource = EntityModel.of(userDTO);
userResource.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(this.getClass())
.getUserById(createdUser.getId())).withSelfRel());
userResource.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(this.getClass())
.getAllUsers(0, 10, "id", "asc")).withRel("all-users"));
return new ResponseEntity<>(userResource, HttpStatus.CREATED);
}
@GetMapping
@Operation(summary = "Get all users", description = "Retrieves a paginated list of all users")
public ResponseEntity<PagedModel<EntityModel<UserDTO>>> getAllUsers(
@Parameter(description = "Page number (0-based)")
@RequestParam(defaultValue = "0") @Min(0) int page,
@Parameter(description = "Page size")
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size,
@Parameter(description = "Sort field")
@RequestParam(defaultValue = "id") String sortBy,
@Parameter(description = "Sort direction")
@RequestParam(defaultValue = "asc") String sortDirection) {
log.info("Request to get all users - page: {}, size: {}", page, size);
Page<User> usersPage = userService.getAllUsers(page, size, sortBy, sortDirection);
List<EntityModel<UserDTO>> userResources = usersPage.getContent().stream()
.map(user -> {
UserDTO userDTO = userMapper.toDto(user);
EntityModel<UserDTO> userResource = EntityModel.of(userDTO);
userResource.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(this.getClass())
.getUserById(user.getId())).withSelfRel());
return userResource;
})
.collect(Collectors.toList());
PagedModel.PageMetadata pageMetadata = new PagedModel.PageMetadata(
usersPage.getSize(),
usersPage.getNumber(),
usersPage.getTotalElements(),
usersPage.getTotalPages()
);
PagedModel<EntityModel<UserDTO>> pagedModel = PagedModel.of(userResources, pageMetadata);
pagedModel.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(this.getClass())
.getAllUsers(page, size, sortBy, sortDirection)).withSelfRel());
return ResponseEntity.ok(pagedModel);
}
@GetMapping("/{id}")
@Operation(summary = "Get user by ID", description = "Retrieves a specific user by their ID")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "User found"),
@ApiResponse(responseCode = "404", description = "User not found")
})
public ResponseEntity<EntityModel<UserDTO>> getUserById(
@Parameter(description = "User ID") @PathVariable Long id) {
log.info("Request to get user by ID: {}", id);
User user = userService.getUserById(id);
UserDTO userDTO = userMapper.toDto(user);
EntityModel<UserDTO> userResource = EntityModel.of(userDTO);
userResource.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(this.getClass())
.getUserById(id)).withSelfRel());
userResource.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(this.getClass())
.getAllUsers(0, 10, "id", "asc")).withRel("all-users"));
return ResponseEntity.ok(userResource);
}
@PutMapping("/{id}")
@Operation(summary = "Update user", description = "Updates an existing user")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "User updated successfully"),
@ApiResponse(responseCode = "400", description = "Invalid input data"),
@ApiResponse(responseCode = "404", description = "User not found"),
@ApiResponse(responseCode = "409", description = "Email already exists")
})
public ResponseEntity<EntityModel<UserDTO>> updateUser(
@Parameter(description = "User ID") @PathVariable Long id,
@Valid @RequestBody CreateUserDTO createUserDTO) {
log.info("Request to update user with ID: {}", id);
User userDetails = userMapper.toEntity(createUserDTO);
User updatedUser = userService.updateUser(id, userDetails);
UserDTO userDTO = userMapper.toDto(updatedUser);
EntityModel<UserDTO> userResource = EntityModel.of(userDTO);
userResource.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(this.getClass())
.getUserById(id)).withSelfRel());
return ResponseEntity.ok(userResource);
}
@DeleteMapping("/{id}")
@Operation(summary = "Delete user", description = "Deletes a user from the system")
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "User deleted successfully"),
@ApiResponse(responseCode = "404", description = "User not found")
})
public ResponseEntity<Void> deleteUser(
@Parameter(description = "User ID") @PathVariable Long id) {
log.info("Request to delete user with ID: {}", id);
userService.deleteUser(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/search")
@Operation(summary = "Search users by name", description = "Searches for users whose names contain the specified text")
public ResponseEntity<PagedModel<EntityModel<UserDTO>>> searchUsers(
@Parameter(description = "Search term for user name")
@RequestParam String name,
@RequestParam(defaultValue = "0") @Min(0) int page,
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size) {
log.info("Request to search users by name: {}", name);
Page<User> usersPage = userService.searchUsersByName(name, page, size);
List<EntityModel<UserDTO>> userResources = usersPage.getContent().stream()
.map(user -> {
UserDTO userDTO = userMapper.toDto(user);
EntityModel<UserDTO> userResource = EntityModel.of(userDTO);
userResource.add(WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(this.getClass())
.getUserById(user.getId())).withSelfRel());
return userResource;
})
.collect(Collectors.toList());
PagedModel.PageMetadata pageMetadata = new PagedModel.PageMetadata(
usersPage.getSize(),
usersPage.getNumber(),
usersPage.getTotalElements(),
usersPage.getTotalPages()
);
PagedModel<EntityModel<UserDTO>> pagedModel = PagedModel.of(userResources, pageMetadata);
return ResponseEntity.ok(pagedModel);
}
}
Step 9: Configuration (The Finishing Touches)
Application Properties
# Database configuration
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# JPA configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
# Swagger/OpenAPI configuration
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.swagger-ui.operationsSorter=method
# Validation configuration
server.error.include-message=always
server.error.include-binding-errors=always
# Logging configuration
logging.level.com.example.api=DEBUG
logging.level.org.springframework.web=DEBUG
OpenAPI Configuration
package com.example.api.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Enhanced User Management API")
.version("1.0.0")
.description("A production-ready REST API for user management with validation, error handling, and documentation")
.contact(new Contact()
.name("Your Name")
.email("your.email@example.com")
.url("https://your-website.com"))
.license(new License()
.name("MIT License")
.url("https://opensource.org/licenses/MIT")));
}
}
Step 10: Testing Your API (The Fun Part!)
Let's fire up the application and see our creation in action:
mvn spring-boot:run
Manual Testing with cURL
Here are some commands to test your API:
# Create a new user
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john.doe@example.com"
}'
# Get all users with pagination
curl -X GET "http://localhost:8080/api/v1/users?page=0&size=5&sortBy=name&sortDirection=asc"
# Get a specific user
curl -X GET http://localhost:8080/api/v1/users/1
# Update a user
curl -X PUT http://localhost:8080/api/v1/users/1 \
-H "Content-Type: application/json" \
-d '{
"name": "John Smith",
"email": "john.smith@example.com"
}'
# Search users by name
curl -X GET "http://localhost:8080/api/v1/users/search?name=John&page=0&size=10"
# Delete a user
curl -X DELETE http://localhost:8080/api/v1/users/1
Testing Validation (The Error Cases)
Try these to see your validation in action:
# Invalid email format
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"name": "Test User",
"email": "invalid-email"
}'
# Empty name
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"name": "",
"email": "test@example.com"
}'
# Duplicate email (run twice)
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"name": "Duplicate User",
"email": "duplicate@example.com"
}'
You should see nicely formatted error responses instead of ugly stack traces!
Step 11: Swagger Documentation (Your API's Resume)
Visit http://localhost:8080/swagger-ui.html
to see your beautiful, interactive API documentation. This is what makes your API professional - clients can explore and test endpoints directly from the browser.
The Swagger UI will show:
- All available endpoints
- Request/response schemas
- Parameter descriptions
- Example requests
- Try-it-out functionality
Note: Good API documentation is often the difference between an API that gets adopted and one that gets ignored. Swagger makes this effortless!
Step 12: Advanced Features (Level Up Your API)
Health Check Endpoints
Add Spring Boot Actuator for monitoring:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
# Enable health endpoint
management.endpoints.web.exposure.include=health,info,metrics
management.endpoint.health.show-details=always
Custom Validation
Create a custom validator for email domains:
package com.example.api.validation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.*;
@Documented
@Constraint(validatedBy = AllowedEmailDomainValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AllowedEmailDomain {
String message() default "Email domain is not allowed";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String[] domains() default {};
}
// Validator implementation
@Component
public class AllowedEmailDomainValidator implements ConstraintValidator<AllowedEmailDomain, String> {
private Set<String> allowedDomains;
@Override
public void initialize(AllowedEmailDomain constraint) {
allowedDomains = Set.of(constraint.domains());
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null || email.isEmpty()) {
return true; // Let @NotBlank handle empty validation
}
String domain = email.substring(email.indexOf('@') + 1);
return allowedDomains.contains(domain);
}
}
Use it in your DTO:
@AllowedEmailDomain(domains = {"example.com", "company.com"},
message = "Email must be from an allowed domain")
private String email;
Caching for Performance
Add Redis caching for frequently accessed data:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
@Service
@Slf4j
@Transactional
public class UserService {
@Cacheable(value = "users", key = "#id")
public User getUserById(Long id) {
log.info("Fetching user from database for ID: {}", id);
return userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User not found with ID: " + id));
}
@CacheEvict(value = "users", key = "#result.id")
public User updateUser(Long id, User userDetails) {
// Update logic here
}
@CacheEvict(value = "users", key = "#id")
public void deleteUser(Long id) {
// Delete logic here
}
}
Step 13: Testing Strategy (Don't Skip This!)
Here's a comprehensive test for your controller:
package com.example.api.controller;
import com.example.api.dto.CreateUserDTO;
import com.example.api.service.UserService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
void createUser_ValidInput_ReturnsCreated() throws Exception {
// Given
CreateUserDTO createUserDTO = new CreateUserDTO("John Doe", "john@example.com");
User mockUser = new User(1L, "John Doe", "john@example.com", LocalDateTime.now(), LocalDateTime.now());
when(userService.createUser(any(User.class))).thenReturn(mockUser);
// When & Then
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createUserDTO)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("John Doe"))
.andExpect(jsonPath("$.email").value("john@example.com"))
.andExpect(jsonPath("$._links.self.href").exists());
}
@Test
void createUser_InvalidEmail_ReturnsBadRequest() throws Exception {
// Given
CreateUserDTO createUserDTO = new CreateUserDTO("John Doe", "invalid-email");
// When & Then
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createUserDTO)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Validation Failed"))
.andExpect(jsonPath("$.errors.email").exists());
}
}
Step 14: Production Considerations
Before you ship this to production, consider these additional features:
Security Configuration
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/v1/users/**").hasRole("USER")
.requestMatchers("/swagger-ui/**", "/api-docs/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()));
return http.build();
}
}
Rate Limiting
@RestController
@RequestMapping("/api/v1/users")
public class UserController {
@RateLimiter(name = "api")
@GetMapping
public ResponseEntity<PagedModel<EntityModel<UserDTO>>> getAllUsers(/* parameters */) {
// Implementation
}
}
API Versioning
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
// Current implementation
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
// Future enhanced version
}
Testing Your Enhanced API
Now that everything's set up, let's test the complete flow:
-
Start the application:
mvn spring-boot:run
-
Visit Swagger UI:
http://localhost:8080/swagger-ui.html
-
Test error handling by trying invalid inputs
-
Check H2 console:
http://localhost:8080/h2-console
(use connection details from properties) -
Monitor health:
http://localhost:8080/actuator/health
What Makes This API Production-Ready?
Here's what separates this from a basic tutorial API:
- Proper Error Handling: Instead of generic 500 errors, you get meaningful responses
- Input Validation: Bad data is caught before it reaches your business logic
- HATEOAS Support: Your API is self-documenting and discoverable
- Pagination: Won't crash when you have millions of users
- Comprehensive Logging: You can actually debug issues when they occur
- Auto-generated Documentation: Swagger makes your API discoverable
- Layered Architecture: Easy to maintain and extend
Common Gotchas and How to Avoid Them
1. The N+1 Query Problem
// Bad - causes N+1 queries
@GetMapping("/users-with-orders")
public List<UserWithOrdersDTO> getUsersWithOrders() {
return userService.getAllUsers().stream()
.map(user -> {
List<Order> orders = orderService.getOrdersByUserId(user.getId()); // N queries!
return new UserWithOrdersDTO(user, orders);
})
.collect(Collectors.toList());
}
// Good - single query with join
@Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id IN :userIds")
List<User> findUsersWithOrders(@Param("userIds") List<Long> userIds);
2. Transaction Boundaries
// Bad - transaction per operation
public void processUserBatch(List<CreateUserDTO> users) {
for (CreateUserDTO userDto : users) {
userService.createUser(userMapper.toEntity(userDto)); // New transaction each time
}
}
// Good - single transaction
@Transactional
public void processUserBatch(List<CreateUserDTO> users) {
List<User> userEntities = users.stream()
.map(userMapper::toEntity)
.collect(Collectors.toList());
userRepository.saveAll(userEntities); // Batch operation
}
3. Handling Large Datasets
// For large datasets, use streaming
@GetMapping("/export")
public void exportUsers(HttpServletResponse response) {
response.setContentType("application/json");
response.setHeader("Content-Disposition", "attachment; filename=users.json");
try (PrintWriter writer = response.getWriter()) {
writer.print("[");
userRepository.findAll().forEach(user -> {
// Stream users one by one instead of loading all into memory
writer.print(objectMapper.writeValueAsString(userMapper.toDto(user)));
writer.print(",");
});
writer.print("]");
}
}
Key Takeaways
Here are the 5 most important lessons from building this enhanced API:
- Validation is your first line of defense - Catch bad data before it corrupts your system
- DTOs create clean boundaries - Separate your internal model from external contracts
- Proper error handling improves developer experience - Meaningful errors save hours of debugging
- Documentation isn't optional - Swagger makes your API self-explanatory
- Think in layers - Repository, Service, Controller separation makes code maintainable
What's Next?
This API is a solid foundation, but here are some enhancements you might consider:
- Authentication with JWT: Secure your endpoints
- Database migration with Flyway: Version control your schema changes
- Metrics and monitoring: Add Micrometer for production observability
- Integration tests: Test the complete flow end-to-end
- Docker containerization: Make deployment consistent across environments
Additional Resources
- Spring Boot Documentation
- ModelMapper Documentation
- Spring HATEOAS Reference
- OpenAPI 3 Specification
Building APIs that can handle real-world challenges takes practice, but following these patterns will give you a huge head start. The extra effort you put into proper validation, error handling, and documentation pays off massively when you're debugging at 2 AM or onboarding new team members.
What's your experience with Spring Boot APIs? Any patterns or practices that have saved you time? Drop a comment below - I'd love to hear your stories! 🎯