Spring Boot 3 Patterns
Controller Layer
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserResponse createUser(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
@GetMapping
public Page<UserResponse> listUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return userService.findAll(PageRequest.of(page, size));
}
}
Service Layer
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final UserMapper userMapper;
public UserResponse findById(Long id) {
return userRepository.findById(id)
.map(userMapper::toResponse)
.orElseThrow(() -> new ResourceNotFoundException("User", id));
}
@Transactional
public UserResponse create(CreateUserRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateResourceException("Email already in use");
}
User user = userMapper.toEntity(request);
return userMapper.toResponse(userRepository.save(user));
}
}
Repository Layer
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email);
Optional<User> findByEmail(String email);
@Query("SELECT u FROM User u WHERE u.status = :status AND u.createdAt > :since")
Page<User> findActiveUsersSince(@Param("status") UserStatus status,
@Param("since") Instant since,
Pageable pageable);
}
Global Exception Handling
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
detail.setTitle("Resource Not Found");
detail.setDetail(ex.getMessage());
return detail;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail detail = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
detail.setTitle("Validation Failed");
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
detail.setProperty("errors", errors);
return detail;
}
}
Application Properties (YAML)
spring:
application:
name: my-service
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USER}
password: ${DB_PASS}
jpa:
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate.default_batch_fetch_size: 20
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized
Profiles
@Configuration
@Profile("prod")
public class ProdConfig {
@Bean
public CacheManager cacheManager() {
return new RedisCacheManager(...);
}
}
// application-dev.yml — local H2
// application-prod.yml — real Postgres + Redis
Actuator Custom Health Indicator
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
@Override
public Health health() {
try (Connection conn = dataSource.getConnection()) {
conn.prepareStatement("SELECT 1").execute();
return Health.up().build();
} catch (SQLException ex) {
return Health.down(ex).build();
}
}
}
Key Rules
- Use
@Transactional(readOnly = true) on service class, override with @Transactional on write methods
- Return
ResponseEntity only when you need to set headers/status dynamically; use @ResponseStatus otherwise
- Validate DTOs with
@Valid; use record types for immutable request/response objects
- Use
ProblemDetail (RFC 7807) for error responses — Spring Boot 3 native support
- Externalize all secrets via
${ENV_VAR} — never hardcode credentials