| name | spring-boot-patterns |
| description | Use when implementing Spring Boot applications to follow Controller/Service/Repository patterns, DTO mapping, exception handling, and configuration best practices. |
Spring Boot Patterns
Layered Architecture
Controller (REST) -> Service (Business Logic) -> Repository (Data Access)
| | |
DTOs Domain Entities JPA Entities
Controller Layer
REST Controller
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public ResponseEntity<Page<ProductResponse>> findAll(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ResponseEntity.ok(productService.findAll(PageRequest.of(page, size)));
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> findById(@PathVariable Long id) {
return ResponseEntity.ok(productService.findById(id));
}
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody ProductRequest request) {
ProductResponse created = productService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> update(
@PathVariable Long id,
@Valid @RequestBody ProductRequest request) {
return ResponseEntity.ok(productService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
productService.delete(id);
return ResponseEntity.noContent().build();
}
}
Controller rules:
- No business logic
- Only request/response handling
- Use
@Valid for input validation
- Return
ResponseEntity with proper status codes
- Constructor injection only
Service Layer
@Service
public class ProductService {
private static final Logger log = LoggerFactory.getLogger(ProductService.class);
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
private final ProductMapper productMapper;
public ProductService(ProductRepository productRepository,
CategoryRepository categoryRepository,
ProductMapper productMapper) {
this.productRepository = productRepository;
this.categoryRepository = categoryRepository;
this.productMapper = productMapper;
}
@Transactional(readOnly = true)
public Page<ProductResponse> findAll(Pageable pageable) {
return productRepository.findAll(pageable)
.map(productMapper::toResponse);
}
@Transactional(readOnly = true)
public ProductResponse findById(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
return productMapper.toResponse(product);
}
@Transactional
public ProductResponse create(ProductRequest request) {
if (productRepository.existsByName(request.name())) {
throw new DuplicateResourceException("Product", "name", request.name());
}
Category category = categoryRepository.findById(request.categoryId())
.orElseThrow(() -> new ResourceNotFoundException("Category", request.categoryId()));
Product product = productMapper.toEntity(request);
product.setCategory(category);
Product saved = productRepository.save(product);
log.info("Product created: id={}, name={}", saved.getId(), saved.getName());
return productMapper.toResponse(saved);
}
@Transactional
public ProductResponse update(Long id, ProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
productMapper.updateEntity(product, request);
Product saved = productRepository.save(product);
log.info("Product updated: id={}", saved.getId());
return productMapper.toResponse(saved);
}
@Transactional
public void delete(Long id) {
if (!productRepository.existsById(id)) {
throw new ResourceNotFoundException("Product", id);
}
productRepository.deleteById(id);
log.info("Product deleted: id={}", id);
}
}
Service rules:
- All business logic lives here
@Transactional on write methods, @Transactional(readOnly = true) on reads
- Throw domain exceptions, not HTTP exceptions
- Log business events
Repository Layer
public interface ProductRepository extends JpaRepository<Product, Long> {
boolean existsByName(String name);
Optional<Product> findByName(String name);
@EntityGraph(attributePaths = {"category"})
Page<Product> findByCategoryId(Long categoryId, Pageable pageable);
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max")
List<Product> findByPriceRange(@Param("min") BigDecimal min,
@Param("max") BigDecimal max);
@Modifying
@Query("UPDATE Product p SET p.active = false WHERE p.lastModified < :cutoff")
int deactivateStaleProducts(@Param("cutoff") LocalDateTime cutoff);
}
DTO Pattern
Request and Response Records
public record ProductRequest(
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
String name,
@Size(max = 500, message = "Description must be at most 500 characters")
String description,
@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
BigDecimal price,
@NotNull(message = "Category ID is required")
Long categoryId,
@NotEmpty(message = "At least one tag is required")
List<String> tags
) {}
public record ProductResponse(
Long id,
String name,
String description,
BigDecimal price,
String categoryName,
List<String> tags,
boolean active,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}
Mapper Component
@Component
public class ProductMapper {
public Product toEntity(ProductRequest request) {
Product product = new Product();
product.setName(request.name());
product.setDescription(request.description());
product.setPrice(request.price());
product.setTags(request.tags());
product.setActive(true);
return product;
}
public void updateEntity(Product product, ProductRequest request) {
product.setName(request.name());
product.setDescription(request.description());
product.setPrice(request.price());
product.setTags(request.tags());
}
public ProductResponse toResponse(Product product) {
return new ProductResponse(
product.getId(),
product.getName(),
product.getDescription(),
product.getPrice(),
product.getCategory() != null ? product.getCategory().getName() : null,
product.getTags(),
product.isActive(),
product.getCreatedAt(),
product.getUpdatedAt()
);
}
}
Global Exception Handling
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(HttpStatus.NOT_FOUND.value(), ex.getMessage()));
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<ErrorResponse> handleDuplicate(DuplicateResourceException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(HttpStatus.CONFLICT.value(), ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.toList();
return ResponseEntity.badRequest()
.body(new ErrorResponse(400, "Validation failed", errors));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
List<String> errors = ex.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.toList();
return ResponseEntity.badRequest()
.body(new ErrorResponse(400, "Validation failed", errors));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
log.error("Unexpected error", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse(500, "An unexpected error occurred"));
}
}
public record ErrorResponse(
int status,
String message,
List<String> errors,
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss")
LocalDateTime timestamp
) {
public ErrorResponse(int status, String message) {
this(status, message, List.of(), LocalDateTime.now());
}
public ErrorResponse(int status, String message, List<String> errors) {
this(status, message, errors, LocalDateTime.now());
}
}
Domain Exceptions
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String resource, Object id) {
super(resource + " not found with id: " + id);
}
}
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String resource, String field, Object value) {
super(resource + " already exists with " + field + ": " + value);
}
}
public class BusinessRuleViolationException extends RuntimeException {
public BusinessRuleViolationException(String message) {
super(message);
}
}
Configuration
Application Properties
spring:
application:
name: ${project.artifactId}
datasource:
url: jdbc:postgresql://localhost:5432/mydb
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
properties:
hibernate:
jdbc:
batch_size: 50
order_inserts: true
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when-authorized
Configuration Properties Class
@ConfigurationProperties(prefix = "app")
@Validated
public class AppProperties {
@NotBlank
private String name;
@Positive
private int maxRetries = 3;
@DurationMin(seconds = 1)
private Duration timeout = Duration.ofSeconds(30);
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getMaxRetries() { return maxRetries; }
public void setMaxRetries(int maxRetries) { this.maxRetries = maxRetries; }
public Duration getTimeout() { return timeout; }
public void setTimeout(Duration timeout) { this.timeout = timeout; }
}
@Configuration
@EnableConfigurationProperties(AppProperties.class)
public class AppConfig {
}
Health Check
@Component
public class ExternalServiceHealthIndicator implements HealthIndicator {
private final ExternalServiceClient client;
public ExternalServiceHealthIndicator(ExternalServiceClient client) {
this.client = client;
}
@Override
public Health health() {
try {
client.ping();
return Health.up()
.withDetail("service", "external-api")
.withDetail("status", "reachable")
.build();
} catch (Exception e) {
return Health.down()
.withDetail("service", "external-api")
.withException(e)
.build();
}
}
}