Production-grade Spring Boot patterns — controller/service/repository layers, global exception handling, DTO validation with Bean Validation, pagination, HATEOAS, and security configuration with Spring Security 6.
You are a senior Java engineer. Follow these patterns for all Spring Boot REST APIs.
Controller → Service → Repository
(HTTP) (Logic) (Data Access)
Controllers never contain business logic. Services never import HTTP types. Repositories never throw business exceptions.
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public Page<UserResponse> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.getUsers(PageRequest.of(page, size))
.map(UserResponse::from);
}
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
return UserResponse.from(userService.getUserById(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
User user = userService.createUser(request.toEntity());
return UserResponse.from(user);
}
@PutMapping("/{id}")
public UserResponse updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
User user = userService.updateUser(id, request);
return UserResponse.from(user);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
}
}
Never expose entity objects directly. Use DTOs with validation annotations.
public record CreateUserRequest(
@NotBlank(message = "Name is required")
@Size(max = 100, message = "Name must be under 100 characters")
String name,
@NotBlank(message = "Email is required")
@Email(message = "Invalid email format")
String email,
@NotBlank(message = "Password is required")
@Size(min = 8, max = 128, message = "Password must be 8-128 characters")
String password
) {
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.password(password)
.build();
}
}
public record UserResponse(Long id, String name, String email, LocalDateTime createdAt) {
public static UserResponse from(User user) {
return new UserResponse(user.getId(), user.getName(), user.getEmail(), user.getCreatedAt());
}
}
Centralize all exception handling with @ControllerAdvice.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse(ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ValidationErrorResponse handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
error -> Objects.requireNonNullElse(error.getDefaultMessage(), "Invalid"),
(a, b) -> a
));
return new ValidationErrorResponse("Validation failed", errors);
}
@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorResponse handleConflict(DataIntegrityViolationException ex) {
return new ErrorResponse("Resource already exists");
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGeneral(Exception ex) {
log.error("Unhandled exception", ex);
return ();
}
}
{}
{}
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public Page<User> getUsers(Pageable pageable) {
return userRepository.findAll(pageable);
}
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
@Transactional
public User createUser(User user) {
if (userRepository.existsByEmail(user.getEmail())) {
throw new ConflictException("Email already registered");
}
user.setPassword(passwordEncoder.encode(user.getPassword()));
return userRepository.save(user);
}
@Transactional
public void deleteUser(Long id) {
if (!userRepository.existsById(id)) {
throw new ResourceNotFoundException("User", id);
}
userRepository.deleteById(id);
}
}
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
@Query("SELECT u FROM User u WHERE u.name LIKE %:query% OR u.email LIKE %:query%")
Page<User> search(@Param("query") String query, Pageable pageable);
}
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/v1/posts/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@RequiredArgsConstructor) over field injection (@Autowired).@Transactional(readOnly = true) at class level, override with @Transactional on write methods.Optional for nullable returns, never return null./api/v1/) from day one.