| name | spring-boot-patterns |
| description | Use when implementing Spring Boot idioms including configuration, dependency injection, profiles, actuator, caching, async processing, and application structure. |
Spring Boot Patterns Skill
You are an expert in Spring Boot 3.x patterns and best practices. You guide idiomatic usage of Spring features.
Application Structure
src/main/java/pl/piomin/services/
Application.java
config/
WebConfig.java
SecurityConfig.java
CacheConfig.java
controller/
EmployeeController.java
service/
EmployeeService.java
repository/
EmployeeRepository.java
model/
entity/
Employee.java
dto/
CreateEmployeeRequest.java
EmployeeResponse.java
mapper/
EmployeeMapper.java
exception/
ResourceNotFoundException.java
GlobalExceptionHandler.java
Configuration Properties
@ConfigurationProperties(prefix = "app.payment")
@Validated
public record PaymentProperties(
@NotBlank String gatewayUrl,
@NotBlank String apiKey,
@Positive int timeoutSeconds,
@Positive int maxRetries,
RetryProperties retry
) {
public record RetryProperties(
@Positive int maxAttempts,
@Positive long initialDelayMs,
@Positive double multiplier
) {}
}
@SpringBootApplication
@EnableConfigurationProperties(PaymentProperties.class)
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@Service
public class PaymentService {
private final PaymentProperties properties;
public PaymentService(PaymentProperties properties) {
this.properties = properties;
}
}
app:
payment:
gateway-url: https://api.payment.example.com
api-key: ${PAYMENT_API_KEY}
timeout-seconds: 30
max-retries: 3
retry:
max-attempts: 3
initial-delay-ms: 1000
multiplier: 2.0
Profile-Based Configuration
spring:
application:
name: employee-service
---
spring:
datasource:
url: jdbc:postgresql://localhost:5432/employees_dev
jpa:
show-sql: true
hibernate:
ddl-auto: create-drop
logging:
level:
pl.piomin.services: DEBUG
---
spring:
datasource:
url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
jpa:
show-sql: false
hibernate:
ddl-auto: validate
logging:
level:
root: WARN
pl.piomin.services: INFO
Exception Handling
public class ResourceNotFoundException extends RuntimeException {
private final String resource;
private final Object identifier;
public ResourceNotFoundException(String resource, Object identifier) {
super("%s not found with id: %s".formatted(resource, identifier));
this.resource = resource;
this.identifier = identifier;
}
public String getResource() { return resource; }
public Object getIdentifier() { return identifier; }
}
public class BusinessRuleException extends RuntimeException {
private final String errorCode;
public BusinessRuleException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex,
HttpServletRequest request) {
log.warn("Resource not found: {}", ex.getMessage());
return buildError(HttpStatus.NOT_FOUND, ex.getMessage(), request);
}
@ExceptionHandler(BusinessRuleException.class)
public ResponseEntity<ApiError> handleBusinessRule(BusinessRuleException ex,
HttpServletRequest request) {
log.warn("Business rule violation: code={}, message={}", ex.getErrorCode(), ex.getMessage());
return buildError(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), request);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex,
HttpServletRequest request) {
var fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new ApiError.FieldError(fe.getField(), fe.getDefaultMessage()))
.toList();
var error = new ApiError(Instant.now(), 400, "Bad Request",
"Validation failed", request.getRequestURI(), fieldErrors);
return ResponseEntity.badRequest().body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleGeneral(Exception ex, HttpServletRequest request) {
log.error("Unexpected error", ex);
return buildError(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred", request);
}
private ResponseEntity<ApiError> buildError(HttpStatus status, String message,
HttpServletRequest request) {
var error = new ApiError(Instant.now(), status.value(), status.getReasonPhrase(),
message, request.getRequestURI(), List.of());
return ResponseEntity.status(status).body(error);
}
}
Caching
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
var caffeine = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats();
var manager = new CaffeineCacheManager();
manager.setCaffeine(caffeine);
return manager;
}
}
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(value = "products", key = "#id")
@Transactional(readOnly = true)
public Optional<Product> findById(Long id) {
return productRepository.findById(id);
}
@CachePut(value = "products", key = "#result.id")
@Transactional
public Product update(Long id, UpdateProductRequest request) {
var product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
product.setName(request.name());
product.setPrice(request.price());
return productRepository.save(product);
}
@CacheEvict(value = "products", key = "#id")
@Transactional
public void delete(Long id) {
productRepository.deleteById(id);
}
}
Async Processing
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
var executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
@Service
public class NotificationService {
private static final Logger log = LoggerFactory.getLogger(NotificationService.class);
@Async("taskExecutor")
public CompletableFuture<Void> sendEmailAsync(String to, String subject, String body) {
log.info("Sending email to: {}", to);
return CompletableFuture.completedFuture(null);
}
}
Health Checks and Actuator
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
private final PaymentGateway paymentGateway;
public PaymentGatewayHealthIndicator(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
@Override
public Health health() {
try {
var reachable = paymentGateway.ping();
if (reachable) {
return Health.up()
.withDetail("gateway", "reachable")
.build();
}
return Health.down()
.withDetail("gateway", "unreachable")
.build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized
info:
env:
enabled: true