Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:28:30 +08:00
commit 171acedaa4
220 changed files with 85967 additions and 0 deletions

View File

@@ -0,0 +1,190 @@
---
name: spring-boot-actuator
description: Configure Spring Boot Actuator for production-grade monitoring, health probes, secured management endpoints, and Micrometer metrics across JVM services.
allowed-tools: Read, Write, Bash
category: backend
tags: [spring-boot, actuator, monitoring, health-checks, metrics, production]
version: 1.1.0
context7_library: /websites/spring_io_spring-boot_3_5
context7_trust_score: 7.5
---
# Spring Boot Actuator Skill
## Overview
- Deliver production-ready observability for Spring Boot services using Actuator endpoints, probes, and Micrometer integration.
- Standardize health, metrics, and diagnostics configuration while delegating deep reference material to `references/`.
- Support platform requirements for secure operations, SLO reporting, and incident diagnostics.
## When to Use
- Trigger: "enable actuator endpoints" Bootstrap Actuator for a new or existing Spring Boot service.
- Trigger: "secure management port" Apply Spring Security policies to protect management traffic.
- Trigger: "configure health probes" Define readiness and liveness groups for orchestrators.
- Trigger: "export metrics to prometheus" Wire Micrometer registries and tune metric exposure.
- Trigger: "debug actuator startup" Inspect condition evaluations and startup metrics when endpoints are missing or slow.
## Quick Start
1. Add the starter dependency.
```xml
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
```
```gradle
// Gradle
dependencies {
implementation "org.springframework.boot:spring-boot-starter-actuator"
}
```
2. Restart the service and verify `/actuator/health` and `/actuator/info` respond with `200 OK`.
## Implementation Workflow
### 1. Expose the required endpoints
- Set `management.endpoints.web.exposure.include` to the precise list or `"*"` for internal deployments.
- Adjust `management.endpoints.web.base-path` (e.g., `/management`) when the default `/actuator` conflicts with routing.
- Review detailed endpoint semantics in `references/endpoint-reference.md`.
### 2. Secure management traffic
- Apply an isolated `SecurityFilterChain` using `EndpointRequest.toAnyEndpoint()` with role-based rules.
- Combine `management.server.port` with firewall controls or service mesh policies for operator-only access.
- Keep `/actuator/health/**` publicly accessible only when required; otherwise enforce authentication.
### 3. Configure health probes
- Enable `management.endpoint.health.probes.enabled=true` for `/health/liveness` and `/health/readiness`.
- Group indicators via `management.endpoint.health.group.*` to match platform expectations.
- Implement custom indicators by extending `HealthIndicator` or `ReactiveHealthContributor`; sample implementations live in `references/examples.md#custom-health-indicator`.
### 4. Publish metrics and traces
- Activate Micrometer exporters (Prometheus, OTLP, Wavefront, StatsD) via `management.metrics.export.*`.
- Apply `MeterRegistryCustomizer` beans to add `application`, `environment`, and business tags for observability correlation.
- Surface HTTP request metrics with `server.observation.*` configuration when using Spring Boot 3.2+.
### 5. Enable diagnostics tooling
- Turn on `/actuator/startup` (Spring Boot 3.5+) and `/actuator/conditions` during incident response to inspect auto-configuration decisions.
- Register an `HttpExchangeRepository` (e.g., `InMemoryHttpExchangeRepository`) before enabling `/actuator/httpexchanges` for request auditing.
- Consult `references/official-actuator-docs.md` for endpoint behaviors and limits.
## Examples
### Basic Expose health and info safely
```yaml
management:
endpoints:
web:
exposure:
include: "health,info"
endpoint:
health:
show-details: never
```
### Intermediate Readiness group with custom indicator
```java
@Component
public class PaymentsGatewayHealth implements HealthIndicator {
private final PaymentsClient client;
public PaymentsGatewayHealth(PaymentsClient client) {
this.client = client;
}
@Override
public Health health() {
boolean reachable = client.ping();
return reachable ? Health.up().withDetail("latencyMs", client.latency()).build()
: Health.down().withDetail("error", "Gateway timeout").build();
}
}
```
```yaml
management:
endpoint:
health:
probes:
enabled: true
group:
readiness:
include: "readinessState,db,paymentsGateway"
show-details: always
```
### Advanced Dedicated management port with Prometheus export
```yaml
management:
server:
port: 9091
ssl:
enabled: true
endpoints:
web:
exposure:
include: "health,info,metrics,prometheus"
base-path: "/management"
metrics:
export:
prometheus:
descriptions: true
step: 30s
endpoint:
health:
show-details: when-authorized
roles: "ENDPOINT_ADMIN"
```
```java
@Configuration
public class ActuatorSecurityConfig {
@Bean
SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
http.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(c -> c
.requestMatchers(EndpointRequest.to("health")).permitAll()
.anyRequest().hasRole("ENDPOINT_ADMIN"))
.httpBasic(Customizer.withDefaults());
return http.build();
}
}
```
More end-to-end samples are available in `references/examples.md`.
## Best Practices
- Keep SKILL.md concise and rely on `references/` for verbose documentation to conserve context.
- Apply the principle of least privilege: expose only required endpoints and restrict sensitive ones.
- Use immutable configuration via profile-specific YAML to align environments.
- Monitor actuator traffic separately to detect scraping abuse or brute-force attempts.
- Automate regression checks by scripting `curl` probes in CI/CD pipelines.
## Constraints
- Avoid exposing `/actuator/env`, `/actuator/configprops`, `/actuator/logfile`, and `/actuator/heapdump` on public networks.
- Do not ship custom health indicators that block event loop threads or exceed 250ms unless absolutely necessary.
- Ensure Actuator metrics exporters run on supported Micrometer registries; unsupported exporters require custom registry beans.
- Maintain compatibility with Spring Boot 3.5.x conventions; older versions may lack probes and observation features.
## Reference Materials
- [Endpoint quick reference](references/endpoint-reference.md)
- [Implementation examples](references/examples.md)
- [Official documentation extract](references/official-actuator-docs.md)
- [Auditing with Actuator](references/auditing.md)
- [Cloud Foundry integration](references/cloud-foundry.md)
- [Enabling Actuator features](references/enabling.md)
- [HTTP exchange recording](references/http-exchanges.md)
- [JMX exposure](references/jmx.md)
- [Monitoring and metrics](references/monitoring.md)
- [Logging configuration](references/loggers.md)
- [Metrics exporters](references/metrics.md)
- [Observability with Micrometer](references/observability.md)
- [Process and Monitoring](references/process-monitoring.md)
- [Tracing](references/tracing.md)
- Scripts directory (`scripts/`) reserved for future automation; no runtime dependencies today.
## Validation Checklist
- Confirm `mvn spring-boot:run` or `./gradlew bootRun` exposes expected endpoints under `/actuator` (or custom base path).
- Verify `/actuator/health/readiness` returns `UP` with all mandatory components before promoting to production.
- Scrape `/actuator/metrics` or `/actuator/prometheus` to ensure required meters (`http.server.requests`, `jvm.memory.used`) are present.
- Run security scans to validate only intended ports and endpoints are reachable from outside the trusted network.

View File

@@ -0,0 +1,519 @@
# Auditing with Spring Boot Actuator
Once Spring Security is in play, Spring Boot Actuator has a flexible audit framework that publishes events (by default, "authentication success", "failure" and "access denied" exceptions). This feature can be very useful for reporting and for implementing a lock-out policy based on authentication failures.
You can enable auditing by providing a bean of type `AuditEventRepository` in your application's configuration. For convenience, Spring Boot offers an `InMemoryAuditEventRepository`. `InMemoryAuditEventRepository` has limited capabilities, and we recommend using it only for development environments. For production environments, consider creating your own alternative `AuditEventRepository` implementation.
## Basic Audit Configuration
### In-Memory Audit Repository (Development)
```java
@Configuration
public class AuditConfiguration {
@Bean
public AuditEventRepository auditEventRepository() {
return new InMemoryAuditEventRepository();
}
}
```
### Database Audit Repository (Production)
```java
@Entity
@Table(name = "audit_events")
public class PersistentAuditEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "principal", nullable = false)
private String principal;
@Column(name = "audit_event_type", nullable = false)
private String auditEventType;
@Column(name = "audit_event_date", nullable = false)
private Instant auditEventDate;
@ElementCollection
@MapKeyColumn(name = "name")
@Column(name = "value")
@CollectionTable(name = "audit_event_data",
joinColumns = @JoinColumn(name = "event_id"))
private Map<String, String> data = new HashMap<>();
// Constructors, getters, setters
}
@Repository
public class CustomAuditEventRepository implements AuditEventRepository {
private final PersistentAuditEventRepository repository;
public CustomAuditEventRepository(PersistentAuditEventRepository repository) {
this.repository = repository;
}
@Override
public void add(AuditEvent event) {
PersistentAuditEvent persistentEvent = new PersistentAuditEvent();
persistentEvent.setPrincipal(event.getPrincipal());
persistentEvent.setAuditEventType(event.getType());
persistentEvent.setAuditEventDate(event.getTimestamp());
persistentEvent.setData(event.getData());
repository.save(persistentEvent);
}
@Override
public List<AuditEvent> find(String principal, Instant after, String type) {
List<PersistentAuditEvent> events = repository.findByPrincipalAndAuditEventDateAfterAndAuditEventType(
principal, after, type);
return events.stream()
.map(this::convertToAuditEvent)
.collect(Collectors.toList());
}
private AuditEvent convertToAuditEvent(PersistentAuditEvent persistentEvent) {
return new AuditEvent(persistentEvent.getAuditEventDate(),
persistentEvent.getPrincipal(),
persistentEvent.getAuditEventType(),
persistentEvent.getData());
}
}
```
## Custom Auditing
### Custom Audit Events
You can publish custom audit events using `AuditEventRepository`:
```java
@Service
public class UserService {
private final AuditEventRepository auditEventRepository;
private final UserRepository userRepository;
public UserService(AuditEventRepository auditEventRepository,
UserRepository userRepository) {
this.auditEventRepository = auditEventRepository;
this.userRepository = userRepository;
}
public User createUser(CreateUserRequest request) {
User user = userRepository.save(request.toUser());
// Publish audit event
Map<String, String> data = new HashMap<>();
data.put("userId", user.getId().toString());
data.put("username", user.getUsername());
data.put("email", user.getEmail());
AuditEvent event = new AuditEvent(getCurrentUsername(), "USER_CREATED", data);
auditEventRepository.add(event);
return user;
}
public void deleteUser(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
userRepository.delete(user);
// Publish audit event
Map<String, String> data = new HashMap<>();
data.put("userId", userId.toString());
data.put("username", user.getUsername());
AuditEvent event = new AuditEvent(getCurrentUsername(), "USER_DELETED", data);
auditEventRepository.add(event);
}
private String getCurrentUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "system";
}
}
```
### Custom Audit Event Publisher
```java
@Component
public class AuditEventPublisher {
private final AuditEventRepository auditEventRepository;
public AuditEventPublisher(AuditEventRepository auditEventRepository) {
this.auditEventRepository = auditEventRepository;
}
public void publishEvent(String type, Map<String, String> data) {
String principal = getCurrentPrincipal();
AuditEvent event = new AuditEvent(principal, type, data);
auditEventRepository.add(event);
}
public void publishSecurityEvent(String type, String details) {
Map<String, String> data = new HashMap<>();
data.put("details", details);
data.put("timestamp", Instant.now().toString());
data.put("source", "security");
publishEvent(type, data);
}
public void publishBusinessEvent(String type, String entityId, String action) {
Map<String, String> data = new HashMap<>();
data.put("entityId", entityId);
data.put("action", action);
data.put("timestamp", Instant.now().toString());
publishEvent(type, data);
}
private String getCurrentPrincipal() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "anonymous";
}
}
```
## Method-Level Auditing
### Using AOP for Automatic Auditing
```java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String value() default "";
String type() default "";
boolean includeArgs() default false;
boolean includeResult() default false;
}
@Aspect
@Component
public class AuditableAspect {
private final AuditEventPublisher auditEventPublisher;
public AuditableAspect(AuditEventPublisher auditEventPublisher) {
this.auditEventPublisher = auditEventPublisher;
}
@Around("@annotation(auditable)")
public Object auditMethod(ProceedingJoinPoint joinPoint, Auditable auditable) throws Throwable {
String methodName = joinPoint.getSignature().getName();
String className = joinPoint.getTarget().getClass().getSimpleName();
String auditType = auditable.type().isEmpty() ?
className + "." + methodName : auditable.type();
Map<String, String> data = new HashMap<>();
data.put("method", methodName);
data.put("class", className);
if (auditable.includeArgs()) {
Object[] args = joinPoint.getArgs();
for (int i = 0; i < args.length; i++) {
data.put("arg" + i, String.valueOf(args[i]));
}
}
try {
Object result = joinPoint.proceed();
if (auditable.includeResult() && result != null) {
data.put("result", String.valueOf(result));
}
data.put("status", "success");
auditEventPublisher.publishEvent(auditType, data);
return result;
} catch (Exception ex) {
data.put("status", "failure");
data.put("error", ex.getMessage());
auditEventPublisher.publishEvent(auditType, data);
throw ex;
}
}
}
```
### Usage Example
```java
@Service
public class OrderService {
@Auditable(type = "ORDER_CREATED", includeArgs = true)
public Order createOrder(CreateOrderRequest request) {
// Order creation logic
return new Order();
}
@Auditable(type = "ORDER_CANCELLED", includeResult = true)
public Order cancelOrder(Long orderId) {
// Order cancellation logic
return cancelledOrder;
}
@Auditable(type = "PAYMENT_PROCESSED")
public PaymentResult processPayment(PaymentRequest request) {
// Payment processing logic
return new PaymentResult();
}
}
```
## Security Audit Events
### Authentication Events
Spring Boot automatically publishes authentication events when using Spring Security:
- `AUTHENTICATION_SUCCESS`
- `AUTHENTICATION_FAILURE`
- `ACCESS_DENIED`
### Custom Security Events
```java
@Component
public class SecurityAuditService {
private final AuditEventPublisher auditEventPublisher;
public SecurityAuditService(AuditEventPublisher auditEventPublisher) {
this.auditEventPublisher = auditEventPublisher;
}
@EventListener
public void handleAuthenticationSuccess(AuthenticationSuccessEvent event) {
Map<String, String> data = new HashMap<>();
data.put("username", event.getAuthentication().getName());
data.put("authorities", event.getAuthentication().getAuthorities().toString());
data.put("source", getClientIP());
auditEventPublisher.publishEvent("AUTHENTICATION_SUCCESS", data);
}
@EventListener
public void handleAuthenticationFailure(AbstractAuthenticationFailureEvent event) {
Map<String, String> data = new HashMap<>();
data.put("username", event.getAuthentication().getName());
data.put("exception", event.getException().getClass().getSimpleName());
data.put("message", event.getException().getMessage());
data.put("source", getClientIP());
auditEventPublisher.publishEvent("AUTHENTICATION_FAILURE", data);
}
@EventListener
public void handleAccessDenied(AuthorizationDeniedEvent event) {
Map<String, String> data = new HashMap<>();
data.put("username", event.getAuthentication().getName());
data.put("resource", event.getAuthorizationDecision().toString());
data.put("source", getClientIP());
auditEventPublisher.publishEvent("ACCESS_DENIED", data);
}
private String getClientIP() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes instanceof ServletRequestAttributes) {
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
return request.getRemoteAddr();
}
return "unknown";
}
}
```
### Password Change Auditing
```java
@Service
public class PasswordService {
private final AuditEventPublisher auditEventPublisher;
private final PasswordEncoder passwordEncoder;
public PasswordService(AuditEventPublisher auditEventPublisher,
PasswordEncoder passwordEncoder) {
this.auditEventPublisher = auditEventPublisher;
this.passwordEncoder = passwordEncoder;
}
public void changePassword(String oldPassword, String newPassword) {
String username = getCurrentUsername();
try {
// Validate old password
if (!isCurrentPassword(oldPassword)) {
Map<String, String> data = new HashMap<>();
data.put("username", username);
data.put("reason", "invalid_old_password");
auditEventPublisher.publishEvent("PASSWORD_CHANGE_FAILED", data);
throw new InvalidPasswordException("Invalid old password");
}
// Change password
updatePassword(newPassword);
// Audit success
Map<String, String> data = new HashMap<>();
data.put("username", username);
auditEventPublisher.publishEvent("PASSWORD_CHANGED", data);
} catch (Exception ex) {
Map<String, String> data = new HashMap<>();
data.put("username", username);
data.put("error", ex.getMessage());
auditEventPublisher.publishEvent("PASSWORD_CHANGE_ERROR", data);
throw ex;
}
}
private boolean isCurrentPassword(String password) {
// Implementation
return true;
}
private void updatePassword(String newPassword) {
// Implementation
}
private String getCurrentUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "anonymous";
}
}
```
## Audit Events Endpoint
The `/actuator/auditevents` endpoint exposes audit events:
```
GET /actuator/auditevents
GET /actuator/auditevents?principal=user&after=2023-01-01T00:00:00Z&type=USER_CREATED
```
Response format:
```json
{
"events": [
{
"timestamp": "2023-12-01T10:30:00Z",
"principal": "admin",
"type": "USER_CREATED",
"data": {
"userId": "123",
"username": "newuser",
"email": "user@example.com"
}
}
]
}
```
## Production Configuration
### Secure Audit Endpoint
```java
@Configuration
public class AuditSecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain auditSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.requestMatcher(EndpointRequest.to("auditevents"))
.authorizeHttpRequests(requests ->
requests.anyRequest().hasRole("AUDITOR"))
.httpBasic(withDefaults())
.build();
}
}
```
### Audit Configuration
```yaml
management:
endpoint:
auditevents:
enabled: true
cache:
time-to-live: 10s
endpoints:
web:
exposure:
include: "auditevents"
# Custom audit properties
audit:
retention-days: 90
max-events-per-request: 100
sensitive-data-masking: true
```
## Best Practices
1. **Data Sensitivity**: Never include sensitive data (passwords, tokens) in audit events
2. **Performance**: Consider async processing for high-volume audit events
3. **Retention**: Implement audit data retention policies
4. **Security**: Secure the audit endpoint and audit data storage
5. **Monitoring**: Monitor audit system health and performance
6. **Compliance**: Ensure audit events meet regulatory requirements
7. **Immutability**: Ensure audit events cannot be modified after creation
### Async Audit Processing
```java
@Configuration
@EnableAsync
public class AsyncAuditConfiguration {
@Bean
public TaskExecutor auditTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("audit-");
executor.initialize();
return executor;
}
}
@Service
public class AsyncAuditEventRepository implements AuditEventRepository {
private final AuditEventRepository delegate;
public AsyncAuditEventRepository(AuditEventRepository delegate) {
this.delegate = delegate;
}
@Override
@Async("auditTaskExecutor")
public void add(AuditEvent event) {
delegate.add(event);
}
@Override
public List<AuditEvent> find(String principal, Instant after, String type) {
return delegate.find(principal, after, type);
}
}
```

View File

@@ -0,0 +1,48 @@
# Cloud Foundry Support
Spring Boot Actuator includes additional support when you deploy to a compatible Cloud Foundry instance. The `/cloudfoundryapplication` path provides an alternative secured route to all `@Endpoint` beans.
## Cloud Foundry Configuration
When running on Cloud Foundry, Spring Boot automatically configures:
- Cloud Foundry-specific health indicators
- Cloud Foundry application information
- Secure endpoint access through Cloud Foundry's security model
### Basic Configuration
```yaml
management:
cloudfoundry:
enabled: true
endpoints:
web:
exposure:
include: "*"
```
### Cloud Foundry Health
```java
@Component
public class CloudFoundryHealthIndicator implements HealthIndicator {
@Override
public Health health() {
// Cloud Foundry specific health checks
return Health.up()
.withDetail("cloud-foundry", "available")
.withDetail("instance-index", System.getenv("CF_INSTANCE_INDEX"))
.withDetail("application-id", System.getenv("VCAP_APPLICATION"))
.build();
}
}
```
## Best Practices
1. **Security**: Use Cloud Foundry's built-in security for actuator endpoints
2. **Service Binding**: Leverage VCAP_SERVICES for automatic configuration
3. **Health Checks**: Configure appropriate health endpoints for load balancer checks
4. **Metrics**: Export metrics to Cloud Foundry monitoring systems

View File

@@ -0,0 +1,28 @@
# Enabling Actuator
The `spring-boot-actuator` module provides all of Spring Boot's production-ready features. The recommended way to enable the features is to add a dependency on the `spring-boot-starter-actuator` starter.
> **Definition of Actuator**
>
> An actuator is a manufacturing term that refers to a mechanical device for moving or controlling something. Actuators can generate a large amount of motion from a small change.
## Adding the Actuator Dependency
To add the actuator to a Maven-based project, add the following starter dependency:
```xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
```
For Gradle, use the following declaration:
```gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
```

View File

@@ -0,0 +1,165 @@
# Endpoint Reference
This document provides a comprehensive reference for all available Spring Boot Actuator endpoints.
## Built-in Endpoints
| Endpoint | HTTP Method | Description | Default Exposure |
|----------|-------------|-------------|------------------|
| `auditevents` | GET | Audit events for the application | JMX |
| `beans` | GET | Complete list of Spring beans | JMX |
| `caches` | GET, DELETE | Available caches | JMX |
| `conditions` | GET | Configuration and auto-configuration conditions | JMX |
| `configprops` | GET | Configuration properties | JMX |
| `env` | GET, POST | Environment properties | JMX |
| `flyway` | GET | Flyway database migrations | JMX |
| `health` | GET | Application health information | Web, JMX |
| `heapdump` | GET | Heap dump | JMX |
| `httpexchanges` | GET | HTTP exchange information | JMX |
| `info` | GET | Application information | Web, JMX |
| `integrationgraph` | GET | Spring Integration graph | JMX |
| `logfile` | GET | Application log file | JMX |
| `loggers` | GET, POST | Logger configuration | JMX |
| `liquibase` | GET | Liquibase database migrations | JMX |
| `mappings` | GET | Request mapping information | JMX |
| `metrics` | GET | Application metrics | JMX |
| `prometheus` | GET | Prometheus metrics | None |
| `quartz` | GET | Quartz scheduler information | JMX |
| `scheduledtasks` | GET | Scheduled tasks | JMX |
| `sessions` | GET, DELETE | User sessions | JMX |
| `shutdown` | POST | Graceful application shutdown | JMX |
| `startup` | GET | Application startup information | JMX |
| `threaddump` | GET | Thread dump | JMX |
## Endpoint URLs
### Web Endpoints
- Base path: `/actuator`
- Example: `GET /actuator/health`
- Custom base path: `management.endpoints.web.base-path`
### JMX Endpoints
- Domain: `org.springframework.boot`
- Example: `org.springframework.boot:type=Endpoint,name=Health`
## Endpoint Configuration
### Global Configuration
```yaml
management:
endpoints:
enabled-by-default: true
web:
exposure:
include: "health,info,metrics"
exclude: "env,beans"
base-path: "/actuator"
path-mapping:
health: "status"
jmx:
exposure:
include: "*"
```
### Individual Endpoint Configuration
```yaml
management:
endpoint:
health:
enabled: true
show-details: when-authorized
show-components: always
cache:
time-to-live: 10s
metrics:
enabled: true
cache:
time-to-live: 0s
info:
enabled: true
```
## Security Configuration
### Web Security
```java
@Configuration
public class ActuatorSecurityConfiguration {
@Bean
@Order(1)
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.requestMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(requests ->
requests
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.anyRequest().hasRole("ACTUATOR")
)
.httpBasic(withDefaults())
.build();
}
}
```
### Method-level Security
```java
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/actuator/shutdown")
public Object shutdown() {
// Shutdown logic
}
```
## Custom Endpoints
### Creating Custom Endpoints
```java
@Component
@Endpoint(id = "custom")
public class CustomEndpoint {
@ReadOperation
public Map<String, Object> customEndpoint() {
return Map.of("custom", "data");
}
@WriteOperation
public void writeOperation(@Selector String name, String value) {
// Write operation
}
@DeleteOperation
public void deleteOperation(@Selector String name) {
// Delete operation
}
}
```
### Web-specific Endpoints
```java
@Component
@WebEndpoint(id = "web-custom")
public class WebCustomEndpoint {
@ReadOperation
public WebEndpointResponse<Map<String, Object>> webCustomEndpoint() {
Map<String, Object> data = Map.of("web", "specific");
return new WebEndpointResponse<>(data, 200);
}
}
```
## Best Practices
1. **Security**: Always secure actuator endpoints in production
2. **Exposure**: Only expose necessary endpoints
3. **Performance**: Configure appropriate caching for endpoints
4. **Monitoring**: Monitor actuator endpoint usage
5. **Documentation**: Document custom endpoints thoroughly

View File

@@ -0,0 +1,390 @@
# Actuator Endpoints
Actuator endpoints let you monitor and interact with your application. Spring Boot includes a number of built-in endpoints and lets you add your own. For example, the `health` endpoint provides basic application health information.
You can control access to each individual endpoint and expose them (make them remotely accessible) over HTTP or JMX. An endpoint is considered to be available when access to it is permitted and it is exposed. The built-in endpoints are auto-configured only when they are available. Most applications choose exposure over HTTP, where the ID of the endpoint and a prefix of `/actuator` is mapped to a URL. For example, by default, the `health` endpoint is mapped to `/actuator/health`.
> **TIP**
>
> To learn more about the Actuator's endpoints and their request and response formats, see the [Spring Boot Actuator API documentation](https://docs.spring.io/spring-boot/docs/current/actuator-api/htmlsingle/).
## Available Endpoints
The following technology-agnostic endpoints are available:
| ID | Description |
|----|----|
| `auditevents` | Exposes audit events information for the current application. Requires an `AuditEventRepository` bean. |
| `beans` | Displays a complete list of all the Spring beans in your application. |
| `caches` | Exposes available caches. |
| `conditions` | Shows the conditions that were evaluated on configuration and auto-configuration classes and the reasons why they did or did not match. |
| `configprops` | Displays a collated list of all `@ConfigurationProperties`. Subject to sanitization. |
| `env` | Exposes properties from Spring's `ConfigurableEnvironment`. Subject to sanitization. |
| `flyway` | Shows any Flyway database migrations that have been applied. Requires one or more `Flyway` beans. |
| `health` | Shows application health information. |
| `httpexchanges` | Displays HTTP exchange information (by default, the last 100 HTTP request-response exchanges). Requires an `HttpExchangeRepository` bean. |
| `info` | Displays arbitrary application info. |
| `integrationgraph` | Shows the Spring Integration graph. Requires a dependency on `spring-integration-core`. |
| `loggers` | Shows and modifies the configuration of loggers in the application. |
| `liquibase` | Shows any Liquibase database migrations that have been applied. Requires one or more `Liquibase` beans. |
| `metrics` | Shows metrics information for the current application. |
| `mappings` | Displays a collated list of all `@RequestMapping` paths. |
| `quartz` | Shows information about Quartz Scheduler jobs. Subject to sanitization. |
| `scheduledtasks` | Displays the scheduled tasks in your application. |
| `sessions` | Allows retrieval and deletion of user sessions from a Spring Session-backed session store. Requires a servlet-based web application that uses Spring Session. |
| `shutdown` | Lets the application be gracefully shutdown. Only works when using jar packaging. Disabled by default. |
| `startup` | Shows the startup steps data collected by the `ApplicationStartup`. Requires the `SpringApplication` to be configured with a `BufferingApplicationStartup`. |
| `threaddump` | Performs a thread dump. |
If your application is a web application (Spring MVC, Spring WebFlux, or Jersey), you can use the following additional endpoints:
| ID | Description |
|----|----|
| `heapdump` | Returns a heap dump file. On a HotSpot JVM, an `HPROF`-format file is returned. On an OpenJ9 JVM, a `PHD`-format file is returned. |
| `logfile` | Returns the contents of the logfile (if the `logging.file.name` or the `logging.file.path` property has been set). Supports the use of the HTTP `Range` header to retrieve part of the log file's content. |
| `prometheus` | Exposes metrics in a format that can be scraped by a Prometheus server. Requires a dependency on `micrometer-registry-prometheus`. |
## Controlling Access to Endpoints
By default, access to all endpoints except for `shutdown` and `heapdump` is unrestricted. To configure the permitted access to an endpoint, use its `management.endpoint.<id>.access` property. The following example allows unrestricted access to the `shutdown` endpoint:
```yaml
management:
endpoint:
shutdown:
access: unrestricted
```
If you prefer access to be opt-in rather than opt-out, set the `management.endpoints.access.default` property to `none` and use individual endpoint `access` properties to opt back in. The following example allows read-only access to the `loggers` endpoint and denies access to all other endpoints:
```yaml
management:
endpoints:
access:
default: none
endpoint:
loggers:
access: read-only
```
> **NOTE**
>
> Inaccessible endpoints are removed entirely from the application context. If you want to change only the technologies over which an endpoint is exposed, use the `include` and `exclude` properties instead.
### Limiting Access
Application-wide endpoint access can be limited using the `management.endpoints.access.max-permitted` property. This property takes precedence over the default access or an individual endpoint's access level. Set it to `none` to make all endpoints inaccessible. Set it to `read-only` to only allow read access to endpoints.
For `@Endpoint`, `@JmxEndpoint`, and `@WebEndpoint`, read access equates to the endpoint methods annotated with `@ReadOperation`. For `@ControllerEndpoint` and `@RestControllerEndpoint`, read access equates to HTTP GET requests.
## Exposing Endpoints
By default, only the `health` endpoint is exposed over HTTP and JMX. To configure which endpoints are exposed, use the `include` and `exclude` properties:
```yaml
management:
endpoints:
web:
exposure:
include: "health,info,metrics"
exclude: "beans"
```
The `include` property lists the IDs of the endpoints that are exposed. The `exclude` property lists the IDs of the endpoints that should not be exposed. The `exclude` property takes precedence over the `include` property.
To expose all endpoints over HTTP:
```yaml
management:
endpoints:
web:
exposure:
include: "*"
```
## Security
For security purposes, only the `/health` endpoint is exposed over HTTP by default. You can use the `management.endpoints.web.exposure.include` property to configure the endpoints that are exposed.
If Spring Security is on the classpath and no other `WebSecurityConfigurer` bean is present, all actuators other than `/health` are secured by Spring Boot auto-configuration. If you define a custom `WebSecurityConfigurer` bean, Spring Boot auto-configuration backs off and lets you fully control the actuator access rules.
## Custom Endpoints
You can add additional endpoints by using `@Endpoint` and `@Component` annotations:
```java
@Component
@Endpoint(id = "custom")
public class CustomEndpoint {
@ReadOperation
public String customEndpoint() {
return "Custom endpoint response";
}
}
```
### Web Endpoints
For web-specific endpoints, use `@WebEndpoint`:
```java
@Component
@WebEndpoint(id = "web-custom")
public class WebCustomEndpoint {
@ReadOperation
public String webCustomEndpoint() {
return "Web custom endpoint response";
}
}
```
### JMX Endpoints
For JMX-specific endpoints, use `@JmxEndpoint`:
```java
@Component
@JmxEndpoint(id = "jmx-custom")
public class JmxCustomEndpoint {
@ReadOperation
public String jmxCustomEndpoint() {
return "JMX custom endpoint response";
}
}
```
## Health Endpoint
The `health` endpoint provides detailed information about the health of the application. By default, only health status is shown to unauthenticated users:
```json
{
"status": "UP"
}
```
To show detailed health information:
```yaml
management:
endpoint:
health:
show-details: always
```
### Custom Health Indicators
You can provide custom health information by registering Spring beans that implement the `HealthIndicator` interface:
```java
@Component
public class CustomHealthIndicator implements HealthIndicator {
@Override
public Health health() {
// Perform custom health check
boolean isHealthy = checkHealth();
if (isHealthy) {
return Health.up()
.withDetail("custom", "Service is running")
.build();
} else {
return Health.down()
.withDetail("custom", "Service is down")
.build();
}
}
private boolean checkHealth() {
// Custom health check logic
return true;
}
}
```
## Info Endpoint
The `info` endpoint publishes information about your application. You can customize this information by implementing `InfoContributor`:
```java
@Component
public class CustomInfoContributor implements InfoContributor {
@Override
public void contribute(Info.Builder builder) {
builder.withDetail("custom", "Custom application info");
}
}
```
### Git Information
To expose git information in the `info` endpoint, add the following to your build:
**Maven:**
```xml
<plugin>
<groupId>pl.project13.maven</groupId>
<artifactId>git-commit-id-plugin</artifactId>
</plugin>
```
**Gradle:**
```groovy
plugins {
id "com.gorylenko.gradle-git-properties" version "2.4.1"
}
```
### Build Information
Build information can be added to the `info` endpoint by configuring the build plugins:
**Maven:**
```xml
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>build-info</goal>
</goals>
</execution>
</executions>
</plugin>
```
**Gradle:**
```groovy
springBoot {
buildInfo()
}
```
## Metrics Endpoint
The `metrics` endpoint provides access to application metrics collected by Micrometer. You can view all available metrics:
```
GET /actuator/metrics
```
Or view a specific metric:
```
GET /actuator/metrics/jvm.memory.used
```
### Custom Metrics
You can add custom metrics using Micrometer:
```java
@Component
public class CustomMetrics {
private final Counter customCounter;
private final Timer customTimer;
public CustomMetrics(MeterRegistry meterRegistry) {
this.customCounter = Counter.builder("custom.requests")
.description("Custom request counter")
.register(meterRegistry);
this.customTimer = Timer.builder("custom.processing.time")
.description("Custom processing time")
.register(meterRegistry);
}
public void incrementCounter() {
customCounter.increment();
}
public void recordTime(Duration duration) {
customTimer.record(duration);
}
}
```
## Environment Endpoint
The `env` endpoint exposes properties from the Spring `Environment`. This includes configuration properties, system properties, environment variables, and more.
To view a specific property:
```
GET /actuator/env/server.port
```
## Loggers Endpoint
The `loggers` endpoint shows and allows modification of logger levels in your application.
To view all loggers:
```
GET /actuator/loggers
```
To view a specific logger:
```
GET /actuator/loggers/com.example.MyClass
```
To change a logger level:
```
POST /actuator/loggers/com.example.MyClass
Content-Type: application/json
{
"configuredLevel": "DEBUG"
}
```
## Configuration Properties Endpoint
The `configprops` endpoint displays all `@ConfigurationProperties` in your application:
```
GET /actuator/configprops
```
Properties that may contain sensitive information are masked by default.
## Thread Dump Endpoint
The `threaddump` endpoint provides a thread dump of the application:
```
GET /actuator/threaddump
```
This is useful for diagnosing performance issues and detecting deadlocks.
## Shutdown Endpoint
The `shutdown` endpoint allows you to gracefully shut down the application. It's disabled by default for security reasons:
```yaml
management:
endpoint:
shutdown:
enabled: true
```
To trigger shutdown:
```
POST /actuator/shutdown
```
> **WARNING**
>
> The shutdown endpoint should be secured in production environments as it can terminate the application.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,504 @@
# HTTP Exchanges
You can enable recording of HTTP exchanges by providing a bean of type `HttpExchangeRepository` in your application's configuration. For convenience, Spring Boot offers `InMemoryHttpExchangeRepository`, which, by default, stores the last 100 request-response exchanges. `InMemoryHttpExchangeRepository` is limited compared to tracing solutions, and we recommend using it only for development environments. For production environments, we recommend using a production-ready tracing or observability solution, such as Zipkin or OpenTelemetry. Alternatively, you can create your own `HttpExchangeRepository`.
You can use the `httpexchanges` endpoint to obtain information about the request-response exchanges that are stored in the `HttpExchangeRepository`.
## Basic Configuration
### In-Memory Repository (Development)
```java
@Configuration
public class HttpExchangesConfiguration {
@Bean
public InMemoryHttpExchangeRepository httpExchangeRepository() {
return new InMemoryHttpExchangeRepository();
}
}
```
### Custom Repository Size
```java
@Configuration
public class HttpExchangesConfiguration {
@Bean
public InMemoryHttpExchangeRepository httpExchangeRepository() {
return new InMemoryHttpExchangeRepository(1000); // Store last 1000 exchanges
}
}
```
## Custom HTTP Exchange Recording
To customize the items that are included in each recorded exchange, use the `management.httpexchanges.recording.include` configuration property:
```yaml
management:
httpexchanges:
recording:
include:
- request-headers
- response-headers
- cookie-headers
- authorization-header
- principal
- remote-address
- session-id
- time-taken
```
Available options:
- `request-headers`: Include request headers
- `response-headers`: Include response headers
- `cookie-headers`: Include cookie headers
- `authorization-header`: Include authorization header
- `principal`: Include principal information
- `remote-address`: Include remote address
- `session-id`: Include session ID
- `time-taken`: Include request processing time
## Custom HTTP Exchange Repository
### Database-backed Repository
```java
@Entity
@Table(name = "http_exchanges")
public class HttpExchangeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "timestamp")
private Instant timestamp;
@Column(name = "method")
private String method;
@Column(name = "uri", length = 2000)
private String uri;
@Column(name = "status")
private Integer status;
@Column(name = "time_taken")
private Long timeTaken;
@Column(name = "principal")
private String principal;
@Column(name = "remote_address")
private String remoteAddress;
@Column(name = "session_id")
private String sessionId;
@Lob
@Column(name = "request_headers")
private String requestHeaders;
@Lob
@Column(name = "response_headers")
private String responseHeaders;
// Constructors, getters, setters
}
@Repository
public interface HttpExchangeEntityRepository extends JpaRepository<HttpExchangeEntity, Long> {
List<HttpExchangeEntity> findTop100ByOrderByTimestampDesc();
@Modifying
@Query("DELETE FROM HttpExchangeEntity h WHERE h.timestamp < :cutoff")
void deleteOlderThan(@Param("cutoff") Instant cutoff);
}
@Component
public class DatabaseHttpExchangeRepository implements HttpExchangeRepository {
private final HttpExchangeEntityRepository repository;
private final ObjectMapper objectMapper;
public DatabaseHttpExchangeRepository(HttpExchangeEntityRepository repository) {
this.repository = repository;
this.objectMapper = new ObjectMapper();
}
@Override
public List<HttpExchange> findAll() {
return repository.findTop100ByOrderByTimestampDesc()
.stream()
.map(this::toHttpExchange)
.collect(Collectors.toList());
}
@Override
public void add(HttpExchange httpExchange) {
HttpExchangeEntity entity = toEntity(httpExchange);
repository.save(entity);
}
private HttpExchangeEntity toEntity(HttpExchange exchange) {
HttpExchangeEntity entity = new HttpExchangeEntity();
entity.setTimestamp(exchange.getTimestamp());
HttpExchange.Request request = exchange.getRequest();
entity.setMethod(request.getMethod());
entity.setUri(request.getUri().toString());
entity.setPrincipal(exchange.getPrincipal() != null ?
exchange.getPrincipal().getName() : null);
entity.setRemoteAddress(request.getRemoteAddress());
if (exchange.getResponse() != null) {
entity.setStatus(exchange.getResponse().getStatus());
}
entity.setTimeTaken(exchange.getTimeTaken() != null ?
exchange.getTimeTaken().toMillis() : null);
try {
entity.setRequestHeaders(objectMapper.writeValueAsString(request.getHeaders()));
if (exchange.getResponse() != null) {
entity.setResponseHeaders(objectMapper.writeValueAsString(
exchange.getResponse().getHeaders()));
}
} catch (Exception e) {
// Handle serialization error
}
return entity;
}
private HttpExchange toHttpExchange(HttpExchangeEntity entity) {
// Implement conversion from entity to HttpExchange
// This is complex due to HttpExchange being immutable
// Consider using a builder pattern or reflection
return null; // Simplified for brevity
}
@Scheduled(fixedRate = 3600000) // Clean up every hour
public void cleanup() {
Instant cutoff = Instant.now().minus(Duration.ofDays(7));
repository.deleteOlderThan(cutoff);
}
}
```
### Filtered HTTP Exchange Repository
```java
@Component
public class FilteredHttpExchangeRepository implements HttpExchangeRepository {
private final HttpExchangeRepository delegate;
private final Set<String> excludePaths;
private final Set<String> excludeUserAgents;
public FilteredHttpExchangeRepository(HttpExchangeRepository delegate) {
this.delegate = delegate;
this.excludePaths = Set.of("/actuator/health", "/actuator/metrics", "/favicon.ico");
this.excludeUserAgents = Set.of("kube-probe", "ELB-HealthChecker");
}
@Override
public List<HttpExchange> findAll() {
return delegate.findAll();
}
@Override
public void add(HttpExchange httpExchange) {
if (shouldRecord(httpExchange)) {
delegate.add(httpExchange);
}
}
private boolean shouldRecord(HttpExchange exchange) {
String path = exchange.getRequest().getUri().getPath();
// Skip health check and monitoring endpoints
if (excludePaths.contains(path)) {
return false;
}
// Skip requests from monitoring tools
String userAgent = exchange.getRequest().getHeaders().getFirst("User-Agent");
if (userAgent != null && excludeUserAgents.stream().anyMatch(userAgent::contains)) {
return false;
}
// Skip successful static resource requests
if (path.startsWith("/static/") || path.startsWith("/css/") || path.startsWith("/js/")) {
return exchange.getResponse() == null || exchange.getResponse().getStatus() >= 400;
}
return true;
}
}
```
## Async HTTP Exchange Recording
### Async Repository Wrapper
```java
@Component
public class AsyncHttpExchangeRepository implements HttpExchangeRepository {
private final HttpExchangeRepository delegate;
private final TaskExecutor taskExecutor;
public AsyncHttpExchangeRepository(HttpExchangeRepository delegate,
@Qualifier("httpExchangeTaskExecutor") TaskExecutor taskExecutor) {
this.delegate = delegate;
this.taskExecutor = taskExecutor;
}
@Override
public List<HttpExchange> findAll() {
return delegate.findAll();
}
@Override
public void add(HttpExchange httpExchange) {
taskExecutor.execute(() -> {
try {
delegate.add(httpExchange);
} catch (Exception e) {
// Log error but don't let it affect the main request
log.error("Failed to record HTTP exchange", e);
}
});
}
}
@Configuration
public class HttpExchangeTaskExecutorConfiguration {
@Bean("httpExchangeTaskExecutor")
public TaskExecutor httpExchangeTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(1000);
executor.setThreadNamePrefix("http-exchange-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy());
executor.initialize();
return executor;
}
}
```
## HTTP Exchanges Endpoint
### Accessing HTTP Exchanges
```
GET /actuator/httpexchanges
```
Response format:
```json
{
"exchanges": [
{
"timestamp": "2023-12-01T10:30:00.123Z",
"request": {
"method": "GET",
"uri": "http://localhost:8080/api/users/123",
"headers": {
"accept": ["application/json"],
"user-agent": ["Mozilla/5.0..."]
},
"remoteAddress": "192.168.1.100"
},
"response": {
"status": 200,
"headers": {
"content-type": ["application/json"],
"content-length": ["256"]
}
},
"principal": {
"name": "john.doe"
},
"session": {
"id": "JSESSIONID123"
},
"timeTaken": "PT0.025S"
}
]
}
```
### Securing the Endpoint
```java
@Configuration
public class HttpExchangesSecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain httpExchangesSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.requestMatcher(EndpointRequest.to("httpexchanges"))
.authorizeHttpRequests(requests ->
requests.anyRequest().hasRole("ADMIN"))
.httpBasic(withDefaults())
.build();
}
}
```
## Custom HTTP Exchange Information
### Including Custom Data
```java
@Component
public class CustomHttpExchangeRepository implements HttpExchangeRepository {
private final InMemoryHttpExchangeRepository delegate;
public CustomHttpExchangeRepository() {
this.delegate = new InMemoryHttpExchangeRepository();
}
@Override
public List<HttpExchange> findAll() {
return delegate.findAll();
}
@Override
public void add(HttpExchange httpExchange) {
HttpExchange enrichedExchange = enrichExchange(httpExchange);
delegate.add(enrichedExchange);
}
private HttpExchange enrichExchange(HttpExchange original) {
// Add custom information to the exchange
// Note: HttpExchange is immutable, so we need to create a wrapper
// or use reflection to modify internal state
// For demonstration, we'll just add it normally
// In practice, you might need to create a custom implementation
return original;
}
}
@Component
public class HttpExchangeEnricher {
public void enrich(HttpServletRequest request, HttpServletResponse response) {
// Add custom attributes that can be picked up by the repository
request.setAttribute("custom.trace.id", getTraceId());
request.setAttribute("custom.user.role", getUserRole());
request.setAttribute("custom.api.version", getApiVersion(request));
}
private String getTraceId() {
// Get from tracing context
return "trace-123";
}
private String getUserRole() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getAuthorities().toString() : "anonymous";
}
private String getApiVersion(HttpServletRequest request) {
return request.getHeader("API-Version");
}
}
```
## Performance Considerations
### Configuration for Production
```yaml
management:
httpexchanges:
recording:
include:
- time-taken
- principal
- remote-address
# Exclude detailed headers to reduce memory usage
exclude:
- request-headers
- response-headers
endpoint:
httpexchanges:
enabled: false # Disable in production for security
```
### Custom Sampling
```java
@Component
public class SamplingHttpExchangeRepository implements HttpExchangeRepository {
private final HttpExchangeRepository delegate;
private final Random random = new Random();
private final double samplingRate;
public SamplingHttpExchangeRepository(HttpExchangeRepository delegate,
@Value("${app.http-exchanges.sampling-rate:0.1}") double samplingRate) {
this.delegate = delegate;
this.samplingRate = samplingRate;
}
@Override
public List<HttpExchange> findAll() {
return delegate.findAll();
}
@Override
public void add(HttpExchange httpExchange) {
if (random.nextDouble() < samplingRate) {
delegate.add(httpExchange);
}
}
}
```
## Best Practices
1. **Production Use**: Disable HTTP exchanges endpoint in production or secure it properly
2. **Memory Management**: Use limited-size repositories to prevent memory leaks
3. **Sensitive Data**: Be careful not to log sensitive information in headers
4. **Performance**: Consider async recording for high-throughput applications
5. **Sampling**: Use sampling in production to reduce overhead
6. **Retention**: Implement cleanup policies for stored exchanges
7. **Security**: Ensure recorded data doesn't contain credentials or tokens
### Production Configuration Example
```yaml
management:
endpoint:
httpexchanges:
enabled: false # Disabled in production
httpexchanges:
recording:
include:
- time-taken
- principal
- remote-address
exclude:
- authorization-header
- cookie-headers
- request-headers
- response-headers
logging:
level:
org.springframework.boot.actuate.web.exchanges: WARN
```

View File

@@ -0,0 +1,582 @@
# JMX with Spring Boot Actuator
Java Management Extensions (JMX) provide a standard mechanism to monitor and manage applications. By default, this feature is not enabled. You can turn it on by setting the `spring.jmx.enabled` configuration property to `true`. Spring Boot exposes the most suitable `MBeanServer` as a bean with an ID of `mbeanServer`. Any of your beans that are annotated with Spring JMX annotations (`@ManagedResource`, `@ManagedAttribute`, or `@ManagedOperation`) are exposed to it.
If your platform provides a standard `MBeanServer`, Spring Boot uses that and defaults to the VM `MBeanServer`, if necessary. If all that fails, a new `MBeanServer` is created.
> **NOTE**
>
> `spring.jmx.enabled` affects only the management beans provided by Spring. Enabling management beans provided by other libraries (for example Log4j2 or Quartz) is independent.
## Basic JMX Configuration
### Enabling JMX
```yaml
spring:
jmx:
enabled: true
default-domain: com.example.myapp
management:
endpoints:
jmx:
exposure:
include: "*"
endpoint:
jmx:
enabled: true
```
### Custom MBean Server Configuration
```java
@Configuration
public class JmxConfiguration {
@Bean
@Primary
public MBeanServer mbeanServer() {
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
return server;
}
@Bean
public JmxMetricsExporter jmxMetricsExporter(MeterRegistry meterRegistry) {
return new JmxMetricsExporter(meterRegistry);
}
}
```
## Creating Custom MBeans
### Using @ManagedResource Annotation
```java
@Component
@ManagedResource(
objectName = "com.example:type=ApplicationMetrics,name=UserService",
description = "User Service Management Bean"
)
public class UserServiceMBean {
private final UserService userService;
private long totalUsers = 0;
private long activeUsers = 0;
public UserServiceMBean(UserService userService) {
this.userService = userService;
}
@ManagedAttribute(description = "Total number of users")
public long getTotalUsers() {
return userService.getTotalUserCount();
}
@ManagedAttribute(description = "Number of active users")
public long getActiveUsers() {
return userService.getActiveUserCount();
}
@ManagedAttribute(description = "Cache hit ratio")
public double getCacheHitRatio() {
return userService.getCacheHitRatio();
}
@ManagedOperation(description = "Clear user cache")
public void clearCache() {
userService.clearCache();
}
@ManagedOperation(description = "Refresh user statistics")
public String refreshStatistics() {
userService.refreshStatistics();
return "Statistics refreshed at " + Instant.now();
}
@ManagedOperation(description = "Get user by ID")
@ManagedOperationParameters({
@ManagedOperationParameter(name = "userId", description = "User ID")
})
public String getUserInfo(Long userId) {
User user = userService.findById(userId);
return user != null ? user.toString() : "User not found";
}
}
```
### Implementing MBean Interface
```java
public interface ApplicationConfigMBean {
String getEnvironment();
void setLogLevel(String loggerName, String level);
boolean isMaintenanceMode();
void setMaintenanceMode(boolean maintenanceMode);
void reloadConfiguration();
Map<String, String> getSystemProperties();
}
@Component
public class ApplicationConfig implements ApplicationConfigMBean {
private final Environment environment;
private final LoggingSystem loggingSystem;
private boolean maintenanceMode = false;
public ApplicationConfig(Environment environment, LoggingSystem loggingSystem) {
this.environment = environment;
this.loggingSystem = loggingSystem;
}
@Override
public String getEnvironment() {
return String.join(",", environment.getActiveProfiles());
}
@Override
public void setLogLevel(String loggerName, String level) {
LogLevel logLevel = level != null ? LogLevel.valueOf(level.toUpperCase()) : null;
loggingSystem.setLogLevel(loggerName, logLevel);
}
@Override
public boolean isMaintenanceMode() {
return maintenanceMode;
}
@Override
public void setMaintenanceMode(boolean maintenanceMode) {
this.maintenanceMode = maintenanceMode;
// Publish event or notify other components
}
@Override
public void reloadConfiguration() {
// Implement configuration reload logic
// This could refresh @ConfigurationProperties beans
}
@Override
public Map<String, String> getSystemProperties() {
return System.getProperties().entrySet().stream()
.collect(Collectors.toMap(
e -> String.valueOf(e.getKey()),
e -> String.valueOf(e.getValue())
));
}
@PostConstruct
public void registerMBean() {
try {
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
ObjectName objectName = new ObjectName("com.example:type=ApplicationConfig");
server.registerMBean(this, objectName);
} catch (Exception e) {
throw new RuntimeException("Failed to register MBean", e);
}
}
}
```
## Application Metrics via JMX
### Custom Metrics MBean
```java
@Component
@ManagedResource(
objectName = "com.example:type=Performance,name=ApplicationMetrics",
description = "Application Performance Metrics"
)
public class ApplicationMetricsMBean {
private final MeterRegistry meterRegistry;
private final Counter requestCounter;
private final Timer responseTimer;
private final Gauge activeConnections;
public ApplicationMetricsMBean(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.requestCounter = Counter.builder("application.requests.total")
.description("Total number of requests")
.register(meterRegistry);
this.responseTimer = Timer.builder("application.response.time")
.description("Response time")
.register(meterRegistry);
this.activeConnections = Gauge.builder("application.connections.active")
.description("Active connections")
.register(meterRegistry, this, ApplicationMetricsMBean::getActiveConnectionsCount);
}
@ManagedAttribute(description = "Total requests processed")
public long getTotalRequests() {
return (long) requestCounter.count();
}
@ManagedAttribute(description = "Average response time in milliseconds")
public double getAverageResponseTime() {
return responseTimer.mean(TimeUnit.MILLISECONDS);
}
@ManagedAttribute(description = "95th percentile response time")
public double getResponse95thPercentile() {
return responseTimer.percentile(0.95, TimeUnit.MILLISECONDS);
}
@ManagedAttribute(description = "Current active connections")
public long getActiveConnections() {
return getActiveConnectionsCount();
}
@ManagedAttribute(description = "JVM memory usage percentage")
public double getMemoryUsagePercentage() {
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
return (double) heapUsage.getUsed() / heapUsage.getMax() * 100;
}
@ManagedOperation(description = "Reset request counter")
public void resetRequestCounter() {
// Note: Micrometer counters cannot be reset, this would require custom implementation
// or using a different metric type
}
private long getActiveConnectionsCount() {
// Implementation to get actual active connections
return 42; // Placeholder
}
}
```
### Database Connection Pool MBean
```java
@Component
@ManagedResource(
objectName = "com.example:type=Database,name=ConnectionPool",
description = "Database Connection Pool Metrics"
)
public class DatabaseConnectionPoolMBean {
private final DataSource dataSource;
public DatabaseConnectionPoolMBean(DataSource dataSource) {
this.dataSource = dataSource;
}
@ManagedAttribute(description = "Active connections")
public int getActiveConnections() {
if (dataSource instanceof HikariDataSource) {
return ((HikariDataSource) dataSource).getHikariPoolMXBean().getActiveConnections();
}
return -1; // Not supported
}
@ManagedAttribute(description = "Idle connections")
public int getIdleConnections() {
if (dataSource instanceof HikariDataSource) {
return ((HikariDataSource) dataSource).getHikariPoolMXBean().getIdleConnections();
}
return -1; // Not supported
}
@ManagedAttribute(description = "Total connections")
public int getTotalConnections() {
if (dataSource instanceof HikariDataSource) {
return ((HikariDataSource) dataSource).getHikariPoolMXBean().getTotalConnections();
}
return -1; // Not supported
}
@ManagedAttribute(description = "Threads awaiting connection")
public int getThreadsAwaitingConnection() {
if (dataSource instanceof HikariDataSource) {
return ((HikariDataSource) dataSource).getHikariPoolMXBean().getThreadsAwaitingConnection();
}
return -1; // Not supported
}
@ManagedOperation(description = "Suspend connection pool")
public void suspendPool() {
if (dataSource instanceof HikariDataSource) {
((HikariDataSource) dataSource).getHikariPoolMXBean().suspendPool();
}
}
@ManagedOperation(description = "Resume connection pool")
public void resumePool() {
if (dataSource instanceof HikariDataSource) {
((HikariDataSource) dataSource).getHikariPoolMXBean().resumePool();
}
}
}
```
## Security and JMX
### Securing JMX Access
```yaml
spring:
jmx:
enabled: true
management:
endpoints:
jmx:
exposure:
include: "health,info,metrics"
exclude: "env,configprops" # Exclude sensitive endpoints
# JMX-specific security
com.sun.management.jmxremote.port: 9999
com.sun.management.jmxremote.authenticate: true
com.sun.management.jmxremote.ssl: false
com.sun.management.jmxremote.access.file: /path/to/jmxremote.access
com.sun.management.jmxremote.password.file: /path/to/jmxremote.password
```
### Custom JMX Security
```java
@Configuration
public class JmxSecurityConfiguration {
@Bean
public JMXConnectorServer jmxConnectorServer() throws Exception {
JMXServiceURL url = new JMXServiceURL("service:jmx:rmi://localhost:9999");
Map<String, Object> environment = new HashMap<>();
environment.put(JMXConnectorServer.AUTHENTICATOR, new CustomJMXAuthenticator());
JMXConnectorServer server = JMXConnectorServerFactory.newJMXConnectorServer(
url, environment, ManagementFactory.getPlatformMBeanServer());
server.start();
return server;
}
private static class CustomJMXAuthenticator implements JMXAuthenticator {
@Override
public Subject authenticate(Object credentials) {
if (!(credentials instanceof String[])) {
throw new SecurityException("Credentials must be String[]");
}
String[] creds = (String[]) credentials;
if (creds.length != 2) {
throw new SecurityException("Credentials must contain username and password");
}
String username = creds[0];
String password = creds[1];
// Implement your authentication logic
if ("admin".equals(username) && "password".equals(password)) {
return new Subject();
}
throw new SecurityException("Authentication failed");
}
}
}
```
## Monitoring and Alerting with JMX
### Health Check MBean
```java
@Component
@ManagedResource(
objectName = "com.example:type=Health,name=ApplicationHealth",
description = "Application Health Monitoring"
)
public class ApplicationHealthMBean {
private final HealthEndpoint healthEndpoint;
private final List<String> healthIssues = new ArrayList<>();
public ApplicationHealthMBean(HealthEndpoint healthEndpoint) {
this.healthEndpoint = healthEndpoint;
}
@ManagedAttribute(description = "Overall application health status")
public String getHealthStatus() {
HealthComponent health = healthEndpoint.health();
return health.getStatus().getCode();
}
@ManagedAttribute(description = "Detailed health information")
public String getHealthDetails() {
HealthComponent health = healthEndpoint.health();
return health.toString();
}
@ManagedAttribute(description = "Database health status")
public String getDatabaseHealth() {
HealthComponent health = healthEndpoint.healthForPath("db");
return health != null ? health.getStatus().getCode() : "UNKNOWN";
}
@ManagedAttribute(description = "Current health issues")
public String[] getHealthIssues() {
return healthIssues.toArray(new String[0]);
}
@ManagedOperation(description = "Refresh health status")
public void refreshHealth() {
HealthComponent health = healthEndpoint.health();
healthIssues.clear();
if (health instanceof CompositeHealthComponent) {
CompositeHealthComponent composite = (CompositeHealthComponent) health;
composite.getComponents().forEach((name, component) -> {
if (!Status.UP.equals(component.getStatus())) {
healthIssues.add(name + ": " + component.getStatus().getCode());
}
});
}
}
@PostConstruct
public void init() {
refreshHealth();
}
}
```
### Notification MBean
```java
@Component
@ManagedResource(
objectName = "com.example:type=Notifications,name=AlertManager",
description = "Application Alert Management"
)
public class AlertManagerMBean extends NotificationBroadcasterSupport {
private final AtomicLong sequenceNumber = new AtomicLong(0);
private boolean alertsEnabled = true;
@ManagedAttribute(description = "Are alerts enabled")
public boolean isAlertsEnabled() {
return alertsEnabled;
}
@ManagedAttribute(description = "Enable or disable alerts")
public void setAlertsEnabled(boolean alertsEnabled) {
this.alertsEnabled = alertsEnabled;
}
@ManagedOperation(description = "Send test alert")
public void sendTestAlert() {
sendAlert("TEST", "Test alert from JMX", "INFO");
}
public void sendAlert(String type, String message, String severity) {
if (!alertsEnabled) {
return;
}
Notification notification = new Notification(
type,
this,
sequenceNumber.incrementAndGet(),
System.currentTimeMillis(),
message
);
notification.setUserData(Map.of(
"severity", severity,
"timestamp", Instant.now().toString()
));
sendNotification(notification);
}
@Override
public MBeanNotificationInfo[] getNotificationInfo() {
return new MBeanNotificationInfo[]{
new MBeanNotificationInfo(
new String[]{"HEALTH", "PERFORMANCE", "SECURITY", "TEST"},
Notification.class.getName(),
"Application alerts and notifications"
)
};
}
}
```
## Best Practices
1. **Naming Convention**: Use consistent ObjectName patterns
2. **Security**: Always secure JMX access in production
3. **Performance**: Be mindful of expensive operations in MBean methods
4. **Documentation**: Provide clear descriptions for attributes and operations
5. **Error Handling**: Handle exceptions gracefully in MBean operations
6. **Resource Management**: Properly manage resources in MBean operations
7. **Monitoring**: Monitor JMX itself for availability and performance
### Production JMX Configuration
```yaml
# Production JMX configuration
spring:
jmx:
enabled: true
default-domain: "com.mycompany.myapp"
management:
endpoints:
jmx:
exposure:
include: "health,info,metrics"
exclude: "env,configprops,beans"
endpoint:
jmx:
enabled: true
# JVM JMX settings (set as JVM arguments)
# -Dcom.sun.management.jmxremote=true
# -Dcom.sun.management.jmxremote.port=9999
# -Dcom.sun.management.jmxremote.authenticate=true
# -Dcom.sun.management.jmxremote.ssl=true
# -Dcom.sun.management.jmxremote.access.file=/etc/jmx/jmxremote.access
# -Dcom.sun.management.jmxremote.password.file=/etc/jmx/jmxremote.password
```
### JMX Client Example
```java
public class JmxClient {
public static void main(String[] args) throws Exception {
String url = "service:jmx:rmi:///jndi/rmi://localhost:9999/jmxrmi";
JMXServiceURL serviceURL = new JMXServiceURL(url);
Map<String, Object> environment = new HashMap<>();
environment.put(JMXConnector.CREDENTIALS, new String[]{"admin", "password"});
try (JMXConnector connector = JMXConnectorFactory.connect(serviceURL, environment)) {
MBeanServerConnection connection = connector.getMBeanServerConnection();
// Get application health
ObjectName healthName = new ObjectName("com.example:type=Health,name=ApplicationHealth");
String healthStatus = (String) connection.getAttribute(healthName, "HealthStatus");
System.out.println("Health Status: " + healthStatus);
// Invoke operation
connection.invoke(healthName, "refreshHealth", null, null);
// Listen for notifications
ObjectName alertName = new ObjectName("com.example:type=Notifications,name=AlertManager");
connection.addNotificationListener(alertName,
(notification, handback) -> {
System.out.println("Alert: " + notification.getMessage());
}, null, null);
}
}
}
```

View File

@@ -0,0 +1,385 @@
# Loggers Endpoint
Spring Boot Actuator includes the ability to view and configure the log levels of your application at runtime. You can view either the entire list or an individual logger's configuration, which is made up of both the explicitly configured logging level as well as the effective logging level given to it by the logging framework. These levels can be one of:
- `TRACE`
- `DEBUG`
- `INFO`
- `WARN`
- `ERROR`
- `FATAL`
- `OFF`
- `null`
`null` indicates that there is no explicit configuration.
## Viewing Logger Configuration
### View All Loggers
To view the configuration of all loggers:
```
GET /actuator/loggers
```
Response example:
```json
{
"levels": ["OFF", "ERROR", "WARN", "INFO", "DEBUG", "TRACE"],
"loggers": {
"ROOT": {
"configuredLevel": "INFO",
"effectiveLevel": "INFO"
},
"com.example": {
"configuredLevel": null,
"effectiveLevel": "INFO"
},
"com.example.MyClass": {
"configuredLevel": "DEBUG",
"effectiveLevel": "DEBUG"
}
}
}
```
### View Specific Logger
To view the configuration of a specific logger:
```
GET /actuator/loggers/com.example.MyClass
```
Response example:
```json
{
"configuredLevel": "DEBUG",
"effectiveLevel": "DEBUG"
}
```
## Configuring a Logger
To configure a given logger, `POST` a partial entity to the resource's URI, as the following example shows:
```
POST /actuator/loggers/com.example.MyClass
Content-Type: application/json
{
"configuredLevel": "DEBUG"
}
```
> **TIP**
>
> To "reset" the specific level of the logger (and use the default configuration instead), you can pass a value of `null` as the `configuredLevel`.
### Reset Logger Level
To reset a logger to its default level:
```
POST /actuator/loggers/com.example.MyClass
Content-Type: application/json
{
"configuredLevel": null
}
```
## Configuration Examples
### Enable Debug Logging for Specific Package
```bash
curl -X POST http://localhost:8080/actuator/loggers/com.example.service \
-H "Content-Type: application/json" \
-d '{"configuredLevel": "DEBUG"}'
```
### Enable Trace Logging for Spring Security
```bash
curl -X POST http://localhost:8080/actuator/loggers/org.springframework.security \
-H "Content-Type: application/json" \
-d '{"configuredLevel": "TRACE"}'
```
### Set Root Logger Level
```bash
curl -X POST http://localhost:8080/actuator/loggers/ROOT \
-H "Content-Type: application/json" \
-d '{"configuredLevel": "WARN"}'
```
## Programmatic Logger Management
You can also manage loggers programmatically in your application:
```java
@RestController
public class LoggerController {
private final LoggingSystem loggingSystem;
public LoggerController(LoggingSystem loggingSystem) {
this.loggingSystem = loggingSystem;
}
@PostMapping("/admin/logger/{name}")
public void setLogLevel(@PathVariable String name, @RequestBody LogLevelRequest request) {
LogLevel level = request.getLevel() != null ?
LogLevel.valueOf(request.getLevel().toUpperCase()) : null;
loggingSystem.setLogLevel(name, level);
}
public static class LogLevelRequest {
private String level;
public String getLevel() { return level; }
public void setLevel(String level) { this.level = level; }
}
}
```
## Conditional Logging
### Environment-based Configuration
```yaml
logging:
level:
com.example: ${LOGGING_LEVEL_EXAMPLE:INFO}
org.springframework.web: ${LOGGING_LEVEL_WEB:WARN}
org.hibernate.SQL: ${LOGGING_LEVEL_SQL:WARN}
org.hibernate.type.descriptor.sql: ${LOGGING_LEVEL_SQL_PARAMS:WARN}
---
spring:
config:
activate:
on-profile: development
logging:
level:
com.example: DEBUG
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql: TRACE
---
spring:
config:
activate:
on-profile: production
logging:
level:
root: WARN
com.example: INFO
```
### Feature Toggle Logging
```java
@Component
public class FeatureLoggingController {
private final LoggingSystem loggingSystem;
private final Environment environment;
public FeatureLoggingController(LoggingSystem loggingSystem, Environment environment) {
this.loggingSystem = loggingSystem;
this.environment = environment;
}
@EventListener
public void handleFeatureToggleChange(FeatureToggleEvent event) {
if ("debug-logging".equals(event.getFeatureName())) {
if (event.isEnabled()) {
enableDebugLogging();
} else {
disableDebugLogging();
}
}
}
private void enableDebugLogging() {
loggingSystem.setLogLevel("com.example.service", LogLevel.DEBUG);
loggingSystem.setLogLevel("com.example.repository", LogLevel.DEBUG);
}
private void disableDebugLogging() {
loggingSystem.setLogLevel("com.example.service", null);
loggingSystem.setLogLevel("com.example.repository", null);
}
}
```
## Security Considerations
### Securing the Loggers Endpoint
```java
@Configuration
public class LoggersSecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain loggersSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.requestMatcher(EndpointRequest.to("loggers"))
.authorizeHttpRequests(requests ->
requests.anyRequest().hasRole("ADMIN"))
.httpBasic(withDefaults())
.build();
}
}
```
### Read-only Access
To provide read-only access to the loggers endpoint:
```yaml
management:
endpoint:
loggers:
access: read-only
```
Or configure programmatically:
```java
@Configuration
public class LoggersAccessConfig {
@Bean
@Order(1)
public SecurityFilterChain loggersSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.requestMatcher(EndpointRequest.to("loggers"))
.authorizeHttpRequests(requests ->
requests
.requestMatchers(HttpMethod.GET).hasRole("LOGGER_READER")
.requestMatchers(HttpMethod.POST).hasRole("LOGGER_ADMIN")
.anyRequest().denyAll())
.httpBasic(withDefaults())
.build();
}
}
```
## OpenTelemetry Integration
By default, logging via OpenTelemetry is not configured. You have to provide the location of the OpenTelemetry logs endpoint to configure it:
```yaml
management:
otlp:
logging:
endpoint: "https://otlp.example.com:4318/v1/logs"
```
> **NOTE**
>
> The OpenTelemetry Logback appender and Log4j appender are not part of Spring Boot. For more details, see the [OpenTelemetry Logback appender](https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/logback/logback-appender-1.0/library) or the [OpenTelemetry Log4j2 appender](https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/instrumentation/log4j/log4j-appender-2.17/library) in the [OpenTelemetry Java instrumentation GitHub repository](https://github.com/open-telemetry/opentelemetry-java-instrumentation).
> **TIP**
>
> You have to configure the appender in your `logback-spring.xml` or `log4j2-spring.xml` configuration to get OpenTelemetry logging working.
The `OpenTelemetryAppender` for both Logback and Log4j requires access to an `OpenTelemetry` instance to function properly. This instance must be set programmatically during application startup:
```java
@Component
public class OpenTelemetryAppenderInitializer {
public OpenTelemetryAppenderInitializer(OpenTelemetry openTelemetry) {
// Configure Logback appender
if (LoggerFactory.getILoggerFactory() instanceof LoggerContext) {
LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
context.getStatusManager().add(new OnConsoleStatusListener());
OpenTelemetryAppender appender = new OpenTelemetryAppender();
appender.setContext(context);
appender.setOpenTelemetry(openTelemetry);
appender.start();
ch.qos.logback.classic.Logger rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME);
rootLogger.addAppender(appender);
}
}
}
```
## Best Practices
1. **Monitor Performance**: Changing log levels at runtime can impact application performance
2. **Security**: Always secure the loggers endpoint in production environments
3. **Audit Changes**: Log when log levels are changed and by whom
4. **Temporary Changes**: Consider making runtime log level changes temporary
5. **Documentation**: Document the purpose of different log levels in your application
6. **Testing**: Test your application with different log levels to ensure it performs well
7. **Correlation IDs**: Use correlation IDs to track requests across log entries
### Audit Log Level Changes
```java
@Component
public class LoggerAuditListener {
private static final Logger logger = LoggerFactory.getLogger(LoggerAuditListener.class);
@EventListener
public void handleLoggerConfigurationChange(LoggerConfigurationChangeEvent event) {
String username = getCurrentUsername();
logger.info("Logger level changed: logger={}, oldLevel={}, newLevel={}, user={}",
event.getLoggerName(),
event.getOldLevel(),
event.getNewLevel(),
username);
}
private String getCurrentUsername() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "system";
}
}
```
### Temporary Log Level Changes
```java
@Component
public class TemporaryLogLevelManager {
private final LoggingSystem loggingSystem;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final Map<String, LogLevel> originalLevels = new ConcurrentHashMap<>();
public TemporaryLogLevelManager(LoggingSystem loggingSystem) {
this.loggingSystem = loggingSystem;
}
public void setTemporaryLogLevel(String loggerName, LogLevel level, Duration duration) {
// Store original level
LoggerConfiguration config = loggingSystem.getLoggerConfiguration(loggerName);
originalLevels.put(loggerName, config.getConfiguredLevel());
// Set new level
loggingSystem.setLogLevel(loggerName, level);
// Schedule reset
scheduler.schedule(() -> resetLogLevel(loggerName), duration.toMillis(), TimeUnit.MILLISECONDS);
}
private void resetLogLevel(String loggerName) {
LogLevel originalLevel = originalLevels.remove(loggerName);
loggingSystem.setLogLevel(loggerName, originalLevel);
}
}
```

View File

@@ -0,0 +1,578 @@
# Metrics with Spring Boot Actuator
Spring Boot Actuator provides dependency management and auto-configuration for [Micrometer](https://micrometer.io/), an application metrics facade that supports numerous monitoring systems, including:
- AppOptics
- Atlas
- Datadog
- Dynatrace
- Elastic
- Ganglia
- Graphite
- Humio
- InfluxDB
- JMX
- KairosDB
- New Relic
- OpenTelemetry Protocol (OTLP)
- Prometheus
- Simple (in-memory)
- Google Cloud Monitoring (Stackdriver)
- StatsD
- Wavefront
> **TIP**
>
> To learn more about Micrometer's capabilities, see its [reference documentation](https://micrometer.io/docs), in particular the [concepts section](https://micrometer.io/docs/concepts).
## Getting Started
Spring Boot auto-configures a composite `MeterRegistry` and adds a registry to the composite for each of the supported implementations that it finds on the classpath. Having a dependency on `micrometer-registry-{system}` in your runtime classpath is enough for Spring Boot to configure the registry.
Most registries share common features. For instance, you can disable a particular registry even if the Micrometer registry implementation is on the classpath. The following example disables Datadog:
```yaml
management:
datadog:
metrics:
export:
enabled: false
```
You can also disable all registries unless stated otherwise by the registry-specific property, as the following example shows:
```yaml
management:
defaults:
metrics:
export:
enabled: false
```
Spring Boot also adds any auto-configured registries to the global static composite registry on the `Metrics` class, unless you explicitly tell it not to:
```yaml
management:
metrics:
use-global-registry: false
```
You can register any number of `MeterRegistryCustomizer` beans to further configure the registry, such as applying common tags, before any meters are registered with the registry:
```java
@Component
public class MyMeterRegistryConfiguration {
@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config().commonTags("region", "us-east-1");
}
}
```
You can apply customizations to particular registry implementations by being more specific about the generic type:
```java
@Component
public class MyMeterRegistryConfiguration {
@Bean
public MeterRegistryCustomizer<GraphiteMeterRegistry> graphiteMetricsNamingConvention() {
return registry -> registry.config().namingConvention(this::toGraphiteConvention);
}
private String toGraphiteConvention(String name, Meter.Type type, String baseUnit) {
return name.toLowerCase().replace(".", "_");
}
}
```
Spring Boot also configures built-in instrumentation that you can control through configuration or dedicated annotation markers.
## Supported Metrics
Spring Boot provides automatic meter registration for a wide variety of technologies. In most situations, the defaults provide sensible metrics that can be published to any of the supported monitoring systems.
### JVM Metrics
JVM metrics are published under the `jvm.` meter name. The following JVM metrics are provided:
- Memory and buffer pools
- Statistics related to garbage collection
- Thread utilization
- Number of classes loaded/unloaded
### System Metrics
System metrics are published under the `system.`, `process.`, and `disk.` meter names. The following system metrics are provided:
- CPU metrics
- File descriptor metrics
- Uptime metrics
- Disk space metrics
### Application Startup Metrics
Application startup metrics are published under the `application.started.time` meter name. The following startup metrics are provided:
- Application startup time
- Application ready time
### HTTP Request Metrics
HTTP request metrics are automatically recorded for all HTTP requests. Metrics are published under the `http.server.requests` meter name.
Tags added to HTTP server request metrics:
- `method`: The request's HTTP method (e.g., `GET` or `POST`)
- `uri`: The request's URI template prior to variable substitution (e.g., `/api/person/{id}`)
- `status`: The response's HTTP status code (e.g., `200` or `500`)
- `outcome`: The request's outcome based on the status code (`SUCCESS`, `REDIRECTION`, `CLIENT_ERROR`, `SERVER_ERROR`, or `UNKNOWN`)
To customize the tags, provide a `@Bean` that implements `WebMvcTagsContributor`:
```java
@Component
public class MyWebMvcTagsContributor implements WebMvcTagsContributor {
@Override
public Iterable<Tag> getTags(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Throwable exception) {
return Tags.of("custom.tag", "custom-value");
}
@Override
public Iterable<Tag> getLongRequestTags(HttpServletRequest request,
Object handler) {
return Tags.of("custom.tag", "custom-value");
}
}
```
### WebFlux Metrics
WebFlux metrics are automatically recorded for all WebFlux requests. Metrics are published under the `http.server.requests` meter name.
Tags added to WebFlux request metrics:
- `method`: The request's HTTP method
- `uri`: The request's URI template
- `status`: The response's HTTP status code
- `outcome`: The request's outcome
### Data Source Metrics
Auto-configuration enables the instrumentation of all available DataSource objects with metrics prefixed with `hikaricp.`, `tomcat.datasource.`, or `dbcp2.`.
Connection pool metrics are published under the following meter names:
- `hikaricp.connections` (HikariCP)
- `tomcat.datasource.connections` (Tomcat)
- `dbcp2.connections` (Apache DBCP2)
### Cache Metrics
Auto-configuration enables the instrumentation of all available Cache managers on startup with metrics prefixed with `cache.`. The cache instrumentation is standardized for a basic set of metrics.
Cache metrics include:
- Size
- Hit ratio
- Evictions
- Puts and misses
### Task Execution and Scheduling Metrics
Auto-configuration enables the instrumentation of all available `ThreadPoolTaskExecutor` and `ThreadPoolTaskScheduler` beans with metrics prefixed with `executor.` and `scheduler.` respectively.
Executor metrics include:
- Active threads
- Pool size
- Queue size
- Task completion
## Custom Metrics
To record your own metrics, inject `MeterRegistry` into your component:
```java
@Component
public class MyService {
private final Counter counter;
private final Timer timer;
private final Gauge gauge;
public MyService(MeterRegistry meterRegistry) {
this.counter = Counter.builder("my.counter")
.description("A simple counter")
.register(meterRegistry);
this.timer = Timer.builder("my.timer")
.description("A simple timer")
.register(meterRegistry);
this.gauge = Gauge.builder("my.gauge")
.description("A simple gauge")
.register(meterRegistry, this, MyService::calculateGaugeValue);
}
public void doSomething() {
counter.increment();
Timer.Sample sample = Timer.start(meterRegistry);
// ... do work
sample.stop(timer);
}
private double calculateGaugeValue(MyService self) {
return Math.random();
}
}
```
### Using @Timed Annotation
You can use the `@Timed` annotation to time method executions:
```java
@Component
public class MyService {
@Timed(name = "my.method.time", description = "Time taken to execute my method")
public void timedMethod() {
// method body
}
}
```
For the `@Timed` annotation to work, you need to enable timing support:
```java
@Configuration
@EnableConfigurationProperties
public class TimedConfiguration {
@Bean
public TimedAspect timedAspect(MeterRegistry registry) {
return new TimedAspect(registry);
}
}
```
### Using @Counted Annotation
You can use the `@Counted` annotation to count method invocations:
```java
@Component
public class MyService {
@Counted(name = "my.method.count", description = "Number of times my method is called")
public void countedMethod() {
// method body
}
}
```
For the `@Counted` annotation to work, you need to enable counting support:
```java
@Configuration
public class CountedConfiguration {
@Bean
public CountedAspect countedAspect(MeterRegistry registry) {
return new CountedAspect(registry);
}
}
```
## Meter Filters
You can register any number of `MeterFilter` beans to control how meters are registered:
```java
@Configuration
public class MetricsConfiguration {
@Bean
public MeterFilter renameFilter() {
return MeterFilter.rename("old.metric.name", "new.metric.name");
}
@Bean
public MeterFilter denyFilter() {
return MeterFilter.deny(id -> id.getName().contains("unwanted"));
}
@Bean
public MeterFilter tagFilter() {
return MeterFilter.commonTags("application", "my-app");
}
}
```
## Metrics Endpoint
The `metrics` endpoint provides access to all the metrics collected by the application. You can view the names of all available meters by visiting `/actuator/metrics`.
To view the value of a particular meter, specify its name as a path parameter:
```
GET /actuator/metrics/jvm.memory.used
```
The response contains the meter's measurements:
```json
{
"name": "jvm.memory.used",
"description": "The amount of used memory",
"baseUnit": "bytes",
"measurements": [
{
"statistic": "VALUE",
"value": 8.73E8
}
],
"availableTags": [
{
"tag": "area",
"values": ["heap", "nonheap"]
},
{
"tag": "id",
"values": ["Compressed Class Space", "PS Eden Space", "PS Survivor Space"]
}
]
}
```
You can drill down to a particular meter by adding query parameters:
```
GET /actuator/metrics/jvm.memory.used?tag=area:heap&tag=id:PS%20Eden%20Space
```
## Monitoring System Integration
### Prometheus
To export metrics to Prometheus, add the following dependency:
```xml
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
```
This exposes a `/actuator/prometheus` endpoint that presents metrics in the format expected by a Prometheus server.
Configuration example:
```yaml
management:
endpoints:
web:
exposure:
include: "prometheus"
metrics:
export:
prometheus:
enabled: true
step: 1m
descriptions: true
```
### Datadog
To export metrics to Datadog, add the following dependency:
```xml
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-datadog</artifactId>
</dependency>
```
Configuration:
```yaml
management:
metrics:
export:
datadog:
api-key: ${DATADOG_API_KEY}
application-key: ${DATADOG_APP_KEY}
uri: https://api.datadoghq.com
step: 1m
```
### InfluxDB
To export metrics to InfluxDB, add the following dependency:
```xml
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-influx</artifactId>
</dependency>
```
Configuration:
```yaml
management:
metrics:
export:
influx:
uri: http://localhost:8086
db: mydb
username: ${INFLUX_USERNAME}
password: ${INFLUX_PASSWORD}
step: 1m
```
### New Relic
To export metrics to New Relic, add the following dependency:
```xml
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-newrelic</artifactId>
</dependency>
```
Configuration:
```yaml
management:
metrics:
export:
newrelic:
api-key: ${NEW_RELIC_API_KEY}
account-id: ${NEW_RELIC_ACCOUNT_ID}
step: 1m
```
### Simple Registry (In-Memory)
The simple registry is automatically configured if no other registry is found on the classpath. It stores metrics in memory and is useful for development and testing:
```yaml
management:
metrics:
export:
simple:
enabled: true
step: 1m
```
## Performance Considerations
### Meter Cardinality
Be mindful of meter cardinality when adding tags. High-cardinality tags (like user IDs) can lead to performance issues:
```java
// Bad - high cardinality
Timer.builder("user.request.time")
.tag("user.id", userId) // Could be millions of different values
.register(registry);
// Good - low cardinality
Timer.builder("user.request.time")
.tag("user.type", userType) // Limited number of values
.register(registry);
```
### Sampling
For high-throughput applications, consider using sampling to reduce overhead:
```java
@Bean
public MeterFilter samplingFilter() {
return MeterFilter.maximumExpectedValue("http.server.requests",
Duration.ofMillis(500));
}
```
### Meter Registry Configuration
Configure appropriate publishing intervals to balance between timeliness and performance:
```yaml
management:
metrics:
export:
prometheus:
step: 30s # Adjust based on your needs
```
## Security Considerations
### Sensitive Data
Be careful not to include sensitive information in metric tags or names:
```java
// Bad - exposes sensitive data
Counter.builder("login.attempts")
.tag("username", username) // Could expose usernames
.register(registry);
// Good - uses hashed or anonymized data
Counter.builder("login.attempts")
.tag("outcome", successful ? "success" : "failure")
.register(registry);
```
### Endpoint Security
Secure the metrics endpoint in production:
```yaml
management:
endpoints:
web:
exposure:
include: "metrics"
endpoint:
metrics:
access: restricted
```
Or using Spring Security:
```java
@Configuration
public class ActuatorSecurity {
@Bean
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.requestMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(requests ->
requests.anyRequest().hasRole("ACTUATOR"))
.httpBasic(withDefaults())
.build();
}
}
```
## Best Practices
1. **Use meaningful meter names**: Follow naming conventions specific to your monitoring system
2. **Add appropriate tags**: Use tags to add dimensions but avoid high cardinality
3. **Monitor meter cardinality**: High cardinality can impact performance
4. **Use meter filters**: Filter out unwanted metrics or rename meters
5. **Configure appropriate publishing intervals**: Balance between timeliness and performance
6. **Secure sensitive endpoints**: Protect metrics endpoints in production
7. **Test metrics in development**: Verify metrics are collected correctly before deploying
8. **Document custom metrics**: Maintain documentation for custom metrics and their purposes

View File

@@ -0,0 +1,318 @@
# HTTP Monitoring and Management
If you are developing a web application, Spring Boot Actuator auto-configures all enabled endpoints to be exposed over HTTP. The default convention is to use the `id` of the endpoint with a prefix of `/actuator` as the URL path. For example, `health` is exposed as `/actuator/health`.
> **TIP**
>
> Actuator is supported natively with Spring MVC, Spring WebFlux, and Jersey. If both Jersey and Spring MVC are available, Spring MVC is used.
> **NOTE**
>
> Jackson is a required dependency in order to get the correct JSON responses as documented in the [API documentation](https://docs.spring.io/spring-boot/docs/current/actuator-api/htmlsingle/).
## Customizing the Management Endpoint Paths
Sometimes, it is useful to customize the prefix for the management endpoints. For example, your application might already use `/actuator` for another purpose. You can use the `management.endpoints.web.base-path` property to change the prefix for your management endpoint, as the following example shows:
```yaml
management:
endpoints:
web:
base-path: "/manage"
```
The preceding example changes the endpoint from `/actuator/{id}` to `/manage/{id}` (for example, `/manage/info`).
> **NOTE**
>
> Unless the management port has been configured to expose endpoints by using a different HTTP port, `management.endpoints.web.base-path` is relative to `server.servlet.context-path` (for servlet web applications) or `spring.webflux.base-path` (for reactive web applications). If `management.server.port` is configured, `management.endpoints.web.base-path` is relative to `management.server.base-path`.
If you want to map endpoints to a different path, you can use the `management.endpoints.web.path-mapping` property.
The following example remaps `/actuator/health` to `/healthcheck`:
```yaml
management:
endpoints:
web:
base-path: "/"
path-mapping:
health: "healthcheck"
```
## Customizing the Management Server Port
Exposing management endpoints by using the default HTTP port is a sensible choice for cloud-based deployments. If, however, your application runs inside your own data center, you may prefer to expose endpoints by using a different HTTP port.
You can set the `management.server.port` property to change the HTTP port, as the following example shows:
```yaml
management:
server:
port: 8081
```
> **NOTE**
>
> On Cloud Foundry, by default, applications receive requests only on port 8080 for both HTTP and TCP routing. If you want to use a custom management port on Cloud Foundry, you need to explicitly set up the application's routes to forward traffic to the custom port.
## Configuring Management-specific SSL
When configured to use a custom port, you can also configure the management server with its own SSL by using the various `management.server.ssl.*` properties. For example, doing so lets a management server be available over HTTP while the main application uses HTTPS, as the following property settings show:
```yaml
server:
port: 8443
ssl:
enabled: true
key-store: "classpath:store.jks"
key-password: "secret"
management:
server:
port: 8080
ssl:
enabled: false
```
Alternatively, both the main server and the management server can use SSL but with different key stores, as follows:
```yaml
server:
port: 8443
ssl:
enabled: true
key-store: "classpath:main.jks"
key-password: "secret"
management:
server:
port: 8080
ssl:
enabled: true
key-store: "classpath:management.jks"
key-password: "secret"
```
## Customizing the Management Server Address
You can customize the address on which the management endpoints are available by setting the `management.server.address` property. Doing so can be useful if you want to listen only on an internal or ops-facing network or to listen only for connections from `localhost`.
> **NOTE**
>
> You can listen on a different address only when the port differs from the main server port.
The following example does not allow remote management connections:
```yaml
management:
server:
port: 8081
address: "127.0.0.1"
```
## Disabling HTTP Endpoints
If you do not want to expose endpoints over HTTP, you can set the management port to `-1`, as the following example shows:
```yaml
management:
server:
port: -1
```
You can also achieve this by using the `management.endpoints.web.exposure.exclude` property, as the following example shows:
```yaml
management:
endpoints:
web:
exposure:
exclude: "*"
```
## Security Configuration for Management Endpoints
### Basic Authentication
To secure management endpoints with basic authentication:
```yaml
spring:
security:
user:
name: admin
password: secret
roles: ACTUATOR
management:
endpoints:
web:
exposure:
include: "*"
endpoint:
health:
show-details: when-authorized
```
### Custom Security Configuration
For more granular control, create a custom security configuration:
```java
@Configuration
public class ManagementSecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.requestMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(requests ->
requests
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.anyRequest().hasRole("ACTUATOR")
)
.httpBasic(withDefaults())
.build();
}
@Bean
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(requests ->
requests.anyRequest().authenticated())
.formLogin(withDefaults())
.build();
}
}
```
### Role-based Access Control
Different endpoints can require different roles:
```java
@Configuration
public class ActuatorSecurityConfig {
@Bean
@Order(1)
public SecurityFilterChain actuatorSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.requestMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(requests ->
requests
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.requestMatchers(EndpointRequest.to("metrics", "prometheus")).hasRole("METRICS_READER")
.requestMatchers(EndpointRequest.to("env", "configprops")).hasRole("CONFIG_READER")
.requestMatchers(EndpointRequest.to("shutdown")).hasRole("ADMIN")
.anyRequest().hasRole("ACTUATOR")
)
.httpBasic(withDefaults())
.build();
}
}
```
## CORS Configuration
To enable Cross-Origin Resource Sharing (CORS) for management endpoints:
```yaml
management:
endpoints:
web:
cors:
allowed-origins: "https://example.com"
allowed-methods: "GET,POST"
allowed-headers: "*"
allow-credentials: true
```
## Custom Management Context Path
When using a separate management port, you can configure a custom context path:
```yaml
management:
server:
port: 9090
base-path: "/admin"
endpoints:
web:
base-path: "/actuator"
```
This configuration makes endpoints available at `http://localhost:9090/admin/actuator/*`.
## Load Balancer Configuration
When running behind a load balancer, configure the health endpoint appropriately:
```yaml
management:
endpoint:
health:
probes:
enabled: true
group:
liveness:
include: "livenessState"
readiness:
include: "readinessState,db"
endpoints:
web:
exposure:
include: "health,info,metrics"
```
This allows the load balancer to check:
- Liveness: `GET /actuator/health/liveness`
- Readiness: `GET /actuator/health/readiness`
## Best Practices
1. **Separate Management Port**: Use a different port for management endpoints in production
2. **Secure Endpoints**: Always secure management endpoints in production environments
3. **Limit Exposure**: Only expose necessary endpoints (`include` specific endpoints rather than using `*`)
4. **Monitor Access**: Log and monitor access to management endpoints
5. **Network Security**: Use firewalls to restrict access to management ports
6. **SSL/TLS**: Use HTTPS for management endpoints in production
7. **Health Checks**: Configure appropriate health indicators for your infrastructure
8. **Graceful Shutdown**: Consider enabling graceful shutdown for production deployments
```yaml
# Production-ready configuration example
server:
port: 8080
shutdown: graceful
management:
server:
port: 8081
address: "127.0.0.1" # Only local access
ssl:
enabled: true
key-store: "classpath:management.p12"
key-store-password: "${KEYSTORE_PASSWORD}"
endpoints:
web:
exposure:
include: "health,info,metrics,prometheus"
enabled-by-default: false
endpoint:
health:
enabled: true
show-details: when-authorized
probes:
enabled: true
info:
enabled: true
metrics:
enabled: true
prometheus:
enabled: true
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
```

View File

@@ -0,0 +1,543 @@
# Observability with Spring Boot Actuator
Spring Boot Actuator provides comprehensive observability features through integration with Micrometer, including metrics, tracing, and structured logging. This enables monitoring, alerting, and debugging of Spring Boot applications in production.
## Three Pillars of Observability
### 1. Metrics
Quantitative measurements of application behavior:
- Application metrics (requests/second, response times)
- JVM metrics (memory usage, garbage collection)
- System metrics (CPU, disk usage)
- Custom business metrics
### 2. Tracing
Request flow tracking across distributed systems:
- Distributed tracing with OpenTelemetry or Zipkin
- Span creation and propagation
- Request correlation across services
- Performance bottleneck identification
### 3. Logging
Structured application event recording:
- Centralized logging with correlation IDs
- Log level management
- Structured logging formats (JSON)
- Integration with tracing context
## Observability Configuration
### Basic Setup
```yaml
management:
endpoints:
web:
exposure:
include: "health,info,metrics,prometheus,loggers"
metrics:
export:
prometheus:
enabled: true
distribution:
percentiles-histogram:
http.server.requests: true
http.client.requests: true
tracing:
sampling:
probability: 0.1 # 10% sampling in production
zipkin:
tracing:
endpoint: "http://zipkin:9411/api/v2/spans"
logging:
pattern:
level: "%5p [%X{traceId:-},%X{spanId:-}]"
level:
org.springframework.web: DEBUG
```
### Micrometer Integration
```java
@Configuration
public class ObservabilityConfiguration {
@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> registry.config()
.commonTags("application", "my-app")
.commonTags("environment", getEnvironment());
}
@Bean
public ObservationRegistryCustomizer<ObservationRegistry> observationRegistryCustomizer() {
return registry -> registry.observationConfig()
.observationHandler(new LoggingObservationHandler())
.observationHandler(new MetricsObservationHandler(meterRegistry()))
.observationHandler(new TracingObservationHandler(tracer()));
}
private String getEnvironment() {
return System.getProperty("spring.profiles.active", "development");
}
}
```
## Custom Observability Components
### Custom Health Indicators
```java
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
public DatabaseHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
try (Connection connection = dataSource.getConnection()) {
boolean isValid = connection.isValid(5);
if (isValid) {
return Health.up()
.withDetail("database", "PostgreSQL")
.withDetail("connection_pool", getConnectionPoolInfo())
.build();
} else {
return Health.down()
.withDetail("database", "Connection validation failed")
.build();
}
} catch (Exception ex) {
return Health.down(ex)
.withDetail("database", "Connection failed")
.build();
}
}
private Map<String, Object> getConnectionPoolInfo() {
// Return connection pool metrics
return Map.of(
"active", 5,
"idle", 3,
"max", 10
);
}
}
```
### Custom Metrics
```java
@Component
public class BusinessMetrics {
private final Counter orderCounter;
private final Timer orderProcessingTime;
private final Gauge activeUsers;
public BusinessMetrics(MeterRegistry meterRegistry) {
this.orderCounter = Counter.builder("orders.total")
.description("Total number of orders")
.tag("type", "all")
.register(meterRegistry);
this.orderProcessingTime = Timer.builder("orders.processing.time")
.description("Order processing time")
.register(meterRegistry);
this.activeUsers = Gauge.builder("users.active")
.description("Number of active users")
.register(meterRegistry, this, BusinessMetrics::getActiveUserCount);
}
public void recordOrder(String orderType) {
orderCounter.increment(Tags.of("type", orderType));
}
public void recordOrderProcessingTime(Duration duration) {
orderProcessingTime.record(duration);
}
private double getActiveUserCount() {
// Implement logic to get active user count
return 150.0;
}
}
```
### Observation Aspects
```java
@Aspect
@Component
public class ObservationAspect {
private final ObservationRegistry observationRegistry;
public ObservationAspect(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
}
@Around("@annotation(observed)")
public Object observe(ProceedingJoinPoint joinPoint, Observed observed) throws Throwable {
String operationName = observed.name().isEmpty() ?
joinPoint.getSignature().getName() : observed.name();
return Observation.createNotStarted(operationName, observationRegistry)
.lowCardinalityKeyValues(observed.lowCardinalityKeyValues())
.observe(() -> {
try {
return joinPoint.proceed();
} catch (RuntimeException ex) {
throw ex;
} catch (Throwable ex) {
throw new RuntimeException(ex);
}
});
}
}
```
## Distributed Observability
### Service Correlation
```java
@RestController
public class OrderController {
private final OrderService orderService;
private final ObservationRegistry observationRegistry;
public OrderController(OrderService orderService, ObservationRegistry observationRegistry) {
this.orderService = orderService;
this.observationRegistry = observationRegistry;
}
@PostMapping("/orders")
public ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
return Observation.createNotStarted("order.create", observationRegistry)
.lowCardinalityKeyValue("operation", "create")
.lowCardinalityKeyValue("service", "order-service")
.observe(() -> {
Order order = orderService.createOrder(request);
return ResponseEntity.ok(order);
});
}
}
@Service
public class OrderService {
private final PaymentServiceClient paymentClient;
@Observed(name = "order.processing")
public Order createOrder(CreateOrderRequest request) {
// Business logic with automatic observation
PaymentResult payment = paymentClient.processPayment(request.getPayment());
if (payment.isSuccessful()) {
return saveOrder(request);
} else {
throw new PaymentFailedException("Payment failed");
}
}
private Order saveOrder(CreateOrderRequest request) {
// Save order logic
return new Order();
}
}
```
### Cross-Service Tracing
```java
@Component
public class PaymentServiceClient {
private final WebClient webClient;
private final ObservationRegistry observationRegistry;
public PaymentServiceClient(WebClient.Builder webClientBuilder,
ObservationRegistry observationRegistry) {
this.webClient = webClientBuilder
.baseUrl("http://payment-service")
.build();
this.observationRegistry = observationRegistry;
}
public PaymentResult processPayment(PaymentRequest request) {
return Observation.createNotStarted("payment.process", observationRegistry)
.lowCardinalityKeyValue("service", "payment-service")
.lowCardinalityKeyValue("method", "POST")
.observe(() -> {
return webClient
.post()
.uri("/payments")
.bodyValue(request)
.retrieve()
.bodyToMono(PaymentResult.class)
.block();
});
}
}
```
## Alerting and Monitoring
### Health-based Alerting
```java
@Component
public class HealthAlertManager {
private final HealthEndpoint healthEndpoint;
private final NotificationService notificationService;
@Scheduled(fixedRate = 30000) // Check every 30 seconds
public void checkHealth() {
HealthComponent health = healthEndpoint.health();
if (!Status.UP.equals(health.getStatus())) {
Alert alert = Alert.builder()
.severity(Alert.Severity.HIGH)
.title("Application Health Check Failed")
.description("Application health status: " + health.getStatus())
.details(health.getDetails())
.build();
notificationService.sendAlert(alert);
}
}
}
```
### Metric-based Alerting
```java
@Component
public class MetricAlertManager {
private final MeterRegistry meterRegistry;
private final NotificationService notificationService;
@Scheduled(fixedRate = 60000) // Check every minute
public void checkMetrics() {
// Check error rate
double errorRate = getErrorRate();
if (errorRate > 0.05) { // 5% error rate threshold
sendAlert("High Error Rate",
String.format("Error rate: %.2f%%", errorRate * 100));
}
// Check response time
double avgResponseTime = getAverageResponseTime();
if (avgResponseTime > 1000) { // 1 second threshold
sendAlert("High Response Time",
String.format("Average response time: %.2f ms", avgResponseTime));
}
// Check memory usage
double memoryUsage = getMemoryUsage();
if (memoryUsage > 0.9) { // 90% memory usage
sendAlert("High Memory Usage",
String.format("Memory usage: %.2f%%", memoryUsage * 100));
}
}
private double getErrorRate() {
Timer successTimer = meterRegistry.find("http.server.requests")
.tag("status", "200")
.timer();
Timer errorTimer = meterRegistry.find("http.server.requests")
.tag("status", "500")
.timer();
if (successTimer != null && errorTimer != null) {
double total = successTimer.count() + errorTimer.count();
return total > 0 ? errorTimer.count() / total : 0.0;
}
return 0.0;
}
private double getAverageResponseTime() {
Timer timer = meterRegistry.find("http.server.requests").timer();
return timer != null ? timer.mean(TimeUnit.MILLISECONDS) : 0.0;
}
private double getMemoryUsage() {
Gauge memoryUsed = meterRegistry.find("jvm.memory.used").gauge();
Gauge memoryMax = meterRegistry.find("jvm.memory.max").gauge();
if (memoryUsed != null && memoryMax != null) {
return memoryUsed.value() / memoryMax.value();
}
return 0.0;
}
private void sendAlert(String title, String message) {
Alert alert = Alert.builder()
.severity(Alert.Severity.MEDIUM)
.title(title)
.description(message)
.timestamp(Instant.now())
.build();
notificationService.sendAlert(alert);
}
}
```
## Production Observability Setup
### Prometheus Configuration
```yaml
management:
endpoints:
web:
exposure:
include: "health,info,metrics,prometheus"
metrics:
export:
prometheus:
enabled: true
step: 30s
descriptions: true
distribution:
percentiles-histogram:
"[http.server.requests]": true
percentiles:
"[http.server.requests]": 0.5, 0.95, 0.99
slo:
"[http.server.requests]": 100ms, 500ms, 1s
prometheus:
metrics:
export:
enabled: true
```
### Grafana Dashboard Configuration
```json
{
"dashboard": {
"title": "Spring Boot Application Dashboard",
"panels": [
{
"title": "Request Rate",
"type": "graph",
"targets": [
{
"expr": "rate(http_server_requests_seconds_count[5m])",
"legendFormat": "{{method}} {{uri}}"
}
]
},
{
"title": "Response Time",
"type": "graph",
"targets": [
{
"expr": "histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m]))",
"legendFormat": "95th percentile"
}
]
},
{
"title": "JVM Memory",
"type": "graph",
"targets": [
{
"expr": "jvm_memory_used_bytes / jvm_memory_max_bytes * 100",
"legendFormat": "Memory Usage %"
}
]
}
]
}
}
```
## Best Practices
1. **Observability Strategy**: Define clear observability goals and SLIs/SLOs
2. **Metric Cardinality**: Keep metric labels low-cardinality to avoid performance issues
3. **Sampling**: Use appropriate sampling rates for tracing in high-throughput applications
4. **Security**: Secure observability endpoints and ensure no sensitive data is exposed
5. **Performance**: Monitor the performance impact of observability instrumentation
6. **Alerting**: Set up meaningful alerts based on business metrics, not just technical metrics
7. **Documentation**: Document your observability setup and runbooks for incident response
### Complete Production Configuration
```yaml
# Production observability configuration
management:
endpoints:
web:
exposure:
include: "health,info,metrics,prometheus"
base-path: "/actuator"
endpoint:
health:
show-details: when-authorized
probes:
enabled: true
metrics:
enabled: true
prometheus:
enabled: true
metrics:
export:
prometheus:
enabled: true
step: 30s
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5, 0.95, 0.99
tracing:
sampling:
probability: 0.01 # 1% sampling in production
zipkin:
tracing:
endpoint: "${ZIPKIN_URL:http://localhost:9411/api/v2/spans}"
logging:
pattern:
level: "%5p [%X{traceId:-},%X{spanId:-}]"
level:
root: INFO
com.example: DEBUG
org.springframework.web: WARN
# Custom application properties
app:
observability:
alerts:
error-rate-threshold: 0.05
response-time-threshold: 1000
memory-threshold: 0.9
retention:
metrics: 30d
traces: 7d
logs: 30d
```

View File

@@ -0,0 +1,138 @@
# Process Monitoring
Spring Boot Actuator provides several features for monitoring the application process, including process information, thread dumps, and heap dumps.
## Process Information
The `info` endpoint can provide process-specific information:
```java
@Component
public class ProcessInfoContributor implements InfoContributor {
@Override
public void contribute(Info.Builder builder) {
RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
builder.withDetail("process", Map.of(
"pid", ProcessHandle.current().pid(),
"uptime", Duration.ofMillis(runtime.getUptime()),
"start-time", Instant.ofEpochMilli(runtime.getStartTime()),
"jvm-name", runtime.getVmName(),
"jvm-version", runtime.getVmVersion()
));
}
}
```
## Thread Monitoring
### Thread Dump Endpoint
Access thread dumps via:
```
GET /actuator/threaddump
```
### Custom Thread Monitoring
```java
@Component
@ManagedResource(objectName = "com.example:type=ThreadMonitor")
public class ThreadMonitorMBean {
@ManagedAttribute
public int getActiveThreadCount() {
return Thread.activeCount();
}
@ManagedAttribute
public long getTotalStartedThreadCount() {
return ManagementFactory.getThreadMXBean().getTotalStartedThreadCount();
}
@ManagedOperation
public String getThreadDump() {
ThreadMXBean threadBean = ManagementFactory.getThreadMXBean();
ThreadInfo[] threadInfos = threadBean.dumpAllThreads(true, true);
StringBuilder dump = new StringBuilder();
for (ThreadInfo threadInfo : threadInfos) {
dump.append(threadInfo.toString()).append("\n");
}
return dump.toString();
}
}
```
## Memory Monitoring
### Heap Dump Endpoint
Access heap dumps via:
```
GET /actuator/heapdump
```
### Memory Metrics
```java
@Component
public class MemoryMetrics {
private final MeterRegistry meterRegistry;
public MemoryMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
Gauge.builder("memory.heap.usage")
.description("Heap memory usage percentage")
.register(meterRegistry, this, MemoryMetrics::getHeapUsagePercentage);
}
private double getHeapUsagePercentage() {
MemoryMXBean memoryBean = ManagementFactory.getMemoryMXBean();
MemoryUsage heapUsage = memoryBean.getHeapMemoryUsage();
return (double) heapUsage.getUsed() / heapUsage.getMax() * 100;
}
}
```
## Process Health Monitoring
```java
@Component
public class ProcessHealthIndicator implements HealthIndicator {
@Override
public Health health() {
try {
// Check process health
long pid = ProcessHandle.current().pid();
ProcessHandle process = ProcessHandle.of(pid).orElseThrow();
if (process.isAlive()) {
return Health.up()
.withDetail("pid", pid)
.withDetail("cpu-time", process.info().totalCpuDuration())
.withDetail("start-time", process.info().startInstant())
.build();
} else {
return Health.down()
.withDetail("reason", "Process not alive")
.build();
}
} catch (Exception ex) {
return Health.down(ex).build();
}
}
}
```
## Best Practices
1. **Security**: Secure heap dump and thread dump endpoints in production
2. **Performance**: Monitor the performance impact of process monitoring
3. **Storage**: Be aware that heap dumps can be very large files
4. **Automation**: Set up automated collection of thread dumps during incidents
5. **Analysis**: Use appropriate tools for analyzing heap and thread dumps

View File

@@ -0,0 +1,557 @@
# Distributed Tracing with Spring Boot Actuator
Spring Boot Actuator provides dependency management and auto-configuration for [Micrometer Tracing](https://micrometer.io/docs/tracing), a facade for popular tracer libraries.
> **TIP**
>
> To learn more about Micrometer Tracing capabilities, see its [reference documentation](https://micrometer.io/docs/tracing).
## Supported Tracers
Spring Boot ships auto-configuration for the following tracers:
- [OpenTelemetry](https://opentelemetry.io/) with [Zipkin](https://zipkin.io/), [Wavefront](https://docs.wavefront.com/), or [OTLP](https://opentelemetry.io/docs/reference/specification/protocol/)
- [OpenZipkin Brave](https://github.com/openzipkin/brave) with [Zipkin](https://zipkin.io/) or [Wavefront](https://docs.wavefront.com/)
## Getting Started with OpenTelemetry and Zipkin
### Dependencies
Add the following dependencies to your project:
**Maven:**
```xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>
</dependencies>
```
**Gradle:**
```groovy
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-tracing-bridge-otel'
implementation 'io.opentelemetry:opentelemetry-exporter-zipkin'
}
```
### Configuration
Add the following application properties:
```yaml
management:
tracing:
sampling:
probability: 1.0 # Sample 100% of requests in development
zipkin:
tracing:
endpoint: "http://localhost:9411/api/v2/spans"
logging:
pattern:
level: "%5p [%X{traceId:-},%X{spanId:-}]"
```
### Basic Application Example
```java
@SpringBootApplication
@RestController
public class MyApplication {
private static final Logger logger = LoggerFactory.getLogger(MyApplication.class);
@GetMapping("/")
public String home() {
logger.info("Handling home request");
return "Hello World!";
}
public static void main(String[] args) {
SpringApplication.run(MyApplication.class, args);
}
}
```
## Configuration Options
### Sampling Configuration
Control which traces are collected:
```yaml
management:
tracing:
sampling:
probability: 0.1 # Sample 10% of requests in production
rate: 100 # Maximum 100 traces per second
```
### Zipkin Configuration
```yaml
management:
zipkin:
tracing:
endpoint: "http://zipkin:9411/api/v2/spans"
timeout: 1s
connect-timeout: 1s
read-timeout: 10s
```
### OpenTelemetry OTLP Configuration
```yaml
management:
otlp:
tracing:
endpoint: "http://otlp-collector:4318/v1/traces"
timeout: 1s
compression: gzip
headers:
Authorization: "Bearer your-token"
```
### Wavefront Configuration
```yaml
management:
wavefront:
tracing:
application-name: "my-application"
service-name: "my-service"
api-token: "${WAVEFRONT_API_TOKEN}"
uri: "https://your-instance.wavefront.com"
```
## Custom Spans
### Using @Observed Annotation
```java
@Service
public class UserService {
@Observed(name = "user.service.find-by-id")
public User findById(Long id) {
// Service logic
return userRepository.findById(id);
}
@Observed(
name = "user.service.create",
contextualName = "creating-user",
lowCardinalityKeyValues = {"operation", "create"}
)
public User createUser(CreateUserRequest request) {
// Creation logic
return save(request.toUser());
}
}
```
### Programmatic Span Creation
```java
@Service
public class OrderService {
private final ObservationRegistry observationRegistry;
public OrderService(ObservationRegistry observationRegistry) {
this.observationRegistry = observationRegistry;
}
public Order processOrder(OrderRequest request) {
return Observation.createNotStarted("order.processing", observationRegistry)
.lowCardinalityKeyValue("order.type", request.getType())
.observe(() -> {
// Add custom tags
Observation.Scope scope = Observation.start("order.validation", observationRegistry);
try {
validateOrder(request);
} finally {
scope.close();
}
// Process order
return saveOrder(request);
});
}
private void validateOrder(OrderRequest request) {
// Validation logic
}
private Order saveOrder(OrderRequest request) {
// Save logic
return new Order();
}
}
```
### Using Micrometer's Tracer API
```java
@Service
public class PaymentService {
private final Tracer tracer;
public PaymentService(Tracer tracer) {
this.tracer = tracer;
}
public PaymentResult processPayment(PaymentRequest request) {
Span span = tracer.nextSpan()
.name("payment.processing")
.tag("payment.method", request.getMethod())
.tag("payment.amount", String.valueOf(request.getAmount()))
.start();
try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) {
// Add events
span.event("payment.validation.started");
validatePayment(request);
span.event("payment.validation.completed");
span.event("payment.processing.started");
PaymentResult result = processPaymentInternal(request);
span.event("payment.processing.completed");
// Add result information
span.tag("payment.status", result.getStatus());
return result;
} catch (Exception ex) {
span.tag("error", ex.getMessage());
throw ex;
} finally {
span.end();
}
}
private void validatePayment(PaymentRequest request) {
// Validation logic
}
private PaymentResult processPaymentInternal(PaymentRequest request) {
// Processing logic
return new PaymentResult();
}
}
```
## Baggage
Baggage allows you to pass context information across service boundaries:
```java
@Service
public class UserService {
private final BaggageManager baggageManager;
public UserService(BaggageManager baggageManager) {
this.baggageManager = baggageManager;
}
public User getCurrentUser(String userId) {
// Set baggage that will be propagated to downstream services
try (BaggageInScope baggageInScope =
baggageManager.createBaggage("user.id", userId).makeCurrent()) {
return fetchUserFromDatabase(userId);
}
}
private User fetchUserFromDatabase(String userId) {
// This method and any downstream calls will have access to the baggage
String currentUserId = baggageManager.getBaggage("user.id").get();
// Use the user ID for security context, logging, etc.
return userRepository.findById(userId);
}
}
```
## HTTP Client Tracing
### WebClient Tracing
Spring Boot automatically configures tracing for WebClient:
```java
@Service
public class ExternalApiService {
private final WebClient webClient;
public ExternalApiService(WebClient.Builder webClientBuilder) {
this.webClient = webClientBuilder
.baseUrl("https://api.example.com")
.build();
}
public ApiResponse callExternalApi(String data) {
return webClient
.post()
.uri("/process")
.bodyValue(data)
.retrieve()
.bodyToMono(ApiResponse.class)
.block();
}
}
```
### RestTemplate Tracing
For RestTemplate, add the interceptor manually:
```java
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.interceptors(new TraceRestTemplateInterceptor())
.build();
}
}
```
## Database Tracing
### JPA/Hibernate Tracing
Enable SQL tracing with additional configuration:
```yaml
spring:
jpa:
properties:
hibernate:
generate_statistics: true
session:
events:
log:
LOG_QUERIES_SLOWER_THAN_MS: 25
management:
tracing:
enabled: true
metrics:
distribution:
percentiles-histogram:
http.server.requests: true
```
### Custom Database Observation
```java
@Repository
public class UserRepository {
private final JdbcTemplate jdbcTemplate;
private final ObservationRegistry observationRegistry;
public UserRepository(JdbcTemplate jdbcTemplate,
ObservationRegistry observationRegistry) {
this.jdbcTemplate = jdbcTemplate;
this.observationRegistry = observationRegistry;
}
public User findById(Long id) {
return Observation.createNotStarted("db.user.find-by-id", observationRegistry)
.lowCardinalityKeyValue("db.operation", "select")
.lowCardinalityKeyValue("db.table", "users")
.observe(() -> {
String sql = "SELECT * FROM users WHERE id = ?";
return jdbcTemplate.queryForObject(sql,
new UserRowMapper(), id);
});
}
}
```
## Async Processing Tracing
### @Async Methods
```java
@Service
public class NotificationService {
@Async
@Observed(name = "notification.send")
public CompletableFuture<Void> sendNotificationAsync(String recipient, String message) {
// Async notification logic
return CompletableFuture.completedFuture(null);
}
}
```
### Manual Trace Propagation
```java
@Service
public class EmailService {
private final Tracer tracer;
private final ExecutorService executorService;
public EmailService(Tracer tracer) {
this.tracer = tracer;
this.executorService = Executors.newFixedThreadPool(5);
}
public void sendEmailAsync(String recipient, String subject, String body) {
TraceContext traceContext = tracer.currentSpan().context();
executorService.submit(() -> {
try (Tracer.SpanInScope ws = tracer.withSpanInScope(
tracer.toSpan(traceContext))) {
Span span = tracer.nextSpan()
.name("email.send")
.tag("email.recipient", recipient)
.start();
try (Tracer.SpanInScope emailScope = tracer.withSpanInScope(span)) {
// Send email logic
sendEmailInternal(recipient, subject, body);
} finally {
span.end();
}
}
});
}
private void sendEmailInternal(String recipient, String subject, String body) {
// Email sending implementation
}
}
```
## Production Configuration
### Performance Optimizations
```yaml
management:
tracing:
sampling:
probability: 0.01 # Sample 1% in production
rate: 1000 # Max 1000 traces per second
baggage:
enabled: false # Disable if not needed
remote-fields: []
zipkin:
tracing:
endpoint: "${ZIPKIN_ENDPOINT:http://zipkin:9411/api/v2/spans}"
timeout: 1s
connect-timeout: 1s
# Optimize logging for performance
logging:
pattern:
level: "%5p [%X{traceId:-},%X{spanId:-}]"
level:
io.micrometer.tracing: WARN
org.springframework.web.servlet.mvc.method.annotation: WARN
```
### Security Considerations
```yaml
management:
endpoints:
web:
exposure:
include: "health,info,metrics"
exclude: "trace" # Don't expose trace endpoint
endpoint:
trace:
enabled: false
tracing:
baggage:
correlation:
enabled: false # Disable MDC correlation if sensitive data
remote-fields: [] # Don't propagate sensitive fields
```
## Troubleshooting
### Common Issues
1. **No traces appearing**: Check sampling probability and endpoint configuration
2. **High overhead**: Reduce sampling probability or disable baggage
3. **Missing spans**: Ensure proper dependency injection of ObservationRegistry
4. **Broken trace context**: Check async processing and thread boundaries
### Debug Configuration
```yaml
logging:
level:
io.micrometer.tracing: DEBUG
io.opentelemetry: DEBUG
brave: DEBUG
zipkin2: DEBUG
management:
tracing:
sampling:
probability: 1.0 # Sample everything for debugging
```
### Health Check for Tracing
```java
@Component
public class TracingHealthIndicator implements HealthIndicator {
private final Tracer tracer;
public TracingHealthIndicator(Tracer tracer) {
this.tracer = tracer;
}
@Override
public Health health() {
try {
Span span = tracer.nextSpan().name("health.check.tracing").start();
span.end();
return Health.up()
.withDetail("tracer", tracer.getClass().getSimpleName())
.build();
} catch (Exception ex) {
return Health.down()
.withDetail("error", ex.getMessage())
.build();
}
}
}
```
## Best Practices
1. **Sampling Strategy**: Use lower sampling rates in production (1-10%)
2. **Span Naming**: Use consistent, meaningful span names with low cardinality
3. **Tag Strategy**: Add meaningful tags but avoid high-cardinality values
4. **Error Handling**: Always properly handle and tag errors in spans
5. **Performance**: Monitor the overhead of tracing in production
6. **Security**: Be careful not to include sensitive data in span tags or baggage
7. **Correlation**: Use correlation IDs to link traces across service boundaries
8. **Testing**: Include tracing in your testing strategy with TestObservationRegistry

View File

@@ -0,0 +1,191 @@
---
name: spring-boot-cache
description: Instruction set for enabling and operating the Spring Cache abstraction in Spring Boot when implementing application-level caching for performance-sensitive workloads.
allowed-tools: Read, Write, Bash
category: backend
tags: [spring-boot, caching, performance, cacheable, cache-managers]
version: 1.1.0
---
# Spring Boot Cache Abstraction
## Overview
Spring Boot ships with a cache abstraction that wraps expensive service calls
behind annotation-driven caches. This abstraction supports multiple cache
providers (ConcurrentMap, Caffeine, Redis, Ehcache, JCache) without changing
business code. The skill provides a concise workflow for enabling caching,
managing cache lifecycles, and validating behavior in Spring Boot 3.5+ services.
## When to Use
- Add `@Cacheable`, `@CachePut`, or `@CacheEvict` to Spring Boot service methods.
- Configure Caffeine, Redis, or JCache cache managers for Spring Boot.
- Diagnose cache invalidation, eviction scheduling, or cache key issues.
- Expose cache management endpoints or scheduled eviction routines.
Use trigger phrases such as **"implement service caching"**, **"configure
CaffeineCacheManager"**, **"evict caches on update"**, or **"test Spring cache
behavior"** to load this skill.
## Prerequisites
- Java 17+ project based on Spring Boot 3.5.x (records encouraged for DTOs).
- Dependency `spring-boot-starter-cache`; add provider-specific starters as
needed (`spring-boot-starter-data-redis`, `caffeine`, `ehcache`, etc.).
- Constructor-injected services that expose deterministic method signatures.
- Observability stack (Actuator, Micrometer) when operating caches in
production.
## Quick Start
1. **Add dependencies**
```xml
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency> <!-- Optional: Caffeine -->
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
```
```gradle
implementation "org.springframework.boot:spring-boot-starter-cache"
implementation "com.github.ben-manes.caffeine:caffeine"
```
2. **Enable caching**
```java
@Configuration
@EnableCaching
class CacheConfig {
@Bean
CacheManager cacheManager() {
return new CaffeineCacheManager("users", "orders");
}
}
```
3. **Annotate service methods**
```java
@Service
@CacheConfig(cacheNames = "users")
class UserService {
@Cacheable(key = "#id", unless = "#result == null")
User findUser(Long id) { ... }
@CachePut(key = "#user.id")
User refreshUser(User user) { ... }
@CacheEvict(key = "#id", beforeInvocation = false)
void deleteUser(Long id) { ... }
}
```
4. **Verify behavior**
- Run focused unit tests that call cached methods twice and assert repository
invocations.
- Inspect Actuator `cache` endpoint (if enabled) for hit/miss counters.
## Implementation Workflow
### 1. Define Cache Strategy
- Map hot-path read operations to `@Cacheable`.
- Use `@CachePut` on write paths that must refresh cache entries.
- Apply `@CacheEvict` (`allEntries = true` when invalidating derived caches).
- Combine operations with `@Caching` to keep multi-cache updates consistent.
### 2. Shape Cache Keys and Conditions
- Generate deterministic keys via SpEL (e.g. `key = "#user.id"`).
- Guard caching with `condition = "#price > 0"` for selective caching.
- Prevent null or stale values with `unless = "#result == null"`.
- Synchronize concurrent updates via `sync = true` when needed.
### 3. Manage Providers and TTLs
- Configure provider-specific options:
- Caffeine spec: `spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m`
- Redis TTL: `spring.cache.redis.time-to-live=600000`
- Ehcache XML: define `ttl` and heap/off-heap resources.
- Expose cache names via `spring.cache.cache-names=users,orders,catalog`.
- Avoid on-demand cache name creation in production unless metrics cover usage.
### 4. Operate and Observe Caches
- Surface cache maintenance via a dedicated `CacheManagementService` with
programmatic `cacheManager.getCache(name)` access.
- Schedule periodic eviction for time-bound caches using `@Scheduled`.
- Wire Actuator `cache` endpoint and Micrometer meters to track hit ratio,
eviction count, and size.
### 5. Test and Validate
- Prefer slice or unit tests with Mockito/SpyBean to ensure method invocation
counts.
- Add integration tests with Testcontainers for Redis/Ehcache when using
external providers.
- Validate concurrency behavior under load (e.g. `sync = true` scenarios).
## Advanced Options
- Integrate JCache annotations when interoperating with providers that favor
JSR-107 (`@CacheResult`, `@CacheRemove`). Avoid mixing with Spring annotations
on the same method.
- Cache reactive return types (`Mono`, `Flux`) or `CompletableFuture` values.
Spring stores resolved values and resubscribes on hits; consider TTL alignment
with publisher semantics.
- Apply HTTP caching headers using `CacheControl` when exposing cached responses
via REST.
## Examples
- Load [`references/cache-examples.md`](references/cache-examples.md) for
progressive scenarios (basic product cache, conditional caching, multilevel
eviction, Redis integration).
- Load [`references/cache-core-reference.md`](references/cache-core-reference.md)
for annotation matrices, configuration tables, and property samples.
## References
- [`references/spring-framework-cache-docs.md`](references/spring-framework-cache-docs.md):
curated excerpts from the Spring Framework Reference Guide (official).
- [`references/spring-cache-doc-snippet.md`](references/spring-cache-doc-snippet.md):
narrative overview extracted from Spring documentation.
- [`references/cache-core-reference.md`](references/cache-core-reference.md):
annotation parameters, dependency matrices, property catalogs.
- [`references/cache-examples.md`](references/cache-examples.md):
end-to-end examples with tests.
## Best Practices
- Prefer constructor injection and immutable DTOs for cache entries.
- Separate cache names per aggregate (`users`, `orders`) to simplify eviction.
- Log cache hits/misses only at debug to avoid noise; push metrics via Micrometer.
- Tune TTLs based on data staleness tolerance; document rationale in code.
- Guard caches that store PII or credentials with encryption or avoid caching.
- Align cache eviction with transactional boundaries to prevent dirty reads.
## Constraints and Warnings
- Avoid caching mutable entities that depend on open persistence contexts.
- Do not mix Spring cache annotations with JCache annotations on the same
method.
- Ensure multi-level caches (e.g. Caffeine + Redis) maintain consistency; prefer
publish/subscribe invalidation channels.
- Validate serialization compatibility when caching across service instances.
- Monitor memory footprint to prevent OOM when using in-memory stores.
## Related Skills
- [`skills/spring-boot/spring-boot-rest-api-standards`](../spring-boot-rest-api-standards/SKILL.md)
- [`skills/spring-boot/spring-boot-test-patterns`](../spring-boot-test-patterns/SKILL.md)
- [`skills/junit-test/unit-test-caching`](../../junit-test/unit-test-caching/SKILL.md)

View File

@@ -0,0 +1,579 @@
# Spring Boot Cache Abstraction - References
Complete API reference and external resources for Spring Boot caching.
## Spring Cache Abstraction API Reference
### Core Interfaces
#### CacheManager
Interface for managing cache instances.
```java
public interface CacheManager {
// Get a cache by name
Cache getCache(String name);
// Get all available cache names
Collection<String> getCacheNames();
}
```
**Common Implementations:**
- `ConcurrentMapCacheManager` - In-memory, thread-safe caching
- `SimpleCacheManager` - Simple static cache configuration
- `CaffeineCacheManager` - High-performance caching with Caffeine library
- `EhCacheManager` - Enterprise caching with EhCache
- `RedisCacheManager` - Distributed caching with Redis
#### Cache
Interface representing a single cache.
```java
public interface Cache {
// Get cache name
String getName();
// Get native cache implementation
Object getNativeCache();
// Get value by key
ValueWrapper get(Object key);
// Put value in cache
void put(Object key, Object value);
// Remove entry from cache
void evict(Object key);
// Clear entire cache
void clear();
}
```
### Cache Annotations
| Annotation | Purpose | Target | Parameters |
|-----------|---------|--------|-----------|
| `@Cacheable` | Cache method result before execution | Methods | `value`, `key`, `condition`, `unless` |
| `@CachePut` | Always execute, then cache result | Methods | `value`, `key`, `condition`, `unless` |
| `@CacheEvict` | Remove entry/entries from cache | Methods | `value`, `key`, `allEntries`, `condition`, `beforeInvocation` |
| `@Caching` | Combine multiple cache operations | Methods | `cacheable`, `put`, `evict` |
| `@CacheConfig` | Class-level cache configuration | Classes | `cacheNames` |
| `@EnableCaching` | Enable caching support | Configuration classes | None |
### Annotation Parameters
#### value / cacheNames
Name(s) of the cache(s) to use.
```java
@Cacheable(value = "products") // Single cache
@Cacheable(value = {"products", "inventory"}) // Multiple caches
```
#### key
SpEL expression to generate cache key (if not using method parameters as key).
```java
@Cacheable(value = "products", key = "#id")
@Cacheable(value = "products", key = "#p0") // First parameter
@Cacheable(value = "products", key = "#root.methodName + #id")
@Cacheable(value = "products", key = "T(java.util.Objects).hash(#id, #name)")
```
**SpEL Context Variables:**
- `#root.methodName` - Method name being invoked
- `#root.method` - Method object
- `#root.target` - Target object
- `#root.targetClass` - Target class
- `#root.args[0]` - Method arguments array
- `#a0`, `#p0` - First argument
- `#result` - Method result (only in @CachePut, @CacheEvict)
#### condition
SpEL expression evaluated before cache operation. Operation only executes if true.
```java
@Cacheable(value = "products", condition = "#id > 0")
@Cacheable(value = "products", condition = "#price > 100 && #active == true")
@Cacheable(value = "products", condition = "#size() > 0") // For collections
```
#### unless
SpEL expression evaluated AFTER method execution. Entry is cached only if false.
```java
@Cacheable(value = "products", unless = "#result == null")
@CachePut(value = "products", unless = "#result.isPrivate()")
```
#### beforeInvocation
For @CacheEvict only. If true, cache is evicted BEFORE method execution (default: false).
```java
@CacheEvict(value = "products", beforeInvocation = true) // Evict before call
@CacheEvict(value = "products", beforeInvocation = false) // Evict after call
```
#### allEntries
For @CacheEvict only. If true, entire cache is cleared instead of single entry.
```java
@CacheEvict(value = "products", allEntries = true) // Clear all entries
```
## Configuration Reference
### Maven Dependencies
```xml
<!-- Spring Cache Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
<version>3.5.6</version>
</dependency>
<!-- Caffeine (Optional, for advanced caching) -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.6</version>
</dependency>
<!-- EhCache (Optional, for distributed caching) -->
<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
<version>1.1.1</version>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.10.8</version>
<classifier>jakarta</classifier>
</dependency>
<!-- Redis (Optional, for distributed caching) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>3.5.6</version>
</dependency>
```
### Gradle Dependencies
```gradle
dependencies {
// Spring Cache Starter
implementation 'org.springframework.boot:spring-boot-starter-cache:3.5.6'
// Caffeine
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.6'
// EhCache
implementation 'javax.cache:cache-api:1.1.1'
implementation 'org.ehcache:ehcache:3.10.8'
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis:3.5.6'
}
```
### Application Properties (application.properties)
```properties
# General Caching Configuration
spring.cache.type=simple # Type: simple, redis, caffeine, ehcache, jcache
# Caffeine Configuration
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=10m
spring.cache.cache-names=products,users,orders
# Redis Configuration
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.password=
spring.cache.redis.time-to-live=600000 # 10 minutes in ms
# EhCache Configuration
spring.cache.jcache.config=classpath:ehcache.xml
```
### Application Properties (application.yml)
```yaml
spring:
cache:
type: simple
cache-names:
- products
- users
- orders
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m
redis:
time-to-live: 600000 # 10 minutes in ms
jcache:
config: classpath:ehcache.xml
```
## Performance Tuning Reference
### Cache Types Comparison
| Type | Use Case | Memory | Thread-Safe | Distributed |
|------|----------|--------|------------|-------------|
| Simple | Local, small data | Low | Yes | No |
| Caffeine | High-performance local | Medium | Yes | No |
| EhCache | Enterprise local | High | Yes | Optional |
| Redis | Distributed, large | External | Yes | Yes |
### Performance Tips
**1. Key Generation Strategy:**
```java
// Fast (uses method parameters directly)
@Cacheable(value = "products") // Uses all parameters as key
@Cacheable(value = "products", key = "#id") // Specific parameter
// Slower (computed SpEL)
@Cacheable(value = "products", key = "T(java.util.Objects).hash(#id, #name)")
```
**2. Cache Size Tuning:**
```properties
# Caffeine: Set appropriate maximumSize
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=15m
# Redis: Monitor memory usage
# MEMORY STATS command in Redis CLI
```
**3. TTL Configuration:**
```properties
# Redis: TTL in milliseconds
spring.cache.redis.time-to-live=600000 # 10 minutes
# Caffeine: In spec
spring.cache.caffeine.spec=expireAfterWrite=10m
```
## Spring Boot Auto-Configuration
### Auto-Detected Cache Managers
Spring Boot auto-configures a CacheManager based on classpath presence (in priority order):
1. **Redis** - if `spring-boot-starter-data-redis` is present
2. **Caffeine** - if `caffeine` library is present
3. **EhCache** - if `ehcache` library is present
4. **Simple** - default in-memory caching
To explicitly set the cache type:
```properties
spring.cache.type=redis
```
### Conditional Bean Creation
```java
@Bean
@ConditionalOnMissingBean(CacheManager.class)
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("products", "users");
}
```
## Transaction Integration
### Cache + @Transactional Interaction
```java
@Service
@Transactional
public class ProductService {
@Cacheable(value = "products", key = "#id")
@Transactional(readOnly = true) // Combines with cache
public Product getProduct(Long id) {
return productRepository.findById(id).orElse(null);
}
@CachePut(value = "products", key = "#product.id")
@Transactional // Ensure atomicity of save + cache update
public Product updateProduct(Product product) {
return productRepository.save(product);
}
@CacheEvict(value = "products", key = "#id")
@Transactional
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
}
```
## Monitoring and Metrics
### Spring Boot Actuator Integration
```properties
# Enable caching metrics
management.endpoints.web.exposure.include=metrics,health
# View cache metrics
GET http://localhost:8080/actuator/metrics
GET http://localhost:8080/actuator/metrics/cache.hits
GET http://localhost:8080/actuator/metrics/cache.misses
```
### Custom Cache Metrics
```java
@Component
public class CacheMetricsCollector {
private final MeterRegistry meterRegistry;
public void recordCacheHit(String cacheName) {
meterRegistry.counter("cache.hits", "cache", cacheName).increment();
}
public void recordCacheMiss(String cacheName) {
meterRegistry.counter("cache.misses", "cache", cacheName).increment();
}
}
```
## EhCache XML Configuration Reference
### ehcache.xml Structure
```xml
<?xml version="1.0" encoding="UTF-8"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://www.ehcache.org/v3"
xmlns:jsr107="http://www.ehcache.org/v3/jsr107"
xsi:schemaLocation="
http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.0.xsd
http://www.ehcache.org/v3/jsr107 http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd">
<!-- Cache Configuration -->
<cache alias="cacheName">
<key-type>java.lang.Long</key-type>
<value-type>com.example.Product</value-type>
<!-- Time to Live -->
<expiry>
<ttl unit="minutes">30</ttl>
</expiry>
<!-- Storage Configuration -->
<resources>
<heap unit="entries">1000</heap>
<offheap unit="MB">50</offheap>
<disk unit="GB">1</disk>
</resources>
<!-- Listeners (optional) -->
<listeners>
<listener>
<class>com.example.CustomCacheEventListener</class>
<event-firing-mode>ASYNCHRONOUS</event-firing-mode>
<events-to-fire-on>CREATED</events-to-fire-on>
<events-to-fire-on>EXPIRED</events-to-fire-on>
</listener>
</listeners>
</cache>
</config>
```
### Common EhCache Attributes
- `heap` - On-heap memory storage (fast, limited)
- `offheap` - Off-heap memory storage (slower, larger)
- `disk` - Disk storage (slowest, unlimited)
- `ttl` - Time to live before expiration
- `idle` - Time to idle before expiration (if not accessed)
## Common Pitfalls and Solutions
### Problem 1: Cache Not Working
**Symptoms:** Cache is never hit, always querying database.
**Causes & Solutions:**
```java
// Problem: @Cacheable on public method called from same bean
@Service
public class ProductService {
@Cacheable("products")
public Product get(Long id) { }
public Product getDetails(Long id) {
return this.get(id); // ❌ Won't use cache (no proxy)
}
}
// Solution: Inject service or call through interface
@Service
public class DetailsService {
@Autowired
private ProductService productService;
public Product getDetails(Long id) {
return productService.get(id); // ✅ Uses cache
}
}
// Problem: Caching non-serializable objects with Redis
@Cacheable("products")
public Product get(Long id) {
Product p = new Product();
p.setConnection(dbConnection); // ❌ Not serializable
return p;
}
// Solution: Ensure all cached objects are serializable
@Cacheable("products")
public ProductDTO get(Long id) {
return mapper.toDTO(productRepository.findById(id)); // ✅ DTO is serializable
}
```
### Problem 2: Stale Cache Data
**Symptoms:** Updates aren't reflected in cached data.
**Solution:**
```java
// Always evict cache on update
@CacheEvict(value = "products", key = "#id")
public void updateProduct(Long id, UpdateRequest req) {
Product product = productRepository.findById(id).orElseThrow();
product.update(req);
productRepository.save(product);
}
// Or use @CachePut to keep cache fresh
@CachePut(value = "products", key = "#result.id")
public Product updateProduct(Long id, UpdateRequest req) {
Product product = productRepository.findById(id).orElseThrow();
product.update(req);
return productRepository.save(product);
}
```
### Problem 3: Memory Leak
**Symptoms:** Memory usage grows unbounded.
**Solution:**
```properties
# Configure cache eviction policies
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=10m
# Redis: Set TTL
spring.cache.redis.time-to-live=600000
# Monitor cache size
```
## External Resources
### Official Documentation
- [Spring Cache Abstraction](https://docs.spring.io/spring-framework/reference/integration/cache.html)
- [Spring Boot Caching Documentation](https://docs.spring.io/spring-boot/docs/current/reference/html/io.html#io.caching)
- [Spring Framework Caching Guide](https://spring.io/guides/gs/caching/)
### Third-Party Libraries
- [Caffeine Cache](https://github.com/ben-manes/caffeine/wiki)
- [EhCache Documentation](https://www.ehcache.org/documentation/3.10/)
- [Redis Documentation](https://redis.io/documentation)
### Related Skills
- **spring-boot-performance-tuning** - Comprehensive performance optimization
- **spring-boot-data-persistence** - Database optimization patterns
- **spring-boot-rest-api-standards** - API design with caching headers
### Useful Articles
- [Spring Cache Abstraction Tutorial](https://www.baeldung.com/spring-cache-tutorial)
- [Redis Caching in Spring Boot](https://www.baeldung.com/spring-boot-redis)
- [Cache Stampede Problem](https://en.wikipedia.org/wiki/Cache_stampede)
- [Cache Invalidation Strategies](https://martinfowler.com/bliki/TwoHardThings.html)
## SpEL Reference for Cache Keys
### Basic Expressions
```java
// Method parameters
@Cacheable(key = "#id") // Single parameter
@Cacheable(key = "#user.id") // Object property
@Cacheable(key = "#root.args[0]") // First argument
// Composite keys
@Cacheable(key = "#id + '-' + #type")
@Cacheable(key = "T(java.util.Objects).hash(#id, #type)")
// Collections
@Cacheable(key = "#ids.toString()")
@Cacheable(condition = "#ids.size() > 0")
```
### SpEL Context Variables
| Variable | Description |
|----------|-------------|
| `#root.method` | Method object |
| `#root.methodName` | Method name |
| `#root.target` | Target object |
| `#root.targetClass` | Target class |
| `#root.args` | Arguments array |
| `#p<index>` | Argument at index |
| `#<name>` | Named argument |
| `#result` | Method result (@CachePut, @CacheEvict) |
## Testing Reference
### Testing Cache Behavior
```java
@Test
void shouldCacheResult() {
// Arrange
when(repository.find(1L)).thenReturn(mockObject);
// Act - First call
service.get(1L);
// Assert - Database was queried
verify(repository, times(1)).find(1L);
// Act - Second call
service.get(1L);
// Assert - Database NOT queried again (cache hit)
verify(repository, times(1)).find(1L);
}
```
### Disabling Cache in Tests
```java
@SpringBootTest
@PropertySource("classpath:application-test.properties")
class MyServiceTest {
// In application-test.properties:
// spring.cache.type=none
}
```

View File

@@ -0,0 +1,617 @@
# Spring Boot Cache Abstraction - Examples
This document provides concrete, progressive examples demonstrating Spring Boot caching patterns from basic to advanced scenarios.
## Example 1: Basic Product Caching
A simple e-commerce scenario with product lookup caching.
### Domain Model
```java
@Getter
@ToString
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Product {
private Long id;
private String name;
private BigDecimal price;
private Integer stock;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
```
### Service with @Cacheable
```java
@Service
@CacheConfig(cacheNames = "products")
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private final ProductRepository productRepository;
@Cacheable
public Product getProductById(Long id) {
log.info("Fetching product {} from database", id);
return productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
}
@Cacheable(key = "#name")
public Product getProductByName(String name) {
log.info("Fetching product by name: {}", name);
return productRepository.findByName(name)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
}
@CachePut(key = "#product.id")
public Product updateProduct(Product product) {
log.info("Updating product {}", product.getId());
return productRepository.save(product);
}
@CacheEvict
public void deleteProduct(Long id) {
log.info("Deleting product {}", id);
productRepository.deleteById(id);
}
@CacheEvict(allEntries = true)
public void refreshAllProducts() {
log.info("Refreshing all product cache");
}
}
```
### Test Example
```java
@SpringBootTest
@Testcontainers
class ProductServiceCacheTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
@Autowired
private ProductService productService;
@SpyBean
private ProductRepository productRepository;
@Test
void shouldCacheProductAfterFirstCall() {
// Given
Product product = Product.builder()
.id(1L)
.name("Laptop")
.price(BigDecimal.valueOf(999.99))
.stock(10)
.build();
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
// When - First call
Product result1 = productService.getProductById(1L);
// Then - Verify database was called
verify(productRepository, times(1)).findById(1L);
assertThat(result1).isEqualTo(product);
// When - Second call (should hit cache)
Product result2 = productService.getProductById(1L);
// Then - Database not called again
verify(productRepository, times(1)).findById(1L); // Still 1x
assertThat(result2).isEqualTo(result1);
}
@Test
void shouldEvictCacheOnDelete() {
// Given
Product product = Product.builder()
.id(1L)
.name("Laptop")
.price(BigDecimal.valueOf(999.99))
.build();
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
// Populate cache
productService.getProductById(1L);
verify(productRepository, times(1)).findById(1L);
// When - Delete (evicts cache)
productService.deleteProduct(1L);
// Then - Next call should query database again
when(productRepository.findById(1L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> productService.getProductById(1L))
.isInstanceOf(ResourceNotFoundException.class);
verify(productRepository, times(2)).findById(1L);
}
}
```
---
## Example 2: Conditional Caching with Business Logic
Cache products only under specific conditions (e.g., only expensive items).
```java
@Service
@RequiredArgsConstructor
@Slf4j
public class PremiumProductService {
private final ProductRepository productRepository;
@Cacheable(
value = "premiumProducts",
condition = "#price > 500", // Cache only items over 500
unless = "#result == null"
)
public Product getPremiumProduct(Long id, BigDecimal price) {
log.info("Fetching premium product {} (price: {})", id, price);
return productRepository.findById(id)
.orElse(null);
}
@CachePut(
value = "discountedProducts",
key = "#product.id",
condition = "#product.price < 50" // Cache only discounted items
)
public Product updateDiscountedProduct(Product product) {
log.info("Updating discounted product {}", product.getId());
return productRepository.save(product);
}
}
```
**Test:**
```java
@Test
void shouldCachePremiumProductsOnly() {
// Given - Cheap product
Product cheapProduct = Product.builder()
.id(1L)
.name("Budget Item")
.price(BigDecimal.valueOf(29.99))
.build();
// When - Call with cheap price (won't cache due to condition)
Product result = premiumProductService.getPremiumProduct(1L, BigDecimal.valueOf(29.99));
// Then - Result should be cached (condition false, so not cached)
verify(productRepository, times(1)).findById(1L);
// Second call should hit DB again
premiumProductService.getPremiumProduct(1L, BigDecimal.valueOf(29.99));
verify(productRepository, times(2)).findById(1L);
}
```
---
## Example 3: Multiple Caches and @Caching
Handle complex scenarios with multiple cache operations.
```java
@Service
@RequiredArgsConstructor
@Slf4j
public class InventoryService {
private final ProductRepository productRepository;
@Caching(
cacheable = @Cacheable("inventoryCache"),
put = {
@CachePut(value = "stockCache", key = "#id"),
@CachePut(value = "priceCache", key = "#id")
}
)
public Product getInventoryDetails(Long id) {
log.info("Fetching inventory details for {}", id);
return productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
}
@Caching(
evict = {
@CacheEvict("inventoryCache"),
@CacheEvict("stockCache"),
@CacheEvict("priceCache")
}
)
public void reloadInventory(Long id) {
log.info("Reloading inventory for {}", id);
// Trigger inventory sync from external system
}
}
```
---
## Example 4: Programmatic Cache Management
Manually managing caches for advanced scenarios.
```java
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheManagementService {
private final CacheManager cacheManager;
public void evictProductCache(Long productId) {
Cache cache = cacheManager.getCache("products");
if (cache != null) {
cache.evict(productId);
log.info("Evicted product {} from cache", productId);
}
}
public void clearAllCaches() {
cacheManager.getCacheNames().forEach(cacheName -> {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.clear();
log.info("Cleared cache: {}", cacheName);
}
});
}
public <T> T getOrCompute(String cacheName, Object key, Callable<T> valueLoader) {
Cache cache = cacheManager.getCache(cacheName);
if (cache == null) {
log.warn("Cache {} not found", cacheName);
return null;
}
Cache.ValueWrapper wrapper = cache.get(key);
if (wrapper != null) {
return (T) wrapper.get();
}
try {
T value = valueLoader.call();
cache.put(key, value);
return value;
} catch (Exception e) {
log.error("Error computing cache value", e);
throw new RuntimeException(e);
}
}
}
```
---
## Example 5: Cache Warming/Preloading
Populate cache with frequently accessed data at startup.
```java
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheWarmupService implements InitializingBean {
private final ProductService productService;
private final ProductRepository productRepository;
@Override
public void afterPropertiesSet() {
warmupCache();
}
private void warmupCache() {
log.info("Warming up product cache...");
// Load top 100 products
List<Product> topProducts = productRepository.findTop100ByOrderByPopularityDesc();
topProducts.forEach(product -> {
try {
productService.getProductById(product.getId());
} catch (Exception e) {
log.warn("Failed to warm cache for product {}", product.getId(), e);
}
});
log.info("Cache warmup completed. {} products cached", topProducts.size());
}
}
```
---
## Example 6: Cache Statistics and Monitoring
Track cache performance metrics.
```java
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheStatsService {
private final CacheManager cacheManager;
@Scheduled(fixedRate = 60000) // Every minute
public void logCacheStats() {
cacheManager.getCacheNames().forEach(cacheName -> {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null && cache.getNativeCache() instanceof ConcurrentMapCache) {
ConcurrentMapCache concreteCache = (ConcurrentMapCache) cache.getNativeCache();
log.info("Cache [{}] - Size: {}", cacheName, concreteCache.getStore().size());
}
});
}
@GetMapping("/cache/stats")
public ResponseEntity<Map<String, CacheStats>> getCacheStatistics() {
Map<String, CacheStats> stats = new HashMap<>();
cacheManager.getCacheNames().forEach(cacheName -> {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
CacheStats cacheStats = new CacheStats(
cacheName,
getCacheSize(cache),
LocalDateTime.now()
);
stats.put(cacheName, cacheStats);
}
});
return ResponseEntity.ok(stats);
}
private int getCacheSize(Cache cache) {
if (cache.getNativeCache() instanceof ConcurrentMap) {
return ((ConcurrentMap<?, ?>) cache.getNativeCache()).size();
}
return 0;
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
class CacheStats {
private String cacheName;
private int size;
private LocalDateTime timestamp;
}
```
---
## Example 7: TTL-Based Cache with Scheduled Eviction
Expire cache entries after a specific time.
```java
@Configuration
@EnableCaching
@EnableScheduling
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("products", "users", "orders");
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheExpirationService {
private final CacheManager cacheManager;
private final Map<String, LocalDateTime> cacheExpirations = new ConcurrentHashMap<>();
public void setExpiration(String cacheName, Object key, Duration duration) {
String expirationKey = cacheName + ":" + key;
cacheExpirations.put(expirationKey, LocalDateTime.now().plus(duration));
log.info("Set cache expiration for {} after {}", expirationKey, duration);
}
@Scheduled(fixedRate = 5000) // Check every 5 seconds
public void evictExpiredEntries() {
LocalDateTime now = LocalDateTime.now();
cacheExpirations.entrySet()
.removeIf(entry -> {
if (now.isAfter(entry.getValue())) {
String[] parts = entry.getKey().split(":");
String cacheName = parts[0];
String key = parts[1];
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.evict(key);
log.info("Evicted expired cache entry: {}", entry.getKey());
}
return true;
}
return false;
});
}
}
```
---
## Example 8: Cache Invalidation Pattern with Events
Use domain events to invalidate cache across services.
```java
public class ProductUpdatedEvent extends ApplicationEvent {
private final Long productId;
private final String changeType; // UPDATED, DELETED, CREATED
public ProductUpdatedEvent(Object source, Long productId, String changeType) {
super(source);
this.productId = productId;
this.changeType = changeType;
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;
public Product updateProduct(Long id, UpdateProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
product.setName(request.getName());
product.setPrice(request.getPrice());
Product updated = productRepository.save(product);
// Publish event to invalidate cache
eventPublisher.publishEvent(new ProductUpdatedEvent(this, id, "UPDATED"));
return updated;
}
}
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheInvalidationListener {
private final CacheManager cacheManager;
@EventListener
public void onProductUpdated(ProductUpdatedEvent event) {
log.info("Invalidating cache for product {}", event.getProductId());
Cache productsCache = cacheManager.getCache("products");
if (productsCache != null) {
productsCache.evict(event.getProductId());
}
Cache productsListCache = cacheManager.getCache("productsList");
if (productsListCache != null) {
productsListCache.clear();
}
}
}
```
---
## Example 9: Distributed Caching with Caffeine
Using Caffeine for local caching with advanced features.
```java
@Configuration
@EnableCaching
public class CaffeineCacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("products", "users");
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats());
return cacheManager;
}
}
@Component
@RequiredArgsConstructor
public class CacheMetricsService {
private final CacheManager cacheManager;
@GetMapping("/cache/metrics")
public ResponseEntity<Map<String, Object>> getCacheMetrics() {
Map<String, Object> metrics = new HashMap<>();
cacheManager.getCacheNames().forEach(cacheName -> {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null && cache.getNativeCache() instanceof com.github.benmanes.caffeine.cache.Cache) {
com.github.benmanes.caffeine.cache.Cache<?, ?> caffeineCache =
(com.github.benmanes.caffeine.cache.Cache<?, ?>) cache.getNativeCache();
com.github.benmanes.caffeine.cache.stats.CacheStats stats = caffeineCache.stats();
metrics.put(cacheName, Map.of(
"hitCount", stats.hitCount(),
"missCount", stats.missCount(),
"hitRate", stats.hitRate(),
"size", caffeineCache.estimatedSize()
));
}
});
return ResponseEntity.ok(metrics);
}
}
```
---
## Example 10: Testing Cache-Related Scenarios
```java
@SpringBootTest
class CacheIntegrationTest {
@Autowired
private ProductService productService;
@Autowired
private CacheManager cacheManager;
@MockBean
private ProductRepository productRepository;
@Test
void shouldDemonstrateCachingLifecycle() {
// Given
Product product = Product.builder()
.id(1L)
.name("Test Product")
.price(BigDecimal.TEN)
.build();
when(productRepository.findById(1L)).thenReturn(Optional.of(product));
// Verify cache is empty
Cache cache = cacheManager.getCache("products");
assertThat(cache.get(1L)).isNull();
// First call - populates cache
Product result1 = productService.getProductById(1L);
verify(productRepository, times(1)).findById(1L);
// Cache is now populated
assertThat(cache.get(1L)).isNotNull();
// Second call - uses cache
Product result2 = productService.getProductById(1L);
verify(productRepository, times(1)).findById(1L); // Still 1x
assertThat(result1).isEqualTo(result2);
// Manual eviction
cache.evict(1L);
assertThat(cache.get(1L)).isNull();
// Next call queries database again
Product result3 = productService.getProductById(1L);
verify(productRepository, times(2)).findById(1L);
}
}
```

View File

@@ -0,0 +1,381 @@
The Spring Framework provides support for transparently adding caching
to an application. At its core, the abstraction applies caching to
methods, thus reducing the number of executions based on the information
available in the cache. The caching logic is applied transparently,
without any interference to the invoker. Spring Boot auto-configures the
cache infrastructure as long as caching support is enabled by using the
`org.springframework.cache.annotation.EnableCaching`[format=annotation]
annotation.
> [!NOTE]
> Check the {url-spring-framework-docs}/integration/cache.html[relevant
> section] of the Spring Framework reference for more details.
In a nutshell, to add caching to an operation of your service add the
relevant annotation to its method, as shown in the following example:
include-code::MyMathService[]
This example demonstrates the use of caching on a potentially costly
operation. Before invoking `computePiDecimal`, the abstraction looks for
an entry in the `piDecimals` cache that matches the `precision`
argument. If an entry is found, the content in the cache is immediately
returned to the caller, and the method is not invoked. Otherwise, the
method is invoked, and the cache is updated before returning the value.
> [!CAUTION]
> You can also use the standard JSR-107 (JCache) annotations (such as
> `javax.cache.annotation.CacheResult`[format=annotation])
> transparently. However, we strongly advise you to not mix and match
> the Spring Cache and JCache annotations.
If you do not add any specific cache library, Spring Boot
auto-configures a [simple
provider](io/caching.xml#io.caching.provider.simple) that uses
concurrent maps in memory. When a cache is required (such as
`piDecimals` in the preceding example), this provider creates it for
you. The simple provider is not really recommended for production usage,
but it is great for getting started and making sure that you understand
the features. When you have made up your mind about the cache provider
to use, please make sure to read its documentation to figure out how to
configure the caches that your application uses. Nearly all providers
require you to explicitly configure every cache that you use in the
application. Some offer a way to customize the default caches defined by
the configprop:spring.cache.cache-names[] property.
> [!TIP]
> It is also possible to transparently
> {url-spring-framework-docs}/integration/cache/annotations.html#cache-annotations-put[update]
> or
> {url-spring-framework-docs}/integration/cache/annotations.html#cache-annotations-evict[evict]
> data from the cache.
# Supported Cache Providers
The cache abstraction does not provide an actual store and relies on
abstraction materialized by the
`org.springframework.cache.Cache[] and
`org.springframework.cache.CacheManager[] interfaces.
If you have not defined a bean of type
`org.springframework.cache.CacheManager[] or a
`org.springframework.cache.interceptor.CacheResolver[] named
`cacheResolver` (see
`org.springframework.cache.annotation.CachingConfigurer[]),
Spring Boot tries to detect the following providers (in the indicated
order):
1. [io/caching.xml](io/caching.xml#io.caching.provider.generic)
2. [io/caching.xml](io/caching.xml#io.caching.provider.jcache) (EhCache
3, Hazelcast, Infinispan, and others)
3. [io/caching.xml](io/caching.xml#io.caching.provider.hazelcast)
4. [io/caching.xml](io/caching.xml#io.caching.provider.infinispan)
5. [io/caching.xml](io/caching.xml#io.caching.provider.couchbase)
6. [io/caching.xml](io/caching.xml#io.caching.provider.redis)
7. [io/caching.xml](io/caching.xml#io.caching.provider.caffeine)
8. [io/caching.xml](io/caching.xml#io.caching.provider.cache2k)
9. [io/caching.xml](io/caching.xml#io.caching.provider.simple)
Additionally, {url-spring-boot-for-apache-geode-site}[Spring Boot for
Apache Geode] provides
{url-spring-boot-for-apache-geode-docs}#geode-caching-provider[auto-configuration
for using Apache Geode as a cache provider].
> [!TIP]
> If the `org.springframework.cache.CacheManager[] is
> auto-configured by Spring Boot, it is possible to *force* a particular
> cache provider by setting the configprop:spring.cache.type[]
> property. Use this property if you need to [use no-op
> caches](io/caching.xml#io.caching.provider.none) in certain
> environments (such as tests).
> [!TIP]
> Use the `spring-boot-starter-cache` starter to quickly add basic
> caching dependencies. The starter brings in `spring-context-support`.
> If you add dependencies manually, you must include
> `spring-context-support` in order to use the JCache or Caffeine
> support.
If the `org.springframework.cache.CacheManager[] is
auto-configured by Spring Boot, you can further tune its configuration
before it is fully initialized by exposing a bean that implements the
`org.springframework.boot.autoconfigure.cache.CacheManagerCustomizer[]
interface. The following example sets a flag to say that `null` values
should not be passed down to the underlying map:
include-code::MyCacheManagerConfiguration[]
> [!NOTE]
> In the preceding example, an auto-configured
> `org.springframework.cache.concurrent.ConcurrentMapCacheManager[]
> is expected. If that is not the case (either you provided your own
> config or a different cache provider was auto-configured), the
> customizer is not invoked at all. You can have as many customizers as
> you want, and you can also order them by using
> `org.springframework.core.annotation.Order`[format=annotation]
> or `org.springframework.core.Ordered[].
## Generic
Generic caching is used if the context defines *at least* one
`org.springframework.cache.Cache[] bean. A
`org.springframework.cache.CacheManager[] wrapping all beans of
that type is created.
## JCache (JSR-107)
[JCache](https://jcp.org/en/jsr/detail?id=107) is bootstrapped through
the presence of a `javax.cache.spi.CachingProvider[] on the
classpath (that is, a JSR-107 compliant caching library exists on the
classpath), and the
`org.springframework.cache.jcache.JCacheCacheManager[] is
provided by the `spring-boot-starter-cache` starter. Various compliant
libraries are available, and Spring Boot provides dependency management
for Ehcache 3, Hazelcast, and Infinispan. Any other compliant library
can be added as well.
It might happen that more than one provider is present, in which case
the provider must be explicitly specified. Even if the JSR-107 standard
does not enforce a standardized way to define the location of the
configuration file, Spring Boot does its best to accommodate setting a
cache with implementation details, as shown in the following example:
# Only necessary if more than one provider is present
spring:
cache:
jcache:
provider: "com.example.MyCachingProvider"
config: "classpath:example.xml"
> [!NOTE]
> When a cache library offers both a native implementation and JSR-107
> support, Spring Boot prefers the JSR-107 support, so that the same
> features are available if you switch to a different JSR-107
> implementation.
> [!TIP]
> Spring Boot has [general support for Hazelcast](io/hazelcast.xml). If
> a single `com.hazelcast.core.HazelcastInstance[] is
> available, it is automatically reused for the
> `javax.cache.CacheManager[] as well, unless the
> configprop:spring.cache.jcache.config[] property is specified.
There are two ways to customize the underlying
`javax.cache.CacheManager[]:
- Caches can be created on startup by setting the
configprop:spring.cache.cache-names[] property. If a custom
`javax.cache.configuration.Configuration[] bean is defined,
it is used to customize them.
- `org.springframework.boot.autoconfigure.cache.JCacheManagerCustomizer[]
beans are invoked with the reference of the
`javax.cache.CacheManager[] for full customization.
> [!TIP]
> If a standard `javax.cache.CacheManager[] bean is defined, it
> is wrapped automatically in an
> `org.springframework.cache.CacheManager[] implementation that
> the abstraction expects. No further customization is applied to it.
## Hazelcast
Spring Boot has [general support for Hazelcast](io/hazelcast.xml). If a
`com.hazelcast.core.HazelcastInstance[] has been
auto-configured and `com.hazelcast:hazelcast-spring` is on the
classpath, it is automatically wrapped in a
`org.springframework.cache.CacheManager[].
> [!NOTE]
> Hazelcast can be used as a JCache compliant cache or as a Spring
> `org.springframework.cache.CacheManager[] compliant cache.
> When setting configprop:spring.cache.type[] to `hazelcast`, Spring
> Boot will use the `org.springframework.cache.CacheManager[]
> based implementation. If you want to use Hazelcast as a JCache
> compliant cache, set configprop:spring.cache.type[] to `jcache`. If
> you have multiple JCache compliant cache providers and want to force
> the use of Hazelcast, you have to [explicitly set the JCache
> provider](io/caching.xml#io.caching.provider.jcache).
## Infinispan
[Infinispan](https://infinispan.org/) has no default configuration file
location, so it must be specified explicitly. Otherwise, the default
bootstrap is used.
spring:
cache:
infinispan:
config: "infinispan.xml"
Caches can be created on startup by setting the
configprop:spring.cache.cache-names[] property. If a custom
`org.infinispan.configuration.cache.ConfigurationBuilder[] bean
is defined, it is used to customize the caches.
To be compatible with Spring Boots Jakarta EE 9 baseline, Infinispans
`-jakarta` modules must be used. For every module with a `-jakarta`
variant, the variant must be used in place of the standard module. For
example, `infinispan-core-jakarta` and `infinispan-commons-jakarta` must
be used in place of `infinispan-core` and `infinispan-commons`
respectively.
## Couchbase
If Spring Data Couchbase is available and Couchbase is
[configured](data/nosql.xml#data.nosql.couchbase), a
`org.springframework.data.couchbase.cache.CouchbaseCacheManager[]
is auto-configured. It is possible to create additional caches on
startup by setting the configprop:spring.cache.cache-names[] property
and cache defaults can be configured by using `spring.cache.couchbase.*`
properties. For instance, the following configuration creates `cache1`
and `cache2` caches with an entry *expiration* of 10 minutes:
spring:
cache:
cache-names: "cache1,cache2"
couchbase:
expiration: "10m"
If you need more control over the configuration, consider registering a
`org.springframework.boot.autoconfigure.cache.CouchbaseCacheManagerBuilderCustomizer[]
bean. The following example shows a customizer that configures a
specific entry expiration for `cache1` and `cache2`:
include-code::MyCouchbaseCacheManagerConfiguration[]
## Redis
If [Redis](https://redis.io/) is available and configured, a
`org.springframework.data.redis.cache.RedisCacheManager[] is
auto-configured. It is possible to create additional caches on startup
by setting the configprop:spring.cache.cache-names[] property and
cache defaults can be configured by using `spring.cache.redis.*`
properties. For instance, the following configuration creates `cache1`
and `cache2` caches with a *time to live* of 10 minutes:
spring:
cache:
cache-names: "cache1,cache2"
redis:
time-to-live: "10m"
> [!NOTE]
> By default, a key prefix is added so that, if two separate caches use
> the same key, Redis does not have overlapping keys and cannot return
> invalid values. We strongly recommend keeping this setting enabled if
> you create your own
> `org.springframework.data.redis.cache.RedisCacheManager[].
> [!TIP]
> You can take full control of the default configuration by adding a
> `org.springframework.data.redis.cache.RedisCacheConfiguration[]
> `org.springframework.context.annotation.Bean`[format=annotation]
> of your own. This can be useful if you need to customize the default
> serialization strategy.
If you need more control over the configuration, consider registering a
`org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer[]
bean. The following example shows a customizer that configures a
specific time to live for `cache1` and `cache2`:
include-code::MyRedisCacheManagerConfiguration[]
## Caffeine
[Caffeine](https://github.com/ben-manes/caffeine) is a Java 8 rewrite of
Guavas cache that supersedes support for Guava. If Caffeine is present,
a `org.springframework.cache.caffeine.CaffeineCacheManager[]
(provided by the `spring-boot-starter-cache` starter) is
auto-configured. Caches can be created on startup by setting the
configprop:spring.cache.cache-names[] property and can be customized
by one of the following (in the indicated order):
1. A cache spec defined by `spring.cache.caffeine.spec`
2. A `com.github.benmanes.caffeine.cache.CaffeineSpec[] bean
is defined
3. A `com.github.benmanes.caffeine.cache.Caffeine[] bean is
defined
For instance, the following configuration creates `cache1` and `cache2`
caches with a maximum size of 500 and a *time to live* of 10 minutes
spring:
cache:
cache-names: "cache1,cache2"
caffeine:
spec: "maximumSize=500,expireAfterAccess=600s"
If a `com.github.benmanes.caffeine.cache.CacheLoader[] bean is
defined, it is automatically associated to the
`org.springframework.cache.caffeine.CaffeineCacheManager[].
Since the `com.github.benmanes.caffeine.cache.CacheLoader[] is
going to be associated with *all* caches managed by the cache manager,
it must be defined as `CacheLoader<Object, Object>`. The
auto-configuration ignores any other generic type.
## Cache2k
[Cache2k](https://cache2k.org/) is an in-memory cache. If the Cache2k
spring integration is present, a `SpringCache2kCacheManager` is
auto-configured.
Caches can be created on startup by setting the
configprop:spring.cache.cache-names[] property. Cache defaults can be
customized using a
`org.springframework.boot.autoconfigure.cache.Cache2kBuilderCustomizer[]
bean. The following example shows a customizer that configures the
capacity of the cache to 200 entries, with an expiration of 5 minutes:
include-code::MyCache2kDefaultsConfiguration[]
## Simple
If none of the other providers can be found, a simple implementation
using a `java.util.concurrent.ConcurrentHashMap[] as the cache
store is configured. This is the default if no caching library is
present in your application. By default, caches are created as needed,
but you can restrict the list of available caches by setting the
`cache-names` property. For instance, if you want only `cache1` and
`cache2` caches, set the `cache-names` property as follows:
spring:
cache:
cache-names: "cache1,cache2"
If you do so and your application uses a cache not listed, then it fails
at runtime when the cache is needed, but not on startup. This is similar
to the way the "real" cache providers behave if you use an undeclared
cache.
## None
When
`org.springframework.cache.annotation.EnableCaching`[format=annotation]
is present in your configuration, a suitable cache configuration is
expected as well. If you have a custom `
org.springframework.cache.CacheManager`, consider defining it in a
separate
`org.springframework.context.annotation.Configuration`[format=annotation]
class so that you can override it if necessary. None uses a no-op
implementation that is useful in tests, and slice tests use that by
default via
`org.springframework.boot.test.autoconfigure.core.AutoConfigureCache`[format=annotation].
If you need to use a no-op cache rather than the auto-configured cache
manager in a certain environment, set the cache type to `none`, as shown
in the following example:
spring:
cache:
type: "none"

View File

@@ -0,0 +1,116 @@
# Spring Framework Cache Reference (Official)
Curated excerpts from the official Spring Framework reference documentation
covering caching fundamentals and annotation usage. Source pages are from the
[Spring Framework Reference Guide 6.2](https://docs.spring.io/spring-framework/reference/6.2/-SNAPSHOT/integration/cache/).
## Cache Abstraction Overview
- **Purpose**: Transparently wrap expensive service methods and reuse results
resolved from configured cache managers.
- **Enablement**:
```java
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("books");
}
}
```
Source: [/integration/cache/annotations](https://docs.spring.io/spring-framework/reference/6.2/-SNAPSHOT/integration/cache/annotations)
- **Supported return types**: `CompletableFuture`, Reactor `Mono`/`Flux`,
blocking objects, and collections are all cacheable. For async/reactive types,
Spring stores the resolved value and rehydrates it on retrieval.
## Core Annotations
### `@Cacheable`
- Cache the method invocation result using the provided cache name and key.
- Supports conditional caching with `condition` (pre-invocation) and `unless`
(post-invocation, access `#result`).
```java
@Cacheable(cacheNames = "book", condition = "#isbn.length() == 13", unless = "#result.hardback")
public Book findBook(String isbn) { ... }
```
Source: [/integration/cache/annotations](https://docs.spring.io/spring-framework/reference/6.2/-SNAPSHOT/integration/cache/annotations)
### `@CachePut` and `@CacheEvict`
- `@CachePut`: Always run the method and update cache entry with fresh result.
- `@CacheEvict`: Remove entries; use `allEntries = true` or `beforeInvocation`
for pre-call eviction.
```java
@CacheEvict(cacheNames = "books", key = "#isbn", beforeInvocation = true)
public void reset(String isbn) { ... }
```
Source: [/integration/cache/annotations](https://docs.spring.io/spring-framework/reference/6.2/-SNAPSHOT/integration/cache/annotations)
### `@Caching`
- Bundle multiple cache operations on a single method:
```java
@Caching(evict = {
@CacheEvict("primary"),
@CacheEvict(cacheNames = "secondary", key = "#isbn")
})
public Book importBooks(String isbn) { ... }
```
Source: [/integration/cache/annotations](https://docs.spring.io/spring-framework/reference/6.2/-SNAPSHOT/integration/cache/annotations)
## Store Configuration Highlights
- **Caffeine**: Configure `CaffeineCacheManager` to create caches on demand.
```java
@Bean
CacheManager cacheManager() {
return new CaffeineCacheManager();
}
```
Source: [/integration/cache/store-configuration](https://docs.spring.io/spring-framework/reference/6.2/-SNAPSHOT/integration/cache/store-configuration)
- **XML alternative**: Use `<cache:annotation-driven cache-manager="..."/>`
when annotation configuration is not feasible.
```xml
<cache:annotation-driven cache-manager="cacheManager"/>
<bean id="cacheManager" class="org.springframework.cache.caffeine.CaffeineCacheManager"/>
```
Source: [/integration/cache/declarative-xml](https://docs.spring.io/spring-framework/reference/6.2/-SNAPSHOT/integration/cache/declarative-xml)
## Reactive and Async Support
- `@Cacheable` works with asynchronous signatures:
```java
@Cacheable("books")
public Mono<Book> findBook(ISBN isbn) { ... }
```
```java
@Cacheable(cacheNames = "foos", sync = true)
public CompletableFuture<Foo> executeExpensiveOperation(String id) { ... }
```
Source: [/integration/cache/annotations](https://docs.spring.io/spring-framework/reference/6.2/-SNAPSHOT/integration/cache/annotations)
## Additional Resources
- [`spring-cache-doc-snippet.md`](spring-cache-doc-snippet.md): Excerpt of the
narrative caching overview from the Spring documentation.
- Refer to [`cache-core-reference.md`](cache-core-reference.md) for expanded
API reference material and `cache-examples.md` for progressive examples.

View File

@@ -0,0 +1,108 @@
---
name: spring-boot-crud-patterns
description: Provide repeatable CRUD workflows for Spring Boot 3 services with Spring Data JPA and feature-focused architecture; apply when modeling aggregates, repositories, controllers, and DTOs for REST APIs.
allowed-tools: Read, Write, Bash
category: backend
tags: [spring-boot, java, ddd, rest-api, crud, jpa, feature-architecture]
version: 1.1.0
---
# Spring Boot CRUD Patterns
## Overview
Deliver feature-aligned CRUD services that separate domain, application, presentation, and infrastructure layers while preserving Spring Boot 3.5+ conventions. This skill distills the essential workflow and defers detailed code listings to reference files for progressive disclosure.
## When to Use
- Implement REST endpoints for create/read/update/delete workflows backed by Spring Data JPA.
- Refine feature packages following DDD-inspired architecture with aggregates, repositories, and application services.
- Introduce DTO records, request validation, and controller mappings for external clients.
- Diagnose CRUD regressions, repository contracts, or transaction boundaries in existing Spring Boot services.
- Trigger phrases: **"implement Spring CRUD controller"**, **"refine feature-based repository"**, **"map DTOs for JPA aggregate"**, **"add pagination to REST list endpoint"**.
## Prerequisites
- Java 17+ project using Spring Boot 3.5.x (or later) with `spring-boot-starter-web` and `spring-boot-starter-data-jpa`.
- Constructor injection enabled (Lombok `@RequiredArgsConstructor` or explicit constructors).
- Access to a relational database (Testcontainers recommended for integration tests).
- Familiarity with validation (`jakarta.validation`) and error handling (`ResponseStatusException`).
## Quickstart Workflow
1. **Establish Feature Structure**
Create `feature/<name>/` directories for `domain`, `application`, `presentation`, and `infrastructure`.
2. **Model the Aggregate**
Define domain entities and value objects without Spring dependencies; capture invariants in methods such as `create` and `update`.
3. **Expose Domain Ports**
Declare repository interfaces in `domain/repository` describing persistence contracts.
4. **Provide Infrastructure Adapter**
Implement Spring Data adapters in `infrastructure/persistence` that map domain models to JPA entities and delegate to `JpaRepository`.
5. **Implement Application Services**
Create transactional use cases under `application/service` that orchestrate aggregates, repositories, and mapping logic.
6. **Publish REST Controllers**
Map DTO records under `presentation/rest`, expose endpoints with proper status codes, and wire validation annotations.
7. **Validate with Tests**
Run unit tests for domain logic and repository/service tests with Testcontainers for persistence verification.
Consult `references/examples-product-feature.md` for complete code listings that align with each step.
## Implementation Patterns
### Domain Layer
- Define immutable aggregates with factory methods (`Product.create`) to centralize invariants.
- Use value objects (`Money`, `Stock`) to enforce type safety and encapsulate validation.
- Keep domain objects framework-free; avoid `@Entity` annotations in the domain package when using adapters.
### Application Layer
- Wrap use cases in `@Service` classes using constructor injection and `@Transactional`.
- Map requests to domain operations and persist through domain repositories.
- Return response DTOs or records produced by dedicated mappers to decouple domain from transport.
### Infrastructure Layer
- Implement adapters that translate between domain aggregates and JPA entities; prefer MapStruct or manual mappers for clarity.
- Configure repositories with Spring Data interfaces (e.g., `JpaRepository<ProductEntity, String>`) and custom queries for pagination or batch updates.
- Externalize persistence properties (naming strategies, DDL mode) via `application.yml`; see `references/spring-official-docs.md`.
### Presentation Layer
- Structure controllers by feature (`ProductController`) and expose REST paths (`/api/products`).
- Return `ResponseEntity` with appropriate codes: `201 Created` on POST, `200 OK` on GET/PUT/PATCH, `204 No Content` on DELETE.
- Apply `@Valid` on request DTOs and handle errors with `@ControllerAdvice` or `ResponseStatusException`.
## Validation and Observability
- Write unit tests that assert domain invariants and repository contracts; refer to `references/examples-product-feature.md` integration test snippets.
- Use `@DataJpaTest` and Testcontainers to validate persistence mapping, pagination, and batch operations.
- Surface health and metrics through Spring Boot Actuator; monitor CRUD throughput and error rates.
- Log key actions at `info` for lifecycle events (create, update, delete) and use structured logging for audit trails.
## Best Practices
- Favor feature modules with clear boundaries; colocate domain, application, and presentation code per aggregate.
- Keep DTOs immutable via Java records; convert domain types at the service boundary.
- Guard write operations with transactions and optimistic locking where concurrency matters.
- Normalize pagination defaults (page, size, sort) and document query parameters.
- Capture links between commands and events where integration with messaging or auditing is required.
## Constraints and Warnings
- Avoid exposing JPA entities directly in controllers to prevent lazy-loading leaks and serialization issues.
- Do not mix field injection with constructor injection; maintain immutability for easier testing.
- Refrain from embedding business logic in controllers or repository adapters; keep it in domain/application layers.
- Validate input aggressively to prevent constraint violations and produce consistent error payloads.
- Ensure migrations (Liquibase/Flyway) mirror aggregate evolution before deploying schema changes.
## References
- [HTTP method matrix, annotation catalog, DTO patterns.](references/crud-reference.md)
- [Progressive examples from starter to advanced feature implementation.](references/examples-product-feature.md)
- [Excerpts from official Spring guides and Spring Boot reference documentation.](references/spring-official-docs.md)
- [Python generator to scaffold CRUD boilerplate from entity spec.](scripts/generate_crud_boilerplate.py) Usage: `python skills/spring-boot/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py --spec entity.json --package com.example.product --output ./generated`
- Templates required: place .tpl files in `skills/spring-boot/spring-boot-crud-patterns/templates/` or pass `--templates-dir <path>`; no fallback to built-ins. See `templates/README.md`.
- Usage guide: [references/generator-usage.md](references/generator-usage.md)
- Example spec: `skills/spring-boot/spring-boot-crud-patterns/assets/specs/product.json`
- Example with relationships: `skills/spring-boot/spring-boot-crud-patterns/assets/specs/product_with_rel.json`

View File

@@ -0,0 +1,9 @@
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
]
}

View File

@@ -0,0 +1,11 @@
{
"entity": "Order",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "orderNumber", "type": "String" },
{ "name": "total", "type": "BigDecimal" }
],
"relationships": [
{ "type": "ONE_TO_MANY", "name": "items", "target": "OrderItem", "mappedBy": "order" }
]
}

View File

@@ -0,0 +1,5 @@
# CRUD Reference (Quick)
- Status codes: POST 201, GET 200, PUT/PATCH 200, DELETE 204.
- Validation: jakarta.validation annotations on DTOs.
- Repositories: feature-scoped interfaces + Spring Data adapters.

View File

@@ -0,0 +1,12 @@
# Product Feature Examples (Skeleton)
Feature structure:
- domain/model/Product.java
- domain/repository/ProductRepository.java
- infrastructure/persistence/ProductEntity.java
- infrastructure/persistence/ProductJpaRepository.java
- infrastructure/persistence/ProductRepositoryAdapter.java
- application/service/ProductService.java
- presentation/dto/ProductRequest.java
- presentation/dto/ProductResponse.java
- presentation/rest/ProductController.java

View File

@@ -0,0 +1,35 @@
# CRUD Generator Usage
Quick start:
```
python skills/spring-boot/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py \
--spec skills/spring-boot/spring-boot-crud-patterns/assets/specs/product.json \
--package com.example.product \
--output ./generated \
--templates-dir skills/spring-boot/spring-boot-crud-patterns/templates [--lombok]
```
Spec (JSON/YAML):
- entity: PascalCase name (e.g., Product)
- id: { name, type (Long|UUID|...), generated: true|false }
- fields: array of { name, type }
- relationships: optional (currently model as FK ids in fields)
What gets generated:
- REST controller at /v1/{resources} with POST 201 + Location header
- Pageable list endpoint returning PageResponse<T>
- Application mapper (application/mapper/${Entity}Mapper) for DTO↔Domain
- Exception types: ${Entity}NotFoundException, ${Entity}ExistException + ${Entity}ExceptionHandler
- GlobalExceptionHandler with validation + DataIntegrityViolationException→409
DTOs:
- Request excludes id when id.generated=true
- Response always includes id
JPA entity:
- @Id with @GeneratedValue(IDENTITY) for numeric generated ids
Notes:
- Provide all templates in templates/ (see templates/README.md)
- Use --lombok to add Lombok annotations without introducing blank lines between annotations

View File

@@ -0,0 +1,5 @@
# Spring Docs Pointers
- Spring Boot Reference Guide
- Spring Data JPA Reference
- Validation (Jakarta Validation)

View File

@@ -0,0 +1,9 @@
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
]
}

View File

@@ -0,0 +1,14 @@
{
"entity": "Product",
"id": { "name": "id", "type": "Long", "generated": true },
"fields": [
{ "name": "name", "type": "String" },
{ "name": "price", "type": "BigDecimal" },
{ "name": "inStock", "type": "Boolean" }
],
"relationships": [
{ "name": "category", "type": "ONE_TO_ONE", "target": "Category", "joinColumn": "category_id", "optional": true },
{ "name": "reviews", "type": "ONE_TO_MANY", "target": "Review", "mappedBy": "product" },
{ "name": "tags", "type": "MANY_TO_MANY", "target": "Tag", "joinTable": { "name": "product_tag", "joinColumn": "product_id", "inverseJoinColumn": "tag_id" } }
]
}

View File

@@ -0,0 +1,898 @@
#!/usr/bin/env python3
"""
Spring Boot CRUD boilerplate generator
Given an entity spec, scaffold a feature-based CRUD template aligned with the
"Spring Boot CRUD Patterns" skill (domain/application/presentation/infrastructure).
Usage:
python skills/spring-boot/spring-boot-crud-patterns/scripts/generate_crud_boilerplate.py \
--spec entity.json --package com.example.product --output ./generated
Spec format (JSON preferred; YAML supported if PyYAML is installed and file ends with .yml/.yaml):
{
"entity": "Product",
"id": {"name": "id", "type": "Long", "generated": true},
"fields": [
{"name": "name", "type": "String"},
{"name": "price", "type": "BigDecimal"},
{"name": "inStock", "type": "Boolean"}
]
}
Notes:
- Generates a feature folder with domain/application/presentation/infrastructure subpackages
- Uses Java records for DTOs, constructor injection, @Transactional, and standard REST codes
- Keep output as a starting point; adapt to your conventions
"""
import argparse
import json
import os
import re
import sys
from textwrap import dedent
from string import Template
try:
import yaml # type: ignore
_HAS_YAML = True
except Exception:
_HAS_YAML = False
# ------------------------- Helpers -------------------------
JAVA_TYPE_IMPORTS = {
"BigDecimal": "import java.math.BigDecimal;",
"UUID": "import java.util.UUID;",
"LocalDate": "import java.time.LocalDate;",
"LocalDateTime": "import java.time.LocalDateTime;",
}
JPA_IMPORTS = dedent(
"""
import jakarta.persistence.*;
import jakarta.validation.constraints.*;
"""
).strip()
COLLECTION_IMPORTS = "import java.util.Set;"
SPRING_IMPORTS = dedent(
"""
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
"""
).strip()
CONTROLLER_IMPORTS = dedent(
"""
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
"""
).strip()
REPOSITORY_IMPORTS = "import org.springframework.data.jpa.repository.JpaRepository;"
SUPPORTED_SIMPLE_TYPES = {
# primitive/object pairs default to wrapper types for null-safety in DTOs
"String": "String",
"Long": "Long",
"Integer": "Integer",
"Boolean": "Boolean",
"BigDecimal": "BigDecimal",
"UUID": "UUID",
"LocalDate": "LocalDate",
"LocalDateTime": "LocalDateTime",
}
def load_spec(spec_path: str) -> dict:
with open(spec_path, "r", encoding="utf-8") as f:
text = f.read()
if spec_path.endswith('.yml') or spec_path.endswith('.yaml'):
if not _HAS_YAML:
raise SystemExit("PyYAML not installed. Install with `pip install pyyaml` or provide JSON spec.")
return yaml.safe_load(text)
return json.loads(text)
def camel_to_snake(name: str) -> str:
import re as _re
s1 = _re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
return _re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
def lower_first(s: str) -> str:
return s[:1].lower() + s[1:] if s else s
def ensure_dir(path: str) -> None:
os.makedirs(path, exist_ok=True)
def write_file(path: str, content: str) -> None:
ensure_dir(os.path.dirname(path))
with open(path, "w", encoding="utf-8") as f:
f.write(content.rstrip() + "\n")
def write_file_if_absent(path: str, content: str) -> None:
if os.path.exists(path):
return
write_file(path, content)
def qualify_imports(types: list[str]) -> str:
imports = []
for t in types:
imp = JAVA_TYPE_IMPORTS.get(t)
if imp and imp not in imports:
imports.append(imp)
return "\n".join(imports)
def indent_block(s: str, n: int = 4) -> str:
prefix = " " * n
return "\n".join((prefix + line if line.strip() else line) for line in (s or "").splitlines())
# ------------------------- Template loading -------------------------
def load_template_text(templates_dir: str | None, filename: str) -> str | None:
if not templates_dir:
return None
candidate = os.path.join(templates_dir, filename)
if os.path.isfile(candidate):
with open(candidate, "r", encoding="utf-8") as f:
return f.read()
return None
def render_template_file(templates_dir: str | None, filename: str, placeholders: dict) -> str | None:
text = load_template_text(templates_dir, filename)
if text is None:
return None
try:
return Template(text).safe_substitute(placeholders).rstrip() + "\n"
except Exception:
# On any template error, fall back to defaults
return None
# ------------------------- Templates -------------------------
def tmpl_domain_model(pkg: str, entity: str, id: dict, fields: list[dict], use_lombok: bool = False) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
fields_src = []
for f in [id, *fields]:
fields_src.append(f" private final {f['type']} {f['name']};")
ctor_params = ", ".join([f"{f['type']} {f['name']}" for f in [id, *fields]])
assigns = "\n".join([f" this.{f['name']} = {f['name']};" for f in [id, *fields]])
lombok_import = "import lombok.Getter;" if use_lombok else ""
extra_imports_full = "\n".join(filter(None, [extra_imports, lombok_import]))
class_annot = "@Getter\n" if use_lombok else ""
getters = "\n".join([f" public {f['type']} {('get' + f['name'][0].upper() + f['name'][1:])}() {{ return {f['name']}; }}" for f in [id, *fields]]) if not use_lombok else ""
return dedent(f"""
package {pkg}.domain.model;
{extra_imports_full}
/**
* Domain aggregate for {entity}.
* Keep framework-free; capture invariants in factories/methods.
*/
{class_annot}public class {entity} {{
{os.linesep.join(fields_src)}
private {entity}({ctor_params}) {{
{assigns}
}}
public static {entity} create({ctor_params}) {{
// TODO: add invariant checks
return new {entity}({', '.join([f['name'] for f in [id, *fields]])});
}}
{getters}
}}
""")
def tmpl_domain_repository(pkg: str, entity: str, id_type: str) -> str:
return dedent(f"""
package {pkg}.domain.repository;
import java.util.Optional;
import java.util.List;
import {pkg}.domain.model.{entity};
public interface {entity}Repository {{
{entity} save({entity} aggregate);
Optional<{entity}> findById({id_type} id);
List<{entity}> findAll(int page, int size);
void deleteById({id_type} id);
boolean existsById({id_type} id);
long count();
}}
""")
def tmpl_jpa_entity(pkg: str, entity: str, id: dict, fields: list[dict], relationships: list[dict], use_lombok: bool = False) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
id_ann = "@GeneratedValue(strategy = GenerationType.IDENTITY)" if id["type"] == "Long" and id.get("generated", True) else ""
all_fields = [id, *fields]
fields_src = [
f" private {f['type']} {f['name']};" if f is id else f" @Column(nullable = false)\n private {f['type']} {f['name']};"
for f in all_fields
]
# Relationship fields and imports
rel_fields_src = []
target_imports = []
need_set = False
for r in relationships or []:
rtype = (r.get("type") or "").upper()
name = r.get("name")
target = r.get("target")
if not name or not target or rtype not in {"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY"}:
continue
target_import = f"import {pkg}.infrastructure.persistence.{target}Entity;"
if target_import not in target_imports:
target_imports.append(target_import)
annotations = []
if rtype == "ONE_TO_ONE":
mapped_by = r.get("mappedBy")
optional = r.get("optional", True)
if mapped_by:
annotations.append(f" @OneToOne(mappedBy = \"{mapped_by}\", fetch = FetchType.LAZY)")
else:
annotations.append(f" @OneToOne(fetch = FetchType.LAZY, optional = {str(optional).lower()})")
join_col = r.get("joinColumn")
if join_col:
annotations.append(f" @JoinColumn(name = \"{join_col}\")")
field_type = f"{target}Entity"
init = ""
elif rtype == "ONE_TO_MANY":
need_set = True
mapped_by = r.get("mappedBy")
if mapped_by:
annotations.append(f" @OneToMany(mappedBy = \"{mapped_by}\", fetch = FetchType.LAZY)")
else:
annotations.append(" @OneToMany(fetch = FetchType.LAZY)")
join_col = r.get("joinColumn")
if join_col:
annotations.append(f" @JoinColumn(name = \"{join_col}\")")
field_type = f"Set<{target}Entity>"
init = " = new java.util.LinkedHashSet<>()"
else: # MANY_TO_MANY
need_set = True
annotations.append(" @ManyToMany(fetch = FetchType.LAZY)")
jt = r.get("joinTable") or {}
jt_name = jt.get("name")
join_col = jt.get("joinColumn")
inv_join_col = jt.get("inverseJoinColumn")
if jt_name and join_col and inv_join_col:
annotations.append(
f" @JoinTable(name = \"{jt_name}\", joinColumns = @JoinColumn(name = \"{join_col}\"), inverseJoinColumns = @JoinColumn(name = \"{inv_join_col}\"))"
)
field_type = f"Set<{target}Entity>"
init = " = new java.util.LinkedHashSet<>()"
rel_fields_src.append("\n".join(annotations + [f" private {field_type} {name}{init};"]))
rel_block = ("\n" + os.linesep.join(rel_fields_src)) if rel_fields_src else ""
lombok_imports = ("\n".join([
"import lombok.Getter;",
"import lombok.Setter;",
"import lombok.NoArgsConstructor;",
"import lombok.AccessLevel;",
]) if use_lombok else "")
imports_block = "\n".join(filter(None, [JPA_IMPORTS, extra_imports, COLLECTION_IMPORTS if need_set else "", "\n".join(target_imports), lombok_imports]))
imports_block_indented = indent_block(imports_block)
rel_getters = os.linesep.join([
f" public {('Set<' + r['target'] + 'Entity>' if r['type'].upper() != 'ONE_TO_ONE' else r['target'] + 'Entity')} get{r['name'][0].upper() + r['name'][1:]}() {{ return {r['name']}; }}"
for r in (relationships or []) if r.get('name') and r.get('target') and r.get('type', '').upper() in {"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY"}
]) if not use_lombok else ""
rel_setters = os.linesep.join([
f" public void set{r['name'][0].upper() + r['name'][1:]}({('Set<' + r['target'] + 'Entity>' if r['type'].upper() != 'ONE_TO_ONE' else r['target'] + 'Entity')} {r['name']}) {{ this.{r['name']} = {r['name']}; }}"
for r in (relationships or []) if r.get('name') and r.get('target') and r.get('type', '').upper() in {"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY"}
]) if not use_lombok else ""
class_annots = ("\n".join([
"@Getter",
"@Setter",
"@NoArgsConstructor(access = AccessLevel.PROTECTED)",
]) + "\n") if use_lombok else ""
class_annots_block = indent_block(class_annots.strip()) if use_lombok else ""
fields_getters = os.linesep.join([f" public {f['type']} {('get' + f['name'][0].upper() + f['name'][1:])}() {{ return {f['name']}; }}" for f in all_fields]) if not use_lombok else ""
fields_setters = os.linesep.join([f" public void set{f['name'][0].upper() + f['name'][1:]}({f['type']} {f['name']}) {{ this.{f['name']} = {f['name']}; }}" for f in all_fields]) if not use_lombok else ""
return dedent(f"""
package {pkg}.infrastructure.persistence;
{imports_block}
@Entity
@Table(name = "{camel_to_snake(entity)}")
{class_annots}public class {entity}Entity {{
@Id
{id_ann}
private {id['type']} {id['name']};
{os.linesep.join(fields_src[1:])}
{rel_block}
{'' if use_lombok else f'protected {entity}Entity() {{ /* for JPA */ }}'}
public {entity}Entity({', '.join([f['type'] + ' ' + f['name'] for f in all_fields])}) {{
{os.linesep.join([f" this.{f['name']} = {f['name']};" for f in all_fields])}
}}
{fields_getters}
{fields_setters}
{rel_getters}
{rel_setters}
}}
""")
def tmpl_spring_data_repo(pkg: str, entity: str, id_type: str) -> str:
return dedent(f"""
package {pkg}.infrastructure.persistence;
{indent_block(REPOSITORY_IMPORTS)}
public interface {entity}JpaRepository extends JpaRepository<{entity}Entity, {id_type}> {{}}
""")
def tmpl_persistence_adapter(pkg: str, entity: str, id: dict, fields: list[dict], use_lombok: bool = False) -> str:
return dedent(f"""
package {pkg}.infrastructure.persistence;
import java.util.Optional;
import java.util.List;
import java.util.stream.Collectors;
import {pkg}.domain.model.{entity};
import {pkg}.domain.repository.{entity}Repository;
import org.springframework.stereotype.Component;
{('import lombok.RequiredArgsConstructor;' if use_lombok else '')}
@Component
{('@RequiredArgsConstructor' if use_lombok else '')}
public class {entity}RepositoryAdapter implements {entity}Repository {{
private final {entity}JpaRepository jpa;
{'' if use_lombok else f'public {entity}RepositoryAdapter({entity}JpaRepository jpa) {{\n this.jpa = jpa;\n }}'}
@Override
public {entity} save({entity} aggregate) {{
{entity}Entity e = toEntity(aggregate);
e = jpa.save(e);
return toDomain(e);
}}
@Override
public Optional<{entity}> findById({id['type']} id) {{
return jpa.findById(id).map(this::toDomain);
}}
@Override
public List<{entity}> findAll(int page, int size) {{
return jpa.findAll(org.springframework.data.domain.PageRequest.of(page, size))
.stream().map(this::toDomain).collect(java.util.stream.Collectors.toList());
}}
@Override
public void deleteById({id['type']} id) {{
jpa.deleteById(id);
}}
@Override
public boolean existsById({id['type']} id) {{
return jpa.existsById(id);
}}
@Override
public long count() {{
return jpa.count();
}}
private {entity}Entity toEntity({entity} a) {{
return new {entity}Entity({', '.join(['a.get' + id['name'][0].upper() + id['name'][1:] + '()'] + ['a.get' + f['name'][0].upper() + f['name'][1:] + '()' for f in fields])});
}}
private {entity} toDomain({entity}Entity e) {{
return {entity}.create({', '.join(['e.get' + id['name'][0].upper() + id['name'][1:] + '()'] + ['e.get' + f['name'][0].upper() + f['name'][1:] + '()' for f in fields])});
}}
}}
""")
def tmpl_application_service(pkg: str, entity: str, id: dict, fields: list[dict], use_lombok: bool = False) -> str:
lc_entity = lower_first(entity)
dto_req = f"{entity}Request"
dto_res = f"{entity}Response"
params = ", ".join([f"request.{f['name']}()" for f in [id, *fields]])
update_params = ", ".join([f"request.{f['name']}()" for f in [*fields]])
return dedent(f"""
package {pkg}.application.service;
{indent_block(SPRING_IMPORTS)}
import java.util.List;
import {pkg}.domain.model.{entity};
import {pkg}.domain.repository.{entity}Repository;
import {pkg}.presentation.dto.{dto_req};
import {pkg}.presentation.dto.{dto_res};
@Service
@Transactional
public class {entity}Service {{
private final {entity}Repository repository;
public {entity}Service({entity}Repository repository) {{
this.repository = repository;
}}
public {dto_res} create({dto_req} request) {{
{entity} {lc_entity} = {entity}.create({params});
{lc_entity} = repository.save({lc_entity});
return {dto_res}.from({lc_entity});
}}
@Transactional(readOnly = true)
public {dto_res} get({id['type']} id) {{
return repository.findById(id).map({dto_res}::from)
.orElseThrow(() -> new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND));
}}
public {dto_res} update({dto_req} request) {{
// In a real app, load existing aggregate and apply changes
{entity} updated = {entity}.create(request.{id['name']}(), {update_params});
updated = repository.save(updated);
return {dto_res}.from(updated);
}}
public void delete({id['type']} id) {{
repository.deleteById(id);
}}
@Transactional(readOnly = true)
public java.util.List<{dto_res}> list(int page, int size) {{
return repository.findAll(page, size).stream().map({dto_res}::from).collect(java.util.stream.Collectors.toList());
}}
}}
""")
def tmpl_dto_request(pkg: str, entity: str, id: dict, fields: list[dict]) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
comps = ", ".join([f"{f['type']} {f['name']}" for f in [id, *fields]])
return dedent(f"""
package {pkg}.presentation.dto;
{extra_imports}
public record {entity}Request({comps}) {{ }}
""")
def tmpl_dto_response(pkg: str, entity: str, id: dict, fields: list[dict]) -> str:
used_types = {id["type"]} | {f["type"] for f in fields}
extra_imports = qualify_imports(sorted(used_types))
comps = ", ".join([f"{f['type']} {f['name']}" for f in [id, *fields]])
getters = ", ".join([f"aggregate.get{f['name'][0].upper() + f['name'][1:]}()" for f in [id, *fields]])
return dedent(f"""
package {pkg}.presentation.dto;
{indent_block(extra_imports)}
import {pkg}.domain.model.{entity};
public record {entity}Response({comps}) {{
public static {entity}Response from({entity} aggregate) {{
return new {entity}Response({getters});
}}
}}
""")
def tmpl_controller(pkg: str, entity: str, id: dict, use_lombok: bool = False) -> str:
base = f"/api/{camel_to_snake(entity)}s" # naive pluralization with 's'
dto_req = f"{entity}Request"
dto_res = f"{entity}Response"
var_name = lower_first(id['name'])
path_seg = "/{" + var_name + "}"
return dedent(f"""
package {pkg}.presentation.rest;
{indent_block(CONTROLLER_IMPORTS)}
import {pkg}.application.service.{entity}Service;
import {pkg}.presentation.dto.{dto_req};
import {pkg}.presentation.dto.{dto_res};
@RestController
@RequestMapping("{base}")
public class {entity}Controller {{
private final {entity}Service service;
public {entity}Controller({entity}Service service) {{
this.service = service;
}}
@PostMapping
public ResponseEntity<{dto_res}> create(@RequestBody @Valid {dto_req} request) {{
var created = service.create(request);
return ResponseEntity.status(201).body(created);
}}
@GetMapping("{path_seg}")
public ResponseEntity<{dto_res}> get(@PathVariable {id['type']} {var_name}) {{
return ResponseEntity.ok(service.get({var_name}));
}}
@PutMapping("{path_seg}")
public ResponseEntity<{dto_res}> update(@PathVariable {id['type']} {var_name},
@RequestBody @Valid {dto_req} request) {{
// In a real app: ensure path id == request id
return ResponseEntity.ok(service.update(request));
}}
@DeleteMapping("{path_seg}")
public ResponseEntity<Void> delete(@PathVariable {id['type']} {var_name}) {{
service.delete({var_name});
return ResponseEntity.noContent().build();
}}
@GetMapping
public ResponseEntity<java.util.List<{dto_res}>> list(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {{
return ResponseEntity.ok(service.list(page, size));
}}
}}
""")
# ------------------------- Main -------------------------
def main():
parser = argparse.ArgumentParser(description="Generate Spring Boot CRUD boilerplate from entity spec")
parser.add_argument("--spec", required=True, help="Path to entity spec (JSON or YAML)")
parser.add_argument("--package", required=True, help="Base package, e.g., com.example.product")
parser.add_argument("--output", default="./generated", help="Output root directory")
parser.add_argument("--lombok", action="store_true", help="Use Lombok annotations for getters/setters where applicable")
parser.add_argument("--templates-dir", help="Directory with override templates (*.tpl). If omitted, auto-detects ../templates relative to this script if present.")
args = parser.parse_args()
spec = load_spec(args.spec)
entity = spec.get("entity")
if not entity or not re.match(r"^[A-Z][A-Za-z0-9_]*$", entity):
raise SystemExit("Spec 'entity' must be a PascalCase identifier, e.g., 'Product'")
id_spec = spec.get("id") or {"name": "id", "type": "Long", "generated": True}
if id_spec["type"] not in SUPPORTED_SIMPLE_TYPES:
raise SystemExit(f"Unsupported id type: {id_spec['type']}")
fields = spec.get("fields", [])
relationships = spec.get("relationships", [])
for f in fields:
if f["type"] not in SUPPORTED_SIMPLE_TYPES:
raise SystemExit(f"Unsupported field type: {f['name']} -> {f['type']}")
feature_name = entity.lower()
base_pkg = args.package
out_root = os.path.abspath(args.output)
java_root = os.path.join(out_root, "src/main/java", base_pkg.replace(".", "/"))
# Paths
paths = {
"domain_model": os.path.join(java_root, "domain/model", f"{entity}.java"),
"domain_repo": os.path.join(java_root, "domain/repository", f"{entity}Repository.java"),
"domain_service": os.path.join(java_root, "domain/service", f"{entity}Service.java"),
"jpa_entity": os.path.join(java_root, "infrastructure/persistence", f"{entity}Entity.java"),
"spring_data_repo": os.path.join(java_root, "infrastructure/persistence", f"{entity}JpaRepository.java"),
"persistence_adapter": os.path.join(java_root, "infrastructure/persistence", f"{entity}RepositoryAdapter.java"),
"app_service_create": os.path.join(java_root, "application/service", f"Create{entity}Service.java"),
"app_service_get": os.path.join(java_root, "application/service", f"Get{entity}Service.java"),
"app_service_update": os.path.join(java_root, "application/service", f"Update{entity}Service.java"),
"app_service_delete": os.path.join(java_root, "application/service", f"Delete{entity}Service.java"),
"app_service_list": os.path.join(java_root, "application/service", f"List{entity}Service.java"),
"dto_req": os.path.join(java_root, "presentation/dto", f"{entity}Request.java"),
"dto_res": os.path.join(java_root, "presentation/dto", f"{entity}Response.java"),
"controller": os.path.join(java_root, "presentation/rest", f"{entity}Controller.java"),
"ex_not_found": os.path.join(java_root, "application/exception", f"{entity}NotFoundException.java"),
"ex_exist": os.path.join(java_root, "application/exception", f"{entity}ExistException.java"),
"entity_exception_handler": os.path.join(java_root, "presentation/rest", f"{entity}ExceptionHandler.java"),
}
# Resolve templates directory (required; no fallback to built-ins)
script_dir = os.path.dirname(os.path.abspath(__file__))
default_templates_dir = os.path.normpath(os.path.join(script_dir, "..", "templates"))
templates_dir = args.templates_dir or (default_templates_dir if os.path.isdir(default_templates_dir) else None)
if not templates_dir or not os.path.isdir(templates_dir):
raise SystemExit("Templates directory not found. Provide --templates-dir or create: " + default_templates_dir)
required_templates = [
"DomainModel.java.tpl",
"DomainRepository.java.tpl",
"DomainService.java.tpl",
"JpaEntity.java.tpl",
"SpringDataRepository.java.tpl",
"PersistenceAdapter.java.tpl",
"CreateService.java.tpl",
"GetService.java.tpl",
"UpdateService.java.tpl",
"DeleteService.java.tpl",
"ListService.java.tpl",
"DtoRequest.java.tpl",
"DtoResponse.java.tpl",
"Controller.java.tpl",
"NotFoundException.java.tpl",
"ExistException.java.tpl",
"EntityExceptionHandler.java.tpl",
]
missing = [name for name in required_templates if load_template_text(templates_dir, name) is None]
if missing:
raise SystemExit("Missing required templates: " + ", ".join(missing) + " in " + templates_dir)
# Build dynamic code fragments for templates
all_fields = [id_spec, *fields]
used_types = {f["type"] for f in all_fields}
extra_imports = qualify_imports(sorted(used_types))
def cap(s: str) -> str:
return s[:1].upper() + s[1:] if s else s
# Domain fragments
final_kw = "" if args.lombok else "final "
domain_fields_decls = "\n".join([f" private {final_kw}{f['type']} {f['name']};" for f in all_fields])
domain_ctor_params = ", ".join([f"{f['type']} {f['name']}" for f in all_fields])
domain_assigns = "\n".join([f" this.{f['name']} = {f['name']};" for f in all_fields])
domain_getters = ("\n".join([f" public {f['type']} get{cap(f['name'])}() {{ return {f['name']}; }}" for f in all_fields]) if not args.lombok else "")
model_constructor_block = (f" private {entity}({domain_ctor_params}) {{\n{domain_assigns}\n }}" if not args.lombok else "")
all_names_csv = ", ".join([f["name"] for f in all_fields])
# JPA fragments
def _jpa_field_decl(f: dict) -> str:
if f is id_spec:
lines = [" @Id"]
if bool(id_spec.get("generated", False)) and id_spec["type"] in ("Long", "Integer"):
lines.append(" @GeneratedValue(strategy = GenerationType.IDENTITY)")
lines.append(f" private {f['type']} {f['name']};")
return "\n".join(lines)
return f" @Column(nullable = false)\n private {f['type']} {f['name']};"
jpa_fields_decls = "\n".join([_jpa_field_decl(f) for f in all_fields])
jpa_ctor_params = domain_ctor_params
jpa_assigns = "\n".join([f" this.{f['name']} = {f['name']};" for f in all_fields])
jpa_getters_setters = "\n".join([
f" public {f['type']} get{cap(f['name'])}() {{ return {f['name']}; }}\n public void set{cap(f['name'])}({f['type']} {f['name']}) {{ this.{f['name']} = {f['name']}; }}" for f in all_fields
])
# DTO components
id_generated = bool(id_spec.get("generated", False))
dto_response_components = ", ".join([f"{f['type']} {f['name']}" for f in all_fields])
dto_request_fields = (fields if id_generated else all_fields)
dto_request_components = ", ".join([f"{f['type']} {f['name']}" for f in dto_request_fields])
# Mapping fragments
adapter_to_entity_args = ", ".join([f"a.get{cap(f['name'])}()" for f in all_fields])
adapter_to_domain_args = ", ".join([f"e.get{cap(f['name'])}()" for f in all_fields])
if id_generated:
request_all_args = ", ".join(["null", *[f"request.{f['name']}()" for f in fields]])
else:
request_all_args = ", ".join([f"request.{id_spec['name']}()", *[f"request.{f['name']}()" for f in fields]])
response_from_agg_args = ", ".join([f"agg.get{cap(f['name'])}()" for f in all_fields])
list_map_response_args = ", ".join([f"a.get{cap(f['name'])}()" for f in all_fields])
update_create_args = ", ".join([id_spec["name"], *[f"request.{f['name']}()" for f in fields]])
mapper_create_args = ", ".join(["id", *[f"request.{f['name']}()" for f in fields]])
create_id_arg = ("null" if id_generated else f"request.{id_spec['name']}()")
table_name = camel_to_snake(entity)
base_path = f"/api/{table_name}"
# Common placeholders for external templates
# Lombok-related placeholders
# Domain model should only have @Getter for DDD immutability
lombok_domain_imports = "import lombok.Getter;" if args.lombok else ""
lombok_domain_annotations = "@Getter" if args.lombok else ""
lombok_domain_annotations_block = ("\n" + lombok_domain_annotations) if lombok_domain_annotations else ""
lombok_model_imports = "import lombok.Getter;\nimport lombok.Setter;\nimport lombok.AllArgsConstructor;" if args.lombok else ""
lombok_common_imports = "import lombok.RequiredArgsConstructor;\nimport lombok.extern.slf4j.Slf4j;" if args.lombok else ""
model_annotations = "@Getter\n@Setter\n@AllArgsConstructor" if args.lombok else ""
service_annotations = "@RequiredArgsConstructor\n@Slf4j" if args.lombok else ""
controller_annotations = "@RequiredArgsConstructor\n@Slf4j" if args.lombok else ""
adapter_annotations = "@RequiredArgsConstructor\n@Slf4j" if args.lombok else ""
# annotation blocks that include a leading newline when present to avoid empty lines
service_annotations_block = ("\n" + service_annotations) if service_annotations else ""
controller_annotations_block = ("\n" + controller_annotations) if controller_annotations else ""
adapter_annotations_block = ("\n" + adapter_annotations) if adapter_annotations else ""
model_annotations_block = ("\n" + model_annotations) if model_annotations else ""
# Common placeholders for external templates
placeholders = {
"entity": entity,
"Entity": entity,
"EntityRequest": f"{entity}Request",
"EntityResponse": f"{entity}Response",
"entity_lower": lower_first(entity),
"package": base_pkg,
"Package": base_pkg.replace(".", "/"),
"table_name": table_name,
"base_path": base_path,
"id_type": id_spec["type"],
"id_name": id_spec["name"],
"id_name_lower": lower_first(id_spec["name"]),
"id_generated": str(id_generated).lower(),
"fields": fields,
"all_fields": all_fields,
"extra_imports": extra_imports,
"final_kw": final_kw,
"domain_fields_decls": domain_fields_decls,
"domain_ctor_params": domain_ctor_params,
"domain_assigns": domain_assigns,
"domain_getters": domain_getters,
"model_constructor_block": model_constructor_block,
"all_names_csv": all_names_csv,
"jpa_fields_decls": jpa_fields_decls,
"jpa_ctor_params": jpa_ctor_params,
"jpa_assigns": jpa_assigns,
"jpa_getters_setters": jpa_getters_setters,
"dto_response_components": dto_response_components,
"dto_request_components": dto_request_components,
"adapter_to_entity_args": adapter_to_entity_args,
"adapter_to_domain_args": adapter_to_domain_args,
"request_all_args": request_all_args,
"response_from_agg_args": response_from_agg_args,
"list_map_response_args": list_map_response_args,
"update_create_args": update_create_args,
"mapper_create_args": mapper_create_args,
"create_id_arg": create_id_arg,
# Domain-specific Lombok placeholders (DDD-compliant)
"lombok_domain_imports": lombok_domain_imports,
"lombok_domain_annotations_block": lombok_domain_annotations_block,
# Infrastructure/infrastructure Lombok placeholders
"lombok_model_imports": lombok_model_imports,
"lombok_common_imports": lombok_common_imports,
"model_annotations": model_annotations,
"service_annotations": service_annotations,
"controller_annotations": controller_annotations,
"adapter_annotations": adapter_annotations,
"service_annotations_block": service_annotations_block,
"controller_annotations_block": controller_annotations_block,
"adapter_annotations_block": adapter_annotations_block,
"model_annotations_block": model_annotations_block,
# Constructor placeholders
"controller_constructor": "",
"adapter_constructor": "",
"create_constructor": "",
"update_constructor": "",
"get_constructor": "",
"list_constructor": "",
"delete_constructor": "",
"domain_service_constructor": "",
}
def _render(name, placeholders_dict):
c = render_template_file(templates_dir, name, placeholders_dict)
if c is None: raise SystemExit(f"Template render failed: {name}")
c = (c.replace("$controller_constructor", placeholders_dict.get("controller_constructor", ""))
.replace("$adapter_constructor", placeholders_dict.get("adapter_constructor", ""))
.replace("$create_constructor", placeholders_dict.get("create_constructor", ""))
.replace("$update_constructor", placeholders_dict.get("update_constructor", ""))
.replace("$get_constructor", placeholders_dict.get("get_constructor", ""))
.replace("$list_constructor", placeholders_dict.get("list_constructor", ""))
.replace("$delete_constructor", placeholders_dict.get("delete_constructor", ""))
.replace("$domain_service_constructor", placeholders_dict.get("domain_service_constructor", "")))
return c
# Write files (templates only, fail on error)
content = _render("DomainModel.java.tpl", placeholders)
write_file(paths["domain_model"], content)
content = _render("DomainRepository.java.tpl", placeholders)
write_file(paths["domain_repo"], content)
content = _render("DomainService.java.tpl", placeholders)
write_file(paths["domain_service"], content)
content = _render("JpaEntity.java.tpl", placeholders)
write_file(paths["jpa_entity"], content)
content = _render("SpringDataRepository.java.tpl", placeholders)
write_file(paths["spring_data_repo"], content)
content = _render("PersistenceAdapter.java.tpl", placeholders)
write_file(paths["persistence_adapter"], content)
content = _render("CreateService.java.tpl", placeholders)
write_file(paths["app_service_create"], content)
content = _render("GetService.java.tpl", placeholders)
write_file(paths["app_service_get"], content)
content = _render("UpdateService.java.tpl", placeholders)
write_file(paths["app_service_update"], content)
content = _render("DeleteService.java.tpl", placeholders)
write_file(paths["app_service_delete"], content)
content = _render("ListService.java.tpl", placeholders)
write_file(paths["app_service_list"], content)
content = _render("DtoRequest.java.tpl", placeholders)
write_file(paths["dto_req"], content)
content = _render("DtoResponse.java.tpl", placeholders)
write_file(paths["dto_res"], content)
content = _render("Controller.java.tpl", placeholders)
write_file(paths["controller"], content)
# Exceptions
content = _render("NotFoundException.java.tpl", placeholders)
write_file(paths["ex_not_found"], content)
content = _render("ExistException.java.tpl", placeholders)
write_file(paths["ex_exist"], content)
content = _render("EntityExceptionHandler.java.tpl", placeholders)
write_file(paths["entity_exception_handler"], content)
# Helpful README
readme = dedent(f"""
# Generated CRUD Feature: {entity}
Base package: {base_pkg}
Structure:
- domain/model/{entity}.java
- domain/repository/{entity}Repository.java
- infrastructure/persistence/{entity}Entity.java
- infrastructure/persistence/{entity}JpaRepository.java
- infrastructure/persistence/{entity}RepositoryAdapter.java
- application/service/Create{entity}Service.java
- application/service/Get{entity}Service.java
- application/service/Update{entity}Service.java
- application/service/Delete{entity}Service.java
- application/service/List{entity}Service.java
- presentation/dto/{entity}Request.java
- presentation/dto/{entity}Response.java
- presentation/rest/{entity}Controller.java
Next steps:
- Add validation and invariants in domain aggregate
- Secure endpoints and add tests (unit + @DataJpaTest + Testcontainers)
- Wire into your Spring Boot app (component scan should pick up beans)
""")
write_file(os.path.join(out_root, "README-GENERATED.md"), readme)
print(f"CRUD boilerplate generated under: {out_root}")
if __name__ == "__main__":
try:
main()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

View File

@@ -0,0 +1,60 @@
package $package.presentation.rest;
$lombok_common_imports
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import $package.application.service.Create${entity}Service;
import $package.application.service.Get${entity}Service;
import $package.application.service.Update${entity}Service;
import $package.application.service.Delete${entity}Service;
import $package.application.service.List${entity}Service;
import $package.presentation.dto.$EntityRequest;
import $package.presentation.dto.$EntityResponse;
import $package.presentation.dto.PageResponse;
import org.springframework.data.domain.Pageable;
@RestController$controller_annotations_block
@RequestMapping("$base_path")
public class ${entity}Controller {
private final Create${entity}Service createService;
private final Get${entity}Service getService;
private final Update${entity}Service updateService;
private final Delete${entity}Service deleteService;
private final List${entity}Service listService;
$controller_constructor
@PostMapping
public ResponseEntity<$EntityResponse> create(@RequestBody @Valid $EntityRequest request) {
$EntityResponse created = createService.create(request);
return ResponseEntity.status(HttpStatus.CREATED)
.header("Location", "$base_path/" + created.$id_name())
.body(created);
}
@GetMapping("/{${id_name_lower}}")
public ResponseEntity<$EntityResponse> get(@PathVariable $id_type ${id_name_lower}) {
return ResponseEntity.ok(getService.get(${id_name_lower}));
}
@PutMapping("/{${id_name_lower}}")
public ResponseEntity<$EntityResponse> update(@PathVariable $id_type ${id_name_lower},
@RequestBody @Valid $EntityRequest request) {
return ResponseEntity.ok(updateService.update(${id_name_lower}, request));
}
@DeleteMapping("/{${id_name_lower}}")
public ResponseEntity<Void> delete(@PathVariable $id_type ${id_name_lower}) {
deleteService.delete(${id_name_lower});
return ResponseEntity.noContent().build();
}
@GetMapping
public ResponseEntity<PageResponse<$EntityResponse>> list(Pageable pageable) {
return ResponseEntity.ok(listService.list(pageable.getPageNumber(), pageable.getPageSize()));
}
}

View File

@@ -0,0 +1,32 @@
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.model.$entity;
import $package.domain.service.${entity}Service;
import $package.application.mapper.${entity}Mapper;
import $package.application.exception.${entity}ExistException;
import org.springframework.dao.DataIntegrityViolationException;
import $package.presentation.dto.$EntityRequest;
import $package.presentation.dto.$EntityResponse;
@Service$service_annotations_block
@Transactional
public class Create${entity}Service {
private final ${entity}Service ${entity_lower}Service;
private final ${entity}Mapper mapper;
$create_constructor
public $EntityResponse create($EntityRequest request) {
try {
$entity agg = mapper.toAggregate($create_id_arg, request);
agg = ${entity_lower}Service.save(agg);
return mapper.toResponse(agg);
} catch (DataIntegrityViolationException ex) {
throw new ${entity}ExistException("Duplicate $entity");
}
}
}

View File

@@ -0,0 +1,23 @@
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.service.${entity}Service;
import $package.application.exception.${entity}NotFoundException;
@Service$service_annotations_block
@Transactional
public class Delete${entity}Service {
private final ${entity}Service ${entity_lower}Service;
$delete_constructor
public void delete($id_type $id_name) {
if (!${entity_lower}Service.existsById($id_name)) {
throw new ${entity}NotFoundException($id_name);
}
${entity_lower}Service.deleteById($id_name);
}
}

View File

@@ -0,0 +1,20 @@
package $package.domain.model;
$lombok_domain_imports
$extra_imports
$lombok_domain_annotations_block
public class $entity {
$domain_fields_decls
private $entity($domain_ctor_params) {
$domain_assigns
}
public static $entity create($domain_ctor_params) {
// TODO: add invariant checks
return new $entity($all_names_csv);
}
$domain_getters
}

View File

@@ -0,0 +1,14 @@
package $package.domain.repository;
import java.util.Optional;
import java.util.List;
import $package.domain.model.$entity;
public interface ${entity}Repository {
$entity save($entity aggregate);
Optional<$entity> findById($id_type $id_name);
List<$entity> findAll(int page, int size);
void deleteById($id_type $id_name);
boolean existsById($id_type $id_name);
long count();
}

View File

@@ -0,0 +1,42 @@
package $package.domain.service;
import java.util.List;
import java.util.Optional;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.model.$entity;
import $package.domain.repository.${entity}Repository;
@Service$service_annotations_block
@Transactional
public class ${entity}Service {
private final ${entity}Repository repository;
$domain_service_constructor
public $entity save($entity aggregate) {
return repository.save(aggregate);
}
public Optional<$entity> findById($id_type $id_name) {
return repository.findById($id_name);
}
public List<$entity> findAll(int page, int size) {
return repository.findAll(page, size);
}
public void deleteById($id_type $id_name) {
repository.deleteById($id_name);
}
public boolean existsById($id_type $id_name) {
return repository.existsById($id_name);
}
public long count() {
return repository.count();
}
}

View File

@@ -0,0 +1,5 @@
package $package.presentation.dto;
$extra_imports
public record $EntityRequest($dto_request_components) { }

View File

@@ -0,0 +1,5 @@
package $package.presentation.dto;
$extra_imports
public record $EntityResponse($dto_response_components) { }

View File

@@ -0,0 +1,35 @@
package $package.presentation.rest;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import $package.application.exception.${entity}NotFoundException;
import $package.application.exception.${entity}ExistException;
import $package.presentation.dto.ErrorResponse;
@RestControllerAdvice
public class ${entity}ExceptionHandler {
@ExceptionHandler(${entity}NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(${entity}NotFoundException ex, org.springframework.web.context.request.WebRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
"Not Found",
ex.getMessage(),
request.getDescription(false).replaceFirst("uri=", "")
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(${entity}ExistException.class)
public ResponseEntity<ErrorResponse> handleExist(${entity}ExistException ex, org.springframework.web.context.request.WebRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(),
"Conflict",
ex.getMessage(),
request.getDescription(false).replaceFirst("uri=", "")
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
}

View File

@@ -0,0 +1,8 @@
package $package.presentation.dto;
public record ErrorResponse(
int status,
String error,
String message,
String path
) { }

View File

@@ -0,0 +1,10 @@
package $package.application.exception;
public class ${entity}ExistException extends RuntimeException {
public ${entity}ExistException(String message) {
super(message);
}
public ${entity}ExistException($id_type $id_name) {
super("$entity already exists: " + $id_name);
}
}

View File

@@ -0,0 +1,25 @@
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.service.${entity}Service;
import $package.application.mapper.${entity}Mapper;
import $package.application.exception.${entity}NotFoundException;
import $package.presentation.dto.$EntityResponse;
@Service$service_annotations_block
@Transactional(readOnly = true)
public class Get${entity}Service {
private final ${entity}Service ${entity_lower}Service;
private final ${entity}Mapper mapper;
$get_constructor
public $EntityResponse get($id_type $id_name) {
return ${entity_lower}Service.findById($id_name)
.map(mapper::toResponse)
.orElseThrow(() -> new ${entity}NotFoundException($id_name));
}
}

View File

@@ -0,0 +1,55 @@
package $package.presentation.rest;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.dao.DataIntegrityViolationException;
import $package.presentation.dto.ErrorResponse;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException ex, org.springframework.web.context.request.WebRequest request) {
String errors = ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(java.util.stream.Collectors.joining(", "));
ErrorResponse error = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Validation Error",
"Validation failed: " + errors,
request.getDescription(false).replaceFirst("uri=", "")
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatusException(
ResponseStatusException ex, org.springframework.web.context.request.WebRequest request) {
ErrorResponse error = new ErrorResponse(
ex.getStatusCode().value(),
ex.getStatusCode().toString(),
ex.getReason(),
request.getDescription(false).replaceFirst("uri=", "")
);
return new ResponseEntity<>(error, ex.getStatusCode());
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrityViolation(
DataIntegrityViolationException ex, org.springframework.web.context.request.WebRequest request) {
ErrorResponse error = new ErrorResponse(
HttpStatus.CONFLICT.value(),
"Conflict",
ex.getMostSpecificCause() != null ? ex.getMostSpecificCause().getMessage() : ex.getMessage(),
request.getDescription(false).replaceFirst("uri=", "")
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
}

View File

@@ -0,0 +1,25 @@
package $package.infrastructure.persistence;
import jakarta.persistence.*;
$extra_imports
$lombok_model_imports
import lombok.NoArgsConstructor;
@Entity
@Table(name = "$table_name")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ${entity}Entity {
$jpa_fields_decls
protected ${entity}Entity() { /* for JPA */ }
// Full constructor (optional, can be removed if not needed)
public ${entity}Entity($jpa_ctor_params) {
$jpa_assigns
}
// Lombok generates getters and setters automatically
}

View File

@@ -0,0 +1,31 @@
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.service.${entity}Service;
import $package.application.mapper.${entity}Mapper;
import $package.presentation.dto.$EntityResponse;
import $package.presentation.dto.PageResponse;
import java.util.List;
import java.util.stream.Collectors;
@Service$service_annotations_block
@Transactional(readOnly = true)
public class List${entity}Service {
private final ${entity}Service ${entity_lower}Service;
private final ${entity}Mapper mapper;
$list_constructor
public PageResponse<$EntityResponse> list(int page, int size) {
List<$EntityResponse> content = ${entity_lower}Service.findAll(page, size)
.stream()
.map(mapper::toResponse)
.collect(Collectors.toList());
long total = ${entity_lower}Service.count();
int totalPages = (int) Math.ceil(total / (double) size);
return new PageResponse<>(content, page, size, total, totalPages);
}
}

View File

@@ -0,0 +1,16 @@
package $package.application.mapper;
import $package.domain.model.$entity;
import $package.presentation.dto.$dto_request;
import $package.presentation.dto.$dto_response;
public class ${entity}Mapper {
public $entity toAggregate($id_type id, $dto_request request) {
return $entity.create($mapper_create_args);
}
public $dto_response toResponse($entity a) {
return new $dto_response($list_map_response_args);
}
}

View File

@@ -0,0 +1,7 @@
package $package.application.exception;
public class ${entity}NotFoundException extends RuntimeException {
public ${entity}NotFoundException($id_type $id_name) {
super("$entity not found: " + $id_name);
}
}

View File

@@ -0,0 +1,9 @@
package $package.presentation.dto;
public record PageResponse<T>(
java.util.List<T> content,
int page,
int size,
long totalElements,
int totalPages
) { }

View File

@@ -0,0 +1,54 @@
package $package.infrastructure.persistence;
$lombok_common_imports
import java.util.Optional;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.data.domain.PageRequest;
import $package.domain.model.$entity;
import $package.domain.repository.${entity}Repository;
import org.springframework.stereotype.Component;
@Component$adapter_annotations_block
public class ${entity}RepositoryAdapter implements ${entity}Repository {
private final ${entity}JpaRepository jpa;
$adapter_constructor
@Override
public $entity save($entity a) {
${entity}Entity e = new ${entity}Entity($adapter_to_entity_args);
e = jpa.save(e);
return $entity.create($adapter_to_domain_args);
}
@Override
public Optional<$entity> findById($id_type $id_name) {
return jpa.findById($id_name).map(e -> $entity.create($adapter_to_domain_args));
}
@Override
public List<$entity> findAll(int page, int size) {
return jpa.findAll(PageRequest.of(page, size))
.stream()
.map(e -> $entity.create($adapter_to_domain_args))
.collect(Collectors.toList());
}
@Override
public void deleteById($id_type $id_name) {
jpa.deleteById($id_name);
}
@Override
public boolean existsById($id_type $id_name) {
return jpa.existsById($id_name);
}
@Override
public long count() {
return jpa.count();
}
}

View File

@@ -0,0 +1,39 @@
# CRUD Generator Templates
You must provide all templates (.tpl) required by the generator; there is no fallback.
How it works:
- The generator loads .tpl files from this directory (or a directory passed via --templates-dir).
- It uses Python string.Template placeholders (e.g., $package, $entity, $id_type, $id_name, $id_name_lower, $base_path, $dto_request, $dto_response).
- If any template is missing or fails to render, generation fails.
Required template filenames:
- DomainModel.java.tpl
- DomainRepository.java.tpl
- JpaEntity.java.tpl
- SpringDataRepository.java.tpl
- PersistenceAdapter.java.tpl
- CreateService.java.tpl
- GetService.java.tpl
- UpdateService.java.tpl
- DeleteService.java.tpl
- ListService.java.tpl
- Mapper.java.tpl
- DtoRequest.java.tpl
- DtoResponse.java.tpl
- PageResponse.java.tpl
- ErrorResponse.java.tpl
- Controller.java.tpl
- GlobalExceptionHandler.java.tpl
- EntityExceptionHandler.java.tpl
- NotFoundException.java.tpl
- ExistException.java.tpl
Tip: Start simple and expand over time; these files are your teams baseline.
Conventions:
- Base path is versioned: /v1/{resources}
- POST returns 201 Created and sets Location: /v1/{resources}/{id}
- GET collection supports pagination via Pageable in controller and returns PageResponse<T>
- Application layer uses ${Entity}Mapper for DTO↔Domain and throws ${Entity}ExistException on duplicates
- Exceptions are mapped by GlobalExceptionHandler and ${Entity}ExceptionHandler

View File

@@ -0,0 +1,5 @@
package $package.infrastructure.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ${entity}JpaRepository extends JpaRepository<${entity}Entity, $id_type> { }

View File

@@ -0,0 +1,32 @@
package $package.application.service;
$lombok_common_imports
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import $package.domain.model.$entity;
import $package.domain.service.${entity}Service;
import $package.application.mapper.${entity}Mapper;
import $package.application.exception.${entity}ExistException;
import org.springframework.dao.DataIntegrityViolationException;
import $package.presentation.dto.$EntityRequest;
import $package.presentation.dto.$EntityResponse;
@Service$service_annotations_block
@Transactional
public class Update${entity}Service {
private final ${entity}Service ${entity_lower}Service;
private final ${entity}Mapper mapper;
$update_constructor
public $EntityResponse update($id_type $id_name, $EntityRequest request) {
try {
$entity agg = mapper.toAggregate($id_name, request);
agg = ${entity_lower}Service.save(agg);
return mapper.toResponse(agg);
} catch (DataIntegrityViolationException ex) {
throw new ${entity}ExistException("Duplicate $entity");
}
}
}

View File

@@ -0,0 +1,150 @@
---
name: spring-boot-dependency-injection
description: Dependency injection workflow for Spring Boot projects covering constructor-first patterns, optional collaborator handling, bean selection, and validation practices.
allowed-tools: Read, Write, Bash
category: backend
tags: [spring-boot, dependency-injection, constructor-injection, bean-configuration, autowiring, testing, java]
version: 1.1.0
context7_library: /spring-projects/spring-framework
context7_trust_score: 9.0
---
# Spring Boot Dependency Injection
This skill captures the dependency injection approach promoted in this repository: constructor-first design, explicit optional collaborators, and deterministic configuration that keeps services testable and framework-agnostic.
## Overview
- Prioritize constructor injection to keep dependencies explicit, immutable, and mockable.
- Treat optional collaborators through guarded setters or providers while documenting defaults.
- Resolve bean ambiguity intentionally through qualifiers, primary beans, and profiles.
- Validate wiring with focused unit tests before relying on Spring's TestContext framework.
## When to Use
- Implement constructor injection for new `@Service`, `@Component`, or `@Repository` classes.
- Replace legacy field injection while modernizing Spring modules.
- Configure optional or pluggable collaborators (feature flags, multi-tenant adapters).
- Audit bean definitions before adding integration tests or migrating Spring Boot versions.
## Prerequisites
- Align project with Java 17+ and Spring Boot 3.5.x (or later) to leverage records and `@ServiceConnection`.
- Keep build tooling ready to run `./gradlew test` or `mvn test` for validation.
- Load supporting material from `./references/` when deeper patterns or samples are required.
## Workflow
### 1. Map Collaborators
- Inventory constructors, `@Autowired` members, and configuration classes.
- Classify dependencies as mandatory (must exist) or optional (feature-flagged, environment-specific).
### 2. Apply Constructor Injection
- Introduce constructors (or Lombok `@RequiredArgsConstructor`) that accept every mandatory collaborator.
- Mark injected fields `final` and protect invariants with `Objects.requireNonNull` if Lombok is not used.
- Update `@Configuration` or `@Bean` factories to pass dependencies explicitly; consult `./references/reference.md` for canonical bean wiring.
### 3. Handle Optional Collaborators
- Supply setters annotated with `@Autowired(required = false)` or inject `ObjectProvider<T>` for lazy access.
- Provide deterministic defaults (for example, no-op implementations) and document them inside configuration modules.
- Follow `./references/examples.md#example-2-setter-injection-for-optional-dependencies` for a full workflow.
### 4. Resolve Bean Selection
- Choose `@Primary` for dominant implementations and `@Qualifier` for niche variants.
- Use profiles, conditional annotations, or factory methods to isolate environment-specific wiring.
- Reference `./references/reference.md#conditional-bean-registration` for conditional and profile-based samples.
### 5. Validate Wiring
- Write unit tests that instantiate classes manually with mocks to prove Spring-free testability.
- Add slice or integration tests (`@WebMvcTest`, `@DataJpaTest`, `@SpringBootTest`) only after constructor contracts are validated.
- Reuse patterns in `./references/reference.md#testing-with-dependency-injection` to select the proper test style.
## Examples
### Basic Constructor Injection
```java
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
public User register(UserRegistrationRequest request) {
User user = User.create(request.email(), request.name());
userRepository.save(user);
emailService.sendWelcome(user);
return user;
}
}
```
- Instantiate directly in tests: `new UserService(mockRepo, mockEmailService);` with no Spring context required.
### Intermediate: Optional Dependency with Guarded Setter
```java
@Service
public class ReportService {
private final ReportRepository reportRepository;
private CacheService cacheService = CacheService.noOp();
public ReportService(ReportRepository reportRepository) {
this.reportRepository = reportRepository;
}
@Autowired(required = false)
public void setCacheService(CacheService cacheService) {
this.cacheService = cacheService;
}
}
```
- Provide fallbacks such as `CacheService.noOp()` to ensure deterministic behavior when the optional bean is absent.
### Advanced: Conditional Configuration Across Modules
```java
@Configuration
@Import(DatabaseConfig.class)
public class MessagingConfig {
@Bean
@ConditionalOnProperty(name = "feature.notifications.enabled", havingValue = "true")
public NotificationService emailNotificationService(JavaMailSender sender) {
return new EmailNotificationService(sender);
}
@Bean
@ConditionalOnMissingBean(NotificationService.class)
public NotificationService noopNotificationService() {
return NotificationService.noOp();
}
}
```
- Combine `@Import`, profiles, and conditional annotations to orchestrate cross-cutting modules.
Additional worked examples (including tests and configuration wiring) are available in `./references/examples.md`.
## Best Practices
- Prefer constructor injection for mandatory dependencies; allow Spring 4.3+ to infer `@Autowired` on single constructors.
- Encapsulate optional behavior inside dedicated adapters or providers instead of accepting `null` pointers.
- Keep service constructors lightweight; extract orchestrators when dependency counts exceed four.
- Favor domain interfaces in the domain layer and defer framework imports to infrastructure adapters.
- Document bean names and qualifiers in shared constants to avoid typo-driven mismatches.
## Constraints
- Avoid field injection and service locator patterns because they obscure dependencies and impede unit testing.
- Prevent circular dependencies by publishing domain events or extracting shared abstractions.
- Limit `@Lazy` usage to performance-sensitive paths and record the deferred initialization risk.
- Do not add profile-specific beans without matching integration tests that activate the profile.
- Ensure each optional collaborator has a deterministic default or feature-flag handling path.
## Reference Materials
- [extended documentation covering annotations, bean scopes, testing, and anti-pattern mitigations](references/reference.md)
- [progressive examples from constructor injection basics to multi-module configurations](references/examples.md)
- [curated excerpts from the official Spring Framework documentation (constructor vs setter guidance, conditional wiring)](references/spring-official-dependency-injection.md)
## Related Skills
- `spring-boot-crud-patterns` service-layer orchestration patterns that rely on constructor injection.
- `spring-boot-rest-api-standards` controller-layer practices that assume explicit dependency wiring.
- `unit-test-service-layer` Mockito-based testing patterns for constructor-injected services.

View File

@@ -0,0 +1,540 @@
# Spring Boot Dependency Injection - Examples
Comprehensive examples demonstrating dependency injection patterns, from basic to advanced scenarios.
## Example 1: Constructor Injection (Recommended)
The preferred pattern for mandatory dependencies.
```java
// With Lombok @RequiredArgsConstructor (RECOMMENDED)
@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
public User registerUser(CreateUserRequest request) {
log.info("Registering user: {}", request.getEmail());
User user = User.builder()
.email(request.getEmail())
.name(request.getName())
.password(passwordEncoder.encode(request.getPassword()))
.build();
User saved = userRepository.save(user);
emailService.sendWelcomeEmail(saved.getEmail());
return saved;
}
}
// Without Lombok (Explicit)
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository,
EmailService emailService,
PasswordEncoder passwordEncoder) {
this.userRepository = Objects.requireNonNull(userRepository);
this.emailService = Objects.requireNonNull(emailService);
this.passwordEncoder = Objects.requireNonNull(passwordEncoder);
}
public User registerUser(CreateUserRequest request) {
// Implementation
}
}
```
### Test (Easy - No Spring Needed)
```java
@Test
void shouldRegisterUserAndSendEmail() {
// Arrange - Create mocks manually
UserRepository mockRepository = mock(UserRepository.class);
EmailService mockEmailService = mock(EmailService.class);
PasswordEncoder mockEncoder = mock(PasswordEncoder.class);
UserService service = new UserService(mockRepository, mockEmailService, mockEncoder);
User user = User.builder().email("test@example.com").build();
when(mockRepository.save(any())).thenReturn(user);
when(mockEncoder.encode("password")).thenReturn("encoded");
// Act
User result = service.registerUser(new CreateUserRequest("test@example.com", "Test", "password"));
// Assert
assertThat(result.getEmail()).isEqualTo("test@example.com");
verify(mockEmailService).sendWelcomeEmail("test@example.com");
}
```
---
## Example 2: Setter Injection for Optional Dependencies
Use setter injection ONLY for optional dependencies with sensible defaults.
```java
@Service
public class ReportService {
private final ReportRepository reportRepository;
private EmailService emailService; // Optional
private CacheService cacheService; // Optional
// Constructor for mandatory dependency
public ReportService(ReportRepository reportRepository) {
this.reportRepository = Objects.requireNonNull(reportRepository);
}
// Setters for optional dependencies
@Autowired(required = false)
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
@Autowired(required = false)
public void setCacheService(CacheService cacheService) {
this.cacheService = cacheService;
}
public Report generateReport(ReportRequest request) {
Report report = reportRepository.create(request.getTitle());
// Use optional services if available
if (emailService != null) {
emailService.sendReport(report);
}
if (cacheService != null) {
cacheService.cache(report);
}
return report;
}
}
```
---
## Example 3: Configuration with Multiple Bean Definitions
```java
@Configuration
public class AppConfig {
// Bean 1: Database
@Bean
public DataSource dataSource(
@Value("${spring.datasource.url}") String url,
@Value("${spring.datasource.username}") String username,
@Value("${spring.datasource.password}") String password) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl(url);
config.setUsername(username);
config.setPassword(password);
config.setMaximumPoolSize(20);
return new HikariDataSource(config);
}
// Bean 2: Transaction Manager (depends on DataSource)
@Bean
public JpaTransactionManager transactionManager(EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
// Bean 3: Repository (depends on DataSource via JPA)
@Bean
public UserRepository userRepository(UserJpaRepository jpaRepository) {
return new UserRepositoryAdapter(jpaRepository);
}
// Bean 4: Service (depends on Repository)
@Bean
public UserService userService(UserRepository repository) {
return new UserService(repository);
}
}
```
---
## Example 4: Resolving Ambiguities with @Qualifier
```java
@Configuration
public class DataSourceConfig {
@Bean(name = "primaryDB")
public DataSource primaryDataSource() {
return new HikariDataSource();
}
@Bean(name = "secondaryDB")
public DataSource secondaryDataSource() {
return new HikariDataSource();
}
}
@Service
public class MultiDatabaseService {
private final DataSource primaryDataSource;
private final DataSource secondaryDataSource;
// Using @Qualifier to resolve ambiguity
public MultiDatabaseService(
@Qualifier("primaryDB") DataSource primary,
@Qualifier("secondaryDB") DataSource secondary) {
this.primaryDataSource = primary;
this.secondaryDataSource = secondary;
}
public void performOperation() {
// Use primary for writes
executeUpdate(primaryDataSource);
// Use secondary for reads
executeQuery(secondaryDataSource);
}
}
// Alternative: Using @Primary
@Configuration
public class PrimaryDataSourceConfig {
@Bean
@Primary // This bean is preferred when multiple exist
public DataSource primaryDataSource() {
return new HikariDataSource();
}
@Bean
public DataSource secondaryDataSource() {
return new HikariDataSource();
}
}
```
---
## Example 5: Conditional Bean Registration
```java
@Configuration
public class OptionalFeatureConfig {
// Only create if feature is enabled
@Bean
@ConditionalOnProperty(name = "feature.notifications.enabled", havingValue = "true")
public NotificationService notificationService() {
return new EmailNotificationService();
}
// Fallback if no other bean exists
@Bean
@ConditionalOnMissingBean(NotificationService.class)
public NotificationService defaultNotificationService() {
return new NoOpNotificationService();
}
// Only create if class is on classpath
@Bean
@ConditionalOnClass(RedisTemplate.class)
public CacheService cacheService() {
return new RedisCacheService();
}
}
@Service
public class OrderService {
private final NotificationService notificationService;
public OrderService(NotificationService notificationService) {
this.notificationService = notificationService; // Works regardless of implementation
}
public void createOrder(Order order) {
// Always works, but behavior depends on enabled features
notificationService.sendConfirmation(order);
}
}
```
---
## Example 6: Profiles and Environment-Specific Configuration
```java
@Configuration
@Profile("production")
public class ProductionConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://prod-db:5432/production");
config.setMaximumPoolSize(30);
config.setMaxLifetime(1800000); // 30 minutes
return new HikariDataSource(config);
}
@Bean
public SecurityService securityService() {
return new StrictSecurityService();
}
}
@Configuration
@Profile("test")
public class TestConfig {
@Bean
public DataSource dataSource() {
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.addScript("classpath:schema.sql")
.addScript("classpath:test-data.sql")
.build();
}
@Bean
public SecurityService securityService() {
return new PermissiveSecurityService();
}
}
@Configuration
@Profile("development")
public class DevelopmentConfig {
@Bean
public DataSource dataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/dev");
return new HikariDataSource(config);
}
@Bean
public SecurityService securityService() {
return new DebugSecurityService();
}
}
```
**Usage:**
```bash
export SPRING_PROFILES_ACTIVE=production
# or in application.properties:
# spring.profiles.active=production
```
---
## Example 7: Lazy Initialization
```java
@Configuration
public class ExpensiveResourceConfig {
@Bean
@Lazy // Created only when first accessed
public ExpensiveService expensiveService() {
System.out.println("ExpensiveService initialized (lazy)");
return new ExpensiveService();
}
@Bean
public NormalService normalService(ExpensiveService expensive) {
// ExpensiveService not created yet
return new NormalService(expensive); // Lazy proxy injected here
}
}
@SpringBootTest
class LazyInitializationTest {
@Test
void shouldInitializeExpensiveServiceLazy() {
ApplicationContext context = new AnnotationConfigApplicationContext(ExpensiveResourceConfig.class);
// ExpensiveService not initialized yet
assertThat(context.getBean(NormalService.class)).isNotNull();
// Now ExpensiveService is initialized
ExpensiveService service = context.getBean(ExpensiveService.class);
assertThat(service).isNotNull();
}
}
```
---
## Example 8: Circular Dependency Resolution with Events
```java
// ❌ BAD - Circular dependency
@Service
public class UserService {
private final OrderService orderService;
public UserService(OrderService orderService) {
this.orderService = orderService; // Circular!
}
}
@Service
public class OrderService {
private final UserService userService;
public OrderService(UserService userService) {
this.userService = userService; // Circular!
}
}
// ✅ GOOD - Use events to decouple
public class UserRegisteredEvent extends ApplicationEvent {
private final String userId;
public UserRegisteredEvent(Object source, String userId) {
super(source);
this.userId = userId;
}
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
public User registerUser(CreateUserRequest request) {
User user = userRepository.save(User.create(request));
eventPublisher.publishEvent(new UserRegisteredEvent(this, user.getId()));
return user;
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@EventListener
public void onUserRegistered(UserRegisteredEvent event) {
// Create welcome order when user registers
Order welcomeOrder = Order.createWelcomeOrder(event.getUserId());
orderRepository.save(welcomeOrder);
}
}
```
---
## Example 9: Component Scanning
```java
@Configuration
@ComponentScan(basePackages = {
"com.example.users",
"com.example.products",
"com.example.orders"
})
public class AppConfig {
}
// Alternative: Exclude packages
@Configuration
@ComponentScan(basePackages = "com.example",
excludeFilters = @ComponentScan.Filter(type = FilterType.REGEX,
pattern = "com\\.example\\.internal\\..*"))
public class AppConfig {
}
// Auto-discovered by Spring Boot
@SpringBootApplication // Implies @ComponentScan("package.of.main.class")
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
```
---
## Example 10: Testing with Constructor Injection
```java
// ❌ Service with field injection (hard to test)
@Service
public class BadUserService {
@Autowired
private UserRepository userRepository;
public User getUser(Long id) {
return userRepository.findById(id).orElse(null);
}
}
@Test
void testBadService() {
// Must use Spring to test this
UserService service = new BadUserService();
// Can't inject mocks without reflection or Spring
}
// ✅ Service with constructor injection (easy to test)
@Service
@RequiredArgsConstructor
public class GoodUserService {
private final UserRepository userRepository;
public User getUser(Long id) {
return userRepository.findById(id).orElse(null);
}
}
@Test
void testGoodService() {
// Can test directly without Spring
UserRepository mockRepository = mock(UserRepository.class);
UserService service = new GoodUserService(mockRepository);
User mockUser = new User(1L, "Test");
when(mockRepository.findById(1L)).thenReturn(Optional.of(mockUser));
User result = service.getUser(1L);
assertThat(result.getName()).isEqualTo("Test");
}
// Integration test
@SpringBootTest
@ActiveProfiles("test")
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void shouldFetchUserFromDatabase() {
User user = User.create("test@example.com");
userRepository.save(user);
User retrieved = userService.getUser(user.getId());
assertThat(retrieved.getEmail()).isEqualTo("test@example.com");
}
}
```
These examples cover constructor injection (recommended), setter injection (optional dependencies), configuration, testing patterns, and common best practices for dependency injection in Spring Boot.

View File

@@ -0,0 +1,640 @@
# Spring Boot Dependency Injection - References
Complete API reference for dependency injection in Spring Boot applications.
## Core Interfaces and Classes
### ApplicationContext
Root interface for Spring IoC container.
```java
public interface ApplicationContext extends EnvironmentCapable, ListableBeanFactory,
HierarchicalBeanFactory, MessageSource,
ApplicationEventPublisher, ResourcePatternResolver {
// Get a bean by type
<T> T getBean(Class<T> requiredType);
// Get a bean by name and type
<T> T getBean(String name, Class<T> requiredType);
// Get all beans of a type
<T> Map<String, T> getBeansOfType(Class<T> type);
// Get all bean names
String[] getBeanDefinitionNames();
}
```
### BeanFactory
Lower-level interface for accessing beans (used internally).
```java
public interface BeanFactory {
Object getBean(String name);
<T> T getBean(String name, Class<T> requiredType);
<T> T getBean(Class<T> requiredType);
Object getBean(String name, Object... args);
}
```
## Dependency Injection Annotations
### @Autowired
Auto-wire dependencies (property, constructor, or method injection).
```java
@Autowired // Required dependency
@Autowired(required = false) // Optional dependency
@Autowired private UserRepository repository; // Field injection (avoid)
```
### @Qualifier
Disambiguate when multiple beans of same type exist.
```java
@Autowired
@Qualifier("primaryDB")
private DataSource dataSource;
@Bean
@Qualifier("cache")
public CacheService cacheService() { }
```
### @Primary
Mark bean as preferred when multiple exist.
```java
@Bean
@Primary
public DataSource primaryDataSource() { }
@Bean
public DataSource secondaryDataSource() { }
```
### @Value
Inject properties and SpEL expressions.
```java
@Value("${app.name}") // Property injection
@Value("${app.port:8080}") // With default value
@Value("#{T(java.lang.Math).PI}") // SpEL expression
@Value("#{'${app.servers}'.split(',')}") // Collection
private String value;
```
### @Lazy
Delay bean initialization until first access.
```java
@Bean
@Lazy
public ExpensiveBean expensiveBean() { }
@Autowired
@Lazy
private ExpensiveBean bean; // Lazy proxy
```
### @Scope
Define bean lifecycle scope.
```java
@Scope("singleton") // One per container (default)
@Scope("prototype") // New instance each time
@Scope("request") // One per HTTP request
@Scope("session") // One per HTTP session
@Scope("application") // One per ServletContext
@Scope("websocket") // One per WebSocket session
```
### @Configuration
Mark class as providing bean definitions.
```java
@Configuration
public class AppConfig {
@Bean
public UserService userService() { }
}
```
### @Bean
Define a bean in configuration class.
```java
@Bean
public UserService userService(UserRepository repository) {
return new UserService(repository);
}
@Bean(name = "customName")
public UserService userService() { }
@Bean(initMethod = "init", destroyMethod = "cleanup")
public UserService userService() { }
```
### @Component / @Service / @Repository / @Controller
Stereotype annotations for component scanning.
```java
@Component // Generic Spring component
@Service // Business logic layer
@Repository // Data access layer
@Controller // Web layer (MVC)
@RestController // Web layer (REST)
public class UserService { }
```
## Conditional Bean Registration
### @ConditionalOnProperty
Create bean only if property exists.
```java
@Bean
@ConditionalOnProperty(
name = "feature.notifications.enabled",
havingValue = "true"
)
public NotificationService notificationService() { }
// OR if property matches any value
@ConditionalOnProperty(name = "feature.enabled")
public NotificationService notificationService() { }
```
### @ConditionalOnClass / @ConditionalOnMissingClass
Create bean based on classpath availability.
```java
@Bean
@ConditionalOnClass(RedisTemplate.class)
public CacheService cacheService() { }
@Bean
@ConditionalOnMissingClass("org.springframework.data.redis.core.RedisTemplate")
public LocalCacheService fallbackCacheService() { }
```
### @ConditionalOnBean / @ConditionalOnMissingBean
Create bean based on other beans.
```java
@Bean
@ConditionalOnBean(DataSource.class)
public UserService userService() { }
@Bean
@ConditionalOnMissingBean
public UserService defaultUserService() { }
```
### @ConditionalOnExpression
Create bean based on SpEL expression.
```java
@Bean
@ConditionalOnExpression("'${environment}'.equals('production')")
public SecurityService securityService() { }
```
## Profile-Based Configuration
### @Profile
Activate bean only in specific profiles.
```java
@Configuration
@Profile("production")
public class ProductionConfig { }
@Bean
@Profile({"dev", "test"})
public TestDataLoader testDataLoader() { }
@Bean
@Profile("!production") // All profiles except production
public DebugService debugService() { }
```
**Activate profiles:**
```properties
# application.properties
spring.profiles.active=production
# application-production.properties
# Profile-specific property file
spring.datasource.url=jdbc:postgresql://prod-db:5432/prod
```
## Component Scanning
### @ComponentScan
Configure component scanning.
```java
@Configuration
@ComponentScan(basePackages = {"com.example.users", "com.example.products"})
public class AppConfig { }
@Configuration
@ComponentScan(
basePackages = "com.example",
excludeFilters = @ComponentScan.Filter(
type = FilterType.REGEX,
pattern = "com\\.example\\.internal\\..*"
)
)
public class AppConfig { }
```
### Filter Types
- `FilterType.ANNOTATION` - By annotation
- `FilterType.ASSIGNABLE_TYPE` - By class type
- `FilterType.ASPECTJ` - By AspectJ pattern
- `FilterType.REGEX` - By regex pattern
- `FilterType.CUSTOM` - Custom filter
## Injection Points
### Constructor Injection (Recommended)
```java
@Service
@RequiredArgsConstructor // Lombok generates constructor
public class UserService {
private final UserRepository repository; // Final field
private final EmailService emailService;
}
// Explicit
@Service
public class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = Objects.requireNonNull(repository);
}
}
```
### Setter Injection (Optional Dependencies Only)
```java
@Service
public class UserService {
private final UserRepository repository;
private EmailService emailService; // Optional
public UserService(UserRepository repository) {
this.repository = repository;
}
@Autowired(required = false)
public void setEmailService(EmailService emailService) {
this.emailService = emailService;
}
}
```
### Field Injection (❌ Avoid)
```java
// ❌ NOT RECOMMENDED
@Service
public class UserService {
@Autowired
private UserRepository repository; // Hidden dependency
@Autowired
private EmailService emailService; // Mutable state
}
```
## Circular Dependency Resolution
### Problem: Circular Dependencies
```java
// ❌ WILL FAIL
@Service
public class UserService {
private final OrderService orderService;
public UserService(OrderService orderService) {
this.orderService = orderService; // Circular!
}
}
@Service
public class OrderService {
private final UserService userService;
public OrderService(UserService userService) {
this.userService = userService; // Circular!
}
}
```
### Solution 1: Setter Injection
```java
@Service
public class UserService {
private final UserRepository userRepository;
private OrderService orderService; // Optional
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Autowired(required = false)
public void setOrderService(OrderService orderService) {
this.orderService = orderService;
}
}
```
### Solution 2: Event-Driven (Recommended)
```java
public class UserRegisteredEvent extends ApplicationEvent {
private final String userId;
public UserRegisteredEvent(Object source, String userId) {
super(source);
this.userId = userId;
}
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
public User registerUser(CreateUserRequest request) {
User user = userRepository.save(User.create(request));
eventPublisher.publishEvent(new UserRegisteredEvent(this, user.getId()));
return user;
}
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
@EventListener
public void onUserRegistered(UserRegisteredEvent event) {
orderRepository.createWelcomeOrder(event.getUserId());
}
}
```
### Solution 3: Refactor to Separate Concerns
```java
// Shared service without circular dependency
@Service
@RequiredArgsConstructor
public class UserOrderService {
private final UserRepository userRepository;
private final OrderRepository orderRepository;
}
@Service
@RequiredArgsConstructor
public class UserService {
private final UserOrderService userOrderService;
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final UserOrderService userOrderService;
}
```
## ObjectProvider for Flexibility
### ObjectProvider Interface
```java
public interface ObjectProvider<T> extends ObjectFactory<T>, Iterable<T> {
T getObject();
T getObject(Object... args);
T getIfAvailable();
T getIfAvailable(Supplier<T> defaultSupplier);
void ifAvailable(Consumer<T> consumer);
void ifAvailableOrElse(Consumer<T> consumer, Runnable emptyRunnable);
<X> ObjectProvider<X> map(Function<? super T, ? extends X> mapper);
<X> ObjectProvider<X> flatMap(Function<? super T, ObjectProvider<X>> mapper);
Optional<T> getIfUnique();
Optional<T> getIfUnique(Supplier<T> defaultSupplier);
}
```
### Usage Example
```java
@Service
public class FlexibleService {
private final ObjectProvider<CacheService> cacheProvider;
public FlexibleService(ObjectProvider<CacheService> cacheProvider) {
this.cacheProvider = cacheProvider;
}
public void process() {
// Safely handle optional bean
cacheProvider.ifAvailable(cache -> cache.invalidate());
// Get with fallback
CacheService cache = cacheProvider.getIfAvailable(() -> new NoOpCache());
// Iterate if multiple beans exist
cacheProvider.forEach(cache -> cache.initialize());
}
}
```
## Bean Lifecycle Hooks
### InitializingBean / DisposableBean
```java
@Component
public class ResourceManager implements InitializingBean, DisposableBean {
@Override
public void afterPropertiesSet() throws Exception {
// Called after constructor and property injection
System.out.println("Bean initialized");
}
@Override
public void destroy() throws Exception {
// Called when context shutdown
System.out.println("Bean destroyed");
}
}
```
### @PostConstruct / @PreDestroy
```java
@Component
public class ResourceManager {
@PostConstruct
public void init() {
// Called after constructor and injection
System.out.println("Bean initialized");
}
@PreDestroy
public void cleanup() {
// Called before bean destroyed
System.out.println("Bean destroyed");
}
}
```
### @Bean with initMethod and destroyMethod
```java
@Configuration
public class AppConfig {
@Bean(initMethod = "init", destroyMethod = "cleanup")
public ResourceManager resourceManager() {
return new ResourceManager();
}
}
public class ResourceManager {
public void init() {
System.out.println("Initialized");
}
public void cleanup() {
System.out.println("Cleaned up");
}
}
```
## Testing Patterns
### Unit Test (No Spring)
```java
class UserServiceTest {
private UserRepository mockRepository;
private UserService service;
@BeforeEach
void setUp() {
mockRepository = mock(UserRepository.class);
service = new UserService(mockRepository); // Manual injection
}
@Test
void shouldFetchUser() {
User user = new User(1L, "Test");
when(mockRepository.findById(1L)).thenReturn(Optional.of(user));
User result = service.getUser(1L);
assertThat(result).isEqualTo(user);
}
}
```
### Integration Test (With Spring)
```java
@SpringBootTest
@ActiveProfiles("test")
class UserServiceIntegrationTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldFetchUserFromDatabase() {
User user = User.create("test@example.com");
userRepository.save(user);
User retrieved = userService.getUser(user.getId());
assertThat(retrieved.getEmail()).isEqualTo("test@example.com");
}
}
```
### Slice Test
```java
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean // Mock the service
private UserService userService;
@Test
void shouldReturnUser() throws Exception {
User user = new User(1L, "Test");
when(userService.getUser(1L)).thenReturn(user);
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk());
}
}
```
## Best Practices Summary
| Practice | Recommendation | Why |
|----------|---|---|
| Constructor injection | ✅ Mandatory | Explicit, immutable, testable |
| Setter injection | ⚠️ Optional deps | Clear optionality |
| Field injection | ❌ Never | Hidden, untestable |
| @Autowired on constructor | ✅ Implicit (4.3+) | Clear intent |
| Lombok @RequiredArgsConstructor | ✅ Recommended | Reduces boilerplate |
| Circular dependencies | ❌ Avoid | Use events instead |
| Too many dependencies | ❌ Avoid | SRP violation |
| @Lazy for expensive beans | ✅ Appropriate | Faster startup |
| Profiles for environments | ✅ Recommended | Environment-specific config |
| @Value for properties | ✅ Recommended | Type-safe injection |
## External Resources
### Official Documentation
- [Spring IoC Container](https://docs.spring.io/spring-framework/reference/core/beans.html)
- [Spring Boot Auto-Configuration](https://docs.spring.io/spring-boot/docs/current/reference/html/using.html#using.auto-configuration)
- [Conditional Bean Registration](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.developing-auto-configuration.condition-annotations)
### Related Skills
- **spring-boot-crud-patterns/SKILL.md** - DI in CRUD applications
- **spring-boot-test-patterns/SKILL.md** - Testing with DI
- **spring-boot-rest-api-standards/SKILL.md** - REST layer with DI
### Books
- "Spring in Action" (latest edition)
- "Spring Microservices in Action"
### Articles
- [Baeldung Spring Dependency Injection](https://www.baeldung.com/spring-dependency-injection)
- [Martin Fowler IoC](https://www.martinfowler.com/articles/injection.html)

View File

@@ -0,0 +1,59 @@
# Spring Framework Official Guidance: Dependency Injection (Clean Excerpt)
Source: https://docs.spring.io/spring-framework/reference/core/beans/dependencies/factory-collaborators.html (retrieved via `u2m -v` on current date).
## Key Highlights
- Emphasize constructor-based dependency injection to make collaborators explicit and enable immutable design.
- Use setter injection only for optional dependencies or when a dependency can change after initialization.
- Field injection is supported but discouraged because it hides dependencies and complicates testing.
- The IoC container resolves constructor arguments by type, name, and order; prefer unique types or qualify arguments with `@Qualifier` or XML attributes when ambiguity exists.
- Static factory methods behave like constructors for dependency injection and can receive collaborators through arguments.
## Constructor-Based DI
```java
public class SimpleMovieLister {
private final MovieFinder movieFinder;
public SimpleMovieLister(MovieFinder movieFinder) {
this.movieFinder = movieFinder;
}
}
```
- The container selects the matching constructor and provides dependencies by type.
- When argument types are ambiguous, specify indexes (`@ConstructorProperties`, XML `index` attribute) or qualifiers.
## Setter-Based DI
```java
public class SimpleMovieLister {
private MovieFinder movieFinder;
@Autowired
public void setMovieFinder(MovieFinder movieFinder) {
this.movieFinder = movieFinder;
}
}
```
- Invoke only when a collaborator is optional or changeable.
- Use `@Autowired(required = false)` or `ObjectProvider<T>` to guard optional collaborators.
## Reference Snippets
```xml
<bean id="exampleBean" class="examples.ExampleBean">
<constructor-arg ref="anotherExampleBean"/>
<constructor-arg ref="yetAnotherBean"/>
<constructor-arg value="1"/>
</bean>
<bean id="exampleBean" class="examples.ExampleBean">
<property name="beanOne" ref="anotherExampleBean"/>
<property name="beanTwo" ref="yetAnotherBean"/>
</bean>
```
- Spring treats constructor-arg entries as positional parameters unless `index` or `type` is provided.
- Setter injection uses `<property>` elements mapped by name.
## Additional Notes
- Combine configuration classes with `@Import` to wire dependencies declared in different modules.
- Lazy initialization (`@Lazy`) delays bean creation but defers error detection; prefer eager initialization unless startup time is critical.
- Profiles (`@Profile`) activate different wiring scenarios per environment (for example, `@Profile("test")`).
- Testing support allows constructor injection in production code while wiring mocks manually (no container required) or relying on the TestContext framework for integration tests.

View File

@@ -0,0 +1,520 @@
---
name: spring-boot-event-driven-patterns
description: Implement Event-Driven Architecture (EDA) in Spring Boot using ApplicationEvent, @EventListener, and Kafka. Use for building loosely-coupled microservices with domain events, transactional event listeners, and distributed messaging patterns.
allowed-tools: Read, Write, Bash
category: backend
tags: [spring-boot, java, event-driven, eda, kafka, messaging, domain-events, microservices, spring-cloud-stream]
version: 1.1.0
---
# Spring Boot Event-Driven Patterns
## Overview
Implement Event-Driven Architecture (EDA) patterns in Spring Boot 3.x using domain events, ApplicationEventPublisher, @TransactionalEventListener, and distributed messaging with Kafka and Spring Cloud Stream.
## When to Use This Skill
Use this skill when building applications that require:
- Loose coupling between microservices through event-based communication
- Domain event publishing from aggregate roots in DDD architectures
- Transactional event listeners ensuring consistency after database commits
- Distributed messaging with Kafka for inter-service communication
- Event streaming with Spring Cloud Stream for reactive systems
- Reliability using the transactional outbox pattern
- Asynchronous communication between bounded contexts
- Event sourcing foundations with proper event sourcing patterns
## Setup and Configuration
### Required Dependencies
To implement event-driven patterns, include these dependencies in your project:
**Maven:**
```xml
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Kafka for distributed messaging -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- Spring Cloud Stream -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream</artifactId>
<version>4.0.4</version> // Use latest compatible version
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers for integration testing -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
</dependencies>
```
**Gradle:**
```gradle
dependencies {
// Spring Boot Web
implementation 'org.springframework.boot:spring-boot-starter-web'
// Spring Data JPA
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// Kafka
implementation 'org.springframework.kafka:spring-kafka'
// Spring Cloud Stream
implementation 'org.springframework.cloud:spring-cloud-stream:4.0.4'
// Testing
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.testcontainers:testcontainers:1.19.0'
}
```
### Basic Configuration
Configure your application for event-driven architecture:
```properties
# Server Configuration
server.port=8080
# Kafka Configuration
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
# Spring Cloud Stream Configuration
spring.cloud.stream.kafka.binder.brokers=localhost:9092
```
## Core Patterns
### 1. Domain Events Design
Create immutable domain events for business domain changes:
```java
// Domain event base class
public abstract class DomainEvent {
private final UUID eventId;
private final LocalDateTime occurredAt;
private final UUID correlationId;
protected DomainEvent() {
this.eventId = UUID.randomUUID();
this.occurredAt = LocalDateTime.now();
this.correlationId = UUID.randomUUID();
}
protected DomainEvent(UUID correlationId) {
this.eventId = UUID.randomUUID();
this.occurredAt = LocalDateTime.now();
this.correlationId = correlationId;
}
// Getters
public UUID getEventId() { return eventId; }
public LocalDateTime getOccurredAt() { return occurredAt; }
public UUID getCorrelationId() { return correlationId; }
}
// Specific domain events
public class ProductCreatedEvent extends DomainEvent {
private final ProductId productId;
private final String name;
private final BigDecimal price;
private final Integer stock;
public ProductCreatedEvent(ProductId productId, String name, BigDecimal price, Integer stock) {
super();
this.productId = productId;
this.name = name;
this.price = price;
this.stock = stock;
}
// Getters
public ProductId getProductId() { return productId; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public Integer getStock() { return stock; }
}
```
### 2. Aggregate Root with Event Publishing
Implement aggregates that publish domain events:
```java
@Entity
@Getter
@ToString
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
@Id
private ProductId id;
private String name;
private BigDecimal price;
private Integer stock;
@Transient
private List<DomainEvent> domainEvents = new ArrayList<>();
public static Product create(String name, BigDecimal price, Integer stock) {
Product product = new Product();
product.id = ProductId.generate();
product.name = name;
product.price = price;
product.stock = stock;
product.domainEvents.add(new ProductCreatedEvent(product.id, name, price, stock));
return product;
}
public void decreaseStock(Integer quantity) {
this.stock -= quantity;
this.domainEvents.add(new ProductStockDecreasedEvent(this.id, quantity, this.stock));
}
public List<DomainEvent> getDomainEvents() {
return new ArrayList<>(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
```
### 3. Application Event Publishing
Publish domain events from application services:
```java
@Service
@RequiredArgsConstructor
@Transactional
public class ProductApplicationService {
private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;
public ProductResponse createProduct(CreateProductRequest request) {
Product product = Product.create(
request.getName(),
request.getPrice(),
request.getStock()
);
productRepository.save(product);
// Publish domain events
product.getDomainEvents().forEach(eventPublisher::publishEvent);
product.clearDomainEvents();
return mapToResponse(product);
}
}
```
### 4. Local Event Handling
Handle events with transactional event listeners:
```java
@Component
@RequiredArgsConstructor
public class ProductEventHandler {
private final NotificationService notificationService;
private final AuditService auditService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductCreated(ProductCreatedEvent event) {
auditService.logProductCreation(
event.getProductId().getValue(),
event.getName(),
event.getPrice(),
event.getCorrelationId()
);
notificationService.sendProductCreatedNotification(event.getName());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductStockDecreased(ProductStockDecreasedEvent event) {
notificationService.sendStockUpdateNotification(
event.getProductId().getValue(),
event.getQuantity()
);
}
}
```
### 5. Distributed Event Publishing
Publish events to Kafka for inter-service communication:
```java
@Component
@RequiredArgsConstructor
public class ProductEventPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
public void publishProductCreatedEvent(ProductCreatedEvent event) {
ProductCreatedEventDto dto = mapToDto(event);
kafkaTemplate.send("product-events", event.getProductId().getValue(), dto);
}
private ProductCreatedEventDto mapToDto(ProductCreatedEvent event) {
return new ProductCreatedEventDto(
event.getEventId(),
event.getProductId().getValue(),
event.getName(),
event.getPrice(),
event.getStock(),
event.getOccurredAt(),
event.getCorrelationId()
);
}
}
```
### 6. Event Consumer with Spring Cloud Stream
Consume events using functional programming style:
```java
@Component
@RequiredArgsConstructor
public class ProductEventStreamConsumer {
private final OrderService orderService;
@Bean
public Consumer<ProductCreatedEventDto> productCreatedConsumer() {
return event -> {
orderService.onProductCreated(event);
};
}
@Bean
public Consumer<ProductStockDecreasedEventDto> productStockDecreasedConsumer() {
return event -> {
orderService.onProductStockDecreased(event);
};
}
}
```
## Advanced Patterns
### Transactional Outbox Pattern
Ensure reliable event publishing with the outbox pattern:
```java
@Entity
@Table(name = "outbox_events")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String aggregateId;
private String eventType;
private String payload;
private UUID correlationId;
private LocalDateTime createdAt;
private LocalDateTime publishedAt;
private Integer retryCount;
}
@Component
@RequiredArgsConstructor
public class OutboxEventProcessor {
private final OutboxEventRepository outboxRepository;
private final KafkaTemplate<String, Object> kafkaTemplate;
@Scheduled(fixedDelay = 5000)
@Transactional
public void processPendingEvents() {
List<OutboxEvent> pendingEvents = outboxRepository.findByPublishedAtNull();
for (OutboxEvent event : pendingEvents) {
try {
kafkaTemplate.send("product-events", event.getAggregateId(), event.getPayload());
event.setPublishedAt(LocalDateTime.now());
outboxRepository.save(event);
} catch (Exception e) {
event.setRetryCount(event.getRetryCount() + 1);
outboxRepository.save(event);
}
}
}
}
```
## Testing Strategies
### Unit Testing Domain Events
Test domain event publishing and handling:
```java
class ProductTest {
@Test
void shouldPublishProductCreatedEventOnCreation() {
Product product = Product.create("Test Product", BigDecimal.TEN, 100);
assertThat(product.getDomainEvents()).hasSize(1);
assertThat(product.getDomainEvents().get(0))
.isInstanceOf(ProductCreatedEvent.class);
}
}
@ExtendWith(MockitoExtension.class)
class ProductEventHandlerTest {
@Mock
private NotificationService notificationService;
@InjectMocks
private ProductEventHandler handler;
@Test
void shouldHandleProductCreatedEvent() {
ProductCreatedEvent event = new ProductCreatedEvent(
ProductId.of("123"), "Product", BigDecimal.TEN, 100
);
handler.onProductCreated(event);
verify(notificationService).sendProductCreatedNotification("Product");
}
}
```
### Integration Testing with Testcontainers
Test Kafka integration with Testcontainers:
```java
@SpringBootTest
@Testcontainers
class KafkaEventIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@Autowired
private ProductApplicationService productService;
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Test
void shouldPublishEventToKafka() {
CreateProductRequest request = new CreateProductRequest(
"Test Product", BigDecimal.valueOf(99.99), 50
);
ProductResponse response = productService.createProduct(request);
// Verify event was published
verify(eventPublisher).publishProductCreatedEvent(any(ProductCreatedEvent.class));
}
}
```
## Best Practices
### Event Design Guidelines
- **Use past tense naming**: ProductCreated, not CreateProduct
- **Keep events immutable**: All fields should be final
- **Include correlation IDs**: For tracing events across services
- **Serialize to JSON**: For cross-service compatibility
### Transactional Consistency
- **Use AFTER_COMMIT phase**: Ensures events are published after successful database transaction
- **Implement idempotent handlers**: Handle duplicate events gracefully
- **Add retry mechanisms**: For failed event processing
### Error Handling
- **Implement dead-letter queues**: For events that fail processing
- **Log all failures**: Include sufficient context for debugging
- **Set appropriate timeouts**: For event processing operations
### Performance Considerations
- **Batch event processing**: When handling high volumes
- **Use proper partitioning**: For Kafka topics
- **Monitor event latencies**: Set up alerts for slow processing
## Examples and References
See the following resources for comprehensive examples:
- [Complete working examples](references/examples.md)
- [Detailed implementation patterns](references/event-driven-patterns-reference.md)
## Troubleshooting
### Common Issues
**Events not being published:**
- Check transaction phase configuration
- Verify ApplicationEventPublisher is properly autowired
- Ensure transaction is committed before event publishing
**Kafka connection issues:**
- Verify bootstrap servers configuration
- Check network connectivity to Kafka
- Ensure proper serialization configuration
**Event handling failures:**
- Check for circular dependencies in event handlers
- Verify transaction boundaries
- Monitor for exceptions in event processing
### Debug Tips
- Enable debug logging for Spring events: `logging.level.org.springframework.context=DEBUG`
- Use correlation IDs to trace events across services
- Monitor event processing metrics in Actuator endpoints
---
This skill provides the essential patterns and best practices for implementing event-driven architectures in Spring Boot applications.

View File

@@ -0,0 +1,485 @@
# Spring Boot Event-Driven Patterns - References
Complete API reference for event-driven architecture in Spring Boot applications.
## Domain Event Annotations and Interfaces
### ApplicationEvent
Base class for Spring events (deprecated in newer versions in favor of plain objects).
```java
public abstract class ApplicationEvent extends EventObject {
private final long timestamp;
public ApplicationEvent(Object source) {
super(source);
this.timestamp = System.currentTimeMillis();
}
public long getTimestamp() {
return timestamp;
}
}
// Modern approach: Use plain POJOs
public record ProductCreatedEvent(String productId, String name, BigDecimal price) {}
```
### Custom Domain Event Base Class
```java
public abstract class DomainEvent {
private final UUID eventId;
private final LocalDateTime occurredAt;
private final UUID correlationId;
protected DomainEvent() {
this.eventId = UUID.randomUUID();
this.occurredAt = LocalDateTime.now();
this.correlationId = UUID.randomUUID();
}
}
```
## Event Publishing Annotations
### @EventListener
Register event listener methods.
```java
@EventListener
public void onProductCreated(ProductCreatedEvent event) { }
@EventListener(condition = "#event.productId == '123'") // SpEL condition
public void onSpecificProduct(ProductCreatedEvent event) { }
@EventListener(classes = { ProductCreatedEvent.class, ProductUpdatedEvent.class })
public void onProductEvent(DomainEvent event) { }
```
### @TransactionalEventListener
Listen to events within transaction lifecycle.
```java
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductCreated(ProductCreatedEvent event) { }
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void beforeCommit(ProductCreatedEvent event) { }
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void afterRollback(ProductCreatedEvent event) { }
```
**TransactionPhase Values:**
- `BEFORE_COMMIT` - Before transaction commits
- `AFTER_COMMIT` - After successful commit (recommended)
- `AFTER_ROLLBACK` - After transaction rollback
- `AFTER_COMPLETION` - After transaction completion (success or rollback)
## Event Publishing Reference
### ApplicationEventPublisher Interface
```java
public interface ApplicationEventPublisher {
void publishEvent(ApplicationEvent event);
void publishEvent(Object event); // Modern approach
}
```
### Usage Pattern
```java
@Service
@RequiredArgsConstructor
public class ProductService {
private final ApplicationEventPublisher eventPublisher;
public Product create(CreateProductRequest request) {
Product product = Product.create(request);
Product saved = repository.save(product);
// Publish events
saved.getDomainEvents().forEach(eventPublisher::publishEvent);
saved.clearDomainEvents();
return saved;
}
}
```
## Kafka Spring Cloud Stream Reference
### Stream Binders Configuration
```yaml
spring:
cloud:
stream:
kafka:
binder:
brokers: localhost:9092
default-binder: kafka
configuration:
linger.ms: 10
batch.size: 1024
bindings:
# Consumer binding
productCreatedConsumer-in-0:
destination: product-events
group: product-service
consumer:
max-attempts: 3
back-off-initial-interval: 1000
back-off-max-interval: 10000
# Producer binding
eventPublisher-out-0:
destination: product-events
producer:
partition-key-expression: headers['partitionKey']
```
### Consumer Function Binding
```java
@Configuration
public class EventConsumers {
@Bean
public java.util.function.Consumer<ProductCreatedEvent> productCreatedConsumer(
InventoryService inventoryService) {
return event -> {
log.info("Consumed: {}", event);
inventoryService.process(event);
};
}
// Multiple consumers
@Bean
public java.util.function.Consumer<ProductUpdatedEvent> productUpdatedConsumer() {
return event -> { };
}
}
// application.yml
spring.cloud.stream.bindings.productCreatedConsumer-in-0.destination=product-events
spring.cloud.stream.bindings.productUpdatedConsumer-in-0.destination=product-events
```
### Producer Function Binding
```java
@Configuration
public class EventProducers {
@Bean
public java.util.function.Supplier<ProductCreatedEvent> eventPublisher() {
return () -> new ProductCreatedEvent("prod-123", "Laptop", BigDecimal.TEN);
}
}
// application.yml
spring.cloud.stream.bindings.eventPublisher-out-0.destination=product-events
```
## Transactional Outbox Pattern Reference
### Outbox Entity Schema
```sql
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255),
event_type VARCHAR(255) NOT NULL,
payload TEXT NOT NULL,
correlation_id UUID,
created_at TIMESTAMP NOT NULL,
published_at TIMESTAMP,
retry_count INTEGER DEFAULT 0,
KEY idx_published (published_at),
KEY idx_created (created_at)
);
```
### Implementation Pattern
```java
// In single transaction:
// 1. Update aggregate
product = repository.save(product);
// 2. Store events in outbox
product.getDomainEvents().forEach(event -> {
outboxRepository.save(new OutboxEvent(
aggregateId, eventType, payload, correlationId
));
});
// Then separately, scheduled task publishes from outbox
@Scheduled(fixedDelay = 5000)
public void publishPendingEvents() {
List<OutboxEvent> pending = outboxRepository.findByPublishedAtIsNull();
pending.forEach(event -> {
kafkaTemplate.send(topic, event.getPayload());
event.setPublishedAt(now());
});
}
```
## Maven Dependencies
```xml
<!-- Local Events (Spring Framework core) -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<!-- Kafka -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- Spring Cloud Stream -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream</artifactId>
<version>4.0.4</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-kafka</artifactId>
<version>4.0.4</version>
</dependency>
<!-- Jackson for JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
```
## Gradle Dependencies
```gradle
dependencies {
// Local Events
implementation 'org.springframework:spring-context'
// Kafka
implementation 'org.springframework.kafka:spring-kafka'
// Spring Cloud Stream
implementation 'org.springframework.cloud:spring-cloud-stream:4.0.4'
implementation 'org.springframework.cloud:spring-cloud-stream-binder-kafka:4.0.4'
// Jackson
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
}
```
## Event Ordering Guarantees
### Kafka Partition Key Strategy
```java
// Events with same product must be in same partition
kafkaTemplate.send(topic,
productId, // Key: ensures ordering per product
event); // Value
// Consumer configuration
spring.kafka.consumer.properties.isolation.level=read_committed
spring.cloud.stream.kafka.binder.configuration.isolation.level=read_committed
```
## Error Handling Patterns
### Retry with Backoff
```yaml
spring:
cloud:
stream:
bindings:
eventConsumer-in-0:
consumer:
max-attempts: 3
back-off-initial-interval: 1000 # 1 second
back-off-max-interval: 10000 # 10 seconds
back-off-multiplier: 2.0 # Exponential
default-retryable: true
retryable-exceptions:
com.example.RetryableException: true
```
### Dead Letter Topic (DLT)
```yaml
spring:
cloud:
stream:
kafka:
bindings:
eventConsumer-in-0:
consumer:
enable-dlq: true
dlq-name: product-events.dlq
dlq-producer-properties:
linger.ms: 5
```
## Idempotency Patterns
### Idempotent Consumer
```java
@Component
public class IdempotentEventHandler {
private final IdempotencyKeyRepository idempotencyRepository;
private final EventProcessingService eventService;
@EventListener
public void handle(DomainEvent event) throws Exception {
String idempotencyKey = event.getEventId().toString();
// Check if already processed
if (idempotencyRepository.exists(idempotencyKey)) {
log.info("Event already processed: {}", idempotencyKey);
return;
}
try {
// Process event
eventService.process(event);
// Mark as processed
idempotencyRepository.save(new IdempotencyKey(idempotencyKey));
} catch (Exception e) {
log.error("Event processing failed: {}", idempotencyKey, e);
throw e;
}
}
}
```
## Testing Event-Driven Systems
### Local Event Testing
```java
@SpringBootTest
class EventDrivenTest {
@Autowired
private ApplicationEventPublisher eventPublisher;
@MockBean
private EventHandler handler;
@Test
void shouldHandleEvent() {
// Arrange
ProductCreatedEvent event = new ProductCreatedEvent("123", "Laptop", BigDecimal.TEN);
// Act
eventPublisher.publishEvent(event);
// Assert
verify(handler).onProductCreated(event);
}
}
```
### Kafka Testing with Testcontainers
```java
@SpringBootTest
@Testcontainers
class KafkaEventTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0"));
@DynamicPropertySource
static void setupProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Autowired
private KafkaTemplate<String, Object> kafkaTemplate;
@Test
void shouldPublishEventToKafka() throws Exception {
ProductCreatedEvent event = new ProductCreatedEvent("123", "Laptop", BigDecimal.TEN);
kafkaTemplate.send("product-events", "123", event).get(5, TimeUnit.SECONDS);
// Verify consumption
}
}
```
## Monitoring and Observability
### Spring Boot Actuator Metrics
```properties
# Enable metrics
management.endpoints.web.exposure.include=metrics,health
# Kafka metrics
kafka.controller.metrics.topic.under_replication_count
kafka.log.leader_election.latency.avg
```
### Custom Event Metrics
```java
@Component
@RequiredArgsConstructor
public class EventMetrics {
private final MeterRegistry meterRegistry;
public void recordEventPublished(String eventType) {
meterRegistry.counter("events.published", "type", eventType).increment();
}
public void recordEventProcessed(String eventType, long durationMs) {
meterRegistry.timer("events.processed", "type", eventType).record(durationMs, TimeUnit.MILLISECONDS);
}
public void recordEventFailed(String eventType) {
meterRegistry.counter("events.failed", "type", eventType).increment();
}
}
```
## Related Skills
- **spring-boot-crud-patterns** - Domain events in CRUD operations
- **spring-boot-rest-api-standards** - Event notifications via webhooks
- **spring-boot-test-patterns** - Testing event-driven systems
- **spring-boot-dependency-injection** - Dependency injection in event handlers
## External Resources
### Official Documentation
- [Spring ApplicationContext](https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html)
- [Spring Cloud Stream](https://spring.io/projects/spring-cloud-stream)
- [Kafka Spring Integration](https://docs.spring.io/spring-kafka/docs/current/reference/html/)
### Patterns and Best Practices
- [Event Sourcing Pattern](https://martinfowler.com/eaaDev/EventSourcing.html)
- [Saga Pattern](https://microservices.io/patterns/data/saga.html)
- [Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html)

View File

@@ -0,0 +1,510 @@
# Spring Boot Event-Driven Patterns - Examples
Comprehensive examples demonstrating event-driven architecture from basic local events to advanced distributed messaging.
## Example 1: Basic Domain Events
A simple product lifecycle with domain events.
```java
// Domain event
public class ProductCreatedEvent extends DomainEvent {
private final String productId;
private final String name;
private final BigDecimal price;
public ProductCreatedEvent(String productId, String name, BigDecimal price) {
super();
this.productId = productId;
this.name = name;
this.price = price;
}
// Getters
}
// Aggregate publishing events
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product {
private String id;
private String name;
private BigDecimal price;
@Transient
private List<DomainEvent> domainEvents = new ArrayList<>();
public static Product create(String name, BigDecimal price) {
Product product = new Product();
product.id = UUID.randomUUID().toString();
product.name = name;
product.price = price;
// Publish domain event
product.publishEvent(new ProductCreatedEvent(product.id, name, price));
return product;
}
protected void publishEvent(DomainEvent event) {
domainEvents.add(event);
}
public List<DomainEvent> getDomainEvents() {
return new ArrayList<>(domainEvents);
}
public void clearDomainEvents() {
domainEvents.clear();
}
}
```
---
## Example 2: Local Event Publishing
Using ApplicationEventPublisher for in-process events.
```java
// Application service
@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class ProductApplicationService {
private final ProductRepository productRepository;
private final ApplicationEventPublisher eventPublisher;
public ProductResponse createProduct(CreateProductRequest request) {
Product product = Product.create(request.getName(), request.getPrice());
Product saved = productRepository.save(product);
// Publish domain events
saved.getDomainEvents().forEach(event -> {
log.debug("Publishing event: {}", event.getClass().getSimpleName());
eventPublisher.publishEvent(event);
});
saved.clearDomainEvents();
return mapper.toResponse(saved);
}
}
// Event listener
@Component
@Slf4j
@RequiredArgsConstructor
public class ProductEventHandler {
private final NotificationService notificationService;
private final InventoryService inventoryService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductCreated(ProductCreatedEvent event) {
log.info("Handling ProductCreatedEvent");
// Send notification
notificationService.sendProductCreatedNotification(
event.getName(), event.getPrice()
);
// Update inventory
inventoryService.registerProduct(event.getProductId());
}
}
// Test
@SpringBootTest
class ProductEventTest {
@Autowired
private ProductApplicationService productService;
@MockBean
private NotificationService notificationService;
@Autowired
private ProductRepository productRepository;
@Test
void shouldPublishProductCreatedEvent() {
// Act
productService.createProduct(
new CreateProductRequest("Laptop", BigDecimal.valueOf(999.99))
);
// Assert - Event was handled
verify(notificationService).sendProductCreatedNotification(
"Laptop", BigDecimal.valueOf(999.99)
);
}
}
```
---
## Example 3: Transactional Outbox Pattern
Ensures reliable event publishing even on failures.
```java
// Outbox entity
@Entity
@Table(name = "outbox_events")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OutboxEvent {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String aggregateId;
private String eventType;
@Column(columnDefinition = "TEXT")
private String payload;
private LocalDateTime createdAt;
private LocalDateTime publishedAt;
private Integer retryCount;
}
// Application service using outbox
@Service
@Slf4j
@RequiredArgsConstructor
@Transactional
public class ProductApplicationService {
private final ProductRepository productRepository;
private final OutboxEventRepository outboxRepository;
private final ObjectMapper objectMapper;
public ProductResponse createProduct(CreateProductRequest request) {
Product product = Product.create(request.getName(), request.getPrice());
Product saved = productRepository.save(product);
// Store event in outbox (same transaction)
saved.getDomainEvents().forEach(event -> {
try {
String payload = objectMapper.writeValueAsString(event);
OutboxEvent outboxEvent = OutboxEvent.builder()
.aggregateId(saved.getId())
.eventType(event.getClass().getSimpleName())
.payload(payload)
.createdAt(LocalDateTime.now())
.retryCount(0)
.build();
outboxRepository.save(outboxEvent);
log.debug("Outbox event created: {}", event.getClass().getSimpleName());
} catch (Exception e) {
log.error("Failed to create outbox event", e);
throw new RuntimeException(e);
}
});
return mapper.toResponse(saved);
}
}
// Scheduled publisher
@Component
@Slf4j
@RequiredArgsConstructor
public class OutboxEventPublisher {
private final OutboxEventRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
@Scheduled(fixedDelay = 5000)
@Transactional
public void publishPendingEvents() {
List<OutboxEvent> pending = outboxRepository.findByPublishedAtIsNull();
for (OutboxEvent event : pending) {
try {
kafkaTemplate.send("product-events",
event.getAggregateId(), event.getPayload());
event.setPublishedAt(LocalDateTime.now());
outboxRepository.save(event);
log.info("Published outbox event: {}", event.getId());
} catch (Exception e) {
log.error("Failed to publish event: {}", event.getId(), e);
event.setRetryCount(event.getRetryCount() + 1);
outboxRepository.save(event);
}
}
}
}
```
---
## Example 4: Kafka Event Publishing
Distributed event publishing with Spring Cloud Stream.
```java
// Application configuration
@Configuration
public class KafkaConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
}
// Event publisher
@Component
@Slf4j
@RequiredArgsConstructor
public class KafkaProductEventPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
public void publishProductCreatedEvent(ProductCreatedEvent event) {
log.info("Publishing ProductCreatedEvent to Kafka: {}", event.getProductId());
kafkaTemplate.send("product-events",
event.getProductId(),
event);
}
}
// Event consumer
@Component
@Slf4j
@RequiredArgsConstructor
public class ProductEventStreamConsumer {
private final InventoryService inventoryService;
@Bean
public java.util.function.Consumer<ProductCreatedEvent> productCreatedConsumer() {
return event -> {
log.info("Consumed ProductCreatedEvent: {}", event.getProductId());
inventoryService.registerProduct(event.getProductId(), event.getName());
};
}
@Bean
public java.util.function.Consumer<ProductUpdatedEvent> productUpdatedConsumer() {
return event -> {
log.info("Consumed ProductUpdatedEvent: {}", event.getProductId());
inventoryService.updateProduct(event.getProductId(), event.getPrice());
};
}
}
// Application properties
```
**application.yml:**
```yaml
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
consumer:
group-id: product-service
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
properties:
spring.json.trusted.packages: "*"
cloud:
stream:
bindings:
productCreatedConsumer-in-0:
destination: product-events
group: product-inventory-service
productUpdatedConsumer-in-0:
destination: product-events
group: product-inventory-service
```
---
## Example 5: Event Saga Pattern
Coordinating multiple services with events.
```java
// Events
public class OrderPlacedEvent extends DomainEvent {
private final String orderId;
private final String productId;
private final Integer quantity;
// ...
}
public class OrderPaymentConfirmedEvent extends DomainEvent {
private final String orderId;
// ...
}
// Saga orchestrator
@Component
@Slf4j
@RequiredArgsConstructor
public class OrderFulfillmentSaga {
private final OrderService orderService;
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final ApplicationEventPublisher eventPublisher;
@Transactional
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
log.info("Starting order fulfillment saga for order: {}", event.getOrderId());
try {
// Step 1: Reserve inventory
inventoryService.reserveStock(event.getProductId(), event.getQuantity());
log.info("Inventory reserved for order: {}", event.getOrderId());
// Step 2: Process payment
paymentService.processPayment(event.getOrderId());
log.info("Payment processed for order: {}", event.getOrderId());
// Step 3: Publish confirmation
eventPublisher.publishEvent(new OrderPaymentConfirmedEvent(event.getOrderId()));
// Step 4: Update order status
orderService.markAsConfirmed(event.getOrderId());
log.info("Order confirmed: {}", event.getOrderId());
} catch (PaymentFailedException e) {
log.warn("Payment failed, releasing inventory");
inventoryService.releaseStock(event.getProductId(), event.getQuantity());
orderService.markAsFailed(event.getOrderId(), e.getMessage());
}
}
}
// Test
@SpringBootTest
class OrderFulfillmentSagaTest {
@Autowired
private ApplicationEventPublisher eventPublisher;
@MockBean
private InventoryService inventoryService;
@MockBean
private PaymentService paymentService;
@MockBean
private OrderService orderService;
@Test
void shouldCompleteOrderFulfillmentSaga() {
// Arrange
OrderPlacedEvent event = new OrderPlacedEvent("order-123", "product-456", 2);
// Act
eventPublisher.publishEvent(event);
// Assert
verify(inventoryService).reserveStock("product-456", 2);
verify(paymentService).processPayment("order-123");
verify(orderService).markAsConfirmed("order-123");
}
}
```
---
## Example 6: Event Sourcing Foundation
Storing state changes as events.
```java
// Event store
@Repository
public interface EventStoreRepository extends JpaRepository<StoredEvent, UUID> {
List<StoredEvent> findByAggregateIdOrderBySequenceAsc(String aggregateId);
}
// Stored event
@Entity
@Table(name = "event_store")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StoredEvent {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String aggregateId;
private String eventType;
private Integer sequence;
@Column(columnDefinition = "TEXT")
private String payload;
private LocalDateTime occurredAt;
}
// Event sourcing service
@Service
@Slf4j
@RequiredArgsConstructor
public class EventSourcingService {
private final EventStoreRepository eventStoreRepository;
private final ObjectMapper objectMapper;
@Transactional
public void storeEvent(String aggregateId, DomainEvent event) {
try {
List<StoredEvent> existing = eventStoreRepository
.findByAggregateIdOrderBySequenceAsc(aggregateId);
Integer nextSequence = existing.isEmpty() ? 1 :
existing.get(existing.size() - 1).getSequence() + 1;
StoredEvent storedEvent = StoredEvent.builder()
.aggregateId(aggregateId)
.eventType(event.getClass().getSimpleName())
.sequence(nextSequence)
.payload(objectMapper.writeValueAsString(event))
.occurredAt(LocalDateTime.now())
.build();
eventStoreRepository.save(storedEvent);
log.info("Event stored: {} for aggregate: {}",
event.getClass().getSimpleName(), aggregateId);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to store event", e);
}
}
public List<DomainEvent> getEventHistory(String aggregateId) {
return eventStoreRepository
.findByAggregateIdOrderBySequenceAsc(aggregateId)
.stream()
.map(this::deserializeEvent)
.collect(Collectors.toList());
}
private DomainEvent deserializeEvent(StoredEvent stored) {
try {
Class<?> eventClass = Class.forName(
"com.example.product.domain.event." + stored.getEventType());
return (DomainEvent) objectMapper.readValue(stored.getPayload(), eventClass);
} catch (Exception e) {
throw new RuntimeException("Failed to deserialize event", e);
}
}
}
```
These examples cover local events, transactional outbox pattern, Kafka publishing, saga coordination, and event sourcing foundations for comprehensive event-driven architecture.

View File

@@ -0,0 +1,624 @@
---
name: spring-boot-openapi-documentation
description: Generate comprehensive REST API documentation using SpringDoc OpenAPI 3.0 and Swagger UI in Spring Boot 3.x applications. Use when setting up API documentation, configuring Swagger UI, adding OpenAPI annotations, implementing security documentation, or enhancing REST endpoints with examples and schemas.
allowed-tools: Read, Write, Bash, Grep
category: backend
tags: [spring-boot, openapi, swagger, api-documentation, springdoc]
version: 1.1.0
---
# Spring Boot OpenAPI Documentation with SpringDoc
Implement comprehensive REST API documentation using SpringDoc OpenAPI 3.0 and Swagger UI in Spring Boot 3.x applications.
## When to Use
Use this skill when you need to:
- Set up SpringDoc OpenAPI in Spring Boot 3.x projects
- Generate OpenAPI 3.0 specifications for REST APIs
- Configure and customize Swagger UI
- Add detailed API documentation with annotations
- Document request/response models with validation
- Implement API security documentation (JWT, OAuth2, Basic Auth)
- Document pageable and sortable endpoints
- Add examples and schemas to API endpoints
- Customize OpenAPI definitions programmatically
- Generate API documentation for WebMvc or WebFlux applications
- Support multiple API groups and versions
- Document error responses and exception handlers
- Add JSR-303 Bean Validation to API documentation
- Support Kotlin-based Spring Boot APIs
## Setup Dependencies
### Add Maven Dependencies
```xml
<!-- Standard WebMVC support -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.13</version> // Use latest stable version
</dependency>
<!-- Optional: therapi-runtime-javadoc for JavaDoc support -->
<dependency>
<groupId>com.github.therapi</groupId>
<artifactId>therapi-runtime-javadoc</artifactId>
<version>0.15.0</version> // Use latest stable version
<scope>provided</scope>
</dependency>
<!-- WebFlux support -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>2.8.13</version> // Use latest stable version
</dependency>
```
### Add Gradle Dependencies
```gradle
// Standard WebMVC support
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13'
// Optional: therapi-runtime-javadoc for JavaDoc support
implementation 'com.github.therapi:therapi-runtime-javadoc:0.15.0'
// WebFlux support
implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.8.13'
```
## Configure SpringDoc
### Basic Configuration
```properties
# application.properties
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui-custom.html
springdoc.swagger-ui.operationsSorter=method
springdoc.swagger-ui.tagsSorter=alpha
springdoc.swagger-ui.enabled=true
springdoc.api-docs.enabled=true
springdoc.packages-to-scan=com.example.controller
springdoc.paths-to-match=/api/**
```
```yaml
# application.yml
springdoc:
api-docs:
path: /api-docs
enabled: true
swagger-ui:
path: /swagger-ui.html
enabled: true
operationsSorter: method
tagsSorter: alpha
tryItOutEnabled: true
packages-to-scan: com.example.controller
paths-to-match: /api/**
```
### Access Endpoints
After configuration:
- **OpenAPI JSON**: `http://localhost:8080/v3/api-docs`
- **OpenAPI YAML**: `http://localhost:8080/v3/api-docs.yaml`
- **Swagger UI**: `http://localhost:8080/swagger-ui/index.html`
## Document Controllers
### Basic Controller Documentation
```java
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 org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/books")
@Tag(name = "Book", description = "Book management APIs")
public class BookController {
@Operation(
summary = "Retrieve a book by ID",
description = "Get a Book object by specifying its ID. The response includes id, title, author and description."
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Successfully retrieved book",
content = @Content(schema = @Schema(implementation = Book.class))
),
@ApiResponse(
responseCode = "404",
description = "Book not found"
)
})
@GetMapping("/{id}")
public Book findById(
@Parameter(description = "ID of book to retrieve", required = true)
@PathVariable Long id
) {
return repository.findById(id)
.orElseThrow(() -> new BookNotFoundException());
}
}
```
### Document Request Bodies
```java
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.media.ExampleObject;
@Operation(summary = "Create a new book")
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book createBook(
@RequestBody(
description = "Book to create",
required = true,
content = @Content(
schema = @Schema(implementation = Book.class),
examples = @ExampleObject(
value = """
{
"title": "Clean Code",
"author": "Robert C. Martin",
"isbn": "978-0132350884",
"description": "A handbook of agile software craftsmanship"
}
"""
)
)
)
Book book
) {
return repository.save(book);
}
```
## Document Models
### Entity with Validation
```java
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
@Entity
@Schema(description = "Book entity representing a published book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Schema(description = "Unique identifier", example = "1", accessMode = Schema.AccessMode.READ_ONLY)
private Long id;
@NotBlank(message = "Title is required")
@Size(min = 1, max = 200)
@Schema(description = "Book title", example = "Clean Code", required = true, maxLength = 200)
private String title;
@Pattern(regexp = "^(?:ISBN(?:-1[03])?:? )?(?=[0-9X]{10}$|(?=(?:[0-9]+[- ]){3})[- 0-9X]{13}$|97[89][0-9]{10}$|(?=(?:[0-9]+[- ]){4})[- 0-9]{17}$)(?:97[89][- ]?)?[0-9]{1,5}[- ]?[0-9]+[- ]?[0-9]+[- ]?[0-9X]$")
@Schema(description = "ISBN number", example = "978-0132350884")
private String isbn;
// Additional fields, constructors, getters, setters
}
```
### Hidden Fields
```java
@Schema(hidden = true)
private String internalField;
@JsonIgnore
@Schema(accessMode = Schema.AccessMode.READ_ONLY)
private LocalDateTime createdAt;
```
## Document Security
### JWT Bearer Authentication
```java
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.security.SecurityScheme;
@Configuration
public class OpenAPISecurityConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.components(new Components()
.addSecuritySchemes("bearer-jwt", new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("JWT authentication")
)
);
}
}
// Apply security requirement
@RestController
@RequestMapping("/api/books")
@SecurityRequirement(name = "bearer-jwt")
public class BookController {
// All endpoints require JWT authentication
}
```
### OAuth2 Configuration
```java
import io.swagger.v3.oas.models.security.OAuthFlow;
import io.swagger.v3.oas.models.security.OAuthFlows;
import io.swagger.v3.oas.models.security.Scopes;
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.components(new Components()
.addSecuritySchemes("oauth2", new SecurityScheme()
.type(SecurityScheme.Type.OAUTH2)
.flows(new OAuthFlows()
.authorizationCode(new OAuthFlow()
.authorizationUrl("https://auth.example.com/oauth/authorize")
.tokenUrl("https://auth.example.com/oauth/token")
.scopes(new Scopes()
.addString("read", "Read access")
.addString("write", "Write access")
)
)
)
)
);
}
```
## Document Pagination
### Spring Data Pageable Support
```java
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@Operation(summary = "Get paginated list of books")
@GetMapping("/paginated")
public Page<Book> findAllPaginated(
@ParameterObject Pageable pageable
) {
return repository.findAll(pageable);
}
```
## Advanced Configuration
### Multiple API Groups
```java
import org.springdoc.core.models.GroupedOpenApi;
@Bean
public GroupedOpenApi publicApi() {
return GroupedOpenApi.builder()
.group("public")
.pathsToMatch("/api/public/**")
.build();
}
@Bean
public GroupedOpenApi adminApi() {
return GroupedOpenApi.builder()
.group("admin")
.pathsToMatch("/api/admin/**")
.build();
}
```
### Custom Operation Customizer
```java
import org.springdoc.core.customizers.OperationCustomizer;
@Bean
public OperationCustomizer customizeOperation() {
return (operation, handlerMethod) -> {
operation.addExtension("x-custom-field", "custom-value");
return operation;
};
}
```
### Hide Endpoints
```java
@Operation(hidden = true)
@GetMapping("/internal")
public String internalEndpoint() {
return "Hidden from docs";
}
// Hide entire controller
@Hidden
@RestController
public class InternalController {
// All endpoints hidden
}
```
## Document Exception Responses
### Global Exception Handler
```java
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
@Operation(hidden = true)
public ErrorResponse handleBookNotFound(BookNotFoundException ex) {
return new ErrorResponse("BOOK_NOT_FOUND", ex.getMessage());
}
@ExceptionHandler(ValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@Operation(hidden = true)
public ErrorResponse handleValidation(ValidationException ex) {
return new ErrorResponse("VALIDATION_ERROR", ex.getMessage());
}
}
@Schema(description = "Error response")
public record ErrorResponse(
@Schema(description = "Error code", example = "BOOK_NOT_FOUND")
String code,
@Schema(description = "Error message", example = "Book with ID 123 not found")
String message,
@Schema(description = "Timestamp", example = "2024-01-15T10:30:00Z")
LocalDateTime timestamp
) {}
```
## Build Integration
### Maven Plugin
```xml
<plugin>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-maven-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<phase>integration-test</phase>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
<outputFileName>openapi.json</outputFileName>
<outputDir>${project.build.directory}</outputDir>
</configuration>
</plugin>
```
### Gradle Plugin
```gradle
plugins {
id 'org.springdoc.openapi-gradle-plugin' version '1.9.0'
}
openApi {
apiDocsUrl = "http://localhost:8080/v3/api-docs"
outputDir = file("$buildDir/docs")
outputFileName = "openapi.json"
}
```
## Examples
### Complete REST Controller Example
```java
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
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 io.swagger.v3.oas.annotations.security.SecurityRequirement;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
@RestController
@RequestMapping("/api/books")
@Tag(name = "Book", description = "Book management APIs")
@SecurityRequirement(name = "bearer-jwt")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@Operation(summary = "Get all books")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Found all books",
content = @Content(
mediaType = "application/json",
array = @ArraySchema(schema = @Schema(implementation = Book.class))
)
)
})
@GetMapping
public List<Book> getAllBooks() {
return bookService.getAllBooks();
}
@Operation(summary = "Get paginated books")
@GetMapping("/paginated")
public Page<Book> getBooksPaginated(@ParameterObject Pageable pageable) {
return bookService.getBooksPaginated(pageable);
}
@Operation(summary = "Get book by ID")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Book found"),
@ApiResponse(responseCode = "404", description = "Book not found")
})
@GetMapping("/{id}")
public Book getBookById(@PathVariable Long id) {
return bookService.getBookById(id);
}
@Operation(summary = "Create new book")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Book created successfully"),
@ApiResponse(responseCode = "400", description = "Invalid input")
})
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book createBook(@Valid @RequestBody Book book) {
return bookService.createBook(book);
}
@Operation(summary = "Update book")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Book updated"),
@ApiResponse(responseCode = "404", description = "Book not found")
})
@PutMapping("/{id}")
public Book updateBook(@PathVariable Long id, @Valid @RequestBody Book book) {
return bookService.updateBook(id, book);
}
@Operation(summary = "Delete book")
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Book deleted"),
@ApiResponse(responseCode = "404", description = "Book not found")
})
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteBook(@PathVariable Long id) {
bookService.deleteBook(id);
}
}
```
## Best Practices
1. **Use descriptive operation summaries and descriptions**
- Summary: Short, clear statement (< 120 chars)
- Description: Detailed explanation with use cases
2. **Document all response codes**
- Include success (2xx), client errors (4xx), server errors (5xx)
- Provide meaningful descriptions for each
3. **Add examples to request/response bodies**
- Use `@ExampleObject` for realistic examples
- Include edge cases when relevant
4. **Leverage JSR-303 validation annotations**
- SpringDoc auto-generates constraints from validation annotations
- Reduces duplication between code and documentation
5. **Use `@ParameterObject` for complex parameters**
- Especially useful for Pageable, custom filter objects
- Keeps controller methods clean
6. **Group related endpoints with @Tag**
- Organize API by domain entities or features
- Use consistent tag names across controllers
7. **Document security requirements**
- Apply `@SecurityRequirement` where authentication needed
- Configure security schemes globally in OpenAPI bean
8. **Hide internal/admin endpoints appropriately**
- Use `@Hidden` or create separate API groups
- Prevent exposing internal implementation details
9. **Customize Swagger UI for better UX**
- Enable filtering, sorting, try-it-out features
- Set appropriate default behaviors
10. **Version your API documentation**
- Include version in OpenAPI Info
- Consider multiple API groups for versioned APIs
## Common Annotations Reference
### Core Annotations
- `@Tag`: Group operations under a tag
- `@Operation`: Describe a single API operation
- `@ApiResponse` / `@ApiResponses`: Document response codes
- `@Parameter`: Document a single parameter
- `@RequestBody`: Document request body (OpenAPI version)
- `@Schema`: Document model schema
- `@SecurityRequirement`: Apply security to operations
- `@Hidden`: Hide from documentation
- `@ParameterObject`: Document complex objects as parameters
### Validation Annotations (Auto-documented)
- `@NotNull`, `@NotBlank`, `@NotEmpty`: Required fields
- `@Size(min, max)`: String/collection length constraints
- `@Min`, `@Max`: Numeric range constraints
- `@Pattern`: Regex validation
- `@Email`: Email validation
- `@DecimalMin`, `@DecimalMax`: Decimal constraints
- `@Positive`, `@PositiveOrZero`, `@Negative`, `@NegativeOrZero`
## Troubleshooting
For common issues and solutions, refer to the troubleshooting guide in @references/troubleshooting.md
## Related Skills
- `spring-boot-rest-api-standards` - REST API design standards
- `spring-boot-dependency-injection` - Dependency injection patterns
- `unit-test-controller-layer` - Testing REST controllers
- `spring-boot-actuator` - Production monitoring and management
## References
- [Comprehensive SpringDoc documentation](references/springdoc-official.md)
- [Common issues and solutions](references/troubleshooting.md)
- [SpringDoc Official Documentation](https://springdoc.org/)
- [OpenAPI 3.0 Specification](https://swagger.io/specification/)
- [Swagger UI Configuration](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/)

View File

@@ -0,0 +1,618 @@
# SpringDoc OpenAPI Official Documentation
## Overview
SpringDoc OpenAPI is a Java library that automates API documentation generation for Spring Boot projects. It examines applications at runtime to infer API semantics based on Spring configurations and annotations.
## Key Features
- **OpenAPI 3 support** with Spring Boot v3 (Java 17 & Jakarta EE 9)
- **Swagger UI integration** for interactive API documentation
- **Scalar support** as an alternative UI
- **Multiple endpoint support** with grouping capabilities
- **Security integration** with Spring Security and OAuth2
- **Functional endpoints** support for WebFlux and WebMvc.fn
## Dependencies
### Maven (Spring Boot 3.x)
```xml
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.13</version>
</dependency>
```
### Gradle (Spring Boot 3.x)
```gradle
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13'
```
### WebFlux Support
```xml
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>2.8.13</version>
</dependency>
```
## Default Endpoints
After adding the dependency:
- **OpenAPI JSON**: `http://localhost:8080/v3/api-docs`
- **OpenAPI YAML**: `http://localhost:8080/v3/api-docs.yaml`
- **Swagger UI**: `http://localhost:8080/swagger-ui/index.html`
## Compatibility Matrix
| Spring Boot Version | SpringDoc OpenAPI Version |
|---------------------|---------------------------|
| 3.4.x | 2.7.x - 2.8.x |
| 3.3.x | 2.6.x |
| 3.2.x | 2.3.x - 2.5.x |
| 3.1.x | 2.2.x |
| 3.0.x | 2.0.x - 2.1.x |
## Basic Configuration
### application.properties
```properties
# Custom API docs path
springdoc.api-docs.path=/api-docs
# Custom Swagger UI path
springdoc.swagger-ui.path=/swagger-ui-custom.html
# Sort operations by HTTP method
springdoc.swagger-ui.operationsSorter=method
# Sort tags alphabetically
springdoc.swagger-ui.tagsSorter=alpha
# Enable/disable Swagger UI
springdoc.swagger-ui.enabled=true
# Disable springdoc-openapi endpoints
springdoc.api-docs.enabled=false
# Show actuator endpoints in documentation
springdoc.show-actuator=true
# Packages to scan
springdoc.packages-to-scan=com.example.controller
# Paths to match
springdoc.paths-to-match=/api/**,/public/**
# Default response messages
springdoc.default-produces-media-type=application/json
springdoc.default-consumes-media-type=application/json
```
### application.yml
```yaml
springdoc:
api-docs:
path: /api-docs
enabled: true
swagger-ui:
path: /swagger-ui.html
enabled: true
operationsSorter: method
tagsSorter: alpha
tryItOutEnabled: true
filter: true
displayRequestDuration: true
packages-to-scan: com.example.controller
paths-to-match: /api/**
show-actuator: false
```
## OpenAPI Information Configuration
### Programmatic Configuration
```java
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
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("Book API")
.version("1.0")
.description("REST API for managing books")
.termsOfService("https://example.com/terms")
.contact(new Contact()
.name("API Support")
.url("https://example.com/support")
.email("support@example.com"))
.license(new License()
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0.html")))
.servers(List.of(
new Server().url("http://localhost:8080").description("Development server"),
new Server().url("https://api.example.com").description("Production server")
));
}
}
```
## Controller Documentation
### Basic Controller Documentation
```java
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 io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/books")
@Tag(name = "Book", description = "Book management APIs")
public class BookController {
private final BookRepository repository;
public BookController(BookRepository repository) {
this.repository = repository;
}
@Operation(
summary = "Retrieve a book by ID",
description = "Get a Book object by specifying its ID. The response is Book object with id, title, author and description."
)
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "Successfully retrieved book",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = Book.class)
)
),
@ApiResponse(
responseCode = "404",
description = "Book not found",
content = @Content
),
@ApiResponse(
responseCode = "500",
description = "Internal server error",
content = @Content
)
})
@GetMapping("/{id}")
public Book findById(
@Parameter(description = "ID of book to retrieve", required = true)
@PathVariable Long id
) {
return repository.findById(id)
.orElseThrow(() -> new BookNotFoundException());
}
}
```
### Request Body Documentation
```java
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.media.ExampleObject;
@Operation(summary = "Create a new book")
@ApiResponses(value = {
@ApiResponse(
responseCode = "201",
description = "Book created successfully",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = Book.class)
)
),
@ApiResponse(responseCode = "400", description = "Invalid input provided")
})
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Book createBook(
@RequestBody(
description = "Book to create",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = Book.class),
examples = @ExampleObject(
value = """
{
"title": "Clean Code",
"author": "Robert C. Martin",
"isbn": "978-0132350884",
"description": "A handbook of agile software craftsmanship"
}
"""
)
)
)
@org.springframework.web.bind.annotation.RequestBody Book book
) {
return repository.save(book);
}
```
## Model Documentation
### Entity with Validation Annotations
```java
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
@Entity
@Schema(description = "Book entity representing a published book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Schema(description = "Unique identifier", example = "1", accessMode = Schema.AccessMode.READ_ONLY)
private Long id;
@NotBlank(message = "Title is required")
@Size(min = 1, max = 200)
@Schema(description = "Book title", example = "Clean Code", required = true, maxLength = 200)
private String title;
@NotBlank(message = "Author is required")
@Size(min = 1, max = 100)
@Schema(description = "Book author", example = "Robert C. Martin", required = true)
private String author;
@Pattern(regexp = "^(?:ISBN(?:-1[03])?:? )?(?=[0-9X]{10}$|(?=(?:[0-9]+[- ]){3})[- 0-9X]{13}$|97[89][0-9]{10}$|(?=(?:[0-9]+[- ]){4})[- 0-9]{17}$)(?:97[89][- ]?)?[0-9]{1,5}[- ]?[0-9]+[- ]?[0-9]+[- ]?[0-9X]$")
@Schema(description = "ISBN number", example = "978-0132350884")
private String isbn;
// Constructor, getters, setters
}
```
### Hidden Fields
```java
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(hidden = true)
private String internalField;
@JsonIgnore
@Schema(accessMode = Schema.AccessMode.READ_ONLY)
private LocalDateTime createdAt;
```
## Security Documentation
### JWT Bearer Authentication
```java
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.security.SecurityScheme;
@Configuration
public class OpenAPISecurityConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.components(new Components()
.addSecuritySchemes("bearer-jwt", new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("JWT authentication")
)
);
}
}
// On controller or method level
@SecurityRequirement(name = "bearer-jwt")
@GetMapping("/secure")
public String secureEndpoint() {
return "Secure data";
}
```
### Basic Authentication
```java
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.components(new Components()
.addSecuritySchemes("basicAuth", new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("basic")
)
);
}
```
### OAuth2 Configuration
```java
import io.swagger.v3.oas.models.security.OAuthFlow;
import io.swagger.v3.oas.models.security.OAuthFlows;
import io.swagger.v3.oas.models.security.Scopes;
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.components(new Components()
.addSecuritySchemes("oauth2", new SecurityScheme()
.type(SecurityScheme.Type.OAUTH2)
.flows(new OAuthFlows()
.authorizationCode(new OAuthFlow()
.authorizationUrl("https://auth.example.com/oauth/authorize")
.tokenUrl("https://auth.example.com/oauth/token")
.scopes(new Scopes()
.addString("read", "Read access")
.addString("write", "Write access")
)
)
)
)
);
}
```
### API Key Authentication
```java
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.components(new Components()
.addSecuritySchemes("api-key", new SecurityScheme()
.type(SecurityScheme.Type.APIKEY)
.in(SecurityScheme.In.HEADER)
.name("X-API-Key")
)
);
}
```
## Pageable and Sorting Documentation
### Spring Data Pageable Support
```java
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@Operation(summary = "Get paginated list of books")
@GetMapping("/paginated")
public Page<Book> findAllPaginated(
@ParameterObject Pageable pageable
) {
return repository.findAll(pageable);
}
```
This automatically generates documentation for:
- `page`: Page number (0-indexed)
- `size`: Page size
- `sort`: Sorting criteria (e.g., "title,asc")
## Advanced Features
### Multiple API Groups
```java
@Bean
public GroupedOpenApi publicApi() {
return GroupedOpenApi.builder()
.group("public")
.pathsToMatch("/api/public/**")
.build();
}
@Bean
public GroupedOpenApi adminApi() {
return GroupedOpenApi.builder()
.group("admin")
.pathsToMatch("/api/admin/**")
.build();
}
```
Access groups at:
- `/v3/api-docs/public`
- `/v3/api-docs/admin`
### Hiding Endpoints
```java
@Operation(hidden = true)
@GetMapping("/internal")
public String internalEndpoint() {
return "Hidden from docs";
}
// Or hide entire controller
@Hidden
@RestController
public class InternalController {
// All endpoints hidden
}
```
### Custom Operation Customizer
```java
import org.springdoc.core.customizers.OperationCustomizer;
@Bean
public OperationCustomizer customizeOperation() {
return (operation, handlerMethod) -> {
operation.addExtension("x-custom-field", "custom-value");
return operation;
};
}
```
### Filtering Packages and Paths
```java
@Bean
public GroupedOpenApi apiGroup() {
return GroupedOpenApi.builder()
.group("api")
.packagesToScan("com.example.controller")
.pathsToMatch("/api/**")
.pathsToExclude("/api/internal/**")
.build();
}
```
## Kotlin Support
### Kotlin Data Class Documentation
```kotlin
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
@Entity
data class Book(
@field:Schema(description = "Unique identifier", accessMode = Schema.AccessMode.READ_ONLY)
@Id
val id: Long = 0,
@field:NotBlank
@field:Size(min = 1, max = 200)
@field:Schema(description = "Book title", example = "Clean Code", required = true)
val title: String = "",
@field:NotBlank
@field:Schema(description = "Author name", example = "Robert Martin")
val author: String = ""
)
@RestController
@RequestMapping("/api/books")
@Tag(name = "Book", description = "Book management APIs")
class BookController(private val repository: BookRepository) {
@Operation(summary = "Get all books")
@ApiResponses(value = [
ApiResponse(
responseCode = "200",
description = "Found books",
content = [Content(
mediaType = "application/json",
array = ArraySchema(schema = Schema(implementation = Book::class))
)]
),
ApiResponse(responseCode = "404", description = "No books found", content = [Content()])
])
@GetMapping
fun getAllBooks(): List<Book> = repository.findAll()
}
```
## Maven and Gradle Plugins
### Maven Plugin for Generating OpenAPI
```xml
<plugin>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-maven-plugin</artifactId>
<version>1.4</version>
<executions>
<execution>
<phase>integration-test</phase>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<apiDocsUrl>http://localhost:8080/v3/api-docs</apiDocsUrl>
<outputFileName>openapi.json</outputFileName>
<outputDir>${project.build.directory}</outputDir>
</configuration>
</plugin>
```
### Gradle Plugin
```gradle
plugins {
id 'org.springdoc.openapi-gradle-plugin' version '1.9.0'
}
openApi {
apiDocsUrl = "http://localhost:8080/v3/api-docs"
outputDir = file("$buildDir/docs")
outputFileName = "openapi.json"
}
```
## Migration from SpringFox
Replace SpringFox dependencies and update annotations:
- `@Api``@Tag`
- `@ApiOperation``@Operation`
- `@ApiParam``@Parameter`
- Remove `Docket` beans, use `GroupedOpenApi` instead
## Common Issues and Solutions
### Parameter Names Not Appearing
Add `-parameters` compiler flag (Spring Boot 3.2+):
```xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
```
### Swagger UI Shows "Unable to render definition"
Ensure `ByteArrayHttpMessageConverter` is registered when overriding converters:
```java
converters.add(new ByteArrayHttpMessageConverter());
converters.add(new MappingJackson2HttpMessageConverter());
```
### Endpoints Not Appearing
Check:
- `springdoc.packages-to-scan` configuration
- `springdoc.paths-to-match` configuration
- Endpoints aren't marked with `@Hidden`
### Security Configuration Issues
Permit SpringDoc endpoints in Spring Security:
```java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.anyRequest().authenticated()
)
.build();
}
```
## External References
- [SpringDoc Official Documentation](https://springdoc.org/)
- [OpenAPI 3.0 Specification](https://swagger.io/specification/)
- [Swagger UI Configuration](https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/)

View File

@@ -0,0 +1,247 @@
# Troubleshooting SpringDoc OpenAPI
## Common Issues and Solutions
### Parameter Names Not Appearing
**Problem**: Parameter names are not showing up in the generated API documentation.
**Solution**: Add `-parameters` compiler flag (Spring Boot 3.2+):
```xml
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>
```
**Gradle equivalent**:
```gradle
tasks.withType(JavaCompile).configureEach {
options.compilerArgs += ["-parameters"]
}
```
### Swagger UI Shows "Unable to render definition"
**Problem**: Swagger UI displays error "Unable to render definition".
**Solution**: Ensure `ByteArrayHttpMessageConverter` is registered when overriding converters:
```java
converters.add(new ByteArrayHttpMessageConverter());
converters.add(new MappingJackson2HttpMessageConverter());
```
**Alternative approach**: Check for missing message converter configuration in your WebMvcConfigurer or similar configuration.
### Endpoints Not Appearing in Documentation
**Problem**: API endpoints are not showing up in the generated OpenAPI specification.
**Solution**: Check these common issues:
1. **Package scanning configuration**:
```properties
# Ensure this is set correctly
springdoc.packages-to-scan=com.example.controller
# Or multiple packages
springdoc.packages-to-scan=com.example.controller,com.example.service
```
2. **Path matching configuration**:
```properties
# Ensure paths match your endpoints
springdoc.paths-to-match=/api/**,public/**
```
3. **Hidden endpoints**: Verify endpoints aren't marked with `@Hidden` annotation.
4. **Component scanning**: Ensure controllers are in packages that are component-scanned by Spring Boot.
### Security Configuration Issues
**Problem**: Spring Security blocks access to Swagger UI and OpenAPI endpoints.
**Solution**: Permit SpringDoc endpoints in Spring Security:
```java
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.anyRequest().authenticated()
)
.build();
}
}
```
### Maven/Gradle Build Issues
**Problem**: Build fails due to conflicting SpringDoc dependencies.
**Solution**: Ensure correct version compatibility:
```xml
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.13</version>
</dependency>
```
For WebFlux applications:
```xml
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
<version>2.8.13</version>
</dependency>
```
### JavaDoc Integration Issues
**Problem**: JavaDoc comments are not appearing in the API documentation.
**Solution**: Add the therapi-runtime-javadoc dependency:
```xml
<dependency>
<groupId>com.github.therapi</groupId>
<artifactId>therapi-runtime-javadoc</artifactId>
<version>0.15.0</version>
<scope>provided</scope>
</dependency>
```
### Kotlin Integration Issues
**Problem**: Kotlin classes or functions are not properly documented.
**Solution**: Use `@field:` annotation prefix for Kotlin properties:
```kotlin
@field:Schema(description = "Book title", example = "Clean Code")
@field:NotBlank
val title: String = ""
```
### Custom Serialization Issues
**Problem**: Custom serialized fields are not appearing in the API documentation.
**Solution**: Ensure proper Jackson configuration:
```java
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JacksonConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
}
}
```
### Performance Issues
**Problem**: SpringDoc causes performance issues during startup.
**Solution**:
1. Use specific package scanning instead of scanning the entire classpath
2. Use path exclusions to filter out unwanted endpoints
3. Consider using grouped OpenAPI definitions
```java
@Bean
public GroupedOpenApi publicApi() {
return GroupedOpenApi.builder()
.group("public")
.packagesToScan("com.example.controller.public")
.pathsToMatch("/api/public/**")
.pathsToExclude("/api/internal/**")
.build();
}
```
### Version Compatibility Issues
**Problem**: SpringDoc works in development but not in production.
**Solution**:
1. Ensure consistent Spring Boot and SpringDoc versions
2. Check for environment-specific configurations
3. Verify production environment matches development setup
```properties
# Production-specific configuration
springdoc.swagger-ui.enabled=true
springdoc.api-docs.enabled=true
springdoc.show-actuator=true
```
### Error Response Documentation
**Problem**: Custom error responses are not properly documented.
**Solution**: Use `@Operation(hidden = true)` on exception handlers and define proper error response schemas:
```java
@ExceptionHandler(BookNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
@Operation(hidden = true)
public ErrorResponse handleBookNotFound(BookNotFoundException ex) {
return new ErrorResponse("BOOK_NOT_FOUND", ex.getMessage());
}
@Schema(description = "Error response")
public record ErrorResponse(
@Schema(description = "Error code", example = "BOOK_NOT_FOUND")
String code,
@Schema(description = "Error message", example = "Book with ID 123 not found")
String message
) {}
```
### Debugging Tips
1. **Check OpenAPI JSON directly**: Access `http://localhost:8080/v3/api-docs` to see the raw OpenAPI specification
2. **Enable debug logging**: Add `logging.level.org.springdoc=DEBUG` to application.properties
3. **Validate OpenAPI specification**: Use online validators like [Swagger Editor](https://editor.swagger.io/)
4. **Check SpringDoc version**: Ensure you're using a recent version with bug fixes
### Performance Optimization
1. **Reduce scope**: Use specific package scanning and path matching
2. **Cache configurations**: Reuse OpenAPI configurations where possible
3. **Group endpoints**: Use multiple grouped OpenAPI definitions instead of one large specification
4. **Disable unnecessary features**: Turn off features you don't use (e.g., actuator integration)
```properties
# Performance optimizations
springdoc.swagger-ui.enabled=true
springdoc.api-docs.enabled=true
springdoc.show-actuator=false
springdoc.writer-default-response-tags=false
springdoc.default-consumes-media-type=application/json
springdoc.default-produces-media-type=application/json
```

View File

@@ -0,0 +1,391 @@
---
name: spring-boot-resilience4j
description: This skill should be used when implementing fault tolerance and resilience patterns in Spring Boot applications using the Resilience4j library. Apply this skill to add circuit breaker, retry, rate limiter, bulkhead, time limiter, and fallback mechanisms to prevent cascading failures, handle transient errors, and manage external service dependencies gracefully in microservices architectures.
allowed-tools: Read, Write, Edit, Bash
category: backend
tags: [spring-boot, resilience4j, circuit-breaker, fault-tolerance, retry, bulkhead, rate-limiter]
version: 1.1.0
---
# Spring Boot Resilience4j Patterns
## When to Use
To implement resilience patterns in Spring Boot applications, use this skill when:
- Preventing cascading failures from external service unavailability with circuit breaker pattern
- Retrying transient failures with exponential backoff
- Rate limiting to protect services from overload or downstream service capacity constraints
- Isolating resources with bulkhead pattern to prevent thread pool exhaustion
- Adding timeout controls to async operations with time limiter
- Combining multiple patterns for comprehensive fault tolerance
Resilience4j is a lightweight, composable library for adding fault tolerance without requiring external infrastructure. It provides annotation-based patterns that integrate seamlessly with Spring Boot's AOP and Actuator.
## Instructions
### 1. Setup and Dependencies
Add Resilience4j dependencies to your project. For Maven, add to `pom.xml`:
```xml
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version> // Use latest stable version
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
```
For Gradle, add to `build.gradle`:
```gradle
implementation "io.github.resilience4j:resilience4j-spring-boot3:2.2.0"
implementation "org.springframework.boot:spring-boot-starter-aop"
implementation "org.springframework.boot:spring-boot-starter-actuator"
```
Enable AOP annotation processing with `@EnableAspectJAutoProxy` (auto-configured by Spring Boot).
### 2. Circuit Breaker Pattern
Apply `@CircuitBreaker` annotation to methods calling external services:
```java
@Service
public class PaymentService {
private final RestTemplate restTemplate;
public PaymentService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
public PaymentResponse processPayment(PaymentRequest request) {
return restTemplate.postForObject("http://payment-api/process",
request, PaymentResponse.class);
}
private PaymentResponse paymentFallback(PaymentRequest request, Exception ex) {
return PaymentResponse.builder()
.status("PENDING")
.message("Service temporarily unavailable")
.build();
}
}
```
Configure in `application.yml`:
```yaml
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 10s
instances:
paymentService:
baseConfig: default
```
See @references/configuration-reference.md for complete circuit breaker configuration options.
### 3. Retry Pattern
Apply `@Retry` annotation for transient failure recovery:
```java
@Service
public class ProductService {
private final RestTemplate restTemplate;
public ProductService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
@Retry(name = "productService", fallbackMethod = "getProductFallback")
public Product getProduct(Long productId) {
return restTemplate.getForObject(
"http://product-api/products/" + productId,
Product.class);
}
private Product getProductFallback(Long productId, Exception ex) {
return Product.builder()
.id(productId)
.name("Unavailable")
.available(false)
.build();
}
}
```
Configure retry in `application.yml`:
```yaml
resilience4j:
retry:
configs:
default:
maxAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
instances:
productService:
baseConfig: default
maxAttempts: 5
```
See @references/configuration-reference.md for retry exception configuration.
### 4. Rate Limiter Pattern
Apply `@RateLimiter` to control request rates:
```java
@Service
public class NotificationService {
private final EmailClient emailClient;
public NotificationService(EmailClient emailClient) {
this.emailClient = emailClient;
}
@RateLimiter(name = "notificationService",
fallbackMethod = "rateLimitFallback")
public void sendEmail(EmailRequest request) {
emailClient.send(request);
}
private void rateLimitFallback(EmailRequest request, Exception ex) {
throw new RateLimitExceededException(
"Too many requests. Please try again later.");
}
}
```
Configure in `application.yml`:
```yaml
resilience4j:
ratelimiter:
configs:
default:
registerHealthIndicator: true
limitForPeriod: 10
limitRefreshPeriod: 1s
timeoutDuration: 500ms
instances:
notificationService:
baseConfig: default
limitForPeriod: 5
```
### 5. Bulkhead Pattern
Apply `@Bulkhead` to isolate resources. Use `type = SEMAPHORE` for synchronous methods:
```java
@Service
public class ReportService {
private final ReportGenerator reportGenerator;
public ReportService(ReportGenerator reportGenerator) {
this.reportGenerator = reportGenerator;
}
@Bulkhead(name = "reportService", type = Bulkhead.Type.SEMAPHORE)
public Report generateReport(ReportRequest request) {
return reportGenerator.generate(request);
}
}
```
Use `type = THREADPOOL` for async/CompletableFuture methods:
```java
@Service
public class AnalyticsService {
@Bulkhead(name = "analyticsService", type = Bulkhead.Type.THREADPOOL)
public CompletableFuture<AnalyticsResult> runAnalytics(
AnalyticsRequest request) {
return CompletableFuture.supplyAsync(() ->
analyticsEngine.analyze(request));
}
}
```
Configure in `application.yml`:
```yaml
resilience4j:
bulkhead:
configs:
default:
maxConcurrentCalls: 10
maxWaitDuration: 100ms
instances:
reportService:
baseConfig: default
maxConcurrentCalls: 5
thread-pool-bulkhead:
instances:
analyticsService:
maxThreadPoolSize: 8
```
### 6. Time Limiter Pattern
Apply `@TimeLimiter` to async methods to enforce timeout boundaries:
```java
@Service
public class SearchService {
@TimeLimiter(name = "searchService", fallbackMethod = "searchFallback")
public CompletableFuture<SearchResults> search(SearchQuery query) {
return CompletableFuture.supplyAsync(() ->
searchEngine.executeSearch(query));
}
private CompletableFuture<SearchResults> searchFallback(
SearchQuery query, Exception ex) {
return CompletableFuture.completedFuture(
SearchResults.empty("Search timed out"));
}
}
```
Configure in `application.yml`:
```yaml
resilience4j:
timelimiter:
configs:
default:
timeoutDuration: 2s
cancelRunningFuture: true
instances:
searchService:
baseConfig: default
timeoutDuration: 3s
```
### 7. Combining Multiple Patterns
Stack multiple patterns on a single method for comprehensive fault tolerance:
```java
@Service
public class OrderService {
@CircuitBreaker(name = "orderService")
@Retry(name = "orderService")
@RateLimiter(name = "orderService")
@Bulkhead(name = "orderService")
public Order createOrder(OrderRequest request) {
return orderClient.createOrder(request);
}
}
```
Execution order: Retry → CircuitBreaker → RateLimiter → Bulkhead → Method
All patterns should reference the same named configuration instance for consistency.
### 8. Exception Handling and Monitoring
Create a global exception handler using `@RestControllerAdvice`:
```java
@RestControllerAdvice
public class ResilienceExceptionHandler {
@ExceptionHandler(CallNotPermittedException.class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public ErrorResponse handleCircuitOpen(CallNotPermittedException ex) {
return new ErrorResponse("SERVICE_UNAVAILABLE",
"Service currently unavailable");
}
@ExceptionHandler(RequestNotPermitted.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public ErrorResponse handleRateLimited(RequestNotPermitted ex) {
return new ErrorResponse("TOO_MANY_REQUESTS",
"Rate limit exceeded");
}
@ExceptionHandler(BulkheadFullException.class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public ErrorResponse handleBulkheadFull(BulkheadFullException ex) {
return new ErrorResponse("CAPACITY_EXCEEDED",
"Service at capacity");
}
}
```
Enable Actuator endpoints for monitoring resilience patterns in `application.yml`:
```yaml
management:
endpoints:
web:
exposure:
include: health,metrics,circuitbreakers,retries,ratelimiters
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: true
ratelimiters:
enabled: true
```
Access monitoring endpoints:
- `GET /actuator/health` - Overall health including resilience patterns
- `GET /actuator/circuitbreakers` - Circuit breaker states
- `GET /actuator/metrics` - Custom resilience metrics
## Best Practices
- **Always provide fallback methods**: Ensure graceful degradation with meaningful responses rather than exceptions
- **Use exponential backoff for retries**: Prevent overwhelming recovering services with aggressive backoff (`exponentialBackoffMultiplier: 2`)
- **Choose appropriate failure thresholds**: Set `failureRateThreshold` between 50-70% depending on acceptable error rates
- **Use constructor injection exclusively**: Never use field injection for Resilience4j dependencies
- **Enable health indicators**: Set `registerHealthIndicator: true` for all patterns to integrate with Spring Boot health
- **Separate failure vs. client errors**: Retry only transient errors (network timeouts, 5xx); skip 4xx and business exceptions
- **Size bulkheads based on load**: Calculate thread pool and semaphore sizes from expected concurrent load and latency
- **Monitor and adjust**: Continuously review metrics and adjust timeouts/thresholds based on production behavior
- **Document fallback behavior**: Make fallback logic clear and predictable to users and maintainers
## Common Mistakes
Refer to `references/testing-patterns.md` for:
- Testing circuit breaker state transitions
- Simulating transient failures with WireMock
- Validating fallback method signatures
- Avoiding common misconfiguration errors
Refer to `references/configuration-reference.md` for:
- Complete property reference for all patterns
- Configuration validation rules
- Exception handling configuration
## References and Examples
- [Complete property reference and configuration patterns](references/configuration-reference.md)
- [Unit and integration testing strategies](references/testing-patterns.md)
- [Real-world e-commerce service example using all patterns](references/examples.md)
- [Resilience4j Documentation](https://resilience4j.readme.io/)
- [Spring Boot Actuator Skill](/skills/spring-boot-actuator/SKILL.md) - Monitoring resilience patterns with Actuator

View File

@@ -0,0 +1,360 @@
# Resilience4j Configuration Reference
## Circuit Breaker Configuration
### Complete Properties List
```yaml
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true # Default: false
slidingWindowType: COUNT_BASED # COUNT_BASED or TIME_BASED
slidingWindowSize: 100 # Default: 100 (calls or seconds)
minimumNumberOfCalls: 10 # Default: 100
failureRateThreshold: 50 # Default: 50 (percentage)
slowCallRateThreshold: 100 # Default: 100 (percentage)
slowCallDurationThreshold: 60s # Default: 60000ms
waitDurationInOpenState: 60s # Default: 60000ms
automaticTransitionFromOpenToHalfOpenEnabled: false # Default: false
permittedNumberOfCallsInHalfOpenState: 10 # Default: 10
maxWaitDurationInHalfOpenState: 0s # Default: 0 (unlimited)
recordExceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
ignoreExceptions:
- java.lang.IllegalArgumentException
eventConsumerBufferSize: 100 # Default: 100
instances:
myService:
baseConfig: default
failureRateThreshold: 60
```
### Circuit Breaker States
1. **CLOSED**: Normal operation, calls pass through
2. **OPEN**: Circuit is open, calls immediately fail with `CallNotPermittedException`
3. **HALF_OPEN**: Testing if service recovered, allows limited test calls
4. **DISABLED**: Circuit breaker disabled, all calls pass through
5. **FORCED_OPEN**: Manually forced to open state for emergency situations
### Sliding Window Types
**COUNT_BASED** (Default)
- Aggregates outcome of last N calls
- Better for services with consistent traffic
- `slidingWindowSize` = number of calls to track
**TIME_BASED**
- Aggregates outcome of calls in last N seconds
- Better for services with variable traffic
- `slidingWindowSize` = time in seconds
## Retry Configuration
### Complete Properties List
```yaml
resilience4j:
retry:
configs:
default:
maxAttempts: 3 # Default: 3
waitDuration: 500ms # Default: 500ms
enableExponentialBackoff: false # Default: false
exponentialBackoffMultiplier: 2 # Default: 2
exponentialMaxWaitDuration: 10s # Default: no limit
enableRandomizedWait: false # Default: false
randomizedWaitFactor: 0.5 # Default: 0.5
retryExceptions:
- java.io.IOException
- org.springframework.web.client.ResourceAccessException
ignoreExceptions:
- java.lang.IllegalArgumentException
failAfterMaxAttempts: false # Default: false
eventConsumerBufferSize: 100 # Default: 100
instances:
myService:
baseConfig: default
maxAttempts: 5
```
### Exponential Backoff Example
```yaml
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2.0
exponentialMaxWaitDuration: 10s
```
Attempt waits:
- Attempt 1: 500ms
- Attempt 2: 1000ms (500 × 2)
- Attempt 3: 2000ms (1000 × 2)
- Attempt 4: 4000ms (2000 × 2)
- Attempt 5: 8000ms (4000 × 2)
- Maximum: 10000ms
## Rate Limiter Configuration
### Complete Properties List
```yaml
resilience4j:
ratelimiter:
configs:
default:
limitForPeriod: 50 # Default: 50
limitRefreshPeriod: 500ns # Default: 500ns
timeoutDuration: 5s # Default: 5s
registerHealthIndicator: true # Default: false
allowHealthIndicatorToFail: true # Default: false
instances:
myService:
baseConfig: default
limitForPeriod: 10
limitRefreshPeriod: 1s
```
### Common Rate Limit Patterns
**10 requests per second**
```yaml
limitForPeriod: 10
limitRefreshPeriod: 1s
timeoutDuration: 0s # Fail immediately if no permits
```
**100 requests per minute**
```yaml
limitForPeriod: 100
limitRefreshPeriod: 1m
timeoutDuration: 500ms # Wait up to 500ms for permit
```
**5 requests per second with queuing**
```yaml
limitForPeriod: 5
limitRefreshPeriod: 1s
timeoutDuration: 2s # Wait up to 2s for permit
```
## Bulkhead Configuration
### Semaphore Bulkhead
```yaml
resilience4j:
bulkhead:
configs:
default:
maxConcurrentCalls: 25 # Default: 25
maxWaitDuration: 0ms # Default: 0
eventConsumerBufferSize: 100 # Default: 100
instances:
myService:
baseConfig: default
maxConcurrentCalls: 10
maxWaitDuration: 100ms
```
### Thread Pool Bulkhead
```yaml
resilience4j:
thread-pool-bulkhead:
configs:
default:
maxThreadPoolSize: 4 # Default: Runtime.availableProcessors()
coreThreadPoolSize: 2 # Default: Runtime.availableProcessors() - 1
queueCapacity: 100 # Default: 100
keepAliveDuration: 20ms # Default: 20ms
writableStackTraceEnabled: true # Default: true
instances:
myService:
baseConfig: default
maxThreadPoolSize: 8
coreThreadPoolSize: 4
queueCapacity: 200
```
## Time Limiter Configuration
### Complete Properties List
```yaml
resilience4j:
timelimiter:
configs:
default:
timeoutDuration: 1s # Default: 1s
cancelRunningFuture: true # Default: true
instances:
myService:
baseConfig: default
timeoutDuration: 3s
```
## Annotation Reference
### @CircuitBreaker
```java
@CircuitBreaker(
name = "serviceName", // Required: Instance name from config
fallbackMethod = "fallbackMethodName" // Optional: Fallback method name
)
// Fallback method signature
public String fallback(Long id, Exception ex) { }
```
### @Retry
```java
@Retry(
name = "serviceName", // Required: Instance name from config
fallbackMethod = "fallbackMethodName" // Optional: Fallback method name
)
```
### @RateLimiter
```java
@RateLimiter(
name = "serviceName",
fallbackMethod = "fallbackMethodName"
)
```
### @Bulkhead
```java
@Bulkhead(
name = "serviceName",
fallbackMethod = "fallbackMethodName",
type = Bulkhead.Type.SEMAPHORE // SEMAPHORE or THREADPOOL
)
```
### @TimeLimiter
```java
@TimeLimiter(
name = "serviceName",
fallbackMethod = "fallbackMethodName"
)
// Works only with CompletableFuture<T> or reactive types (Mono<T>, Flux<T>)
```
## Annotation Execution Order
When combining annotations on a method, execution order from outermost to innermost:
1. `@Retry`
2. `@CircuitBreaker`
3. `@RateLimiter`
4. `@TimeLimiter`
5. `@Bulkhead`
6. Actual method call
## Exception Reference
| Pattern | Exception | HTTP Status | Meaning |
|---------|-----------|-------------|---------|
| Circuit Breaker | `CallNotPermittedException` | 503 | Circuit is OPEN or FORCED_OPEN |
| Rate Limiter | `RequestNotPermitted` | 429 | No permits available |
| Bulkhead | `BulkheadFullException` | 503 | Bulkhead at capacity |
| Time Limiter | `TimeoutException` | 408 | Operation exceeded timeout |
## Programmatic Configuration
### Circuit Breaker
```java
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(10))
.slowCallDurationThreshold(Duration.ofSeconds(2))
.permittedNumberOfCallsInHalfOpenState(3)
.minimumNumberOfCalls(10)
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.slidingWindowSize(100)
.recordExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(IllegalArgumentException.class)
.build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
CircuitBreaker circuitBreaker = registry.circuitBreaker("myService");
```
### Retry
```java
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(500))
.intervalFunction(IntervalFunction.ofExponentialBackoff(
Duration.ofMillis(500),
2.0
))
.retryExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(IllegalArgumentException.class)
.build();
RetryRegistry registry = RetryRegistry.of(config);
Retry retry = registry.retry("myService");
```
## Actuator Endpoints
Access monitoring endpoints when management endpoints are enabled:
| Endpoint | Description |
|----------|-------------|
| `GET /actuator/circuitbreakers` | List all circuit breakers and states |
| `GET /actuator/circuitbreakerevents` | List circuit breaker events |
| `GET /actuator/retryevents` | List retry events |
| `GET /actuator/ratelimiters` | List rate limiters |
| `GET /actuator/bulkheads` | List bulkhead status |
| `GET /actuator/timelimiters` | List time limiters |
| `GET /actuator/metrics` | Custom resilience metrics |
## Micrometer Metrics
Resilience4j exposes the following metrics:
**Circuit Breaker Metrics**
- `resilience4j.circuitbreaker.calls{name, kind}`
- `resilience4j.circuitbreaker.state{name, state}`
- `resilience4j.circuitbreaker.failure.rate{name}`
- `resilience4j.circuitbreaker.slow.call.rate{name}`
**Retry Metrics**
- `resilience4j.retry.calls{name, kind}`
**Rate Limiter Metrics**
- `resilience4j.ratelimiter.available.permissions{name}`
- `resilience4j.ratelimiter.waiting_threads{name}`
**Bulkhead Metrics**
- `resilience4j.bulkhead.available.concurrent.calls{name}`
- `resilience4j.bulkhead.max.allowed.concurrent.calls{name}`
## Version Compatibility
| Resilience4j | Spring Boot | Java | Spring Framework |
|--------------|-------------|------|------------------|
| 2.2.x | 3.x | 17+ | 6.x |
| 2.1.x | 3.x | 17+ | 6.x |
| 2.0.x | 2.7.x | 8+ | 5.3.x |
| 1.7.x | 2.x | 8+ | 5.x |
## References
- [Resilience4j Official Documentation](https://resilience4j.readme.io/)
- [Spring Boot Integration Guide](https://resilience4j.readme.io/docs/getting-started-3)
- [Micrometer Metrics Guide](https://resilience4j.readme.io/docs/micrometer)

View File

@@ -0,0 +1,483 @@
# Resilience4j Real-World Examples
## E-Commerce Order Service
Complete example demonstrating all Resilience4j patterns in a microservices environment.
### Project Structure
```
order-service/
├── src/main/java/com/ecommerce/order/
│ ├── config/
│ │ ├── ResilienceConfig.java
│ │ └── RestTemplateConfig.java
│ ├── controller/
│ │ ├── OrderController.java
│ │ └── GlobalExceptionHandler.java
│ ├── service/
│ │ ├── OrderService.java
│ │ ├── PaymentService.java
│ │ ├── InventoryService.java
│ │ └── NotificationService.java
│ ├── domain/
│ │ ├── Order.java
│ │ ├── OrderStatus.java
│ │ └── Payment.java
└── src/main/resources/
└── application.yml
```
### Configuration
```yaml
server:
port: 8080
spring:
application:
name: order-service
resilience4j:
circuitbreaker:
configs:
default:
registerHealthIndicator: true
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50
waitDurationInOpenState: 30s
instances:
paymentService:
baseConfig: default
waitDurationInOpenState: 60s
inventoryService:
baseConfig: default
retry:
configs:
default:
maxAttempts: 3
waitDuration: 500ms
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
instances:
paymentService:
maxAttempts: 5
waitDuration: 1s
ratelimiter:
configs:
default:
limitForPeriod: 100
limitRefreshPeriod: 1s
instances:
emailService:
limitForPeriod: 10
limitRefreshPeriod: 1m
bulkhead:
configs:
default:
maxConcurrentCalls: 10
maxWaitDuration: 100ms
instances:
orderProcessing:
maxConcurrentCalls: 5
timelimiter:
configs:
default:
timeoutDuration: 3s
instances:
paymentService:
timeoutDuration: 5s
management:
endpoints:
web:
exposure:
include: '*'
endpoint:
health:
show-details: always
health:
circuitbreakers:
enabled: true
ratelimiters:
enabled: true
```
### Order Service Implementation
```java
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final NotificationService notificationService;
@Bulkhead(name = "orderProcessing", type = Bulkhead.Type.SEMAPHORE)
@Transactional
public Order processOrder(OrderRequest request) {
log.info("Processing order for customer: {}", request.getCustomerId());
Order order = createOrder(request);
try {
// Reserve inventory
inventoryService.reserveInventory(order);
// Process payment
String paymentId = paymentService.processPayment(order).get();
order = order.toBuilder().paymentId(paymentId).build();
// Send confirmation (async, best effort)
notificationService.sendOrderConfirmation(order);
log.info("Order processed successfully: {}", order.getId());
return order;
} catch (Exception ex) {
log.error("Order processing failed", ex);
compensateFailedOrder(order);
throw new OrderProcessingException("Failed to process order", ex);
}
}
private void compensateFailedOrder(Order order) {
try {
inventoryService.releaseInventory(order);
if (order.getPaymentId() != null) {
paymentService.refundPayment(order.getPaymentId());
}
} catch (Exception ex) {
log.error("Compensation failed", ex);
}
}
}
```
### Payment Service with Multiple Patterns
```java
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentClient paymentClient;
@CircuitBreaker(name = "paymentService", fallbackMethod = "processPaymentFallback")
@Retry(name = "paymentService")
@TimeLimiter(name = "paymentService")
public CompletableFuture<String> processPayment(Order order) {
return CompletableFuture.supplyAsync(() -> {
Payment payment = Payment.builder()
.orderId(order.getId())
.amount(order.getTotalAmount())
.build();
PaymentResponse response = paymentClient.processPayment(payment);
if (!response.isSuccess()) {
throw new PaymentFailedException(response.getErrorMessage());
}
return response.getPaymentId();
});
}
private CompletableFuture<String> processPaymentFallback(
Order order, Exception ex) {
log.error("Payment processing failed for order: {}", order.getId(), ex);
throw new PaymentServiceUnavailableException(
"Payment service unavailable", ex);
}
@CircuitBreaker(name = "paymentService")
@Retry(name = "paymentService")
public void refundPayment(String paymentId) {
paymentClient.refundPayment(paymentId);
}
}
```
### Exception Handler
```java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CallNotPermittedException.class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public ErrorResponse handleCircuitOpen(CallNotPermittedException ex) {
log.error("Circuit breaker is open", ex);
return ErrorResponse.builder()
.code("SERVICE_UNAVAILABLE")
.message("Service is temporarily unavailable")
.status(HttpStatus.SERVICE_UNAVAILABLE.value())
.build();
}
@ExceptionHandler(RequestNotPermitted.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public ErrorResponse handleRateLimited(RequestNotPermitted ex) {
return ErrorResponse.builder()
.code("TOO_MANY_REQUESTS")
.message("Rate limit exceeded")
.status(HttpStatus.TOO_MANY_REQUESTS.value())
.build();
}
@ExceptionHandler(BulkheadFullException.class)
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public ErrorResponse handleBulkheadFull(BulkheadFullException ex) {
return ErrorResponse.builder()
.code("SERVICE_BUSY")
.message("Service at capacity")
.status(HttpStatus.SERVICE_UNAVAILABLE.value())
.build();
}
@ExceptionHandler(TimeoutException.class)
@ResponseStatus(HttpStatus.REQUEST_TIMEOUT)
public ErrorResponse handleTimeout(TimeoutException ex) {
return ErrorResponse.builder()
.code("REQUEST_TIMEOUT")
.message("Request timed out")
.status(HttpStatus.REQUEST_TIMEOUT.value())
.build();
}
}
```
## Testing Patterns
### Unit Test for Circuit Breaker
```java
@SpringBootTest
class PaymentServiceCircuitBreakerTest {
@Autowired
private PaymentService paymentService;
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@MockBean
private PaymentClient paymentClient;
private CircuitBreaker circuitBreaker;
@BeforeEach
void setup() {
circuitBreaker = circuitBreakerRegistry.circuitBreaker("paymentService");
circuitBreaker.reset();
}
@Test
void shouldOpenCircuitAfterFailures() {
Order order = createTestOrder();
when(paymentClient.processPayment(any()))
.thenThrow(new RuntimeException("Service error"));
// Trigger failures to exceed threshold
for (int i = 0; i < 5; i++) {
try {
paymentService.processPayment(order).get();
} catch (Exception ignored) {}
}
assertThat(circuitBreaker.getState())
.isEqualTo(CircuitBreaker.State.OPEN);
// Next call should fail immediately
assertThatThrownBy(() -> paymentService.processPayment(order).get())
.hasRootCauseInstanceOf(PaymentServiceUnavailableException.class);
}
}
```
### Integration Test with WireMock
```java
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void shouldRetryOnTransientFailure() {
// First two calls fail, third succeeds
stubFor(post("/payment/process")
.inScenario("Retry")
.whenScenarioStateIs(STARTED)
.willReturn(serverError())
.willSetStateTo("First Retry"));
stubFor(post("/payment/process")
.inScenario("Retry")
.whenScenarioStateIs("First Retry")
.willReturn(serverError())
.willSetStateTo("Second Retry"));
stubFor(post("/payment/process")
.inScenario("Retry")
.whenScenarioStateIs("Second Retry")
.willReturn(ok().withBody("{\"paymentId\":\"PAY-123\"}")));
Order order = orderService.processOrder(createOrderRequest());
assertThat(order.getPaymentId()).isEqualTo("PAY-123");
verify(exactly(3), postRequestedFor(urlEqualTo("/payment/process")));
}
}
```
## Advanced Scenarios
### Reactive WebFlux Example
```java
@Service
@RequiredArgsConstructor
public class ReactiveProductService {
private final WebClient webClient;
private final CircuitBreaker circuitBreaker;
private final Retry retry;
public Mono<Product> getProduct(String productId) {
return webClient.get()
.uri("/products/{id}", productId)
.retrieve()
.bodyToMono(Product.class)
.transformDeferred(CircuitBreakerOperator.of(circuitBreaker))
.transformDeferred(RetryOperator.of(retry))
.onErrorResume(throwable ->
Mono.just(Product.unavailable(productId))
);
}
}
```
### Custom Resilience Configuration
```java
@Configuration
@Slf4j
public class ResilienceConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(30))
.slowCallDurationThreshold(Duration.ofSeconds(2))
.permittedNumberOfCallsInHalfOpenState(3)
.minimumNumberOfCalls(5)
.slidingWindowSize(10)
.build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
// Register event consumer
registry.getEventPublisher()
.onEntryAdded(event ->
log.info("CircuitBreaker added: {}",
event.getAddedEntry().getName())
);
return registry;
}
@Bean
public RegistryEventConsumer<CircuitBreaker> circuitBreakerEventConsumer() {
return new RegistryEventConsumer<>() {
@Override
public void onEntryAddedEvent(EntryAddedEvent<CircuitBreaker> event) {
CircuitBreaker cb = event.getAddedEntry();
cb.getEventPublisher()
.onStateTransition(e ->
log.warn("CircuitBreaker {} state changed: {} -> {}",
cb.getName(),
e.getStateTransition().getFromState(),
e.getStateTransition().getToState())
)
.onError(e ->
log.error("CircuitBreaker {} error: {}",
cb.getName(),
e.getThrowable().getMessage())
);
}
@Override
public void onEntryRemovedEvent(EntryRemovedEvent<CircuitBreaker> event) {
log.info("CircuitBreaker removed: {}",
event.getRemovedEntry().getName());
}
@Override
public void onEntryReplacedEvent(EntryReplacedEvent<CircuitBreaker> event) {
log.info("CircuitBreaker replaced: {}",
event.getNewEntry().getName());
}
};
}
}
```
### Monitoring and Metrics
```java
@RestController
@RequestMapping("/api/monitoring")
@RequiredArgsConstructor
public class ResilienceMonitoringController {
private final CircuitBreakerRegistry circuitBreakerRegistry;
@GetMapping("/circuit-breakers")
public List<CircuitBreakerStatus> getStatus() {
return circuitBreakerRegistry.getAllCircuitBreakers().stream()
.map(this::toStatus)
.collect(Collectors.toList());
}
private CircuitBreakerStatus toStatus(CircuitBreaker cb) {
CircuitBreaker.Metrics metrics = cb.getMetrics();
return CircuitBreakerStatus.builder()
.name(cb.getName())
.state(cb.getState().name())
.failureRate(metrics.getFailureRate())
.slowCallRate(metrics.getSlowCallRate())
.numberOfBufferedCalls(metrics.getNumberOfBufferedCalls())
.numberOfFailedCalls(metrics.getNumberOfFailedCalls())
.numberOfSuccessfulCalls(metrics.getNumberOfSuccessfulCalls())
.build();
}
}
@Value
@Builder
class CircuitBreakerStatus {
String name;
String state;
float failureRate;
float slowCallRate;
int numberOfBufferedCalls;
int numberOfFailedCalls;
int numberOfSuccessfulCalls;
}
```
See testing-patterns.md for comprehensive testing strategies and configuration-reference.md for complete configuration options.

View File

@@ -0,0 +1,491 @@
# Resilience4j Testing Patterns
## Circuit Breaker Testing
### Testing State Transitions
```java
@SpringBootTest
class CircuitBreakerStateTest {
@Autowired
private PaymentService paymentService;
@MockBean
private RestTemplate restTemplate;
@Test
void shouldTransitionToOpenAfterFailures() {
// Simulate repeated failures
when(restTemplate.postForObject(anyString(), any(), eq(PaymentResponse.class)))
.thenThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR));
// Trigger failures to exceed threshold
for (int i = 0; i < 5; i++) {
assertThatThrownBy(() -> paymentService.processPayment(new PaymentRequest()))
.isInstanceOf(HttpServerErrorException.class);
}
// Circuit should be open - fallback executes
PaymentResponse response = paymentService.processPayment(new PaymentRequest());
assertThat(response.getStatus()).isEqualTo("PENDING");
}
@Test
void shouldExecuteFallbackWhenCircuitOpen() {
when(restTemplate.postForObject(anyString(), any(), eq(PaymentResponse.class)))
.thenThrow(new RuntimeException("Service unavailable"));
// Force failures to open circuit
for (int i = 0; i < 5; i++) {
try {
paymentService.processPayment(new PaymentRequest());
} catch (Exception ignored) {}
}
// Circuit is open, fallback provides response
PaymentResponse response = paymentService.processPayment(new PaymentRequest());
assertThat(response.getStatus()).isEqualTo("PENDING");
assertThat(response.getMessage()).contains("temporarily unavailable");
}
}
```
### Testing Circuit States Directly
```java
@SpringBootTest
class CircuitBreakerDirectStateTest {
@Autowired
private CircuitBreakerRegistry circuitBreakerRegistry;
@Test
void shouldManuallyOpenAndCloseCircuit() {
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("paymentService");
assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
// Manually open circuit
circuitBreaker.transitionToOpenState();
assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
// Manually close circuit
circuitBreaker.transitionToClosedState();
assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.CLOSED);
}
}
```
## Retry Testing
### Testing Retry Attempts
```java
@SpringBootTest
@AutoConfigureWireMock(port = 0)
class RetryTest {
@Autowired
private OrderService orderService;
@Test
void shouldRetryOnTransientFailure() {
// Setup: First two calls fail, third succeeds
stubFor(post("/orders")
.inScenario("Retry Scenario")
.whenScenarioStateIs(STARTED)
.willReturn(serverError())
.willSetStateTo("First Failure"));
stubFor(post("/orders")
.inScenario("Retry Scenario")
.whenScenarioStateIs("First Failure")
.willReturn(serverError())
.willSetStateTo("Second Failure"));
stubFor(post("/orders")
.inScenario("Retry Scenario")
.whenScenarioStateIs("Second Failure")
.willReturn(ok().withBody("""
{"id":1,"status":"CREATED"}
""")));
Order order = orderService.createOrder(new OrderRequest());
assertThat(order.getId()).isEqualTo(1L);
assertThat(order.getStatus()).isEqualTo("CREATED");
// Verify exactly 3 calls were made
verify(exactly(3), postRequestedFor(urlEqualTo("/orders")));
}
@Test
void shouldThrowExceptionAfterMaxRetries() {
stubFor(post("/orders").willReturn(serverError()));
assertThatThrownBy(() -> orderService.createOrder(new OrderRequest()))
.isInstanceOf(Exception.class);
// Verify retry attempts (maxAttempts = 3)
verify(atLeast(3), postRequestedFor(urlEqualTo("/orders")));
}
}
```
## Rate Limiter Testing
### Testing Rate Limit Enforcement
```java
@SpringBootTest
class RateLimiterTest {
@Autowired
private NotificationService notificationService;
@Test
void shouldRejectRequestsExceedingRateLimit() {
// Configuration: 5 permits per second
// First 5 requests should succeed
for (int i = 0; i < 5; i++) {
notificationService.sendEmail(createEmailRequest(i));
}
// 6th request should fail immediately (no timeout)
assertThatThrownBy(() ->
notificationService.sendEmail(createEmailRequest(6))
).isInstanceOf(RequestNotPermitted.class);
}
@Test
void shouldAlowRequestsAfterWindowReset() throws InterruptedException {
// First batch of requests
for (int i = 0; i < 5; i++) {
notificationService.sendEmail(createEmailRequest(i));
}
// Wait for refresh period (1 second)
Thread.sleep(1100);
// Should succeed - window has reset
notificationService.sendEmail(createEmailRequest(5));
}
private EmailRequest createEmailRequest(int id) {
return EmailRequest.builder()
.to("user" + id + "@example.com")
.subject("Test " + id)
.build();
}
}
```
## Bulkhead Testing
### Testing Semaphore Bulkhead
```java
@SpringBootTest
class BulkheadSemaphoreTest {
@Autowired
private ReportService reportService;
@Test
void shouldLimitConcurrentCalls() {
// Configuration: maxConcurrentCalls = 5
CountDownLatch latch = new CountDownLatch(5);
List<CompletableFuture<Report>> futures = new ArrayList<>();
// Submit 5 concurrent calls
for (int i = 0; i < 5; i++) {
futures.add(CompletableFuture.supplyAsync(() -> {
latch.countDown();
return reportService.generateReport(new ReportRequest());
}));
}
// 6th call should be rejected
assertThatThrownBy(() ->
reportService.generateReport(new ReportRequest())
).isInstanceOf(BulkheadFullException.class);
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
}
```
### Testing Thread Pool Bulkhead
```java
@SpringBootTest
class BulkheadThreadPoolTest {
@Autowired
private AnalyticsService analyticsService;
@Test
void shouldUseThreadPoolForAsync() {
// Configuration: threadPoolSize = 2, queueCapacity = 100
List<CompletableFuture<AnalyticsResult>> futures = new ArrayList<>();
for (int i = 0; i < 10; i++) {
futures.add(analyticsService.runAnalytics(new AnalyticsRequest()));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
for (CompletableFuture<AnalyticsResult> future : futures) {
assertThat(future.join()).isNotNull();
}
}
}
```
## Time Limiter Testing
### Testing Timeout Enforcement
```java
@SpringBootTest
class TimeLimiterTest {
@Autowired
private SearchService searchService;
@Test
void shouldTimeoutExceededOperations() {
// Configuration: timeoutDuration = 1s
SearchQuery slowQuery = new SearchQuery();
slowQuery.setSimulatedDelay(Duration.ofSeconds(2));
assertThatThrownBy(() ->
searchService.search(slowQuery).get()
).hasCauseInstanceOf(TimeoutException.class);
}
@Test
void shouldReturnFallbackOnTimeout() {
SearchQuery slowQuery = new SearchQuery();
slowQuery.setSimulatedDelay(Duration.ofSeconds(2));
CompletableFuture<SearchResults> result = searchService.search(slowQuery);
SearchResults results = result.join();
assertThat(results).isNotNull();
assertThat(results.isTimedOut()).isTrue();
assertThat(results.getMessage()).contains("timed out");
}
}
```
## Fallback Method Signature Validation
### Correct Fallback Signatures
```java
@Service
public class PaymentService {
@CircuitBreaker(name = "payment", fallbackMethod = "paymentFallback")
public PaymentResponse processPayment(PaymentRequest request) {
// method body
}
// CORRECT: Matches return type and parameters + Exception
private PaymentResponse paymentFallback(PaymentRequest request, Exception ex) {
// fallback logic
}
@Retry(name = "product")
public Product getProduct(String productId) {
// method body
}
// CORRECT: Can omit Exception parameter
private Product getProductFallback(String productId) {
// fallback logic
}
}
```
### Common Fallback Signature Errors
```java
@CircuitBreaker(name = "service", fallbackMethod = "fallback")
public String processData(Long id) { }
// WRONG: Missing parameter
public String fallback(Exception ex) { }
// WRONG: Wrong return type
public void fallback(Long id, Exception ex) { }
// WRONG: Wrong parameter type
public String fallback(String id, Exception ex) { }
// CORRECT:
public String fallback(Long id, Exception ex) { }
```
## Integration Testing Configuration
### Test Configuration Profile
```yaml
# application-test.yml
resilience4j:
circuitbreaker:
instances:
testService:
registerHealthIndicator: false
slidingWindowSize: 5
minimumNumberOfCalls: 3
failureRateThreshold: 50
waitDurationInOpenState: 100ms
retry:
instances:
testService:
maxAttempts: 2
waitDuration: 10ms
ratelimiter:
instances:
testService:
limitForPeriod: 10
limitRefreshPeriod: 1s
timeoutDuration: 10ms
```
### Test Helper Methods
```java
@TestConfiguration
public class ResilienceTestConfig {
public static void openCircuitBreaker(CircuitBreaker circuitBreaker) {
circuitBreaker.transitionToOpenState();
}
public static void closeCircuitBreaker(CircuitBreaker circuitBreaker) {
circuitBreaker.transitionToClosedState();
}
public static void simulateFailures(
CircuitBreaker circuitBreaker,
int numberOfFailures) {
for (int i = 0; i < numberOfFailures; i++) {
try {
circuitBreaker.executeSupplier(() -> {
throw new RuntimeException("Simulated failure");
});
} catch (Exception ignored) {}
}
}
public static void resetCircuitBreaker(CircuitBreaker circuitBreaker) {
circuitBreaker.transitionToClosedState();
}
}
```
## Common Testing Mistakes
### Mistake 1: Not Waiting for Sliding Window
```java
// WRONG: Circuit might not open yet
for (int i = 0; i < 3; i++) {
try { service.call(); } catch (Exception e) {}
}
assertThat(circuit.getState()).isEqualTo(CircuitBreaker.State.OPEN); // May fail!
// CORRECT: Exceed minimumNumberOfCalls before checking
for (int i = 0; i < 5; i++) { // minimumNumberOfCalls = 5
try { service.call(); } catch (Exception e) {}
}
assertThat(circuit.getState()).isEqualTo(CircuitBreaker.State.OPEN);
```
### Mistake 2: Incorrect Fallback Method Access
```java
// WRONG: Fallback method is private, not accessible by AOP
@CircuitBreaker(name = "service", fallbackMethod = "fallback")
public String process(String data) { }
private String fallback(String data, Exception ex) { } // Private - won't work!
// CORRECT: Package-private or protected
protected String fallback(String data, Exception ex) { }
```
### Mistake 3: Not Mocking External Dependencies
```java
// WRONG: Circuit breaker might open due to real network calls
@SpringBootTest
class ServiceTest {
@Autowired
private ServiceWithCircuitBreaker service;
// Missing @MockBean for external service
@Test
void test() {
// Real network calls - unpredictable
}
}
// CORRECT: Mock external dependencies
@SpringBootTest
class ServiceTest {
@Autowired
private ServiceWithCircuitBreaker service;
@MockBean
private ExternalService externalService;
@Test
void test() {
when(externalService.call()).thenThrow(new RuntimeException());
// Predictable failure
}
}
```
## Performance Considerations
### Memory Usage in Tests
- **COUNT_BASED sliding window**: Stores last N call outcomes in memory
- **TIME_BASED sliding window**: May require more memory for high-throughput services
- Use smaller `slidingWindowSize` in tests to reduce memory footprint
### Timeout Configuration for Tests
```yaml
resilience4j:
timelimiter:
instances:
testService:
timeoutDuration: 2s # Longer timeout for slower CI/CD environments
circuitbreaker:
instances:
testService:
waitDurationInOpenState: 100ms # Shorter for faster test execution
```
### Avoiding Test Flakiness
- Set deterministic timeouts based on CI/CD environment
- Use `@ActiveProfiles("test")` for test-specific configurations
- Reset circuit breaker state between tests when needed
- Mock external services consistently

View File

@@ -0,0 +1,315 @@
---
name: spring-boot-rest-api-standards
description: Implement REST API design standards and best practices for Spring Boot projects. Use when creating or reviewing REST endpoints, DTOs, error handling, pagination, security headers, HATEOAS and architecture patterns.
category: backend
tags: [spring-boot, rest-api, dto, validation, error-handling, pagination, hateoas, architecture, java]
version: 1.1.0
allowed-tools: Read, Write, Bash
---
# Spring Boot REST API Standards
This skill provides comprehensive guidance for building RESTful APIs in Spring Boot applications with consistent design patterns, proper error handling, validation, and architectural best practices based on REST principles and Spring Boot conventions.
## Overview
Spring Boot REST API standards establish consistent patterns for building production-ready REST APIs. These standards cover resource-based URL design, proper HTTP method usage, status code conventions, DTO patterns, validation, error handling, pagination, security headers, and architectural layering. Implement these patterns to ensure API consistency, maintainability, and adherence to REST principles.
## When to Use This Skill
Use this skill when:
- Creating new REST endpoints and API routes
- Designing request/response DTOs and API contracts
- Planning HTTP methods and status codes
- Implementing error handling and validation
- Setting up pagination, filtering, and sorting
- Designing security headers and CORS policies
- Implementing HATEOAS (Hypermedia As The Engine Of Application State)
- Reviewing REST API architecture and design patterns
- Building microservices with consistent API standards
- Documenting API endpoints with clear contracts
## Instructions
### To Build RESTful API Endpoints
Follow these steps to create well-designed REST API endpoints:
1. **Design Resource-Based URLs**
- Use plural nouns for resource names
- Follow REST conventions: GET /users, POST /users, PUT /users/{id}
- Avoid action-based URLs like /getUserList
2. **Implement Proper HTTP Methods**
- GET: Retrieve resources (safe, idempotent)
- POST: Create resources (not idempotent)
- PUT: Replace entire resources (idempotent)
- PATCH: Partial updates (not idempotent)
- DELETE: Remove resources (idempotent)
3. **Use Appropriate Status Codes**
- 200 OK: Successful GET/PUT/PATCH
- 201 Created: Successful POST with Location header
- 204 No Content: Successful DELETE
- 400 Bad Request: Invalid request data
- 404 Not Found: Resource doesn't exist
- 409 Conflict: Duplicate resource
- 500 Internal Server Error: Unexpected errors
4. **Create Request/Response DTOs**
- Separate API contracts from domain entities
- Use Java records or Lombok `@Data`/`@Value`
- Apply Jakarta validation annotations
- Keep DTOs immutable when possible
5. **Implement Validation**
- Use `@Valid` annotation on `@RequestBody` parameters
- Apply validation constraints (`@NotBlank`, `@Email`, `@Size`, etc.)
- Handle validation errors with `MethodArgumentNotValidException`
6. **Set Up Error Handling**
- Use `@RestControllerAdvice` for global exception handling
- Return standardized error responses with status, error, message, and timestamp
- Use `ResponseStatusException` for specific HTTP status codes
7. **Configure Pagination**
- Use Pageable for large datasets
- Include page, size, sort parameters
- Return metadata with total elements, totalPages, etc.
8. **Add Security Headers**
- Configure CORS policies
- Set content security policy
- Include X-Frame-Options, X-Content-Type-Options
## Examples
### Basic CRUD Controller
```java
@RestController
@RequestMapping("/v1/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int pageSize) {
log.debug("Fetching users page {} size {}", page, pageSize);
Page<UserResponse> users = userService.getAll(page, pageSize);
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
return ResponseEntity.ok(userService.getById(id));
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
UserResponse created = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return ResponseEntity.ok(userService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
```
### Request/Response DTOs
```java
// Request DTO
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateUserRequest {
@NotBlank(message = "User name cannot be blank")
private String name;
@Email(message = "Valid email required")
private String email;
}
// Response DTO
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserResponse {
private Long id;
private String name;
private String email;
private LocalDateTime createdAt;
}
```
### Global Exception Handler
```java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException ex, WebRequest request) {
String errors = ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining(", "));
ErrorResponse errorResponse = new ErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Validation Error",
"Validation failed: " + errors,
request.getDescription(false).replaceFirst("uri=", "")
);
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ErrorResponse> handleResponseStatusException(
ResponseStatusException ex, WebRequest request) {
ErrorResponse error = new ErrorResponse(
ex.getStatusCode().value(),
ex.getStatusCode().toString(),
ex.getReason(),
request.getDescription(false).replaceFirst("uri=", "")
);
return new ResponseEntity<>(error, ex.getStatusCode());
}
}
```
## Best Practices
### 1. Use Constructor Injection
```java
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
// Dependencies are explicit and testable
}
```
### 2. Prefer Immutable DTOs
```java
// Java records (JDK 16+)
public record UserResponse(Long id, String name, String email, LocalDateTime createdAt) {}
// Lombok @Value for immutability
@Value
public class UserResponse {
Long id;
String name;
String email;
LocalDateTime createdAt;
}
```
### 3. Validate Input Early
```java
@PostMapping
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
// Validation happens automatically before method execution
return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(request));
}
```
### 4. Use ResponseEntity Flexibly
```java
return ResponseEntity.status(HttpStatus.CREATED)
.header("Location", "/api/users/" + created.getId())
.header("X-Total-Count", String.valueOf(userService.count()))
.body(created);
```
### 5. Implement Proper Transaction Management
```java
@Service
@Transactional
public class UserService {
@Transactional(readOnly = true)
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
@Transactional
public User create(User user) {
return userRepository.save(user);
}
}
```
### 6. Add Meaningful Logging
```java
@Slf4j
@Service
public class UserService {
public User create(User user) {
log.info("Creating user with email: {}", user.getEmail());
return userRepository.save(user);
}
}
```
### 7. Document APIs with Javadoc
```java
/**
* Retrieves a user by id.
*
* @param id the user id
* @return ResponseEntity containing a UserResponse
* @throws ResponseStatusException with 404 if user not found
*/
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id)
```
## Constraints
### 1. Never Expose Entities Directly
Use DTOs to separate API contracts from domain models. This prevents accidental exposure of internal data structures and allows API evolution without database schema changes.
### 2. Follow REST Conventions Strictly
- Use nouns for resource names, not verbs
- Use correct HTTP methods for operations
- Use plural resource names (/users, not /user)
- Return appropriate HTTP status codes for each operation
### 3. Handle All Exceptions Globally
Use @RestControllerAdvice to catch all exceptions consistently. Don't let raw exceptions bubble up to clients.
### 4. Always Paginate Large Result Sets
For GET endpoints that might return many results, implement pagination to prevent performance issues and DDoS vulnerabilities.
### 5. Validate All Input Data
Never trust client input. Use Jakarta validation annotations on all request DTOs to validate data at the controller boundary.
### 6. Use Constructor Injection Exclusively
Avoid field injection (`@Autowired`) for better testability and explicit dependency declaration.
### 7. Keep Controllers Thin
Controllers should only handle HTTP request/response adaptation. Delegate business logic to service layers.
## References
- See `references/` directory for comprehensive reference material including HTTP status codes, Spring annotations, and detailed examples
- Refer to `agents/spring-boot-code-review-specialist.md` for code review guidelines
- Review `spring-boot-dependency-injection/SKILL.md` for dependency injection patterns
- Check `junit-test-patterns/SKILL.md` for testing REST APIs

View File

@@ -0,0 +1,761 @@
# Architecture Patterns for REST APIs
## Layered Architecture
### Feature-Based Structure
```
feature-name/
├── domain/
│ ├── model/ # Domain entities (Spring-free)
│ │ └── User.java
│ ├── repository/ # Domain ports (interfaces)
│ │ └── UserRepository.java
│ └── service/ # Domain services
│ └── UserService.java
├── application/
│ ├── service/ # Use cases (@Service beans)
│ │ └── UserApplicationService.java
│ └── dto/ # Immutable DTOs/records
│ ├── UserRequest.java
│ └── UserResponse.java
├── presentation/
│ └── rest/ # Controllers and mappers
│ ├── UserController.java
│ ├── UserMapper.java
│ └── UserExceptionHandler.java
└── infrastructure/
└── persistence/ # JPA adapters
└── JpaUserRepository.java
```
### Domain Layer (Clean Architecture)
#### Domain Entity
```java
package com.example.domain.model;
import java.time.LocalDateTime;
import java.util.Objects;
public class User {
private final UserId id;
private final String name;
private final Email email;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
private User(UserId id, String name, Email email) {
this.id = Objects.requireNonNull(id);
this.name = Objects.requireNonNull(name);
this.email = Objects.requireNonNull(email);
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
public static User create(UserId id, String name, Email email) {
return new User(id, name, email);
}
public User updateName(String newName) {
return new User(this.id, newName, this.email);
}
public User updateEmail(Email newEmail) {
return new User(this.id, this.name, newEmail);
}
// Getters
public UserId getId() { return id; }
public String getName() { return name; }
public Email getEmail() { return email; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
}
// Value objects
public class UserId {
private final Long value;
public UserId(Long value) {
this.value = Objects.requireNonNull(value);
if (value <= 0) {
throw new IllegalArgumentException("User ID must be positive");
}
}
public Long getValue() { return value; }
}
public class Email {
private final String value;
public Email(String value) {
this.value = Objects.requireNonNull(value);
if (!isValid(value)) {
throw new IllegalArgumentException("Invalid email format");
}
}
private boolean isValid(String email) {
return email.contains("@") && email.length() > 5;
}
public String getValue() { return value; }
}
```
#### Domain Repository Port
```java
package com.example.domain.repository;
import com.example.domain.model.User;
import com.example.domain.model.UserId;
import java.util.Optional;
public interface UserRepository {
Optional<User> findById(UserId id);
void save(User user);
void delete(UserId id);
boolean existsByEmail(Email email);
}
```
#### Domain Service
```java
package com.example.domain.service;
import com.example.domain.model.User;
import com.example.domain.model.Email;
import com.example.domain.repository.UserRepository;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class UserDomainService {
private final UserRepository userRepository;
public User registerUser(String name, String email) {
Email emailObj = new Email(email);
if (userRepository.existsByEmail(emailObj)) {
throw new BusinessException("Email already exists");
}
User user = User.create(UserId.generate(), name, emailObj);
userRepository.save(user);
return user;
}
}
```
### Application Layer (Use Cases)
#### Application Service
```java
package com.example.application.service;
import com.example.application.dto.UserRequest;
import com.example.application.dto.UserResponse;
import com.example.domain.model.User;
import com.example.domain.repository.UserRepository;
import com.example.application.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@Service
@RequiredArgsConstructor
@Slf4j
public class UserApplicationService {
private final UserRepository userRepository;
private final UserMapper userMapper;
@Transactional
public UserResponse createUser(UserRequest request) {
log.info("Creating user: {}", request.getName());
User user = userMapper.toDomain(request);
User saved = userRepository.save(user);
return userMapper.toResponse(saved);
}
@Transactional(readOnly = true)
public Page<UserResponse> findAllUsers(Pageable pageable) {
return userRepository.findAll(pageable)
.map(userMapper::toResponse);
}
@Transactional(readOnly = true)
public UserResponse findUserById(Long id) {
return userRepository.findById(new UserId(id))
.map(userMapper::toResponse)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
}
@Transactional
public UserResponse updateUser(Long id, UserRequest request) {
User user = userRepository.findById(new UserId(id))
.orElseThrow(() -> new EntityNotFoundException("User not found"));
User updated = user.updateName(request.getName());
User saved = userRepository.save(updated);
return userMapper.toResponse(saved);
}
@Transactional
public void deleteUser(Long id) {
userRepository.delete(new UserId(id));
}
}
```
#### DTOs
```java
package com.example.application.dto;
import lombok.Value;
import jakarta.validation.constraints.*;
@Value
public class UserRequest {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be 2-100 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Valid email required")
private String email;
}
@Value
public class UserResponse {
Long id;
String name;
String email;
LocalDateTime createdAt;
LocalDateTime updatedAt;
}
```
### Presentation Layer (REST API)
#### Controller
```java
package com.example.presentation.rest;
import com.example.application.dto.UserRequest;
import com.example.application.dto.UserResponse;
import com.example.application.service.UserApplicationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserApplicationService userApplicationService;
@PostMapping
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody UserRequest request) {
UserResponse created = userApplicationService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@GetMapping
public ResponseEntity<Page<UserResponse>> getAllUsers(Pageable pageable) {
Page<UserResponse> users = userApplicationService.findAllUsers(pageable);
return ResponseEntity.ok(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
UserResponse user = userApplicationService.findUserById(id);
return ResponseEntity.ok(user);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable Long id,
@Valid @RequestBody UserRequest request) {
UserResponse updated = userApplicationService.updateUser(id, request);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userApplicationService.deleteUser(id);
return ResponseEntity.noContent().build();
}
}
```
#### Mapper
```java
package com.example.application.mapper;
import com.example.application.dto.UserRequest;
import com.example.application.dto.UserResponse;
import com.example.domain.model.User;
import com.example.domain.model.Email;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.NullValuePropertyMappingStrategy;
@Mapper(componentModel = "spring")
public interface UserMapper {
@Mapping(target = "id", source = "id.value")
UserResponse toResponse(User user);
User toDomain(UserRequest request);
default Email toEmail(String email) {
return new Email(email);
}
default String toString(Email email) {
return email.getValue();
}
}
```
### Infrastructure Layer (Adapters)
#### JPA Repository Adapter
```java
package com.example.infrastructure.persistence;
import com.example.domain.model.User;
import com.example.domain.model.UserId;
import com.example.domain.repository.UserRepository;
import com.example.infrastructure.entity.UserEntity;
import com.example.infrastructure.mapper.UserEntityMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class JpaUserRepository implements UserRepository {
private final SpringDataUserRepository springDataRepository;
private final UserEntityMapper entityMapper;
@Override
public Optional<User> findById(UserId id) {
return springDataRepository.findById(id.getValue())
.map(entityMapper::toDomain);
}
@Override
public void save(User user) {
UserEntity entity = entityMapper.toEntity(user);
springDataRepository.save(entity);
}
@Override
public void delete(UserId id) {
springDataRepository.deleteById(id.getValue());
}
@Override
public boolean existsByEmail(Email email) {
return springDataRepository.existsByEmail(email.getValue());
}
}
```
#### Spring Data Repository
```java
package com.example.infrastructure.persistence;
import com.example.infrastructure.entity.UserEntity;
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;
@Repository
public interface SpringDataUserRepository extends JpaRepository<UserEntity, Long> {
boolean existsByEmail(String email);
@Query("SELECT u FROM UserEntity u WHERE u.email = :email")
Optional<UserEntity> findByEmail(@Param("email") String email);
}
```
## CQRS Pattern (Command Query Responsibility Segregation)
### Commands
```java
package com.example.application.command;
import lombok.Value;
import jakarta.validation.constraints.*;
@Value
public class CreateUserCommand {
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100)
private String name;
@NotBlank(message = "Email is required")
@Email
private String email;
}
@Value
public class UpdateUserCommand {
@NotNull(message = "User ID is required")
private Long userId;
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100)
private String name;
}
```
### Command Handlers
```java
package com.example.application.command.handler;
import com.example.application.command.CreateUserCommand;
import com.example.application.command.UpdateUserCommand;
import com.example.domain.model.User;
import com.example.domain.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Slf4j
public class UserCommandHandler {
private final UserRepository userRepository;
@Transactional
public User handle(CreateUserCommand command) {
User user = User.create(UserId.generate(), command.getName(), new Email(command.getEmail()));
return userRepository.save(user);
}
@Transactional
public User handle(UpdateUserCommand command) {
User user = userRepository.findById(new UserId(command.getUserId()))
.orElseThrow(() -> new EntityNotFoundException("User not found"));
User updated = user.updateName(command.getName());
return userRepository.save(updated);
}
}
```
### Queries
```java
package com.example.application.query;
import lombok.Value;
import java.util.List;
@Value
public class FindAllUsersQuery {
int page;
int size;
String sortBy;
String sortDirection;
}
@Value
public class FindUserByIdQuery {
Long userId;
}
```
### Query Handlers
```java
package com.example.application.query.handler;
import com.example.application.query.FindAllUsersQuery;
import com.example.application.query.FindUserByIdQuery;
import com.example.application.dto.UserResponse;
import com.example.domain.model.User;
import com.example.domain.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
@Service
@RequiredArgsConstructor
@Slf4j
public class UserQueryHandler {
private final UserRepository userRepository;
public Page<UserResponse> handle(FindAllUsersQuery query) {
Pageable pageable = PageRequest.of(
query.getPage(),
query.getSize(),
Sort.by(Sort.Direction.fromString(query.getSortDirection()), query.getSortBy())
);
return userRepository.findAll(pageable)
.map(this::toResponse);
}
public UserResponse handle(FindUserByIdQuery query) {
return userRepository.findById(new UserId(query.getUserId()))
.map(this::toResponse)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
}
private UserResponse toResponse(User user) {
return new UserResponse(
user.getId().getValue(),
user.getName(),
user.getEmail().getValue(),
user.getCreatedAt(),
user.getUpdatedAt()
);
}
}
```
## Event-Driven Architecture
### Domain Events
```java
package com.example.domain.event;
import lombok.Value;
import java.time.LocalDateTime;
@Value
public class UserCreatedEvent {
String userId;
String email;
LocalDateTime timestamp;
}
@Value
public class UserUpdatedEvent {
String userId;
String oldName;
String newName;
LocalDateTime timestamp;
}
```
### Event Publisher
```java
package com.example.domain.service;
import com.example.domain.event.UserCreatedEvent;
import com.example.domain.event.UserUpdatedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
@Slf4j
public class UserEventPublisher {
private final ApplicationEventPublisher eventPublisher;
public void publishUserCreated(String userId, String email) {
UserCreatedEvent event = new UserCreatedEvent(userId, email, LocalDateTime.now());
eventPublisher.publishEvent(event);
log.info("Published UserCreatedEvent for user: {}", userId);
}
public void publishUserUpdated(String userId, String oldName, String newName) {
UserUpdatedEvent event = new UserUpdatedEvent(userId, oldName, newName, LocalDateTime.now());
eventPublisher.publishEvent(event);
log.info("Published UserUpdatedEvent for user: {}", userId);
}
}
```
### Event Listeners
```java
package com.example.application.event.listener;
import com.example.domain.event.UserCreatedEvent;
import com.example.domain.event.UserUpdatedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class UserEventListener {
@EventListener
public void handleUserCreated(UserCreatedEvent event) {
log.info("User created: {} with email: {}", event.getUserId(), event.getEmail());
// Send welcome email, update analytics, etc.
}
@EventListener
public void handleUserUpdated(UserUpdatedEvent event) {
log.info("User {} updated: {} -> {}", event.getUserId(), event.getOldName(), event.getNewName());
// Update search index, send notification, etc.
}
}
```
## Microservices Architecture
### API Gateway Pattern
```java
package com.example.gateway;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("user-service", r -> r.path("/api/users/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://user-service"))
.route("order-service", r -> r.path("/api/orders/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://order-service"))
.build();
}
}
```
### Service Communication
```java
package com.example.application.client;
import com.example.application.dto.UserResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@FeignClient(name = "user-service", url = "${user.service.url}")
public interface UserServiceClient {
@GetMapping("/api/users/{id}")
UserResponse getUserById(@PathVariable Long id);
@GetMapping("/api/users")
List<UserResponse> getAllUsers();
}
```
## Testing Strategy
### Unit Tests
```java
package com.example.application.service;
import com.example.application.dto.UserRequest;
import com.example.domain.model.User;
import com.example.domain.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class)
class UserApplicationServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserApplicationService userApplicationService;
@Test
void createUserShouldCreateUser() {
// Arrange
UserRequest request = new UserRequest("John Doe", "john@example.com");
User user = User.create(UserId.generate(), "John Doe", new Email("john@example.com"));
when(userRepository.save(any(User.class))).thenReturn(user);
// Act
UserResponse result = userApplicationService.createUser(request);
// Assert
assertNotNull(result);
assertEquals("John Doe", result.getName());
verify(userRepository).save(any(User.class));
}
}
```
### Integration Tests
```java
package com.example.presentation.rest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void createUserShouldReturnCreatedStatus() throws Exception {
// Arrange
String requestBody = """
{
"name": "John Doe",
"email": "john@example.com"
}
""";
// Act & Assert
mockMvc.perform(post("/api/users")
.contentType("application/json")
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("John Doe"))
.andExpect(jsonPath("$.email").value("john@example.com"));
}
}
```

View File

@@ -0,0 +1,597 @@
# Spring Boot REST API Examples
## Complete CRUD REST API with Validation
### Entity with Validation
```java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be 2-100 characters")
private String name;
@NotBlank(message = "Email is required")
@Email(message = "Valid email required")
@Column(unique = true)
private String email;
@Min(value = 18, message = "Must be at least 18")
@Max(value = 120, message = "Invalid age")
private Integer age;
@Size(min = 8, max = 100, message = "Password must be 8-100 characters")
private String password;
@Column(name = "is_active")
private Boolean active = true;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}
```
### Service with Transaction Management
```java
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
@Transactional(readOnly = true)
public Page<UserResponse> findAll(Pageable pageable) {
log.debug("Fetching users page {} size {}", pageable.getPageNumber(), pageable.getPageSize());
return userRepository.findAll(pageable)
.map(this::toResponse);
}
@Transactional(readOnly = true)
public UserResponse findById(Long id) {
log.debug("Looking for user with id {}", id);
return userRepository.findById(id)
.map(this::toResponse)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
}
@Transactional
public UserResponse create(CreateUserRequest request) {
log.info("Creating user with email: {}", request.getEmail());
if (userRepository.existsByEmail(request.getEmail())) {
throw new BusinessException("Email already exists");
}
User user = new User();
user.setName(request.getName());
user.setEmail(request.getEmail());
user.setAge(request.getAge());
user.setPassword(passwordEncoder.encode(request.getPassword()));
User saved = userRepository.save(user);
emailService.sendWelcomeEmail(saved.getEmail(), saved.getName());
return toResponse(saved);
}
@Transactional
public UserResponse update(Long id, UpdateUserRequest request) {
User user = userRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found"));
if (request.getName() != null) {
user.setName(request.getName());
}
if (request.getEmail() != null) {
if (!user.getEmail().equals(request.getEmail()) &&
userRepository.existsByEmail(request.getEmail())) {
throw new BusinessException("Email already exists");
}
user.setEmail(request.getEmail());
}
if (request.getAge() != null) {
user.setAge(request.getAge());
}
User updated = userRepository.save(user);
return toResponse(updated);
}
@Transactional
public void delete(Long id) {
if (!userRepository.existsById(id)) {
throw new EntityNotFoundException("User not found");
}
User user = userRepository.findById(id).orElseThrow();
emailService.sendDeletionEmail(user.getEmail(), user.getName());
userRepository.deleteById(id);
}
private UserResponse toResponse(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getAge(),
user.getActive(),
user.getCreatedAt(),
user.getUpdatedAt()
);
}
}
```
### Controller with Proper HTTP Methods
```java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "DESC") String sortDirection) {
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
Page<UserResponse> users = userService.findAll(pageable);
HttpHeaders headers = new HttpHeaders();
headers.add("X-Total-Count", String.valueOf(users.getTotalElements()));
headers.add("X-Total-Pages", String.valueOf(users.getTotalPages()));
return ResponseEntity.ok()
.headers(headers)
.body(users);
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUserById(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
public ResponseEntity<UserResponse> createUser(@Valid @RequestBody CreateUserRequest request) {
UserResponse created = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED)
.header("Location", "/api/users/" + created.getId())
.body(created);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
UserResponse updated = userService.update(id, request);
return ResponseEntity.ok(updated);
}
@PatchMapping("/{id}")
public ResponseEntity<UserResponse> patchUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
UserResponse updated = userService.update(id, request);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.noContent().build();
}
}
```
## API Versioning Examples
### URL Versioning
```java
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 {
// Version 1 endpoints
}
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 {
// Version 2 endpoints with different response format
}
```
### Header Versioning
```java
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping
public ResponseEntity<UserResponse> getUsers(
@RequestHeader(value = "Accept-Version", defaultValue = "1.0") String version) {
if (version.equals("2.0")) {
return ResponseEntity.ok(v2UserResponse);
}
return ResponseEntity.ok(v1UserResponse);
}
}
```
### Media Type Versioning
```java
@GetMapping(produces = {
"application/vnd.company.v1+json",
"application/vnd.company.v2+json"
})
public ResponseEntity<UserResponse> getUsers(
@RequestHeader("Accept") String accept) {
if (accept.contains("v2")) {
return ResponseEntity.ok(v2UserResponse);
}
return ResponseEntity.ok(v1UserResponse);
}
```
## HATEOAS Implementation
### Response with Links
```java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserResponseWithLinks {
private Long id;
private String name;
private String email;
private Map<String, String> _links;
// Lombok generates constructors/getters/setters
}
@GetMapping("/{id}")
public ResponseEntity<UserResponseWithLinks> getUserWithLinks(@PathVariable Long id) {
UserResponse user = userService.findById(id);
Map<String, String> links = Map.of(
"self", "/api/users/" + id,
"all", "/api/users",
"update", "/api/users/" + id,
"delete", "/api/users/" + id
);
UserResponseWithLinks response = new UserResponseWithLinks(
user.getId(), user.getName(), user.getEmail(), links);
return ResponseEntity.ok(response);
}
```
### Advanced HATEOAS with Spring HATEOAS
```java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
private final EntityLinks entityLinks;
@GetMapping
public ResponseEntity<CollectionModel<UserResponse>> getAllUsers() {
List<UserResponse> users = userService.findAll().stream()
.map(this::toResponse)
.collect(Collectors.toList());
CollectionModel<UserResponse> resource = CollectionModel.of(users);
resource.add(entityLinks.linkToCollectionResource(UserController.class).withSelfRel());
resource.add(entityLinks.linkToCollectionResource(UserController.class).withRel("users"));
return ResponseEntity.ok(resource);
}
@GetMapping("/{id}")
public ResponseEntity<EntityModel<UserResponse>> getUserById(@PathVariable Long id) {
UserResponse user = userService.findById(id);
EntityModel<UserResponse> resource = EntityModel.of(user);
resource.add(entityLinks.linkToItemResource(UserController.class, id).withSelfRel());
resource.add(entityLinks.linkToCollectionResource(UserController.class).withRel("users"));
resource.add(linkTo(methodOn(UserController.class).getUserOrders(id)).withRel("orders"));
return ResponseEntity.ok(resource);
}
private UserResponse toResponse(User user) {
return new UserResponse(
user.getId(),
user.getName(),
user.getEmail(),
user.getActive(),
user.getCreatedAt()
);
}
}
```
## Async Processing
### Asynchronous Controller
```java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class AsyncUserController {
private final AsyncUserService asyncUserService;
@GetMapping("/{id}")
public CompletableFuture<ResponseEntity<UserResponse>> getUserById(@PathVariable Long id) {
return asyncUserService.getUserById(id)
.thenApply(ResponseEntity::ok)
.exceptionally(ex -> ResponseEntity.notFound().build());
}
@PostMapping
public CompletableFuture<ResponseEntity<UserResponse>> createUser(
@Valid @RequestBody CreateUserRequest request) {
return asyncUserService.createUser(request)
.thenApply(created ->
ResponseEntity.status(HttpStatus.CREATED)
.header("Location", "/api/users/" + created.getId())
.body(created))
.exceptionally(ex -> {
if (ex.getCause() instanceof BusinessException) {
return ResponseEntity.badRequest().build();
}
return ResponseEntity.internalServerError().build();
});
}
}
```
### Async Service Implementation
```java
@Service
@RequiredArgsConstructor
public class AsyncUserService {
private final UserService userService;
private final ExecutorService executor;
@Async
public CompletableFuture<UserResponse> getUserById(Long id) {
return CompletableFuture.supplyAsync(() -> userService.findById(id), executor);
}
@Async
public CompletableFuture<UserResponse> createUser(CreateUserRequest request) {
return CompletableFuture.supplyAsync(() -> userService.create(request), executor);
}
}
```
## File Upload and Download
### File Upload Controller
```java
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {
private final FileStorageService fileStorageService;
@PostMapping("/upload")
public ResponseEntity<FileUploadResponse> uploadFile(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
throw new BusinessException("File is empty");
}
String fileName = fileStorageService.storeFile(file);
String fileDownloadUri = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/api/files/download/")
.path(fileName)
.toUriString();
FileUploadResponse response = new FileUploadResponse(
fileName, fileDownloadUri, file.getContentType(), file.getSize());
return ResponseEntity.ok(response);
}
@GetMapping("/download/{fileName:.+}")
public ResponseEntity<Resource> downloadFile(@PathVariable String fileName) {
Resource resource = fileStorageService.loadFileAsResource(fileName);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + resource.getFilename() + "\"")
.body(resource);
}
}
```
### File Storage Service
```java
@Service
public class FileStorageService {
private final Path fileStorageLocation;
@Autowired
public FileStorageService(FileStorageProperties fileStorageProperties) {
this.fileStorageLocation = Paths.get(fileStorageProperties.getUploadDir())
.toAbsolutePath().normalize();
try {
Files.createDirectories(this.fileStorageLocation);
} catch (Exception ex) {
throw new FileStorageException("Could not create the directory where the uploaded files will be stored.", ex);
}
}
public String storeFile(MultipartFile file) {
String fileName = StringUtils.cleanPath(Objects.requireNonNull(file.getOriginalFilename()));
try {
if (fileName.contains("..")) {
throw new FileStorageException("Sorry! Filename contains invalid path sequence " + fileName);
}
Path targetLocation = this.fileStorageLocation.resolve(fileName);
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
return fileName;
} catch (IOException ex) {
throw new FileStorageException("Could not store file " + fileName + ". Please try again!", ex);
}
}
public Resource loadFileAsResource(String fileName) {
try {
Path filePath = this.fileStorageLocation.resolve(fileName).normalize();
Resource resource = new UrlResource(filePath);
if (resource.exists() && resource.isReadable()) {
return resource;
} else {
throw new FileNotFoundException("File not found " + fileName);
}
} catch (Exception ex) {
throw new FileNotFoundException("File not found " + fileName, ex);
}
}
}
```
## WebSocket Integration
### WebSocket Configuration
```java
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
```
### WebSocket Controller
```java
@Controller
@RequiredArgsConstructor
public class WebSocketController {
private final SimpMessagingTemplate messagingTemplate;
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
return chatMessage;
}
@MessageMapping("/chat.addUser")
@SendTo("/topic/public")
public ChatMessage addUser(@Payload ChatMessage chatMessage,
SimpMessageHeaderAccessor headerAccessor) {
headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
return chatMessage;
}
@Scheduled(fixedRate = 5000)
public void sendPeriodicUpdates() {
messagingTemplate.convertAndSend("/topic/updates",
new UpdateMessage("System update", LocalDateTime.now()));
}
}
```
### Frontend Integration Example
```javascript
// JavaScript WebSocket client
class WebSocketClient {
constructor(url) {
this.url = url;
this.stompClient = null;
this.connected = false;
}
connect() {
const socket = new SockJS(this.url);
this.stompClient = Stomp.over(socket);
this.stompClient.connect({}, (frame) => {
this.connected = true;
console.log('Connected: ' + frame);
// Subscribe to topics
this.stompClient.subscribe('/topic/public', (message) => {
this.onMessage(message);
});
this.stompClient.subscribe('/topic/updates', (update) => {
this.onUpdate(update);
});
}, (error) => {
this.connected = false;
console.error('Error: ' + error);
});
}
sendMessage(message) {
if (this.connected) {
this.stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(message));
}
}
onMessage(message) {
const chatMessage = JSON.parse(message.body);
console.log('Received message:', chatMessage);
// Display message in UI
}
onUpdate(update) {
const updateMessage = JSON.parse(update.body);
console.log('Received update:', updateMessage);
// Update UI with system messages
}
}
```

View File

@@ -0,0 +1,88 @@
# HTTP Methods and Status Codes Reference
## HTTP Methods
| Method | Idempotent | Safe | Purpose | Typical Status |
|--------|-----------|------|---------|----------------|
| GET | Yes | Yes | Retrieve resource | 200, 304, 404 |
| POST | No | No | Create resource | 201, 400, 409 |
| PUT | Yes | No | Replace resource | 200, 204, 404 |
| PATCH | No | No | Partial update | 200, 204, 400 |
| DELETE | Yes | No | Remove resource | 204, 404 |
| HEAD | Yes | Yes | Like GET, no body | 200, 304, 404 |
| OPTIONS | Yes | Yes | Describe communication options | 200 |
### Idempotent Operations
An operation is idempotent if making the same request multiple times produces the same result as making it once.
### Safe Operations
A safe operation doesn't change the state of the server. Safe operations are always idempotent.
## HTTP Status Codes
### 2xx Success
- `200 OK`: Successful GET/PUT/PATCH
- `201 Created`: Successful POST (include Location header)
- `202 Accepted`: Async processing accepted
- `204 No Content`: Successful DELETE or POST with no content
- `206 Partial Content`: Range request successful
### 3xx Redirection
- `301 Moved Permanently`: Resource permanently moved
- `304 Not Modified`: Cache valid, use local copy
- `307 Temporary Redirect`: Temporary redirect
### 4xx Client Errors
- `400 Bad Request`: Invalid format or parameters
- `401 Unauthorized`: Authentication required
- `403 Forbidden`: Authenticated but not authorized
- `404 Not Found`: Resource doesn't exist
- `409 Conflict`: Constraint violation or conflict
- `422 Unprocessable Entity`: Validation failed (semantic error)
- `429 Too Many Requests`: Rate limit exceeded
### 5xx Server Errors
- `500 Internal Server Error`: Unexpected server error
- `502 Bad Gateway`: External service unavailable
- `503 Service Unavailable`: Server temporarily down
- `504 Gateway Timeout`: External service timeout
## Common REST API Patterns
### Resource URLs
```
GET /users # List all users
GET /users/123 # Get specific user
POST /users # Create user
PUT /users/123 # Update user
DELETE /users/123 # Delete user
GET /users/123/orders # Get user's orders
```
### Query Parameters
```
GET /users?page=0&size=20&sort=createdAt,desc
- page: Page number (0-based)
- size: Number of items per page
- sort: Sorting format (field,direction)
```
### Response Headers
```
Location: /api/users/123 # For 201 Created responses
X-Total-Count: 45 # Total items count
Cache-Control: no-cache # Cache control
Content-Type: application/json # Response format
```
## Error Response Format
```json
{
"status": 400,
"error": "Bad Request",
"message": "Validation failed: name: Name cannot be blank, email: Valid email required",
"path": "/api/users",
"timestamp": "2024-01-15T10:30:00Z"
}
```

View File

@@ -0,0 +1,387 @@
# Pagination and Filtering Reference
## Pagination with Spring Data
### Basic Pagination
```java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping
public ResponseEntity<Page<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<UserResponse> users = userService.findAll(pageable);
return ResponseEntity.ok(users);
}
}
```
### Pagination with Sorting
```java
@GetMapping("/users")
public ResponseEntity<Page<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "DESC") String sortDirection) {
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
Page<UserResponse> users = userService.findAll(pageable);
return ResponseEntity.ok(users);
}
```
### Multi-field Sorting
```java
@GetMapping("/users")
public ResponseEntity<Page<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "name") String sortBy,
@RequestParam(defaultValue = "name,createdAt") String sortFields) {
List<Sort.Order> orders = Arrays.stream(sortFields.split(","))
.map(field -> {
String direction = field.startsWith("-") ? "DESC" : "ASC";
String property = field.startsWith("-") ? field.substring(1) : field;
return new Sort.Order(Sort.Direction.fromString(direction), property);
})
.collect(Collectors.toList());
Pageable pageable = PageRequest.of(page, size, Sort.by(orders));
Page<UserResponse> users = userService.findAll(pageable);
return ResponseEntity.ok(users);
}
```
## Response Format
### Standard Page Response
```json
{
"content": [
{
"id": 1,
"name": "John Doe",
"email": "john@example.com",
"createdAt": "2024-01-15T10:30:00Z"
}
],
"pageable": {
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"offset": 0,
"pageNumber": 0,
"pageSize": 10,
"paged": true,
"unpaged": false
},
"last": false,
"totalPages": 5,
"totalElements": 45,
"size": 10,
"number": 0,
"sort": {
"empty": false,
"sorted": true,
"unsorted": false
},
"first": true,
"numberOfElements": 10,
"empty": false
}
```
### Custom Page Response Wrapper
```java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResponse<T> {
private List<T> content;
private PageMetadata metadata;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageMetadata {
private int pageNumber;
private int pageSize;
private long totalElements;
private int totalPages;
private boolean first;
private boolean last;
}
// Controller
@GetMapping("/users")
public ResponseEntity<PageResponse<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Page<User> pageResult = userService.findAll(pageable);
PageMetadata metadata = new PageMetadata(
page,
size,
pageResult.getTotalElements(),
pageResult.getTotalPages(),
pageResult.isFirst(),
pageResult.isLast()
);
PageResponse<UserResponse> response = new PageResponse<>(
pageResult.stream()
.map(this::toResponse)
.collect(Collectors.toList()),
metadata
);
return ResponseEntity.ok(response);
}
```
## Filtering
### Query Parameter Filtering
```java
@GetMapping("/users")
public ResponseEntity<Page<UserResponse>> getUsers(
@RequestParam(required = false) String name,
@RequestParam(required = false) String email,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Specification<User> spec = Specification.where(null);
if (name != null && !name.isEmpty()) {
spec = spec.and((root, query, cb) ->
cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%"));
}
if (email != null && !email.isEmpty()) {
spec = spec.and((root, query, cb) ->
cb.like(cb.lower(root.get("email")), "%" + email.toLowerCase() + "%"));
}
Pageable pageable = PageRequest.of(page, size);
Page<User> pageResult = userService.findAll(spec, pageable);
return ResponseEntity.ok(pageResult.map(this::toResponse));
}
```
### Dynamic Specification Builder
```java
public class UserSpecifications {
public static Specification<User> hasName(String name) {
return (root, query, cb) ->
name == null ? null :
cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
}
public static Specification<User> hasEmail(String email) {
return (root, query, cb) ->
email == null ? null :
cb.like(cb.lower(root.get("email")), "%" + email.toLowerCase() + "%");
}
public static Specification<User> isActive(Boolean active) {
return (root, query, cb) ->
active == null ? null :
cb.equal(root.get("active"), active);
}
public static Specification<User> createdAfter(LocalDate date) {
return (root, query, cb) ->
date == null ? null :
cb.greaterThanOrEqualTo(root.get("createdAt"), date.atStartOfDay());
}
}
// Usage
@GetMapping("/users")
public ResponseEntity<Page<UserResponse>> getUsers(
@RequestParam(required = false) String name,
@RequestParam(required = false) String email,
@RequestParam(required = false) Boolean active,
@RequestParam(required = false) LocalDate createdAfter,
Pageable pageable) {
Specification<User> spec = Specification.where(UserSpecifications.hasName(name))
.and(UserSpecifications.hasEmail(email))
.and(UserSpecifications.isActive(active))
.and(UserSpecifications.createdAfter(createdAfter));
Page<User> pageResult = userService.findAll(spec, pageable);
return ResponseEntity.ok(pageResult.map(this::toResponse));
}
```
### Date Range Filtering
```java
@GetMapping("/orders")
public ResponseEntity<Page<OrderResponse>> getOrders(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
Pageable pageable) {
Specification<Order> spec = Specification.where(null);
if (startDate != null) {
spec = spec.and((root, query, cb) ->
cb.greaterThanOrEqualTo(root.get("createdAt"), startDate.atStartOfDay()));
}
if (endDate != null) {
spec = spec.and((root, query, cb) ->
cb.lessThanOrEqualTo(root.get("createdAt"), endDate.atEndOfDay()));
}
Page<Order> pageResult = orderService.findAll(spec, pageable);
return ResponseEntity.ok(pageResult.map(this::toResponse));
}
```
## Advanced Filtering
### Filter DTO Pattern
```java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserFilter {
private String name;
private String email;
private Boolean active;
private LocalDate createdAfter;
private LocalDate createdBefore;
private List<Long> roleIds;
public Specification<User> toSpecification() {
Specification<User> spec = Specification.where(null);
if (name != null && !name.isEmpty()) {
spec = spec.and(hasName(name));
}
if (email != null && !email.isEmpty()) {
spec = spec.and(hasEmail(email));
}
if (active != null) {
spec = spec.and(isActive(active));
}
if (createdAfter != null) {
spec = spec.and(createdAfter(createdAfter));
}
if (createdBefore != null) {
spec = spec.and(createdBefore(createdBefore));
}
if (roleIds != null && !roleIds.isEmpty()) {
spec = spec.and(hasRoles(roleIds));
}
return spec;
}
}
// Controller
@GetMapping("/users")
public ResponseEntity<Page<UserResponse>> getUsers(
UserFilter filter,
Pageable pageable) {
Specification<User> spec = filter.toSpecification();
Page<User> pageResult = userService.findAll(spec, pageable);
return ResponseEntity.ok(pageResult.map(this::toResponse));
}
```
## Link Headers for Pagination
```java
@GetMapping("/users")
public ResponseEntity<Page<UserResponse>> getAllUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
Pageable pageable) {
Page<UserResponse> pageResult = userService.findAll(pageable);
HttpHeaders headers = new HttpHeaders();
headers.add("X-Total-Count", String.valueOf(pageResult.getTotalElements()));
headers.add("X-Total-Pages", String.valueOf(pageResult.getTotalPages()));
headers.add("X-Page-Number", String.valueOf(pageResult.getNumber()));
headers.add("X-Page-Size", String.valueOf(pageResult.getSize()));
// Link headers for pagination
if (pageResult.hasNext()) {
headers.add("Link", buildLinkHeader(pageResult.getNumber() + 1, size));
}
if (pageResult.hasPrevious()) {
headers.add("Link", buildLinkHeader(pageResult.getNumber() - 1, size));
}
return ResponseEntity.ok()
.headers(headers)
.body(pageResult);
}
private String buildLinkHeader(int page, int size) {
return String.format("<%s/api/users?page=%d&size=%d>; rel=\"next\"",
baseUrl, page, size);
}
```
## Performance Considerations
### Database Optimization
```java
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
@Transactional(readOnly = true)
public Page<UserResponse> findAll(Specification<User> spec, Pageable pageable) {
// Use projection to only fetch needed fields
Page<User> page = userRepository.findAll(spec, pageable);
return page.stream()
.map(this::toResponse)
.collect(Collectors.toList());
}
}
```
### Cache Pagination Results
```java
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final CacheManager cacheManager;
public Page<UserResponse> findAll(Specification<User> spec, Pageable pageable) {
String cacheKey = "users:" + spec.hashCode() + ":" + pageable.hashCode();
return cacheManager.getCache("users").get(cacheKey, () -> {
Page<User> page = userRepository.findAll(spec, pageable);
return page.map(this::toResponse);
});
}
}
```

View File

@@ -0,0 +1,376 @@
# Spring Boot REST API Standards - References
Complete reference for REST API development with Spring Boot.
## HTTP Methods and Status Codes Reference
### HTTP Methods
| Method | Idempotent | Safe | Purpose | Typical Status |
|--------|-----------|------|---------|----------------|
| GET | Yes | Yes | Retrieve resource | 200, 304, 404 |
| POST | No | No | Create resource | 201, 400, 409 |
| PUT | Yes | No | Replace resource | 200, 204, 404 |
| PATCH | No | No | Partial update | 200, 204, 400 |
| DELETE | Yes | No | Remove resource | 204, 404 |
| HEAD | Yes | Yes | Like GET, no body | 200, 304, 404 |
| OPTIONS | Yes | Yes | Describe communication options | 200 |
### HTTP Status Codes
**2xx Success:**
- `200 OK` - Successful GET/PUT/PATCH
- `201 Created` - Successful POST (include Location header)
- `202 Accepted` - Async processing accepted
- `204 No Content` - Successful DELETE or POST with no content
- `206 Partial Content` - Range request successful
**3xx Redirection:**
- `301 Moved Permanently` - Resource permanently moved
- `304 Not Modified` - Cache valid, use local copy
- `307 Temporary Redirect` - Temporary redirect
**4xx Client Errors:**
- `400 Bad Request` - Invalid format or parameters
- `401 Unauthorized` - Authentication required
- `403 Forbidden` - Authenticated but not authorized
- `404 Not Found` - Resource doesn't exist
- `409 Conflict` - Constraint violation or conflict
- `422 Unprocessable Entity` - Validation failed (semantic error)
**5xx Server Errors:**
- `500 Internal Server Error` - Unexpected server error
- `502 Bad Gateway` - External service unavailable
- `503 Service Unavailable` - Server temporarily down
## Spring Web Annotations Reference
### Request Mapping Annotations
```java
@RestController // Combines @Controller + @ResponseBody
@RequestMapping("/api") // Base URL path
@GetMapping // GET requests
@PostMapping // POST requests
@PutMapping // PUT requests
@PatchMapping // PATCH requests
@DeleteMapping // DELETE requests
```
### Parameter Binding Annotations
```java
@PathVariable // URL path variable /{id}
@RequestParam // Query string parameter ?page=0
@RequestParam(required=false) // Optional parameter
@RequestParam(defaultValue="10") // Default value
@RequestBody // Request body JSON/XML
@RequestHeader // HTTP header value
@CookieValue // Cookie value
@MatrixVariable // Matrix variable ;color=red
@Valid // Enable validation
```
### Response Annotations
```java
@ResponseBody // Serialize to response body
@ResponseStatus(status=HttpStatus.CREATED) // HTTP status
ResponseEntity<T> // Full response control
ResponseEntity.ok(body) // 200 OK
ResponseEntity.created(uri).body(body) // 201 Created
ResponseEntity.noContent().build() // 204 No Content
ResponseEntity.notFound().build() // 404 Not Found
```
## DTO Patterns Reference
### Request DTO (using Records)
```java
public record CreateProductRequest(
@NotBlank(message = "Name required") String name,
@NotNull @DecimalMin("0.01") BigDecimal price,
String description,
@NotNull @Min(0) Integer stock
) {}
```
### Response DTO (using Records)
```java
public record ProductResponse(
Long id,
String name,
BigDecimal price,
Integer stock,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}
```
### Update DTO
```java
public record UpdateProductRequest(
@NotBlank String name,
@NotNull @DecimalMin("0.01") BigDecimal price,
String description
) {}
```
## Validation Annotations Reference
### Common Constraints
```java
@NotNull // Cannot be null
@NotEmpty // Collection/String cannot be empty
@NotBlank // String cannot be null/blank
@Size(min=1, max=255) // Length validation
@Min(value=1) // Minimum numeric value
@Max(value=100) // Maximum numeric value
@Positive // Must be positive
@Negative // Must be negative
@Email // Valid email format
@Pattern(regexp="...") // Regex validation
@Future // Date must be future
@Past // Date must be past
@Digits(integer=5, fraction=2) // Numeric precision
@DecimalMin("0.01") // Decimal minimum
@DecimalMax("9999.99") // Decimal maximum
```
### Custom Validation
```java
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
@Autowired
private UserRepository repository;
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
return !repository.existsByEmail(email);
}
}
```
## Pagination Reference
### Pageable Request Building
```java
// Basic pagination
Pageable pageable = PageRequest.of(page, size);
// With sorting
Sort sort = Sort.by("createdAt").descending();
Pageable pageable = PageRequest.of(page, size, sort);
// Multiple sort fields
Sort sort = Sort.by("status").ascending()
.and(Sort.by("createdAt").descending());
Pageable pageable = PageRequest.of(page, size, sort);
```
### Pagination Response Format
```json
{
"content": [
{ "id": 1, "name": "Product 1" },
{ "id": 2, "name": "Product 2" }
],
"pageable": {
"offset": 0,
"pageNumber": 0,
"pageSize": 20,
"paged": true
},
"totalElements": 100,
"totalPages": 5,
"last": false,
"size": 20,
"number": 0,
"numberOfElements": 20,
"first": true,
"empty": false
}
```
## Error Response Format
### Standardized Error Response
```json
{
"status": 400,
"error": "Bad Request",
"message": "Validation failed: name: Name is required",
"path": "/api/v1/products",
"timestamp": "2024-01-15T10:30:00Z"
}
```
### Global Exception Handler Pattern
```java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
// Handle validation errors
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex) {
// Handle not found
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneral(Exception ex) {
// Handle generic exceptions
}
}
```
## Content Negotiation
### Accept Header Examples
```
Accept: application/json # JSON response
Accept: application/xml # XML response
Accept: application/vnd.api+json # JSON:API standard
Accept: text/csv # CSV response
```
### Controller Implementation
```java
@GetMapping(produces = {MediaType.APPLICATION_JSON_VALUE, "application/xml"})
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
// Supports both JSON and XML
return ResponseEntity.ok(productService.findById(id));
}
```
## Pagination Best Practices
```java
// Limit maximum page size
@GetMapping
public ResponseEntity<Page<ProductResponse>> getAll(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20")
@Max(value = 100, message = "Max page size is 100") int size) {
Pageable pageable = PageRequest.of(page, size);
return ResponseEntity.ok(productService.findAll(pageable));
}
```
## Maven Dependencies
```xml
<!-- Spring Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Data JPA (for Pageable) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
```
## Testing REST APIs
### MockMvc Testing
```java
@SpringBootTest
@AutoConfigureMockMvc
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldCreateProduct() throws Exception {
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Test\",\"price\":10.00}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists());
}
}
```
### TestRestTemplate Testing
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ProductIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldFetchProduct() {
ResponseEntity<ProductResponse> response = restTemplate.getForEntity(
"/api/v1/products/1", ProductResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
```
## Related Skills
- **spring-boot-crud-patterns/SKILL.md** - CRUD operations following REST principles
- **spring-boot-dependency-injection/SKILL.md** - Dependency injection in controllers
- **spring-boot-test-patterns/SKILL.md** - Testing REST APIs
- **spring-boot-exception-handling/SKILL.md** - Global error handling
## External Resources
### Official Documentation
- [Spring Web MVC Documentation](https://docs.spring.io/spring-framework/reference/web/webmvc.html)
- [Spring REST Documentation](https://spring.io/guides/gs/rest-service/)
- [REST API Best Practices](https://restfulapi.net/)
### Related Standards
- [JSON:API Specification](https://jsonapi.org/)
- [OpenAPI Specification](https://www.openapis.org/)
- [RFC 7231 - HTTP Semantics](https://tools.ietf.org/html/rfc7231)
### Books
- "RESTful Web Services" by Leonard Richardson & Sam Ruby
- "Spring in Action" (latest edition)

View File

@@ -0,0 +1,521 @@
# Security Headers and CORS Configuration
## Security Headers Configuration
### Basic Security Headers
```java
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"font-src 'self';")
.reportOnly(false))
.frameOptions(frame -> frame
.sameOrigin()
.deny()) // Use sameOrigin() for same-origin iframes, deny() to completely block
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000) // 1 year
.includeSubDomains(true)
.preload(true))
.xssProtection(xss -> xss
.headerValue(XssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
.contentTypeOptions(contentTypeOptions -> contentTypeOptions
.and())
)
.cors(cors -> cors
.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Requested-With"));
configuration.setExposedHeaders(Arrays.asList("X-Total-Count", "X-Content-Type-Options"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
```
### Enhanced Security Configuration
```java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class EnhancedSecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/**")
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; " +
"script-src 'self 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self 'unsafe-inline'; " +
"img-src 'self' data: https:; " +
"font-src 'self'; " +
"connect-src 'self' https:; " +
"frame-src 'none'; " +
"object-src 'none';"))
.frameOptions(frameOptions -> frameOptions.sameOrigin())
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000)
.includeSubDomains(true)
.preload(true)
.includeSubDomains(true))
.permissionsPolicy(permissionsPolicy -> permissionsPolicy
.add("camera", "()")
.add("geolocation", "()")
.add("microphone", "()")
.add("payment", "()"))
.referrerPolicy(referrerPolicy -> referrerPolicy.noReferrer())
.and())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/auth/**")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/api/users/**").hasAnyRole("USER", "ADMIN")
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// Allowed origins (consider restricting to specific domains in production)
configuration.setAllowedOriginPatterns(List.of("https://yourdomain.com", "https://app.yourdomain.com"));
// Allowed methods
configuration.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
));
// Allowed headers
configuration.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"Accept",
"X-Requested-With",
"X-Content-Type-Options",
"X-Total-Count",
"Cache-Control"
));
// Exposed headers to client
configuration.setExposedHeaders(Arrays.asList(
"X-Total-Count",
"X-Content-Type-Options",
"Cache-Control"
));
// Allow credentials
configuration.setAllowCredentials(true);
// Cache preflight requests for 1 hour
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
}
```
## Content Security Policy (CSP)
### Basic CSP Configuration
```java
@Configuration
public class ContentSecurityPolicyConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://yourdomain.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("X-Total-Count")
.allowCredentials(true)
.maxAge(3600);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new ContentSecurityPolicyInterceptor());
}
};
}
}
@Component
public class ContentSecurityPolicyInterceptor implements HandlerInterceptor {
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {
response.setHeader("Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-src 'none'; " +
"object-src 'none';");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("X-Frame-Options", "DENY");
response.setHeader("X-XSS-Protection", "1; mode=block");
}
}
```
### Advanced CSP with Nonce
```java
@Component
@RequiredArgsConstructor
public class SecurityHeadersFilter extends OncePerRequestFilter {
private final AtomicLong nonceCounter = new AtomicLong(0);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// Generate nonce for each request
String nonce = String.valueOf(nonceCounter.incrementAndGet());
// Set CSP header with nonce for inline scripts
response.setHeader("Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' 'nonce-" + nonce + "'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-src 'none'; " +
"object-src 'none';");
// Add nonce to request attributes for templates
request.setAttribute("cspNonce", nonce);
// Set other security headers
response.setHeader("X-Content-Type-Options", "nosniff");
response.setHeader("X-Frame-Options", "SAMEORIGIN");
response.setHeader("Strict-Transport-Security",
"max-age=31536000; includeSubDomains; preload");
response.setHeader("X-Permitted-Cross-Domain-Policies", "none");
response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
filterChain.doFilter(request, response);
}
}
```
## CORS Configuration
### Method-level CORS
```java
@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "https://yourdomain.com", methods = {RequestMethod.GET, RequestMethod.POST})
public class UserController {
@GetMapping
public ResponseEntity<List<User>> getAllUsers() {
// CORS allowed for GET requests
return ResponseEntity.ok(userService.findAll());
}
@PostMapping
@CrossOrigin(origins = "https://app.yourdomain.com")
public ResponseEntity<User> createUser(@RequestBody User user) {
// CORS allowed with different origin for POST requests
return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(user));
}
}
```
### Dynamic CORS Configuration
```java
@Configuration
public class DynamicCorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
// Development configuration
CorsConfiguration devConfig = new CorsConfiguration();
devConfig.setAllowedOriginPatterns(List.of("*"));
devConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
devConfig.setAllowedHeaders(Arrays.asList("*"));
devConfig.setAllowCredentials(true);
source.registerCorsConfiguration("/api/**", devConfig);
// Production configuration - restrict to specific domains
CorsConfiguration prodConfig = new CorsConfiguration();
prodConfig.setAllowedOriginPatterns(List.of(
"https://yourdomain.com",
"https://app.yourdomain.com",
"https://api.yourdomain.com"
));
prodConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
prodConfig.setAllowedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"Accept",
"X-Requested-With"
));
prodConfig.setExposedHeaders(Arrays.asList("X-Total-Count"));
prodConfig.setAllowCredentials(true);
source.registerCorsConfiguration("/api/**", prodConfig);
return source;
}
}
```
## Security Headers Best Practices
### Essential Headers for Production
1. **Content-Security-Policy**: Mitigates XSS attacks
2. **X-Content-Type-Options**: Prevents MIME type sniffing
3. **X-Frame-Options**: Prevents clickjacking
4. **Strict-Transport-Security**: Enforces HTTPS
5. **X-XSS-Protection**: Legacy browser XSS protection
6. **Referrer-Policy**: Controls referrer information
### CSP Examples by Application Type
#### Blog/Content Site
```java
response.setHeader("Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' https://cdn.jsdelivr.net; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' https: data:; " +
"font-src 'self'; " +
"connect-src 'self'; " +
"frame-src https://www.youtube.com; " +
"media-src https://www.youtube.com;");
```
#### Single Page Application (SPA)
```java
response.setHeader("Content-Security-Policy",
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data:; " +
"font-src 'self'; " +
"connect-src 'self' wss:; " +
"frame-src 'none'; " +
"object-src 'none';");
```
#### API Only
```java
response.setHeader("Content-Security-Policy",
"default-src 'self'; " +
"connect-src 'self'; " +
"frame-src 'none'; " +
"object-src 'none';");
```
### Security Header Testing
```java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest
@AutoConfigureMockMvc
class SecurityHeadersTest {
@Autowired
private MockMvc mockMvc;
@Test
void securityHeaders_shouldBeSet() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(header().string("Content-Security-Policy", notNullValue()))
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().string("X-Frame-Options", notNullValue()))
.andExpect(header().string("Strict-Transport-Security", notNullValue()));
}
}
```
## Rate Limiting
### Basic Rate Limiting
```java
@Component
public class RateLimitingFilter extends OncePerRequestFilter {
private final ConcurrentHashMap<String, RateLimit> rateLimits = new ConcurrentHashMap<>();
private static final long REQUEST_LIMIT = 100;
private static final long TIME_WINDOW = 60_000; // 1 minute
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String clientIp = request.getRemoteAddr();
String path = request.getRequestURI();
String key = clientIp + ":" + path;
RateLimit rateLimit = rateLimits.computeIfAbsent(key, k -> new RateLimit());
synchronized (rateLimit) {
if (System.currentTimeMillis() - rateLimit.resetTime > TIME_WINDOW) {
rateLimit.count = 0;
rateLimit.resetTime = System.currentTimeMillis();
}
if (rateLimit.count >= REQUEST_LIMIT) {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("Rate limit exceeded");
return;
}
rateLimit.count++;
}
filterChain.doFilter(request, response);
}
private static class RateLimit {
long count = 0;
long resetTime = System.currentTimeMillis();
}
}
```
## Token-based Authentication Headers
```java
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
UsernamePasswordAuthenticationToken authentication =
jwtTokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
}
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
```
## WebSocket Security
```java
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("https://yourdomain.com")
.withSockJS();
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
// Validate token and authenticate
String token = accessor.getFirstNativeHeader("Authorization");
if (!isValidToken(token)) {
throw new UnauthorizedWebSocketException("Invalid token");
}
}
return message;
}
});
}
}
```

View File

@@ -0,0 +1,309 @@
# Spring Web Annotations Reference
## Controller and Mapping Annotations
### @RestController
```java
@RestController
@RequestMapping("/api/users")
public class UserController {
// Returns JSON responses automatically
}
```
### @Controller
```java
@Controller
@RequestMapping("/users")
public class UserController {
// Returns view names for MVC applications
}
```
### @RequestMapping
```java
// Class level
@RequestMapping("/api")
@RequestMapping(path = "/api", method = RequestMethod.GET)
// Method level
@RequestMapping("/users")
@RequestMapping(path = "/users", method = RequestMethod.POST)
```
### HTTP Method Annotations
```java
@GetMapping("/users")
public List<User> getUsers() { ... }
@PostMapping("/users")
public User createUser(@RequestBody User user) { ... }
@PutMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) { ... }
@PatchMapping("/users/{id}")
public User patchUser(@PathVariable Long id, @RequestBody User user) { ... }
@DeleteMapping("/users/{id}")
public void deleteUser(@PathVariable Long id) { ... }
@HeadMapping("/users/{id}")
public ResponseEntity<Void> headUser(@PathVariable Long id) { ... }
@OptionsMapping("/users")
public ResponseEntity<Void> optionsUsers() { ... }
```
## Parameter Binding Annotations
### @PathVariable
```java
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) { ... }
// Multiple path variables
@GetMapping("/users/{userId}/orders/{orderId}")
public Order getOrder(@PathVariable Long userId, @PathVariable Long orderId) { ... }
// Custom variable name
@GetMapping("/users/{userId}")
public User getUser(@PathVariable("userId") Long id) { ... }
```
### @RequestParam
```java
@GetMapping("/users")
public List<User> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String name,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "DESC") String sortDirection) {
// Handle pagination, filtering, and sorting
}
```
### @RequestBody
```java
@PostMapping("/users")
public User createUser(@RequestBody User user) { ... }
// With validation
@PostMapping("/users")
public User createUser(@Valid @RequestBody User user) { ... }
```
### @RequestHeader
```java
@GetMapping("/users")
public List<User> getUsers(@RequestHeader("Authorization") String authHeader) { ... }
// Multiple headers
@PostMapping("/users")
public User createUser(
@RequestBody User user,
@RequestHeader("X-Custom-Header") String customHeader) { ... }
```
### @CookieValue
```java
@GetMapping("/users")
public List<User> getUsers(@CookieValue("JSESSIONID") String sessionId) { ... }
```
### @MatrixVariable
```java
@GetMapping("/users/{id}")
public User getUser(
@PathVariable Long id,
@MatrixVariable(pathVar = "id", required = false) Map<String, String> params) {
// Handle matrix variables: /users/123;name=John;age=30
}
```
## Response Annotations
### @ResponseStatus
```java
@PostMapping("/users")
@ResponseStatus(HttpStatus.CREATED)
public User createUser(@RequestBody User user) { ... }
```
### @ResponseBody
```java
@Controller
public class UserController {
@GetMapping("/users")
@ResponseBody
public List<User> getUsers() { ... }
}
```
### ResponseEntity
```java
@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return userRepository.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/users")
public ResponseEntity<User> createUser(@RequestBody User user) {
User created = userService.create(user);
return ResponseEntity.status(HttpStatus.CREATED)
.header("Location", "/api/users/" + created.getId())
.body(created);
}
```
## Content Negotiation
### Produces and Consumes
```java
@GetMapping(value = "/users", produces = MediaType.APPLICATION_JSON_VALUE)
public List<User> getUsers() { ... }
@PostMapping(value = "/users", consumes = MediaType.APPLICATION_JSON_VALUE)
public User createUser(@RequestBody User user) { ... }
// Multiple media types
@GetMapping(value = "/users", produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
})
public List<User> getUsers() { ... }
```
### @RequestBody with Content-Type
```java
@PostMapping(value = "/users", consumes = "application/json")
public User createUserJson(@RequestBody User user) { ... }
@PostMapping(value = "/users", consumes = "application/xml")
public User createUserXml(@RequestBody User user) { ... }
```
## Validation Annotations
### @Valid
```java
@PostMapping("/users")
public User createUser(@Valid @RequestBody User user) { ... }
// Validates individual parameters
@GetMapping("/users")
public User getUser(
@Valid @Pattern(regexp = "^[a-zA-Z0-9]+$") @PathVariable String id) { ... }
```
### Jakarta Bean Validation Annotations
```java
public class UserRequest {
@NotBlank(message = "Name is required")
private String name;
@Email(message = "Valid email required")
private String email;
@Size(min = 8, max = 100, message = "Password must be 8-100 characters")
private String password;
@Min(value = 18, message = "Must be at least 18")
@Max(value = 120, message = "Invalid age")
private Integer age;
@Pattern(regexp = "^[A-Z][a-z]+$", message = "Invalid name format")
private String firstName;
@NotEmpty(message = "At least one role required")
private Set<String> roles = new HashSet<>();
@Future(message = "Date must be in the future")
private LocalDate futureDate;
@Past(message = "Date must be in the past")
private LocalDate birthDate;
@Positive(message = "Value must be positive")
private Double positiveValue;
@PositiveOrZero(message = "Value must be positive or zero")
private Double nonNegativeValue;
}
```
## Specialized Annotations
### @RestControllerAdvice
```java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException ex) {
// Handle validation errors globally
}
}
```
### @ExceptionHandler
```java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(NoHandlerFoundException ex) {
return ResponseEntity.notFound().build();
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
return ResponseEntity.internalServerError().build();
}
}
```
### @CrossOrigin
```java
@CrossOrigin(origins = "http://localhost:3000")
@RestController
@RequestMapping("/api/users")
public class UserController {
// Enable CORS for specific origin
}
// Or at method level
@CrossOrigin(origins = "*", methods = {RequestMethod.GET, RequestMethod.POST})
@GetMapping("/users")
public List<User> getUsers() { ... }
```
## Async Processing
### @Async
```java
@Service
public class AsyncService {
@Async
public CompletableFuture<User> processUser(User user) {
// Long-running operation
return CompletableFuture.completedFuture(processedUser);
}
}
@RestController
public class UserController {
@GetMapping("/users/{id}/async")
public CompletableFuture<ResponseEntity<User>> getUserAsync(@PathVariable Long id) {
return userService.processUser(id)
.thenApply(ResponseEntity::ok)
.exceptionally(ex -> ResponseEntity.notFound().build());
}
}
```

View File

@@ -0,0 +1,200 @@
---
name: spring-boot-saga-pattern
description: Implement distributed transactions using the Saga Pattern in Spring Boot microservices. Use when building microservices requiring transaction management across multiple services, handling compensating transactions, ensuring eventual consistency, or implementing choreography or orchestration-based sagas with Spring Boot, Kafka, or Axon Framework.
allowed-tools: Read, Write, Bash
category: backend
tags: [spring-boot, saga, distributed-transactions, choreography, orchestration, microservices]
version: 1.1.0
---
# Spring Boot Saga Pattern
## When to Use
Implement this skill when:
- Building distributed transactions across multiple microservices
- Needing to replace two-phase commit (2PC) with a more scalable solution
- Handling transaction rollback when a service fails in multi-service workflows
- Ensuring eventual consistency in microservices architecture
- Implementing compensating transactions for failed operations
- Coordinating complex business processes spanning multiple services
- Choosing between choreography-based and orchestration-based saga approaches
**Trigger phrases**: distributed transactions, saga pattern, compensating transactions, microservices transaction, eventual consistency, rollback across services, orchestration pattern, choreography pattern
## Overview
The **Saga Pattern** is an architectural pattern for managing distributed transactions in microservices. Instead of using a single ACID transaction across multiple databases, a saga breaks the transaction into a sequence of local transactions. Each local transaction updates its database and publishes an event or message to trigger the next step. If a step fails, the saga executes **compensating transactions** to undo the changes made by previous steps.
### Key Architectural Decisions
When implementing a saga, make these decisions:
1. **Approach Selection**: Choose between **choreography-based** (event-driven, decoupled) or **orchestration-based** (centralized control, easier to track)
2. **Messaging Platform**: Select Kafka, RabbitMQ, or Spring Cloud Stream
3. **Framework**: Use Axon Framework, Eventuate Tram, Camunda, or Apache Camel
4. **State Persistence**: Store saga state in database for recovery and debugging
5. **Idempotency**: Ensure all operations (especially compensations) are idempotent and retryable
## Two Approaches to Implement Saga
### Choreography-Based Saga
Each microservice publishes events and listens to events from other services. **No central coordinator**.
**Best for**: Greenfield microservice applications with few participants
**Advantages**:
- Simple for small number of services
- Loose coupling between services
- No single point of failure
**Disadvantages**:
- Difficult to track workflow state
- Hard to troubleshoot and maintain
- Complexity grows with number of services
### Orchestration-Based Saga
A **central orchestrator** manages the entire transaction flow and tells services what to do.
**Best for**: Brownfield applications, complex workflows, or when centralized control is needed
**Advantages**:
- Centralized visibility and monitoring
- Easier to troubleshoot and maintain
- Clear transaction flow
- Simplified error handling
- Better for complex workflows
**Disadvantages**:
- Orchestrator can become single point of failure
- Additional infrastructure component
## Implementation Steps
### Step 1: Define Transaction Flow
Identify the sequence of operations and corresponding compensating transactions:
```
Order → Payment → Inventory → Shipment → Notification
↓ ↓ ↓ ↓ ↓
Cancel Refund Release Cancel Cancel
```
### Step 2: Choose Implementation Approach
- **Choreography**: Spring Cloud Stream with Kafka or RabbitMQ
- **Orchestration**: Axon Framework, Eventuate Tram, Camunda, or Apache Camel
### Step 3: Implement Services with Local Transactions
Each service handles its local ACID transaction and publishes events or responds to commands.
### Step 4: Implement Compensating Transactions
Every forward transaction must have a corresponding compensating transaction. Ensure **idempotency** and **retryability**.
### Step 5: Handle Failure Scenarios
Implement retry logic, timeouts, and dead-letter queues for failed messages.
## Best Practices
### Design Principles
1. **Idempotency**: Ensure compensating transactions execute safely multiple times
2. **Retryability**: Design operations to handle retries without side effects
3. **Atomicity**: Each local transaction must be atomic within its service
4. **Isolation**: Handle concurrent saga executions properly
5. **Eventual Consistency**: Accept that data becomes consistent over time
### Service Design
- Use **constructor injection** exclusively (never field injection)
- Implement services as **stateless** components
- Store saga state in persistent store (database or event store)
- Use **immutable DTOs** (Java records preferred)
- Separate domain logic from infrastructure concerns
### Error Handling
- Implement **circuit breakers** for service calls
- Use **dead-letter queues** for failed messages
- Log all saga events for debugging and monitoring
- Implement **timeout mechanisms** for long-running sagas
- Design **semantic locks** to prevent concurrent updates
### Testing
- Test happy path scenarios
- Test each failure scenario and its compensation
- Test concurrent saga executions
- Test idempotency of compensating transactions
- Use Testcontainers for integration testing
### Monitoring and Observability
- Track saga execution status and duration
- Monitor compensation transaction execution
- Alert on stuck or failed sagas
- Use distributed tracing (Spring Cloud Sleuth, Zipkin)
- Implement health checks for saga coordinators
## Technology Stack
**Spring Boot 3.x** with dependencies:
**Messaging**: Spring Cloud Stream, Apache Kafka, RabbitMQ, Spring AMQP
**Saga Frameworks**: Axon Framework (4.9.0), Eventuate Tram Sagas, Camunda, Apache Camel
**Persistence**: Spring Data JPA, Event Sourcing (optional), Transactional Outbox Pattern
**Monitoring**: Spring Boot Actuator, Micrometer, Distributed Tracing (Sleuth + Zipkin)
## Anti-Patterns to Avoid
**Tight Coupling**: Services directly calling each other instead of using events
**Missing Compensations**: Not implementing compensating transactions for every step
**Non-Idempotent Operations**: Compensations that cannot be safely retried
**Synchronous Sagas**: Waiting synchronously for each step (defeats the purpose)
**Lost Messages**: Not handling message delivery failures
**No Monitoring**: Running sagas without visibility into their status
**Shared Database**: Using same database across multiple services
**Ignoring Network Failures**: Not handling partial failures gracefully
## When NOT to Use Saga Pattern
Do not implement this pattern when:
- Single service transactions (use local ACID transactions instead)
- Strong consistency is required (consider monolith or shared database)
- Simple CRUD operations without cross-service dependencies
- Low transaction volume with simple flows
- Team lacks experience with distributed systems
## References
For detailed information, consult the following resources:
- [Saga Pattern Definition](references/01-saga-pattern-definition.md)
- [Choreography-Based Implementation](references/02-choreography-implementation.md)
- [Orchestration-Based Implementation](references/03-orchestration-implementation.md)
- [Event-Driven Architecture](references/04-event-driven-architecture.md)
- [Compensating Transactions](references/05-compensating-transactions.md)
- [State Management](references/06-state-management.md)
- [Error Handling and Retry](references/07-error-handling-retry.md)
- [Testing Strategies](references/08-testing-strategies.md)
- [Common Pitfalls and Solutions](references/09-pitfalls-solutions.md)
See also [examples.md](references/examples.md) for complete implementation examples:
- E-Commerce Order Processing (orchestration with Axon Framework)
- Food Delivery Application (choreography with Kafka and Spring Cloud Stream)
- Travel Booking System (complex orchestration with multiple compensations)
- Banking Transfer System
- Real-world microservices patterns

View File

@@ -0,0 +1,68 @@
# Saga Pattern Definition
## What is a Saga?
A **Saga** is a sequence of local transactions where each transaction updates data within a single service. Each local transaction publishes an event or message that triggers the next local transaction in the saga. If a local transaction fails, the saga executes compensating transactions to undo the changes made by preceding transactions.
## Key Characteristics
**Distributed Transactions**: Spans multiple microservices, each with its own database.
**Local Transactions**: Each service performs its own ACID transaction.
**Event-Driven**: Services communicate through events or commands.
**Compensations**: Rollback mechanism using compensating transactions.
**Eventual Consistency**: System reaches a consistent state over time.
## Saga vs Two-Phase Commit (2PC)
| Feature | Saga Pattern | Two-Phase Commit |
|---------|-------------|------------------|
| Locking | No distributed locks | Requires locks during commit |
| Performance | Better performance | Performance bottleneck |
| Scalability | Highly scalable | Limited scalability |
| Complexity | Business logic complexity | Protocol complexity |
| Failure Handling | Compensating transactions | Automatic rollback |
| Isolation | Lower isolation | Full isolation |
| NoSQL Support | Yes | No |
| Microservices Fit | Excellent | Poor |
## ACID vs BASE
**ACID** (Traditional Databases):
- **A**tomicity: All or nothing
- **C**onsistency: Valid state transitions
- **I**solation: Concurrent transactions don't interfere
- **D**urability: Committed data persists
**BASE** (Saga Pattern):
- **B**asically **A**vailable: System is available most of the time
- **S**oft state: State may change over time
- **E**ventual consistency: System becomes consistent eventually
## When to Use Saga Pattern
Use the saga pattern when:
- Building distributed transactions across multiple microservices
- Needing to replace 2PC with a more scalable solution
- Services need to maintain eventual consistency
- Handling long-running processes spanning multiple services
- Implementing compensating transactions for failed operations
## When NOT to Use Saga Pattern
Avoid the saga pattern when:
- Single service transactions (use local ACID transactions)
- Strong consistency is required immediately
- Simple CRUD operations without cross-service dependencies
- Low transaction volume with simple flows
- Team lacks experience with distributed systems
## Migration Path
Many organizations migrate from traditional monolithic systems or 2PC-based systems to sagas:
1. **From Monolith to Saga**: Identify transaction boundaries, extract services gradually, implement sagas incrementally
2. **From 2PC to Saga**: Analyze existing 2PC transactions, design compensating transactions, implement sagas in parallel, monitor and compare results before full migration

View File

@@ -0,0 +1,153 @@
# Choreography-Based Saga Implementation
## Architecture Overview
In choreography-based sagas, each service produces and listens to events. Services know what to do when they receive an event. **No central coordinator** manages the flow.
```
Service A → Event → Service B → Event → Service C
↓ ↓ ↓
Event Event Event
↓ ↓ ↓
Compensation Compensation Compensation
```
## Event Flow
### Success Path
1. **Order Service** creates order → publishes `OrderCreated` event
2. **Payment Service** listens → processes payment → publishes `PaymentProcessed` event
3. **Inventory Service** listens → reserves inventory → publishes `InventoryReserved` event
4. **Shipment Service** listens → prepares shipment → publishes `ShipmentPrepared` event
### Failure Path (When Payment Fails)
1. **Payment Service** publishes `PaymentFailed` event
2. **Order Service** listens → cancels order → publishes `OrderCancelled` event
3. All other services respond to cancellation with cleanup
## Event Publisher
```java
@Component
public class OrderEventPublisher {
private final StreamBridge streamBridge;
public OrderEventPublisher(StreamBridge streamBridge) {
this.streamBridge = streamBridge;
}
public void publishOrderCreatedEvent(String orderId, BigDecimal amount, String itemId) {
OrderCreatedEvent event = new OrderCreatedEvent(orderId, amount, itemId);
streamBridge.send("orderCreated-out-0",
MessageBuilder
.withPayload(event)
.setHeader(MessageHeaders.CONTENT_TYPE, MimeTypeUtils.APPLICATION_JSON)
.build());
}
}
```
## Event Listener
```java
@Component
public class PaymentEventListener {
@Bean
public Consumer<OrderCreatedEvent> handleOrderCreatedEvent() {
return event -> processPayment(event.getOrderId());
}
private void processPayment(String orderId) {
// Payment processing logic
}
}
```
## Event Classes
```java
public record OrderCreatedEvent(
String orderId,
BigDecimal amount,
String itemId
) {}
public record PaymentProcessedEvent(
String paymentId,
String orderId,
String itemId
) {}
public record PaymentFailedEvent(
String paymentId,
String orderId,
String itemId,
String reason
) {}
```
## Spring Cloud Stream Configuration
```yaml
spring:
cloud:
stream:
bindings:
orderCreated-out-0:
destination: order-events
paymentProcessed-out-0:
destination: payment-events
paymentFailed-out-0:
destination: payment-events
kafka:
binder:
brokers: localhost:9092
```
## Maven Dependencies
```xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-stream-binder-kafka</artifactId>
</dependency>
```
## Gradle Dependencies
```groovy
implementation 'org.springframework.cloud:spring-cloud-stream'
implementation 'org.springframework.cloud:spring-cloud-stream-binder-kafka'
```
## Advantages and Disadvantages
### Advantages
- **Simple** for small number of services
- **Loose coupling** between services
- **No single point of failure**
- Each service is independently deployable
### Disadvantages
- **Difficult to track workflow state** - distributed across services
- **Hard to troubleshoot** - following event flow is complex
- **Complexity grows** with number of services
- **Distributed source of truth** - saga state not centralized
## When to Use Choreography
Use choreography-based sagas when:
- Microservices are few in number (< 5 services per saga)
- Loose coupling is critical
- Team is experienced with event-driven architecture
- System can handle eventual consistency
- Workflow doesn't need centralized monitoring

View File

@@ -0,0 +1,232 @@
# Orchestration-Based Saga Implementation
## Architecture Overview
A **central orchestrator** (Saga Coordinator) manages the entire transaction flow, sending commands to services and handling responses.
```
Saga Orchestrator
/ | \
Service A Service B Service C
```
## Orchestrator Responsibilities
1. **Command Dispatch**: Send commands to services
2. **Response Handling**: Process service responses
3. **State Management**: Track saga execution state
4. **Compensation Coordination**: Trigger compensating transactions on failure
5. **Timeout Management**: Handle service timeouts
6. **Retry Logic**: Manage retry attempts
## Axon Framework Implementation
### Saga Class
```java
@Saga
public class OrderSaga {
@Autowired
private transient CommandGateway commandGateway;
@StartSaga
@SagaEventHandler(associationProperty = "orderId")
public void handle(OrderCreatedEvent event) {
String paymentId = UUID.randomUUID().toString();
ProcessPaymentCommand command = new ProcessPaymentCommand(
paymentId,
event.getOrderId(),
event.getAmount(),
event.getItemId()
);
commandGateway.send(command);
}
@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentProcessedEvent event) {
ReserveInventoryCommand command = new ReserveInventoryCommand(
event.getOrderId(),
event.getItemId()
);
commandGateway.send(command);
}
@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentFailedEvent event) {
CancelOrderCommand command = new CancelOrderCommand(event.getOrderId());
commandGateway.send(command);
end();
}
@SagaEventHandler(associationProperty = "orderId")
public void handle(InventoryReservedEvent event) {
PrepareShipmentCommand command = new PrepareShipmentCommand(
event.getOrderId(),
event.getItemId()
);
commandGateway.send(command);
}
@EndSaga
@SagaEventHandler(associationProperty = "orderId")
public void handle(OrderCompletedEvent event) {
// Saga completed successfully
}
}
```
### Aggregate for Order Service
```java
@Aggregate
public class OrderAggregate {
@AggregateIdentifier
private String orderId;
private OrderStatus status;
public OrderAggregate() {
}
@CommandHandler
public OrderAggregate(CreateOrderCommand command) {
apply(new OrderCreatedEvent(
command.getOrderId(),
command.getAmount(),
command.getItemId()
));
}
@EventSourcingHandler
public void on(OrderCreatedEvent event) {
this.orderId = event.getOrderId();
this.status = OrderStatus.PENDING;
}
@CommandHandler
public void handle(CancelOrderCommand command) {
apply(new OrderCancelledEvent(command.getOrderId()));
}
@EventSourcingHandler
public void on(OrderCancelledEvent event) {
this.status = OrderStatus.CANCELLED;
}
}
```
### Aggregate for Payment Service
```java
@Aggregate
public class PaymentAggregate {
@AggregateIdentifier
private String paymentId;
public PaymentAggregate() {
}
@CommandHandler
public PaymentAggregate(ProcessPaymentCommand command) {
this.paymentId = command.getPaymentId();
if (command.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
apply(new PaymentFailedEvent(
command.getPaymentId(),
command.getOrderId(),
command.getItemId(),
"Payment amount must be greater than zero"
));
} else {
apply(new PaymentProcessedEvent(
command.getPaymentId(),
command.getOrderId(),
command.getItemId()
));
}
}
}
```
## Axon Configuration
```yaml
axon:
serializer:
general: jackson
events: jackson
messages: jackson
eventhandling:
processors:
order-processor:
mode: tracking
source: eventBus
axonserver:
enabled: false
```
## Maven Dependencies for Axon
```xml
<dependency>
<groupId>org.axonframework</groupId>
<artifactId>axon-spring-boot-starter</artifactId>
<version>4.9.0</version> // Use latest stable version
</dependency>
```
## Advantages and Disadvantages
### Advantages
- **Centralized visibility** - easy to see workflow status
- **Easier to troubleshoot** - single place to analyze flow
- **Clear transaction flow** - orchestrator defines sequence
- **Simplified error handling** - centralized compensation logic
- **Better for complex workflows** - easier to manage many steps
### Disadvantages
- **Orchestrator becomes single point of failure** - can be mitigated with clustering
- **Additional infrastructure component** - more complexity in deployment
- **Potential tight coupling** - if orchestrator knows too much about services
## Eventuate Tram Sagas
Eventuate Tram is an alternative to Axon for orchestration-based sagas:
```xml
<dependency>
<groupId>io.eventuate.tram.sagas</groupId>
<artifactId>eventuate-tram-sagas-spring-starter</artifactId>
<version>0.28.0</version> // Use latest stable version
</dependency>
```
## Camunda for BPMN-Based Orchestration
Use Camunda when visual workflow design is beneficial:
**Features**:
- Visual workflow design
- BPMN 2.0 standard
- Human tasks support
- Complex workflow modeling
**Use When**:
- Business process modeling needed
- Visual workflow design preferred
- Human approval steps required
- Complex orchestration logic
## When to Use Orchestration
Use orchestration-based sagas when:
- Building brownfield applications with existing microservices
- Handling complex workflows with many steps
- Centralized control and monitoring is critical
- Organization wants clear visibility into saga execution
- Need for human intervention in workflow

View File

@@ -0,0 +1,262 @@
# Event-Driven Architecture in Sagas
## Event Types
### Domain Events
Represent business facts that happened within a service:
```java
public record OrderCreatedEvent(
String orderId,
Instant createdAt,
BigDecimal amount
) implements DomainEvent {}
```
### Integration Events
Communication between bounded contexts (microservices):
```java
public record PaymentRequestedEvent(
String orderId,
String paymentId,
BigDecimal amount
) implements IntegrationEvent {}
```
### Command Events
Request for action by another service:
```java
public record ProcessPaymentCommand(
String paymentId,
String orderId,
BigDecimal amount
) {}
```
## Event Versioning
Handle event schema evolution using versioning:
```java
public record OrderCreatedEventV1(
String orderId,
BigDecimal amount
) {}
public record OrderCreatedEventV2(
String orderId,
BigDecimal amount,
String customerId,
Instant timestamp
) {}
// Event Upcaster
public class OrderEventUpcaster implements EventUpcaster {
@Override
public Stream<IntermediateEventRepresentation> upcast(
Stream<IntermediateEventRepresentation> eventStream) {
return eventStream.map(event -> {
if (event.getType().getName().equals("OrderCreatedEventV1")) {
return upcastV1ToV2(event);
}
return event;
});
}
}
```
## Event Store
Store all events for audit trail and recovery:
```java
@Entity
@Table(name = "saga_events")
public class SagaEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String sagaId;
@Column(nullable = false)
private String eventType;
@Column(columnDefinition = "TEXT")
private String payload;
@Column(nullable = false)
private Instant timestamp;
@Column(nullable = false)
private Integer version;
}
```
## Event Publishing Patterns
### Outbox Pattern (Transactional)
Ensure atomic update of database and event publishing:
```java
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
@Transactional
public void createOrder(CreateOrderRequest request) {
// 1. Create and save order
Order order = new Order(...);
orderRepository.save(order);
// 2. Create outbox entry in same transaction
OutboxEntry entry = new OutboxEntry(
"OrderCreated",
order.getId(),
new OrderCreatedEvent(...)
);
outboxRepository.save(entry);
}
}
@Component
public class OutboxPoller {
@Scheduled(fixedDelay = 1000)
public void pollAndPublish() {
List<OutboxEntry> unpublished = outboxRepository.findUnpublished();
unpublished.forEach(entry -> {
eventPublisher.publish(entry.getEvent());
outboxRepository.markAsPublished(entry.getId());
});
}
}
```
### Direct Publishing Pattern
Publish events immediately after transaction:
```java
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final EventPublisher eventPublisher;
@Transactional
public void createOrder(CreateOrderRequest request) {
Order order = new Order(...);
orderRepository.save(order);
// Publish event after transaction commits
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
eventPublisher.publish(new OrderCreatedEvent(...));
}
}
);
}
}
```
## Event Sourcing
Store all state changes as events instead of current state:
**Benefits**:
- Complete audit trail
- Time-travel debugging
- Natural fit for sagas
- Event replay for recovery
**Implementation**:
```java
@Entity
public class Order {
@Id
private String orderId;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<DomainEvent> events = new ArrayList<>();
public void createOrder(...) {
apply(new OrderCreatedEvent(...));
}
protected void apply(DomainEvent event) {
if (event instanceof OrderCreatedEvent e) {
this.orderId = e.orderId();
this.status = OrderStatus.PENDING;
}
events.add(event);
}
public List<DomainEvent> getUncommittedEvents() {
return new ArrayList<>(events);
}
public void clearUncommittedEvents() {
events.clear();
}
}
```
## Event Ordering and Consistency
### Maintain Event Order
Use partitioning to maintain order within a saga:
```java
@Bean
public ProducerFactory<String, Object> producerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
StringSerializer.class);
return new DefaultKafkaProducerFactory<>(config);
}
@Service
public class EventPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
public void publish(DomainEvent event) {
// Use sagaId as key to maintain order
kafkaTemplate.send("events", event.getSagaId(), event);
}
}
```
### Handle Out-of-Order Events
Use saga state to detect and handle out-of-order events:
```java
@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentProcessedEvent event) {
if (saga.getStatus() != SagaStatus.AWAITING_PAYMENT) {
// Out of order event, ignore or queue for retry
logger.warn("Unexpected event in state: {}", saga.getStatus());
return;
}
// Process event
}
```

View File

@@ -0,0 +1,299 @@
# Compensating Transactions
## Design Principles
### Idempotency
Execute multiple times with same result:
```java
public void cancelPayment(String paymentId) {
Payment payment = paymentRepository.findById(paymentId)
.orElse(null);
if (payment == null) {
// Already cancelled or doesn't exist
return;
}
if (payment.getStatus() == PaymentStatus.CANCELLED) {
// Already cancelled, idempotent
return;
}
payment.setStatus(PaymentStatus.CANCELLED);
paymentRepository.save(payment);
// Refund logic here
}
```
### Retryability
Design operations to handle retries without side effects:
```java
@Retryable(
value = {TransientException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public void releaseInventory(String itemId, int quantity) {
// Use set operations for idempotency
InventoryItem item = inventoryRepository.findById(itemId)
.orElseThrow();
item.increaseAvailableQuantity(quantity);
inventoryRepository.save(item);
}
```
## Compensation Strategies
### Backward Recovery
Undo completed steps in reverse order:
```java
@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentFailedEvent event) {
logger.error("Payment failed, initiating compensation");
// Step 1: Cancel shipment preparation
commandGateway.send(new CancelShipmentCommand(event.getOrderId()));
// Step 2: Release inventory
commandGateway.send(new ReleaseInventoryCommand(event.getOrderId()));
// Step 3: Cancel order
commandGateway.send(new CancelOrderCommand(event.getOrderId()));
end();
}
```
### Forward Recovery
Retry failed operation with exponential backoff:
```java
@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentTransientFailureEvent event) {
if (event.getRetryCount() < MAX_RETRIES) {
// Retry payment with backoff
ProcessPaymentCommand retryCommand = new ProcessPaymentCommand(
event.getPaymentId(),
event.getOrderId(),
event.getAmount()
);
commandGateway.send(retryCommand);
} else {
// After max retries, compensate
handlePaymentFailure(event);
}
}
```
## Semantic Lock Pattern
Prevent concurrent modifications during saga execution:
```java
@Entity
public class Order {
@Id
private String orderId;
@Enumerated(EnumType.STRING)
private OrderStatus status;
@Version
private Long version;
private Instant lockedUntil;
public boolean tryLock(Duration lockDuration) {
if (isLocked()) {
return false;
}
this.lockedUntil = Instant.now().plus(lockDuration);
return true;
}
public boolean isLocked() {
return lockedUntil != null &&
Instant.now().isBefore(lockedUntil);
}
public void unlock() {
this.lockedUntil = null;
}
}
```
## Compensation in Axon Framework
```java
@Saga
public class OrderSaga {
private String orderId;
private String paymentId;
private String inventoryId;
private boolean compensating = false;
@SagaEventHandler(associationProperty = "orderId")
public void handle(InventoryReservationFailedEvent event) {
logger.error("Inventory reservation failed");
compensating = true;
// Compensate: refund payment
RefundPaymentCommand refundCommand = new RefundPaymentCommand(
paymentId,
event.getOrderId(),
event.getReservedAmount(),
"Inventory unavailable"
);
commandGateway.send(refundCommand);
}
@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentRefundedEvent event) {
if (!compensating) return;
logger.info("Payment refunded, cancelling order");
// Compensate: cancel order
CancelOrderCommand command = new CancelOrderCommand(
event.getOrderId(),
"Inventory unavailable - payment refunded"
);
commandGateway.send(command);
}
@EndSaga
@SagaEventHandler(associationProperty = "orderId")
public void handle(OrderCancelledEvent event) {
logger.info("Saga completed with compensation");
}
}
```
## Handling Compensation Failures
Handle cases where compensation itself fails:
```java
@Service
public class CompensationService {
private final DeadLetterQueueService dlqService;
public void handleCompensationFailure(String sagaId, String step, Exception cause) {
logger.error("Compensation failed for saga {} at step {}", sagaId, step, cause);
// Send to dead letter queue for manual intervention
dlqService.send(new FailedCompensation(
sagaId,
step,
cause.getMessage(),
Instant.now()
));
// Create alert for operations team
alertingService.alert(
"Compensation Failure",
"Saga " + sagaId + " failed compensation at " + step
);
}
}
```
## Testing Compensation
Verify that compensation produces expected results:
```java
@Test
void shouldCompensateWhenPaymentFails() {
String orderId = "order-123";
String paymentId = "payment-456";
// Arrange: execute payment
Payment payment = new Payment(paymentId, orderId, BigDecimal.TEN);
paymentRepository.save(payment);
orderRepository.save(new Order(orderId, OrderStatus.PENDING));
// Act: compensate
paymentService.cancelPayment(paymentId);
// Assert: verify idempotency
paymentService.cancelPayment(paymentId);
Payment result = paymentRepository.findById(paymentId).orElseThrow();
assertThat(result.getStatus()).isEqualTo(PaymentStatus.CANCELLED);
}
```
## Common Compensation Patterns
### Inventory Release
```java
@Service
public class InventoryService {
public void releaseInventory(String orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.getItems().forEach(item -> {
InventoryItem inventoryItem = inventoryRepository
.findById(item.getProductId())
.orElseThrow();
inventoryItem.increaseAvailableQuantity(item.getQuantity());
inventoryRepository.save(inventoryItem);
});
}
}
```
### Payment Refund
```java
@Service
public class PaymentService {
public void refundPayment(String paymentId) {
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow();
if (payment.getStatus() == PaymentStatus.PROCESSED) {
payment.setStatus(PaymentStatus.REFUNDED);
paymentGateway.refund(payment.getTransactionId());
paymentRepository.save(payment);
}
}
}
```
### Order Cancellation
```java
@Service
public class OrderService {
public void cancelOrder(String orderId, String reason) {
Order order = orderRepository.findById(orderId)
.orElseThrow();
order.setStatus(OrderStatus.CANCELLED);
order.setCancellationReason(reason);
order.setCancelledAt(Instant.now());
orderRepository.save(order);
}
}
```

View File

@@ -0,0 +1,294 @@
# State Management in Sagas
## Saga State Entity
Persist saga state for recovery and monitoring:
```java
@Entity
@Table(name = "saga_state")
public class SagaState {
@Id
private String sagaId;
@Enumerated(EnumType.STRING)
private SagaStatus status;
@Column(columnDefinition = "TEXT")
private String currentStep;
@Column(columnDefinition = "TEXT")
private String compensationSteps;
private Instant startedAt;
private Instant completedAt;
@Version
private Long version;
}
public enum SagaStatus {
STARTED,
PROCESSING,
COMPENSATING,
COMPLETED,
FAILED,
CANCELLED
}
```
## Saga State Machine with Spring Statemachine
Define saga state transitions explicitly:
```java
@Configuration
@EnableStateMachine
public class SagaStateMachineConfig
extends StateMachineConfigurerAdapter<SagaStatus, SagaEvent> {
@Override
public void configure(
StateMachineStateConfigurer<SagaStatus, SagaEvent> states)
throws Exception {
states
.withStates()
.initial(SagaStatus.STARTED)
.states(EnumSet.allOf(SagaStatus.class))
.end(SagaStatus.COMPLETED)
.end(SagaStatus.FAILED);
}
@Override
public void configure(
StateMachineTransitionConfigurer<SagaStatus, SagaEvent> transitions)
throws Exception {
transitions
.withExternal()
.source(SagaStatus.STARTED)
.target(SagaStatus.PROCESSING)
.event(SagaEvent.ORDER_CREATED)
.and()
.withExternal()
.source(SagaStatus.PROCESSING)
.target(SagaStatus.COMPLETED)
.event(SagaEvent.ALL_STEPS_COMPLETED)
.and()
.withExternal()
.source(SagaStatus.PROCESSING)
.target(SagaStatus.COMPENSATING)
.event(SagaEvent.STEP_FAILED)
.and()
.withExternal()
.source(SagaStatus.COMPENSATING)
.target(SagaStatus.FAILED)
.event(SagaEvent.COMPENSATION_COMPLETED);
}
}
```
## State Transitions
### Successful Saga Flow
```
STARTED → PROCESSING → COMPLETED
```
### Failed Saga with Compensation
```
STARTED → PROCESSING → COMPENSATING → FAILED
```
### Saga with Retry
```
STARTED → PROCESSING → PROCESSING (retry) → COMPLETED
```
## Persisting Saga Context
Store context data for saga execution:
```java
@Entity
@Table(name = "saga_context")
public class SagaContext {
@Id
private String sagaId;
@Column(columnDefinition = "TEXT")
private String contextData; // JSON-serialized
private Instant createdAt;
private Instant updatedAt;
public <T> T getContextData(Class<T> type) {
return JsonUtils.fromJson(contextData, type);
}
public void setContextData(Object data) {
this.contextData = JsonUtils.toJson(data);
}
}
@Service
public class SagaContextService {
private final SagaContextRepository repository;
public void saveContext(String sagaId, Object context) {
SagaContext sagaContext = new SagaContext(sagaId);
sagaContext.setContextData(context);
repository.save(sagaContext);
}
public <T> T loadContext(String sagaId, Class<T> type) {
return repository.findById(sagaId)
.map(ctx -> ctx.getContextData(type))
.orElseThrow(() -> new SagaContextNotFoundException(sagaId));
}
}
```
## Handling Saga Timeouts
Detect and handle sagas that exceed expected duration:
```java
@Service
public class SagaTimeoutHandler {
private final SagaStateRepository repository;
private static final Duration MAX_SAGA_DURATION = Duration.ofMinutes(30);
@Scheduled(fixedDelay = 60000) // Check every minute
public void detectTimeouts() {
Instant timeout = Instant.now().minus(MAX_SAGA_DURATION);
List<SagaState> timedOutSagas = repository
.findByStatusAndStartedAtBefore(SagaStatus.PROCESSING, timeout);
timedOutSagas.forEach(saga -> {
logger.warn("Saga {} timed out", saga.getSagaId());
compensateSaga(saga);
});
}
private void compensateSaga(SagaState saga) {
saga.setStatus(SagaStatus.COMPENSATING);
repository.save(saga);
// Trigger compensation logic
}
}
```
## Saga Recovery
Recover sagas from failures:
```java
@Service
public class SagaRecoveryService {
private final SagaStateRepository stateRepository;
private final CommandGateway commandGateway;
@Scheduled(fixedDelay = 30000) // Check every 30 seconds
public void recoverFailedSagas() {
List<SagaState> failedSagas = stateRepository
.findByStatus(SagaStatus.FAILED);
failedSagas.forEach(saga -> {
if (canBeRetried(saga)) {
logger.info("Retrying saga {}", saga.getSagaId());
retrySaga(saga);
}
});
}
private boolean canBeRetried(SagaState saga) {
return saga.getRetryCount() < 3;
}
private void retrySaga(SagaState saga) {
saga.setStatus(SagaStatus.STARTED);
saga.setRetryCount(saga.getRetryCount() + 1);
stateRepository.save(saga);
// Send retry command
}
}
```
## Saga State Query
Query sagas for monitoring:
```java
@Repository
public interface SagaStateRepository extends JpaRepository<SagaState, String> {
List<SagaState> findByStatus(SagaStatus status);
List<SagaState> findByStatusAndStartedAtBefore(
SagaStatus status, Instant before);
Page<SagaState> findByStatus(SagaStatus status, Pageable pageable);
long countByStatus(SagaStatus status);
long countByStatusAndStartedAtBefore(SagaStatus status, Instant before);
}
@RestController
@RequestMapping("/api/sagas")
public class SagaMonitoringController {
private final SagaStateRepository repository;
@GetMapping("/status/{status}")
public List<SagaState> getSagasByStatus(
@PathVariable SagaStatus status) {
return repository.findByStatus(status);
}
@GetMapping("/stuck")
public List<SagaState> getStuckSagas() {
Instant oneHourAgo = Instant.now().minus(Duration.ofHours(1));
return repository.findByStatusAndStartedAtBefore(
SagaStatus.PROCESSING, oneHourAgo);
}
}
```
## Database Schema for State Management
```sql
CREATE TABLE saga_state (
saga_id VARCHAR(255) PRIMARY KEY,
status VARCHAR(50) NOT NULL,
current_step TEXT,
compensation_steps TEXT,
started_at TIMESTAMP NOT NULL,
completed_at TIMESTAMP,
version BIGINT,
INDEX idx_status (status),
INDEX idx_started_at (started_at)
);
CREATE TABLE saga_context (
saga_id VARCHAR(255) PRIMARY KEY,
context_data LONGTEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP,
FOREIGN KEY (saga_id) REFERENCES saga_state(saga_id)
);
CREATE INDEX idx_saga_state_status_started
ON saga_state(status, started_at);
```

View File

@@ -0,0 +1,323 @@
# Error Handling and Retry Strategies
## Retry Configuration
Use Spring Retry for automatic retry logic:
```java
@Configuration
@EnableRetry
public class RetryConfig {
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();
FixedBackOffPolicy backOffPolicy = new FixedBackOffPolicy();
backOffPolicy.setBackOffPeriod(2000L); // 2 second delay
ExponentialBackOffPolicy exponentialBackOff = new ExponentialBackOffPolicy();
exponentialBackOff.setInitialInterval(1000L);
exponentialBackOff.setMultiplier(2.0);
exponentialBackOff.setMaxInterval(10000L);
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
retryPolicy.setMaxAttempts(3);
retryTemplate.setBackOffPolicy(exponentialBackOff);
retryTemplate.setRetryPolicy(retryPolicy);
return retryTemplate;
}
}
```
## Retry with @Retryable
```java
@Service
public class OrderService {
@Retryable(
value = {TransientException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public void processOrder(String orderId) {
// Order processing logic
}
@Recover
public void recover(TransientException ex, String orderId) {
logger.error("Order processing failed after retries: {}", orderId, ex);
// Fallback logic
}
}
```
## Circuit Breaker with Resilience4j
Prevent cascading failures:
```java
@Configuration
public class CircuitBreakerConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // Open after 50% failures
.waitDurationInOpenState(Duration.ofMillis(1000))
.slidingWindowSize(2) // Check last 2 calls
.build();
return CircuitBreakerRegistry.of(config);
}
}
@Service
public class PaymentService {
private final CircuitBreaker circuitBreaker;
public PaymentService(CircuitBreakerRegistry registry) {
this.circuitBreaker = registry.circuitBreaker("payment");
}
public PaymentResult processPayment(PaymentRequest request) {
return circuitBreaker.executeSupplier(
() -> callPaymentGateway(request)
);
}
private PaymentResult callPaymentGateway(PaymentRequest request) {
// Call external payment gateway
return new PaymentResult(...);
}
}
```
## Dead Letter Queue
Handle failed messages:
```java
@Configuration
public class DeadLetterQueueConfig {
@Bean
public NewTopic deadLetterTopic() {
return new NewTopic("saga-dlq", 1, (short) 1);
}
}
@Component
public class SagaErrorHandler implements ConsumerAwareErrorHandler {
private final KafkaTemplate<String, Object> kafkaTemplate;
@Override
public void handle(Exception thrownException,
List<ConsumerRecord<?, ?>> records,
Consumer<?, ?> consumer,
MessageListenerContainer container) {
records.forEach(record -> {
logger.error("Processing failed for message: {}", record.key());
kafkaTemplate.send("saga-dlq", record.key(), record.value());
});
}
}
```
## Timeout Handling
Define and enforce timeout policies:
```java
@Service
public class TimeoutHandler {
private final SagaStateRepository sagaStateRepository;
private static final Duration STEP_TIMEOUT = Duration.ofSeconds(30);
@Scheduled(fixedDelay = 5000)
public void checkForTimeouts() {
Instant timeoutThreshold = Instant.now().minus(STEP_TIMEOUT);
List<SagaState> timedOutSagas = sagaStateRepository
.findByStatusAndUpdatedAtBefore(SagaStatus.PROCESSING, timeoutThreshold);
timedOutSagas.forEach(saga -> {
logger.warn("Saga {} timed out at step {}",
saga.getSagaId(), saga.getCurrentStep());
compensateSaga(saga);
});
}
private void compensateSaga(SagaState saga) {
saga.setStatus(SagaStatus.COMPENSATING);
sagaStateRepository.save(saga);
}
}
```
## Exponential Backoff
Prevent overwhelming downstream services:
```java
@Service
public class BackoffService {
public Duration calculateBackoff(int attemptNumber) {
long baseDelay = 1000; // 1 second
long delay = baseDelay * (long) Math.pow(2, attemptNumber - 1);
long maxDelay = 30000; // 30 seconds
return Duration.ofMillis(Math.min(delay, maxDelay));
}
@Retryable(
value = {ServiceUnavailableException.class},
maxAttempts = 5,
backoff = @Backoff(
delay = 1000,
multiplier = 2.0,
maxDelay = 30000
)
)
public void callExternalService() {
// External service call
}
}
```
## Idempotent Retry
Ensure retries don't cause duplicate processing:
```java
@Service
public class IdempotentPaymentService {
private final PaymentRepository paymentRepository;
private final Map<String, PaymentResult> processedPayments = new ConcurrentHashMap<>();
public PaymentResult processPayment(String paymentId, BigDecimal amount) {
// Check if already processed
if (processedPayments.containsKey(paymentId)) {
return processedPayments.get(paymentId);
}
// Check database
Optional<Payment> existing = paymentRepository.findById(paymentId);
if (existing.isPresent()) {
return new PaymentResult(existing.get());
}
// Process payment
PaymentResult result = callPaymentGateway(paymentId, amount);
// Cache and persist
processedPayments.put(paymentId, result);
paymentRepository.save(new Payment(paymentId, amount, result.getStatus()));
return result;
}
}
```
## Global Exception Handler
Centralize error handling:
```java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(SagaExecutionException.class)
public ResponseEntity<ErrorResponse> handleSagaError(
SagaExecutionException ex) {
return ResponseEntity
.status(HttpStatus.UNPROCESSABLE_ENTITY)
.body(new ErrorResponse(
"SAGA_EXECUTION_FAILED",
ex.getMessage(),
ex.getSagaId()
));
}
@ExceptionHandler(ServiceUnavailableException.class)
public ResponseEntity<ErrorResponse> handleServiceUnavailable(
ServiceUnavailableException ex) {
return ResponseEntity
.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(new ErrorResponse(
"SERVICE_UNAVAILABLE",
"Required service is temporarily unavailable"
));
}
@ExceptionHandler(TimeoutException.class)
public ResponseEntity<ErrorResponse> handleTimeout(
TimeoutException ex) {
return ResponseEntity
.status(HttpStatus.REQUEST_TIMEOUT)
.body(new ErrorResponse(
"REQUEST_TIMEOUT",
"Request timed out after " + ex.getDuration()
));
}
}
public record ErrorResponse(
String code,
String message,
String details
) {
public ErrorResponse(String code, String message) {
this(code, message, null);
}
}
```
## Monitoring Error Rates
Track failure metrics:
```java
@Component
public class SagaErrorMetrics {
private final MeterRegistry meterRegistry;
public SagaErrorMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
}
public void recordSagaFailure(String sagaType) {
Counter.builder("saga.failure")
.tag("type", sagaType)
.register(meterRegistry)
.increment();
}
public void recordRetry(String sagaType) {
Counter.builder("saga.retry")
.tag("type", sagaType)
.register(meterRegistry)
.increment();
}
public void recordTimeout(String sagaType) {
Counter.builder("saga.timeout")
.tag("type", sagaType)
.register(meterRegistry)
.increment();
}
}
```

View File

@@ -0,0 +1,320 @@
# Testing Strategies for Sagas
## Unit Testing Saga Logic
Test saga behavior with Axon test fixtures:
```java
@Test
void shouldDispatchPaymentCommandWhenOrderCreated() {
// Arrange
String orderId = UUID.randomUUID().toString();
String paymentId = UUID.randomUUID().toString();
SagaTestFixture<OrderSaga> fixture = new SagaTestFixture<>(OrderSaga.class);
// Act & Assert
fixture
.givenNoPriorActivity()
.whenPublishingA(new OrderCreatedEvent(orderId, BigDecimal.TEN, "item-1"))
.expectDispatchedCommands(new ProcessPaymentCommand(paymentId, orderId, BigDecimal.TEN));
}
@Test
void shouldCompensateWhenPaymentFails() {
String orderId = UUID.randomUUID().toString();
String paymentId = UUID.randomUUID().toString();
SagaTestFixture<OrderSaga> fixture = new SagaTestFixture<>(OrderSaga.class);
fixture
.givenNoPriorActivity()
.whenPublishingA(new OrderCreatedEvent(orderId, BigDecimal.TEN, "item-1"))
.whenPublishingA(new PaymentFailedEvent(paymentId, orderId, "item-1", "Insufficient funds"))
.expectDispatchedCommands(new CancelOrderCommand(orderId))
.expectScheduledEventOfType(OrderSaga.class, null);
}
```
## Testing Event Publishing
Verify events are published correctly:
```java
@SpringBootTest
@WebMvcTest
class OrderServiceTest {
@MockBean
private EventPublisher eventPublisher;
@InjectMocks
private OrderService orderService;
@Test
void shouldPublishOrderCreatedEvent() {
// Arrange
CreateOrderRequest request = new CreateOrderRequest("cust-1", BigDecimal.TEN);
// Act
String orderId = orderService.createOrder(request);
// Assert
verify(eventPublisher).publish(
argThat(event -> event instanceof OrderCreatedEvent &&
((OrderCreatedEvent) event).orderId().equals(orderId))
);
}
}
```
## Integration Testing with Testcontainers
Test complete saga flow with real services:
```java
@SpringBootTest
@Testcontainers
class SagaIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.4.0")
);
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
"postgres:15-alpine"
);
@DynamicPropertySource
static void overrideProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Test
void shouldCompleteOrderSagaSuccessfully(@Autowired OrderService orderService,
@Autowired OrderRepository orderRepository,
@Autowired EventPublisher eventPublisher) {
// Arrange
CreateOrderRequest request = new CreateOrderRequest("cust-1", BigDecimal.TEN);
// Act
String orderId = orderService.createOrder(request);
// Wait for async processing
Thread.sleep(2000);
// Assert
Order order = orderRepository.findById(orderId).orElseThrow();
assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETED);
}
}
```
## Testing Idempotency
Verify operations produce same results on retry:
```java
@Test
void compensationShouldBeIdempotent() {
// Arrange
String paymentId = "payment-123";
Payment payment = new Payment(paymentId, "order-1", BigDecimal.TEN);
paymentRepository.save(payment);
// Act - First compensation
paymentService.cancelPayment(paymentId);
Payment firstResult = paymentRepository.findById(paymentId).orElseThrow();
// Act - Second compensation (should be idempotent)
paymentService.cancelPayment(paymentId);
Payment secondResult = paymentRepository.findById(paymentId).orElseThrow();
// Assert
assertThat(firstResult).isEqualTo(secondResult);
assertThat(secondResult.getStatus()).isEqualTo(PaymentStatus.CANCELLED);
assertThat(secondResult.getVersion()).isEqualTo(firstResult.getVersion());
}
```
## Testing Concurrent Sagas
Verify saga isolation under concurrent execution:
```java
@Test
void shouldHandleConcurrentSagaExecutions() throws InterruptedException {
// Arrange
int numThreads = 10;
ExecutorService executor = Executors.newFixedThreadPool(numThreads);
CountDownLatch latch = new CountDownLatch(numThreads);
// Act
for (int i = 0; i < numThreads; i++) {
final int index = i;
executor.submit(() -> {
try {
CreateOrderRequest request = new CreateOrderRequest(
"cust-" + index,
BigDecimal.TEN.multiply(BigDecimal.valueOf(index))
);
orderService.createOrder(request);
} finally {
latch.countDown();
}
});
}
latch.await(10, TimeUnit.SECONDS);
// Assert
long createdOrders = orderRepository.count();
assertThat(createdOrders).isEqualTo(numThreads);
}
```
## Testing Failure Scenarios
Test each failure path and compensation:
```java
@Test
void shouldCompensateWhenInventoryUnavailable() {
// Arrange
String orderId = UUID.randomUUID().toString();
inventoryService.setAvailability("item-1", 0); // No inventory
// Act
String result = orderService.createOrder(
new CreateOrderRequest("cust-1", BigDecimal.TEN)
);
// Wait for saga completion
Thread.sleep(2000);
// Assert
Order order = orderRepository.findById(orderId).orElseThrow();
assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
// Verify payment was refunded
Payment payment = paymentRepository.findByOrderId(orderId).orElseThrow();
assertThat(payment.getStatus()).isEqualTo(PaymentStatus.REFUNDED);
}
@Test
void shouldHandlePaymentGatewayFailure() {
// Arrange
paymentGateway.setFailureRate(1.0); // 100% failure
// Act
String orderId = orderService.createOrder(
new CreateOrderRequest("cust-1", BigDecimal.TEN)
);
// Wait for saga completion
Thread.sleep(2000);
// Assert
Order order = orderRepository.findById(orderId).orElseThrow();
assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
}
```
## Testing State Machine
Verify state transitions:
```java
@Test
void shouldTransitionStatesProperly() {
// Arrange
String sagaId = UUID.randomUUID().toString();
SagaState sagaState = new SagaState(sagaId, SagaStatus.STARTED);
sagaStateRepository.save(sagaState);
// Act & Assert
assertThat(sagaState.getStatus()).isEqualTo(SagaStatus.STARTED);
sagaState.setStatus(SagaStatus.PROCESSING);
sagaStateRepository.save(sagaState);
assertThat(sagaStateRepository.findById(sagaId).get().getStatus())
.isEqualTo(SagaStatus.PROCESSING);
sagaState.setStatus(SagaStatus.COMPLETED);
sagaStateRepository.save(sagaState);
assertThat(sagaStateRepository.findById(sagaId).get().getStatus())
.isEqualTo(SagaStatus.COMPLETED);
}
```
## Test Data Builders
Use builders for cleaner test code:
```java
public class OrderRequestBuilder {
private String customerId = "cust-default";
private BigDecimal totalAmount = BigDecimal.TEN;
private List<OrderItem> items = new ArrayList<>();
public OrderRequestBuilder withCustomerId(String customerId) {
this.customerId = customerId;
return this;
}
public OrderRequestBuilder withAmount(BigDecimal amount) {
this.totalAmount = amount;
return this;
}
public OrderRequestBuilder withItem(String productId, int quantity) {
items.add(new OrderItem(productId, "Product", quantity, BigDecimal.TEN));
return this;
}
public CreateOrderRequest build() {
return new CreateOrderRequest(customerId, totalAmount, items);
}
}
@Test
void shouldCreateOrderWithCustomization() {
CreateOrderRequest request = new OrderRequestBuilder()
.withCustomerId("customer-123")
.withAmount(BigDecimal.valueOf(50))
.withItem("product-1", 2)
.withItem("product-2", 1)
.build();
String orderId = orderService.createOrder(request);
assertThat(orderId).isNotNull();
}
```
## Performance Testing
Measure saga execution time:
```java
@Test
void shouldCompleteOrderSagaWithinTimeLimit() {
// Arrange
CreateOrderRequest request = new CreateOrderRequest("cust-1", BigDecimal.TEN);
long maxDurationMs = 5000; // 5 seconds
// Act
Instant start = Instant.now();
String orderId = orderService.createOrder(request);
Instant end = Instant.now();
// Assert
long duration = Duration.between(start, end).toMillis();
assertThat(duration).isLessThan(maxDurationMs);
}
```

View File

@@ -0,0 +1,471 @@
# Common Pitfalls and Solutions
## Pitfall 1: Lost Messages
### Problem
Messages get lost due to broker failures, network issues, or consumer crashes before acknowledgment.
### Solution
Use persistent messages with acknowledgments:
```java
@Bean
public ProducerFactory<String, Object> producerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.ACKS_CONFIG, "all"); // All replicas must acknowledge
config.put(ProducerConfig.RETRIES_CONFIG, 3); // Retry failed sends
config.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // Prevent duplicates
return new DefaultKafkaProducerFactory<>(config);
}
@Bean
public ConsumerFactory<String, Object> consumerFactory() {
Map<String, Object> config = new HashMap<>();
config.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); // Manual commit
return new DefaultKafkaConsumerFactory<>(config);
}
```
### Prevention Checklist
- ✓ Configure producer to wait for all replicas (`acks=all`)
- ✓ Enable idempotence to prevent duplicate messages
- ✓ Use manual commit for consumers
- ✓ Monitor message lag and broker health
- ✓ Use transactional outbox pattern
---
## Pitfall 2: Duplicate Processing
### Problem
Same message processed multiple times due to failed acknowledgments or retries, causing side effects.
### Solution
Implement idempotency with deduplication:
```java
@Service
public class DeduplicationService {
private final DeduplicationRepository repository;
public boolean isDuplicate(String messageId) {
return repository.existsById(messageId);
}
public void recordProcessed(String messageId) {
DeduplicatedMessage entry = new DeduplicatedMessage(
messageId,
Instant.now()
);
repository.save(entry);
}
}
@Component
public class PaymentEventListener {
private final DeduplicationService deduplicationService;
private final PaymentService paymentService;
@Bean
public Consumer<PaymentEvent> handlePaymentEvent() {
return event -> {
String messageId = event.getMessageId();
if (deduplicationService.isDuplicate(messageId)) {
logger.info("Duplicate message ignored: {}", messageId);
return;
}
paymentService.processPayment(event);
deduplicationService.recordProcessed(messageId);
};
}
}
```
### Prevention Checklist
- ✓ Add unique message ID to all events
- ✓ Implement deduplication cache/database
- ✓ Make all operations idempotent
- ✓ Use version control for entity updates
- ✓ Test with message replay
---
## Pitfall 3: Saga State Inconsistency
### Problem
Saga state in database doesn't match actual service states, leading to orphaned or stuck sagas.
### Solution
Use event sourcing or state reconciliation:
```java
@Service
public class SagaStateReconciler {
private final SagaStateRepository stateRepository;
private final OrderRepository orderRepository;
private final PaymentRepository paymentRepository;
@Scheduled(fixedDelay = 60000) // Run every minute
public void reconcileSagaStates() {
List<SagaState> processingSagas = stateRepository
.findByStatus(SagaStatus.PROCESSING);
processingSagas.forEach(saga -> {
if (isActuallyCompleted(saga)) {
logger.info("Reconciling saga {} - marking as completed", saga.getSagaId());
saga.setStatus(SagaStatus.COMPLETED);
saga.setCompletedAt(Instant.now());
stateRepository.save(saga);
}
});
}
private boolean isActuallyCompleted(SagaState saga) {
String orderId = saga.getSagaId();
Order order = orderRepository.findById(orderId).orElse(null);
if (order == null || order.getStatus() != OrderStatus.COMPLETED) {
return false;
}
Payment payment = paymentRepository.findByOrderId(orderId).orElse(null);
if (payment == null || payment.getStatus() != PaymentStatus.PROCESSED) {
return false;
}
return true;
}
}
```
### Prevention Checklist
- ✓ Use event sourcing for complete audit trail
- ✓ Implement state reconciliation job
- ✓ Add health checks for saga coordinator
- ✓ Monitor saga state transitions
- ✓ Persist compensation steps
---
## Pitfall 4: Orchestrator Single Point of Failure
### Problem
Orchestration-based saga fails when orchestrator is down, blocking all sagas.
### Solution
Implement clustering and failover:
```java
@Configuration
public class SagaOrchestratorClusterConfig {
@Bean
public SagaStateRepository sagaStateRepository() {
// Use shared database for cluster-wide state
return new DatabaseSagaStateRepository();
}
@Bean
@Primary
public CommandGateway clusterAwareCommandGateway(
CommandBus commandBus) {
return new ClusterAwareCommandGateway(commandBus);
}
}
@Component
public class OrchestratorHealthCheck extends AbstractHealthIndicator {
private final SagaStateRepository repository;
@Override
protected void doHealthCheck(Health.Builder builder) {
long stuckSagas = repository.countStuckSagas(Duration.ofMinutes(30));
if (stuckSagas > 100) {
builder.down()
.withDetail("stuckSagas", stuckSagas)
.withDetail("severity", "critical");
} else if (stuckSagas > 10) {
builder.degraded()
.withDetail("stuckSagas", stuckSagas)
.withDetail("severity", "warning");
} else {
builder.up()
.withDetail("stuckSagas", stuckSagas);
}
}
}
```
### Prevention Checklist
- ✓ Deploy orchestrator in cluster with shared state
- ✓ Use distributed coordination (ZooKeeper, Consul)
- ✓ Implement heartbeat monitoring
- ✓ Set up automatic failover
- ✓ Use circuit breakers for service calls
---
## Pitfall 5: Non-Idempotent Compensations
### Problem
Compensation logic fails on retry because it's not idempotent, leaving system in inconsistent state.
### Solution
Design all compensations to be idempotent:
```java
// Bad - Not idempotent
@Service
public class BadPaymentService {
public void refundPayment(String paymentId) {
Payment payment = paymentRepository.findById(paymentId).orElseThrow();
payment.setStatus(PaymentStatus.REFUNDED);
paymentRepository.save(payment);
// If this fails partway, retry causes problems
externalPaymentGateway.refund(payment.getTransactionId());
}
}
// Good - Idempotent
@Service
public class GoodPaymentService {
public void refundPayment(String paymentId) {
Payment payment = paymentRepository.findById(paymentId)
.orElse(null);
if (payment == null) {
// Already deleted or doesn't exist
logger.info("Payment {} not found, skipping refund", paymentId);
return;
}
if (payment.getStatus() == PaymentStatus.REFUNDED) {
// Already refunded
logger.info("Payment {} already refunded", paymentId);
return;
}
try {
externalPaymentGateway.refund(payment.getTransactionId());
payment.setStatus(PaymentStatus.REFUNDED);
paymentRepository.save(payment);
} catch (Exception e) {
logger.error("Refund failed, will retry", e);
throw e;
}
}
}
```
### Prevention Checklist
- ✓ Check current state before making changes
- ✓ Use status flags to track compensation completion
- ✓ Make database updates idempotent
- ✓ Test compensation with replays
- ✓ Document compensation logic
---
## Pitfall 6: Missing Timeouts
### Problem
Sagas hang indefinitely waiting for events that never arrive due to service failures.
### Solution
Implement timeout mechanisms:
```java
@Configuration
public class SagaTimeoutConfig {
@Bean
public SagaLifecycle sagaLifecycle(SagaStateRepository repository) {
return new SagaLifecycle() {
@Override
public void onSagaFinished(Saga saga) {
// Update saga state
}
};
}
}
@Saga
public class OrderSaga {
@Autowired
private transient CommandGateway commandGateway;
private String orderId;
private String paymentId;
private DeadlineManager deadlineManager;
@StartSaga
@SagaEventHandler(associationProperty = "orderId")
public void handle(OrderCreatedEvent event) {
this.orderId = event.orderId();
// Schedule timeout for payment processing
deadlineManager.scheduleDeadline(
Duration.ofSeconds(30),
"PaymentTimeout",
orderId
);
commandGateway.send(new ProcessPaymentCommand(...));
}
@DeadlineHandler(deadlineName = "PaymentTimeout")
public void handlePaymentTimeout() {
logger.warn("Payment processing timed out for order {}", orderId);
// Compensate
commandGateway.send(new CancelOrderCommand(orderId));
end();
}
@SagaEventHandler(associationProperty = "orderId")
public void handle(PaymentProcessedEvent event) {
// Cancel timeout
deadlineManager.cancelDeadline("PaymentTimeout", orderId);
// Continue saga...
}
}
```
### Prevention Checklist
- ✓ Set timeout for each saga step
- ✓ Use deadline manager to track timeouts
- ✓ Cancel timeouts when step completes
- ✓ Log timeout events
- ✓ Alert operations on repeated timeouts
---
## Pitfall 7: Tight Coupling Between Services
### Problem
Saga logic couples services tightly, making independent deployment impossible.
### Solution
Use event-driven communication:
```java
// Bad - Tight coupling
@Service
public class TightlyAgedOrderService {
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(...));
// Direct coupling to payment service
paymentService.processPayment(order.getId(), request.getAmount());
}
}
// Good - Event-driven
@Service
public class LooselyAgedOrderService {
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(...));
// Publish event - services listen independently
eventPublisher.publish(new OrderCreatedEvent(
order.getId(),
request.getAmount()
));
}
}
@Component
public class PaymentServiceListener {
@Bean
public Consumer<OrderCreatedEvent> handleOrderCreated() {
return event -> {
// Payment service can be deployed independently
paymentService.processPayment(
event.orderId(),
event.amount()
);
};
}
}
```
### Prevention Checklist
- ✓ Use events for inter-service communication
- ✓ Avoid direct service-to-service calls
- ✓ Define clear contracts for events
- ✓ Version events for backward compatibility
- ✓ Deploy services independently
---
## Pitfall 8: Inadequate Monitoring
### Problem
Sagas fail silently or get stuck without visibility, making troubleshooting impossible.
### Solution
Implement comprehensive monitoring:
```java
@Component
public class SagaMonitoring {
private final MeterRegistry meterRegistry;
@Bean
public MeterBinder sagaMetrics(SagaStateRepository repository) {
return (registry) -> {
Gauge.builder("saga.active", repository::countByStatus)
.description("Number of active sagas")
.register(registry);
Gauge.builder("saga.stuck", () ->
repository.countStuckSagas(Duration.ofMinutes(30)))
.description("Number of stuck sagas")
.register(registry);
};
}
public void recordSagaStart(String sagaType) {
Counter.builder("saga.started")
.tag("type", sagaType)
.register(meterRegistry)
.increment();
}
public void recordSagaCompletion(String sagaType, long durationMs) {
Timer.builder("saga.duration")
.tag("type", sagaType)
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry)
.record(Duration.ofMillis(durationMs));
}
public void recordSagaFailure(String sagaType, String reason) {
Counter.builder("saga.failed")
.tag("type", sagaType)
.tag("reason", reason)
.register(meterRegistry)
.increment();
}
}
```
### Prevention Checklist
- ✓ Track saga state transitions
- ✓ Monitor step execution times
- ✓ Alert on stuck sagas
- ✓ Log all failures with details
- ✓ Use distributed tracing (Sleuth, Zipkin)
- ✓ Create dashboards for visibility

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,626 @@
---
name: spring-boot-test-patterns
description: Comprehensive testing patterns for Spring Boot applications including unit, integration, slice, and container-based testing with JUnit 5, Mockito, Testcontainers, and performance optimization. Use when implementing robust test suites for Spring Boot applications.
category: testing
tags: [spring-boot, java, testing, junit5, mockito, testcontainers, integration-testing, unit-testing, test-slices]
version: 1.5.0
language: java
license: Complete terms in LICENSE.txt
allowed-tools: Read, Write, Bash
---
# Spring Boot Testing Patterns
## Overview
This skill provides comprehensive guidance for writing robust test suites for Spring Boot applications. It covers unit testing with Mockito, integration testing with Testcontainers, performance-optimized slice testing patterns, and best practices for maintaining fast feedback loops.
## When to Use This Skill
Use this skill when:
- Writing unit tests for services, repositories, or utilities
- Implementing integration tests with real databases using Testcontainers
- Setting up performance-optimized test slices (@DataJpaTest, @WebMvcTest)
- Configuring Spring Boot 3.5+ @ServiceConnection for container management
- Testing REST APIs with MockMvc, TestRestTemplate, or WebTestClient
- Optimizing test performance through context caching and container reuse
- Setting up CI/CD pipelines for integration tests
- Implementing comprehensive test strategies for monolithic or microservices applications
## Core Concepts
### Test Architecture Philosophy
Spring Boot testing follows a layered approach with distinct test types:
**1. Unit Tests**
- Fast, isolated tests without Spring context
- Use Mockito for dependency injection
- Focus on business logic validation
- Target completion time: < 50ms per test
**2. Slice Tests**
- Minimal Spring context loading for specific layers
- Use @DataJpaTest for repository tests
- Use @WebMvcTest for controller tests
- Use @WebFluxTest for reactive controller tests
- Target completion time: < 100ms per test
**3. Integration Tests**
- Full Spring context with real dependencies
- Use @SpringBootTest with @ServiceConnection containers
- Test complete application flows
- Target completion time: < 500ms per test
### Key Testing Annotations
**Spring Boot Test Annotations:**
- `@SpringBootTest`: Load full application context (use sparingly)
- `@DataJpaTest`: Load only JPA components (repositories, entities)
- `@WebMvcTest`: Load only MVC layer (controllers, @ControllerAdvice)
- `@WebFluxTest`: Load only WebFlux layer (reactive controllers)
- `@JsonTest`: Load only JSON serialization components
**Testcontainer Annotations:**
- `@ServiceConnection`: Wire Testcontainer to Spring Boot test (Spring Boot 3.5+)
- `@DynamicPropertySource`: Register dynamic properties at runtime
- `@Testcontainers`: Enable Testcontainers lifecycle management
## Dependencies
### Maven Dependencies
```xml
<dependencies>
<!-- Spring Boot Test Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.0</version>
<scope>test</scope>
</dependency>
<!-- Additional Testing Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
```
### Gradle Dependencies
```kotlin
dependencies {
// Spring Boot Test Starter
testImplementation("org.springframework.boot:spring-boot-starter-test")
// Testcontainers
testImplementation("org.testcontainers:junit-jupiter:1.19.0")
testImplementation("org.testcontainers:postgresql:1.19.0")
// Additional Dependencies
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-web")
}
```
## Instructions
### Unit Testing Pattern
Test business logic with mocked dependencies:
```java
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void shouldFindUserByIdWhenExists() {
// Arrange
Long userId = 1L;
User user = new User();
user.setId(userId);
user.setEmail("test@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
// Act
Optional<User> result = userService.findById(userId);
// Assert
assertThat(result).isPresent();
assertThat(result.get().getEmail()).isEqualTo("test@example.com");
verify(userRepository, times(1)).findById(userId);
}
}
```
### Slice Testing Pattern
Use focused test slices for specific layers:
```java
// Repository test with minimal context
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainerConfig
public class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUserFromDatabase() {
// Arrange
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
// Act
User saved = userRepository.save(user);
userRepository.flush();
Optional<User> retrieved = userRepository.findByEmail("test@example.com");
// Assert
assertThat(retrieved).isPresent();
assertThat(retrieved.get().getName()).isEqualTo("Test User");
}
}
```
### REST API Testing Pattern
Test controllers with MockMvc for faster execution:
```java
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private UserService userService;
@Test
void shouldCreateUserAndReturn201() throws Exception {
User user = new User();
user.setEmail("newuser@example.com");
user.setName("New User");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.email").value("newuser@example.com"))
.andExpect(jsonPath("$.name").value("New User"));
}
}
```
### Testcontainers with @ServiceConnection
Configure containers with Spring Boot 3.5+:
```java
@TestConfiguration
public class TestContainerConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
}
}
```
## Examples
### Basic Unit Test
```java
@Test
void shouldCalculateTotalPrice() {
// Arrange
OrderItem item1 = new OrderItem();
item1.setPrice(10.0);
item1.setQuantity(2);
OrderItem item2 = new OrderItem();
item2.setPrice(15.0);
item2.setQuantity(1);
List<OrderItem> items = List.of(item1, item2);
// Act
double total = orderService.calculateTotal(items);
// Assert
assertThat(total).isEqualTo(35.0);
}
```
### Integration Test with Testcontainers
```java
@SpringBootTest
@TestContainerConfig
public class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Autowired
private UserRepository userRepository;
@MockBean
private PaymentService paymentService;
@Test
void shouldCreateOrderWithRealDatabase() {
// Arrange
User user = new User();
user.setEmail("customer@example.com");
user.setName("John Doe");
User savedUser = userRepository.save(user);
OrderRequest request = new OrderRequest();
request.setUserId(savedUser.getId());
request.setItems(List.of(
new OrderItemRequest(1L, 2),
new OrderItemRequest(2L, 1)
));
when(paymentService.processPayment(any())).thenReturn(true);
// Act
OrderResponse response = orderService.createOrder(request);
// Assert
assertThat(response.getOrderId()).isNotNull();
assertThat(response.getStatus()).isEqualTo("COMPLETED");
verify(paymentService, times(1)).processPayment(any());
}
}
```
### Reactive Test Pattern
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
public class ReactiveUserControllerIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Test
void shouldReturnUserAsJsonReactive() {
// Arrange
User user = new User();
user.setEmail("reactive@example.com");
user.setName("Reactive User");
// Act & Assert
webTestClient.get()
.uri("/api/users/1")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.email").isEqualTo("reactive@example.com")
.jsonPath("$.name").isEqualTo("Reactive User");
}
}
```
## Best Practices
### 1. Choose the Right Test Type
Select appropriate test annotations based on scope:
```java
// Use @DataJpaTest for repository-only tests (fastest)
@DataJpaTest
public class UserRepositoryTest { }
// Use @WebMvcTest for controller-only tests
@WebMvcTest(UserController.class)
public class UserControllerTest { }
// Use @SpringBootTest only for full integration testing
@SpringBootTest
public class UserServiceFullIntegrationTest { }
```
### 2. Use @ServiceConnection for Container Management
Prefer `@ServiceConnection` over manual `@DynamicPropertySource` for cleaner code:
```java
// Good - Spring Boot 3.5+
@TestConfiguration
public class TestConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"));
}
}
```
### 3. Keep Tests Deterministic
Always initialize test data explicitly:
```java
// Good - Explicit setup
@BeforeEach
void setUp() {
userRepository.deleteAll();
User user = new User();
user.setEmail("test@example.com");
userRepository.save(user);
}
// Avoid - Depending on other tests
@Test
void testUserExists() {
// Assumes previous test created a user
Optional<User> user = userRepository.findByEmail("test@example.com");
assertThat(user).isPresent();
}
```
### 4. Use Meaningful Assertions
Leverage AssertJ for readable, fluent assertions:
```java
// Good - Clear, readable assertions
assertThat(user.getEmail())
.isEqualTo("test@example.com");
assertThat(users)
.hasSize(3)
.contains(expectedUser);
// Avoid - JUnit assertions
assertEquals("test@example.com", user.getEmail());
assertTrue(users.size() == 3);
```
### 5. Organize Tests by Layer
Group related tests in separate classes to optimize context caching:
```java
// Repository tests (uses @DataJpaTest)
public class UserRepositoryTest { }
// Controller tests (uses @WebMvcTest)
public class UserControllerTest { }
// Service tests (uses mocks, no context)
public class UserServiceTest { }
// Full integration tests (uses @SpringBootTest)
public class UserFullIntegrationTest { }
```
## Performance Optimization
### Context Caching Strategy
Maximize Spring context caching by grouping tests with similar configurations:
```java
// Group repository tests with same configuration
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainerConfig
@TestPropertySource(properties = "spring.datasource.url=jdbc:postgresql:testdb")
public class UserRepositoryTest { }
// Group controller tests with same configuration
@WebMvcTest(UserController.class)
@AutoConfigureMockMvc
public class UserControllerTest { }
```
### Container Reuse Strategy
Reuse Testcontainers at JVM level for better performance:
```java
@Testcontainers
public class ContainerConfig {
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(
DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@BeforeAll
static void startAll() {
POSTGRES.start();
}
@AfterAll
static void stopAll() {
POSTGRES.stop();
}
}
```
## Test Execution
### Maven Test Execution
```bash
# Run all tests
./mvnw test
# Run specific test class
./mvnw test -Dtest=UserServiceTest
# Run integration tests only
./mvnw test -Dintegration-test=true
# Run tests with coverage
./mvnw clean jacoco:prepare-agent test jacoco:report
```
### Gradle Test Execution
```bash
# Run all tests
./gradlew test
# Run specific test class
./gradlew test --tests UserServiceTest
# Run integration tests only
./gradlew integrationTest
# Run tests with coverage
./gradlew test jacocoTestReport
```
## CI/CD Configuration
### GitHub Actions Example
```yaml
name: Spring Boot Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: test
POSTGRES_USER: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Run tests
run: ./mvnw test -Dspring.profiles.active=test
```
### Docker Compose for Local Testing
```yaml
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
```
## References
For detailed information, refer to the following resources:
- [API Reference](./references/api-reference.md) - Complete test annotations and utilities
- [Best Practices](./references/best-practices.md) - Testing patterns and optimization
- [Workflow Patterns](./references/workflow-patterns.md) - Complete integration test examples
## Related Skills
- **spring-boot-dependency-injection** - Unit testing patterns with constructor injection
- **spring-boot-rest-api-standards** - REST API patterns to test
- **spring-boot-crud-patterns** - CRUD patterns to test
- **unit-test-service-layer** - Advanced service layer testing techniques
## Performance Targets
- **Unit tests**: < 50ms per test
- **Slice tests**: < 100ms per test
- **Integration tests**: < 500ms per test
- **Maximize context caching** by grouping tests with same configuration
- **Reuse Testcontainers** at JVM level where possible
## Key Principles
1. Use test slices for focused, fast tests
2. Prefer @ServiceConnection on Spring Boot 3.5+
3. Keep tests deterministic with explicit setup
4. Mock external dependencies, use real databases
5. Avoid @DirtiesContext unless absolutely necessary
6. Organize tests by layer to optimize context reuse
This skill enables building comprehensive test suites that validate Spring Boot applications reliably while maintaining fast feedback loops for development.

View File

@@ -0,0 +1,74 @@
# Spring Boot Test API Reference
## Test Annotations
**Spring Boot Test Annotations:**
- `@SpringBootTest`: Load full application context (use sparingly)
- `@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)`: Full test with random HTTP port
- `@SpringBootTest(webEnvironment = WebEnvironment.MOCK)`: Full test with mock web environment
- `@DataJpaTest`: Load only JPA components (repositories, entities)
- `@WebMvcTest`: Load only MVC layer (controllers, @ControllerAdvice)
- `@WebFluxTest`: Load only WebFlux layer (reactive controllers)
- `@JsonTest`: Load only JSON serialization components
- `@RestClientTest`: Load only REST client components
- `@AutoConfigureMockMvc`: Provide MockMvc bean in @SpringBootTest
- `@AutoConfigureWebTestClient`: Provide WebTestClient bean for WebFlux tests
- `@AutoConfigureTestDatabase`: Control test database configuration
**Testcontainer Annotations:**
- `@ServiceConnection`: Wire Testcontainer to Spring Boot test (Spring Boot 3.5+)
- `@DynamicPropertySource`: Register dynamic properties at runtime
- `@Container`: Mark field as Testcontainer (requires @Testcontainers)
- `@Testcontainers`: Enable Testcontainers lifecycle management
**Test Lifecycle Annotations:**
- `@BeforeEach`: Run before each test method
- `@AfterEach`: Run after each test method
- `@BeforeAll`: Run once before all tests in class (must be static)
- `@AfterAll`: Run once after all tests in class (must be static)
- `@DisplayName`: Custom test name for reports
- `@Disabled`: Skip test
- `@Tag`: Tag tests for selective execution
**Test Isolation Annotations:**
- `@DirtiesContext`: Clear Spring context after test (forces rebuild)
- `@DirtiesContext(classMode = ClassMode.AFTER_CLASS)`: Clear after entire class
## Common Test Utilities
**MockMvc Methods:**
- `mockMvc.perform(get("/path"))`: Perform GET request
- `mockMvc.perform(post("/path")).contentType(MediaType.APPLICATION_JSON)`: POST with content type
- `.andExpect(status().isOk())`: Assert HTTP status
- `.andExpect(content().contentType("application/json"))`: Assert content type
- `.andExpect(jsonPath("$.field").value("expected"))`: Assert JSON path value
**TestRestTemplate Methods:**
- `restTemplate.getForEntity("/path", String.class)`: GET request
- `restTemplate.postForEntity("/path", body, String.class)`: POST request
- `response.getStatusCode()`: Get HTTP status
- `response.getBody()`: Get response body
**WebTestClient Methods (Reactive):**
- `webTestClient.get().uri("/path").exchange()`: Perform GET request
- `.expectStatus().isOk()`: Assert status
- `.expectBody().jsonPath("$.field").isEqualTo(value)`: Assert JSON
## Test Slices Performance Guidelines
- **Unit tests**: Complete in <50ms each
- **Integration tests**: Complete in <500ms each
- **Maximize context caching** by grouping tests with same configuration
- **Reuse Testcontainers** at JVM level where possible
## Common Test Annotations Reference
| Annotation | Purpose | When to Use |
|------------|---------|-------------|
| `@SpringBootTest` | Full application context | Full integration tests only |
| `@DataJpaTest` | JPA components only | Repository and entity tests |
| `@WebMvcTest` | MVC layer only | Controller tests |
| `@WebFluxTest` | WebFlux layer only | Reactive controller tests |
| `@ServiceConnection` | Container integration | Spring Boot 3.5+ with Testcontainers |
| `@DynamicPropertySource` | Dynamic properties | Pre-3.5 or custom configuration |
| `@DirtiesContext` | Context cleanup | When absolutely necessary |

View File

@@ -0,0 +1,263 @@
# Spring Boot Testing Best Practices
## Choose the Right Test Type
Select the most efficient test annotation for your use case:
```java
// Use @DataJpaTest for repository-only tests (fastest)
@DataJpaTest
public class UserRepositoryTest { }
// Use @WebMvcTest for controller-only tests
@WebMvcTest(UserController.class)
public class UserControllerTest { }
// Use @SpringBootTest only for full integration testing
@SpringBootTest
public class UserServiceFullIntegrationTest { }
```
## Use @ServiceConnection for Container Management (Spring Boot 3.5+)
Prefer `@ServiceConnection` over manual `@DynamicPropertySource` for cleaner code:
```java
// Good - Spring Boot 3.5+
@TestConfiguration
public class TestConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"));
}
}
// Avoid - Manual property registration
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
// ... more properties
}
```
## Keep Tests Deterministic
Always initialize test data explicitly and never depend on test execution order:
```java
// Good - Explicit setup
@BeforeEach
void setUp() {
userRepository.deleteAll();
User user = new User();
user.setEmail("test@example.com");
userRepository.save(user);
}
// Avoid - Depending on other tests
@Test
void testUserExists() {
// Assumes previous test created a user
Optional<User> user = userRepository.findByEmail("test@example.com");
assertThat(user).isPresent();
}
```
## Use Transactional Tests Carefully
Mark test classes with `@Transactional` for automatic rollback, but understand the implications:
```java
@SpringBootTest
@Transactional // Automatically rolls back after each test
public class UserControllerIntegrationTest {
@Test
void shouldCreateUser() throws Exception {
// Changes will be rolled back after test
mockMvc.perform(post("/api/users")....)
.andExpect(status().isCreated());
}
}
```
**Note**: Be aware that `@Transactional` test behavior may differ from production due to lazy loading and flush semantics.
## Organize Tests by Layer
Group related tests in separate classes to optimize context caching:
```java
// Repository tests (uses @DataJpaTest)
public class UserRepositoryTest { }
// Controller tests (uses @WebMvcTest)
public class UserControllerTest { }
// Service tests (uses mocks, no context)
public class UserServiceTest { }
// Full integration tests (uses @SpringBootTest)
public class UserFullIntegrationTest { }
```
## Use Meaningful Assertions
Leverage AssertJ for readable, fluent assertions:
```java
// Good - Clear, readable assertions
assertThat(user.getEmail())
.isEqualTo("test@example.com");
assertThat(users)
.hasSize(3)
.contains(expectedUser);
assertThatThrownBy(() -> userService.save(invalidUser))
.isInstanceOf(ValidationException.class)
.hasMessageContaining("Email is required");
// Avoid - JUnit assertions
assertEquals("test@example.com", user.getEmail());
assertTrue(users.size() == 3);
```
## Mock External Dependencies
Mock external services but use real databases for integration tests:
```java
// Good - Mock external services, use real DB
@SpringBootTest
@TestContainerConfig.class
public class OrderServiceTest {
@MockBean
private EmailService emailService;
@Autowired
private OrderRepository orderRepository;
@Test
void shouldSendConfirmationEmail() {
// Use real database, mock email service
Order order = new Order();
orderService.createOrder(order);
verify(emailService, times(1)).sendConfirmation(order);
}
}
// Avoid - Mocking the database layer
@Test
void shouldCreateOrder() {
when(orderRepository.save(any())).thenReturn(mockOrder);
// Tests don't verify actual database behavior
}
```
## Use Test Fixtures for Common Data
Create reusable test data builders:
```java
public class UserTestFixture {
public static User validUser() {
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
return user;
}
public static User userWithEmail(String email) {
User user = validUser();
user.setEmail(email);
return user;
}
}
// Usage in tests
@Test
void shouldSaveUser() {
User user = UserTestFixture.validUser();
userRepository.save(user);
assertThat(userRepository.count()).isEqualTo(1);
}
```
## Document Complex Test Scenarios
Use `@DisplayName` and comments for complex test logic:
```java
@Test
@DisplayName("Should validate email format and reject duplicates with proper error message")
void shouldValidateEmailBeforePersisting() {
// Given: Two users with the same email
User user1 = new User();
user1.setEmail("test@example.com");
userRepository.save(user1);
User user2 = new User();
user2.setEmail("test@example.com"); // Duplicate email
// When: Attempting to save duplicate
// Then: Should throw exception with clear message
assertThatThrownBy(() -> {
userRepository.save(user2);
userRepository.flush();
})
.isInstanceOf(DataIntegrityViolationException.class)
.hasMessageContaining("unique constraint");
}
```
## Avoid Common Pitfalls
```java
// Avoid: Using @DirtiesContext without reason (forces context rebuild)
@SpringBootTest
@DirtiesContext // DON'T USE unless absolutely necessary
public class ProblematicTest { }
// Avoid: Mixing multiple profiles in same test suite
@SpringBootTest(properties = "spring.profiles.active=dev,test,prod")
public class MultiProfileTest { }
// Avoid: Starting containers manually
@SpringBootTest
public class ManualContainerTest {
static {
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>();
postgres.start(); // Avoid - use @ServiceConnection instead
}
}
// Good: Consistent configuration, minimal context switching
@SpringBootTest
@TestContainerConfig
public class ProperTest { }
```
## Test Naming Conventions
Convention: Use descriptive method names that start with `should` or `test` to make test intent explicit.
**Naming Rules:**
- **Prefix**: Start with `should` or `test` to clearly indicate test purpose
- **Structure**: Use camelCase for readability (no underscores)
- **Clarity**: Name should indicate what is being tested and the expected outcome
- **Example pattern**: `should[ExpectedBehavior]When[Condition]()`
**Examples:**
```
shouldReturnUsersJson()
shouldThrowNotFoundWhenIdDoesntExist()
shouldPropagateExceptionOnPersistenceError()
shouldSaveAndRetrieveUserFromDatabase()
shouldValidateEmailFormatBeforePersisting()
```
Apply these rules consistently across all integration test methods.

View File

@@ -0,0 +1,340 @@
# Spring Boot Testing Workflow Patterns
## Complete Database Integration Test Pattern
**Scenario**: Test a JPA repository with a real PostgreSQL database using Testcontainers.
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainerConfig
public class UserRepositoryIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndRetrieveUserFromDatabase() {
// Arrange
User user = new User();
user.setEmail("test@example.com");
user.setName("Test User");
// Act
User saved = userRepository.save(user);
userRepository.flush();
Optional<User> retrieved = userRepository.findByEmail("test@example.com");
// Assert
assertThat(retrieved).isPresent();
assertThat(retrieved.get().getName()).isEqualTo("Test User");
}
@Test
void shouldThrowExceptionForDuplicateEmail() {
// Arrange
User user1 = new User();
user1.setEmail("duplicate@example.com");
user1.setName("User 1");
User user2 = new User();
user2.setEmail("duplicate@example.com");
user2.setName("User 2");
userRepository.save(user1);
// Act & Assert
assertThatThrownBy(() -> {
userRepository.save(user2);
userRepository.flush();
}).isInstanceOf(DataIntegrityViolationException.class);
}
}
```
## Complete REST API Integration Test Pattern
**Scenario**: Test REST controllers with full Spring context using MockMvc.
```java
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldCreateUserAndReturn201() throws Exception {
User user = new User();
user.setEmail("newuser@example.com");
user.setName("New User");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").exists())
.andExpect(jsonPath("$.email").value("newuser@example.com"))
.andExpect(jsonPath("$.name").value("New User"));
}
@Test
void shouldReturnUserById() throws Exception {
// Arrange
User user = new User();
user.setEmail("existing@example.com");
user.setName("Existing User");
User saved = userRepository.save(user);
// Act & Assert
mockMvc.perform(get("/api/users/" + saved.getId())
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("existing@example.com"))
.andExpect(jsonPath("$.name").value("Existing User"));
}
@Test
void shouldReturnNotFoundForMissingUser() throws Exception {
mockMvc.perform(get("/api/users/99999")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
void shouldUpdateUserAndReturn200() throws Exception {
// Arrange
User user = new User();
user.setEmail("update@example.com");
user.setName("Original Name");
User saved = userRepository.save(user);
User updateData = new User();
updateData.setName("Updated Name");
// Act & Assert
mockMvc.perform(put("/api/users/" + saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateData)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Updated Name"));
}
@Test
void shouldDeleteUserAndReturn204() throws Exception {
// Arrange
User user = new User();
user.setEmail("delete@example.com");
user.setName("To Delete");
User saved = userRepository.save(user);
// Act & Assert
mockMvc.perform(delete("/api/users/" + saved.getId()))
.andExpect(status().isNoContent());
assertThat(userRepository.findById(saved.getId())).isEmpty();
}
}
```
## Service Layer Integration Test Pattern
**Scenario**: Test business logic with mocked repository.
```java
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void shouldFindUserByIdWhenExists() {
// Arrange
Long userId = 1L;
User user = new User();
user.setId(userId);
user.setEmail("test@example.com");
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
// Act
Optional<User> result = userService.findById(userId);
// Assert
assertThat(result).isPresent();
assertThat(result.get().getEmail()).isEqualTo("test@example.com");
verify(userRepository, times(1)).findById(userId);
}
@Test
void shouldReturnEmptyWhenUserNotFound() {
// Arrange
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// Act
Optional<User> result = userService.findById(userId);
// Assert
assertThat(result).isEmpty();
verify(userRepository, times(1)).findById(userId);
}
@Test
void shouldThrowExceptionWhenSavingInvalidUser() {
// Arrange
User invalidUser = new User();
invalidUser.setEmail("invalid-email");
when(userRepository.save(invalidUser))
.thenThrow(new DataIntegrityViolationException("Invalid email"));
// Act & Assert
assertThatThrownBy(() -> userService.save(invalidUser))
.isInstanceOf(DataIntegrityViolationException.class);
}
}
```
## Reactive WebFlux Integration Test Pattern
**Scenario**: Test WebFlux controllers with WebTestClient.
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
public class ReactiveUserControllerIntegrationTest {
@Autowired
private WebTestClient webTestClient;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
void shouldReturnUserAsJsonReactive() {
// Arrange
User user = new User();
user.setEmail("reactive@example.com");
user.setName("Reactive User");
User saved = userRepository.save(user);
// Act & Assert
webTestClient.get()
.uri("/api/users/" + saved.getId())
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.email").isEqualTo("reactive@example.com")
.jsonPath("$.name").isEqualTo("Reactive User");
}
@Test
void shouldReturnArrayOfUsers() {
// Arrange
User user1 = new User();
user1.setEmail("user1@example.com");
user1.setName("User 1");
User user2 = new User();
user2.setEmail("user2@example.com");
user2.setName("User 2");
userRepository.saveAll(List.of(user1, user2));
// Act & Assert
webTestClient.get()
.uri("/api/users")
.exchange()
.expectStatus().isOk()
.expectBodyList(User.class)
.hasSize(2);
}
}
```
## Testcontainers Configuration Patterns
### @ServiceConnection Pattern (Spring Boot 3.5+)
```java
@TestConfiguration
public class TestContainerConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
// Do not call start(); Spring Boot will manage lifecycle for @ServiceConnection beans
}
}
```
### @DynamicPropertySource Pattern (Legacy)
```java
public class SharedContainers {
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@BeforeAll
static void startAll() {
POSTGRES.start();
}
@AfterAll
static void stopAll() {
POSTGRES.stop();
}
@DynamicPropertySource
static void registerProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
```
### Slice Tests with Testcontainers
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainerConfig
public class MyRepositoryIntegrationTest {
// repository tests
}
```

View File

@@ -0,0 +1,184 @@
---
name: spring-data-jpa
description: Implement persistence layers with Spring Data JPA. Use when creating repositories, configuring entity relationships, writing queries (derived and @Query), setting up pagination, database auditing, transactions, UUID primary keys, multiple databases, and database indexing. Covers repository interfaces, JPA entities, custom queries, relationships, and performance optimization patterns.
allowed-tools: Read, Write, Bash, Grep
category: backend
tags: [spring-data, jpa, database, hibernate, orm, persistence]
version: 1.2.0
---
# Spring Data JPA
## Overview
To implement persistence layers with Spring Data JPA, create repository interfaces that provide automatic CRUD operations, entity relationships, query methods, and advanced features like pagination, auditing, and performance optimization.
## When to Use
Use this Skill when:
- Implementing repository interfaces with automatic CRUD operations
- Creating entities with relationships (one-to-one, one-to-many, many-to-many)
- Writing queries using derived method names or custom @Query annotations
- Setting up pagination and sorting for large datasets
- Implementing database auditing with timestamps and user tracking
- Configuring transactions and exception handling
- Using UUID as primary keys for distributed systems
- Optimizing performance with database indexes
- Setting up multiple database configurations
## Instructions
### Create Repository Interfaces
To implement a repository interface:
1. **Extend the appropriate repository interface:**
```java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Custom methods defined here
}
```
2. **Use derived queries for simple conditions:**
```java
Optional<User> findByEmail(String email);
List<User> findByStatusOrderByCreatedDateDesc(String status);
```
3. **Implement custom queries with @Query:**
```java
@Query("SELECT u FROM User u WHERE u.status = :status")
List<User> findActiveUsers(@Param("status") String status);
```
### Configure Entities
1. **Define entities with proper annotations:**
```java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String email;
}
```
2. **Configure relationships using appropriate cascade types:**
```java
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();
```
3. **Set up database auditing:**
```java
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdDate;
```
### Apply Query Patterns
1. **Use derived queries for simple conditions**
2. **Use @Query for complex queries**
3. **Return Optional<T> for single results**
4. **Use Pageable for pagination**
5. **Apply @Modifying for update/delete operations**
### Manage Transactions
1. **Mark read-only operations with @Transactional(readOnly = true)**
2. **Use explicit transaction boundaries for modifying operations**
3. **Specify rollback conditions when needed**
## Examples
### Basic CRUD Repository
```java
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// Derived query
List<Product> findByCategory(String category);
// Custom query
@Query("SELECT p FROM Product p WHERE p.price > :minPrice")
List<Product> findExpensiveProducts(@Param("minPrice") BigDecimal minPrice);
}
```
### Pagination Implementation
```java
@Service
public class ProductService {
private final ProductRepository repository;
public Page<Product> getProducts(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("name").ascending());
return repository.findAll(pageable);
}
}
```
### Entity with Auditing
```java
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdDate;
@LastModifiedDate
private LocalDateTime lastModifiedDate;
@CreatedBy
@Column(nullable = false, updatable = false)
private String createdBy;
}
```
## Best Practices
### Entity Design
- Use constructor injection exclusively (never field injection)
- Prefer immutable fields with `final` modifiers
- Use Java records (16+) or `@Value` for DTOs
- Always provide proper `@Id` and `@GeneratedValue` annotations
- Use explicit `@Table` and `@Column` annotations
### Repository Queries
- Use derived queries for simple conditions
- Use `@Query` for complex queries to avoid long method names
- Always use `@Param` for query parameters
- Return `Optional<T>` for single results
- Apply `@Transactional` on modifying operations
### Performance Optimization
- Use appropriate fetch strategies (LAZY vs EAGER)
- Implement pagination for large datasets
- Use database indexes for frequently queried fields
- Consider using `@EntityGraph` to avoid N+1 query problems
### Transaction Management
- Mark read-only operations with `@Transactional(readOnly = true)`
- Use explicit transaction boundaries
- Avoid long-running transactions
- Specify rollback conditions when needed
## Reference Documentation
For comprehensive examples, detailed patterns, and advanced configurations, see:
- [Examples](references/examples.md) - Complete code examples for common scenarios
- [Reference](references/reference.md) - Detailed patterns and advanced configurations

View File

@@ -0,0 +1,946 @@
# Spring Data JPA - Code Examples
## Example 1: Simple CRUD Application
### Entity Classes
```java
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(columnDefinition = "TEXT")
private String description;
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Product> products = new ArrayList<>();
public Category() {}
public Category(String name) {
this.name = name;
}
// getters, setters, equals, hashCode
}
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 255)
private String name;
@Column(precision = 10, scale = 2)
private BigDecimal price;
@Column(columnDefinition = "INT DEFAULT 0")
private Integer stock;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
public Product() {}
public Product(String name, BigDecimal price, Category category) {
this.name = name;
this.price = price;
this.category = category;
}
// getters, setters
}
```
### Repository Interfaces
```java
@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
Optional<Category> findByName(String name);
boolean existsByName(String name);
}
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByCategory(Category category);
List<Product> findByCategoryAndPriceGreaterThan(Category category, BigDecimal price);
Page<Product> findByNameContainingIgnoreCase(String name, Pageable pageable);
@Query("SELECT p FROM Product p WHERE p.stock = 0 ORDER BY p.updatedAt DESC")
List<Product> findOutOfStockProducts();
@Query("SELECT p FROM Product p WHERE p.price BETWEEN :minPrice AND :maxPrice")
List<Product> findByPriceRange(
@Param("minPrice") BigDecimal minPrice,
@Param("maxPrice") BigDecimal maxPrice
);
}
```
### Service Layer
```java
@Service
@Transactional
public class ProductService {
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
public ProductService(ProductRepository productRepository,
CategoryRepository categoryRepository) {
this.productRepository = productRepository;
this.categoryRepository = categoryRepository;
}
@Transactional(readOnly = true)
public Page<Product> searchProducts(String query, int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return productRepository.findByNameContainingIgnoreCase(query, pageable);
}
@Transactional(readOnly = true)
public List<Product> getProductsByCategory(Long categoryId) {
Category category = categoryRepository.findById(categoryId)
.orElseThrow(() -> new CategoryNotFoundException(categoryId));
return productRepository.findByCategory(category);
}
@Transactional(readOnly = true)
public List<Product> getExpensiveProducts(Long categoryId, BigDecimal minPrice) {
Category category = categoryRepository.findById(categoryId)
.orElseThrow(() -> new CategoryNotFoundException(categoryId));
return productRepository.findByCategoryAndPriceGreaterThan(category, minPrice);
}
public Product createProduct(CreateProductRequest request) {
Category category = categoryRepository.findById(request.categoryId())
.orElseThrow(() -> new CategoryNotFoundException(request.categoryId()));
Product product = new Product(request.name(), request.price(), category);
return productRepository.save(product);
}
public Product updateProduct(Long id, UpdateProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
product.setName(request.name());
product.setPrice(request.price());
return productRepository.save(product);
}
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
@Transactional(readOnly = true)
public List<Product> getOutOfStockProducts() {
return productRepository.findOutOfStockProducts();
}
}
record CreateProductRequest(String name, BigDecimal price, Long categoryId) {}
record UpdateProductRequest(String name, BigDecimal price) {}
```
## Example 2: Complex Query with Auditing
```java
@Entity
@Table(name = "orders")
@EntityListeners(AuditingEntityListener.class)
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String orderNumber;
private String status; // PENDING, PROCESSING, SHIPPED, DELIVERED, CANCELLED
@Column(precision = 12, scale = 2)
private BigDecimal totalAmount;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime modifiedAt;
@CreatedBy
@Column(nullable = false, updatable = false)
private String createdBy;
@LastModifiedBy
private String modifiedBy;
// getters, setters, methods
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
private Integer quantity;
@Column(precision = 10, scale = 2)
private BigDecimal unitPrice;
// getters, setters
}
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("""
SELECT o FROM Order o
JOIN FETCH o.items i
JOIN FETCH o.customer
WHERE o.status = :status
ORDER BY o.createdAt DESC
""")
List<Order> findOrdersWithItems(@Param("status") String status);
@Query("""
SELECT o FROM Order o
WHERE o.customer.id = :customerId
AND o.createdAt BETWEEN :startDate AND :endDate
""")
List<Order> findCustomerOrdersByDateRange(
@Param("customerId") Long customerId,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
@Query(value = """
SELECT o.id, o.order_number, SUM(oi.quantity * oi.unit_price) as total
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.status = :status
GROUP BY o.id
HAVING total > :minAmount
""", nativeQuery = true)
List<Map<String, Object>> findHighValueOrdersByStatus(
@Param("status") String status,
@Param("minAmount") BigDecimal minAmount
);
@Modifying
@Transactional
@Query("UPDATE Order o SET o.status = :newStatus WHERE o.id = :orderId")
void updateOrderStatus(
@Param("orderId") Long orderId,
@Param("newStatus") String newStatus
);
@Modifying
@Transactional
@Query("""
DELETE FROM Order o
WHERE o.status = :status
AND o.createdAt < :cutoffDate
""")
int deleteOldOrders(
@Param("status") String status,
@Param("cutoffDate") LocalDateTime cutoffDate
);
}
@Service
@Transactional
public class OrderService {
private final OrderRepository orderRepository;
@Transactional(readOnly = true)
public List<Order> getPendingOrders() {
return orderRepository.findOrdersWithItems("PENDING");
}
@Transactional(readOnly = true)
public List<Order> getCustomerOrders(Long customerId, LocalDateTime from, LocalDateTime to) {
return orderRepository.findCustomerOrdersByDateRange(customerId, from, to);
}
@Transactional(readOnly = true)
public List<Map<String, Object>> getHighValueOrders(BigDecimal minAmount) {
return orderRepository.findHighValueOrdersByStatus("COMPLETED", minAmount);
}
public void processOrder(Long orderId) {
orderRepository.updateOrderStatus(orderId, "PROCESSING");
}
public int cleanupCancelledOrders(LocalDateTime before) {
return orderRepository.deleteOldOrders("CANCELLED", before);
}
}
```
## Example 3: Many-to-Many Relationship
```java
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
public void addRole(Role role) {
this.roles.add(role);
role.getUsers().add(this);
}
public void removeRole(Role role) {
this.roles.remove(role);
role.getUsers().remove(this);
}
}
@Entity
@Table(name = "roles")
public class Role {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
@ManyToMany(mappedBy = "roles")
private Set<User> users = new HashSet<>();
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
@Query("""
SELECT DISTINCT u FROM User u
LEFT JOIN FETCH u.roles
WHERE u.id = :id
""")
Optional<User> findByIdWithRoles(@Param("id") Long id);
@Query("""
SELECT u FROM User u
JOIN u.roles r
WHERE r.name = :roleName
""")
List<User> findUsersByRole(@Param("roleName") String roleName);
}
@Service
public class UserManagementService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
@Transactional
public void assignRoleToUser(Long userId, Long roleId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
Role role = roleRepository.findById(roleId)
.orElseThrow(() -> new RoleNotFoundException(roleId));
user.addRole(role);
userRepository.save(user);
}
@Transactional
public void removeRoleFromUser(Long userId, Long roleId) {
User user = userRepository.findByIdWithRoles(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
Role role = user.getRoles().stream()
.filter(r -> r.getId().equals(roleId))
.findFirst()
.orElseThrow(() -> new RoleNotFoundException(roleId));
user.removeRole(role);
userRepository.save(user);
}
}
```
## Example 4: Pagination with Dynamic Filtering
```java
public record ProductFilter(
String name,
Long categoryId,
BigDecimal minPrice,
BigDecimal maxPrice,
Boolean inStock
) {}
@Repository
public interface ProductRepository extends JpaRepository<Product, Long>, ProductCustomRepository {
}
public interface ProductCustomRepository {
Page<Product> findByFilter(ProductFilter filter, Pageable pageable);
}
@Repository
public class ProductCustomRepositoryImpl implements ProductCustomRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public Page<Product> findByFilter(ProductFilter filter, Pageable pageable) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Product> cq = cb.createQuery(Product.class);
Root<Product> root = cq.from(Product.class);
List<Predicate> predicates = new ArrayList<>();
if (filter.name() != null) {
predicates.add(cb.like(
cb.lower(root.get("name")),
"%" + filter.name().toLowerCase() + "%"
));
}
if (filter.categoryId() != null) {
predicates.add(cb.equal(root.get("category").get("id"), filter.categoryId()));
}
if (filter.minPrice() != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("price"), filter.minPrice()));
}
if (filter.maxPrice() != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("price"), filter.maxPrice()));
}
if (filter.inStock() != null && filter.inStock()) {
predicates.add(cb.greaterThan(root.get("stock"), 0));
}
cq.where(cb.and(predicates.toArray(new Predicate[0])));
cq.orderBy(cb.desc(root.get("createdAt")));
TypedQuery<Product> query = entityManager.createQuery(cq);
query.setFirstResult((int) pageable.getOffset());
query.setMaxResults(pageable.getPageSize());
List<Product> results = query.getResultList();
long total = getTotal(filter);
return new PageImpl<>(results, pageable, total);
}
private long getTotal(ProductFilter filter) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
Root<Product> root = cq.from(Product.class);
List<Predicate> predicates = new ArrayList<>();
// Add same predicates as above
cq.select(cb.count(root));
cq.where(cb.and(predicates.toArray(new Predicate[0])));
return entityManager.createQuery(cq).getSingleResult();
}
}
@Service
public class ProductSearchService {
private final ProductRepository productRepository;
@Transactional(readOnly = true)
public Page<Product> search(ProductFilter filter, int page, int size) {
Sort sort = Sort.by("createdAt").descending();
Pageable pageable = PageRequest.of(page, size, sort);
return productRepository.findByFilter(filter, pageable);
}
}
```
## Example 5: Batch Operations
```java
@Service
@Transactional
public class BatchOperationService {
private final ProductRepository productRepository;
private static final int BATCH_SIZE = 50;
public void importProducts(List<ProductDTO> products) {
for (int i = 0; i < products.size(); i++) {
Product product = mapToEntity(products.get(i));
productRepository.save(product);
if ((i + 1) % BATCH_SIZE == 0) {
productRepository.flush();
}
}
}
public void importProductsBatch(List<ProductDTO> products) {
List<Product> entities = products.stream()
.map(this::mapToEntity)
.collect(Collectors.toList());
productRepository.saveAll(entities);
productRepository.flush();
}
public long deleteOldProducts(LocalDateTime cutoffDate) {
return productRepository.deleteByCreatedAtBefore(cutoffDate);
}
public int bulkUpdatePrices(List<Long> productIds, BigDecimal newPrice) {
return productRepository.updatePriceForIds(productIds, newPrice);
}
private Product mapToEntity(ProductDTO dto) {
Product product = new Product();
product.setName(dto.name());
product.setPrice(dto.price());
return product;
}
}
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
long deleteByCreatedAtBefore(LocalDateTime date);
@Modifying
@Transactional
@Query("""
UPDATE Product p
SET p.price = :newPrice
WHERE p.id IN :ids
""")
int updatePriceForIds(
@Param("ids") List<Long> ids,
@Param("newPrice") BigDecimal newPrice
);
}
```
## Example 6: Entity Graph for Eager Loading
```java
@Entity
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
private User author;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
}
@Entity
public class Comment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String text;
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
@ManyToOne(fetch = FetchType.LAZY)
private User author;
}
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
// Using EntityGraph annotation
@EntityGraph(attributePaths = {"author", "comments"})
Optional<Post> findById(Long id);
@EntityGraph(attributePaths = {"author", "comments", "comments.author"})
List<Post> findAll();
// Using @Query with JOIN FETCH
@Query("""
SELECT DISTINCT p FROM Post p
JOIN FETCH p.author
JOIN FETCH p.comments c
JOIN FETCH c.author
WHERE p.id = :id
""")
Optional<Post> findByIdWithDetails(@Param("id") Long id);
}
@Service
@Transactional(readOnly = true)
public class PostService {
private final PostRepository postRepository;
public Post getPostWithComments(Long id) {
return postRepository.findById(id)
.orElseThrow(() -> new PostNotFoundException(id));
}
}
```
## Example 7: Transaction Propagation
```java
@Service
public class OrderProcessingService {
private final OrderRepository orderRepository;
private final PaymentService paymentService;
private final InventoryService inventoryService;
@Transactional
public void processOrder(Long orderId) throws PaymentException {
Order order = orderRepository.findById(orderId)
.orElseThrow();
try {
processPayment(order);
updateInventory(order);
order.setStatus("COMPLETED");
orderRepository.save(order);
} catch (PaymentException e) {
order.setStatus("PAYMENT_FAILED");
orderRepository.save(order);
throw e;
}
}
@Transactional(propagation = Propagation.REQUIRED)
private void processPayment(Order order) throws PaymentException {
paymentService.charge(order.getCustomer(), order.getTotalAmount());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
private void updateInventory(Order order) {
order.getItems().forEach(item -> {
inventoryService.decreaseStock(item.getProduct().getId(), item.getQuantity());
});
}
}
```
## Example 8: UUID Primary Keys
```java
@Entity
@Table(name = "articles", indexes = {
@Index(name = "idx_author_created", columnList = "author_id, created_date DESC"),
@Index(name = "idx_status", columnList = "status")
})
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
private String title;
private String content;
private String status; // DRAFT, PUBLISHED
private LocalDateTime createdDate;
private LocalDateTime publishedDate;
@ManyToOne
private User author;
}
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_email", columnList = "email", unique = true),
@Index(name = "idx_username", columnList = "username", unique = true)
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(unique = true, length = 100)
private String email;
@Column(unique = true, length = 100)
private String username;
private String firstName;
private String lastName;
}
@Repository
public interface ArticleRepository extends JpaRepository<Article, UUID> {
// Indexes support these queries efficiently
List<Article> findByStatusOrderByPublishedDateDesc(String status);
List<Article> findByAuthorOrderByCreatedDateDesc(User author);
Page<Article> findByStatusAndPublishedDateAfter(
String status,
LocalDateTime date,
Pageable pageable
);
}
@Repository
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
}
@Service
@Transactional
public class ArticleService {
private final ArticleRepository articleRepository;
private final UserRepository userRepository;
public Article createArticle(CreateArticleRequest request, UUID authorId) {
User author = userRepository.findById(authorId)
.orElseThrow(() -> new UserNotFoundException(authorId));
Article article = new Article();
article.setTitle(request.title());
article.setContent(request.content());
article.setStatus("DRAFT");
article.setCreatedDate(LocalDateTime.now());
article.setAuthor(author);
return articleRepository.save(article); // UUID generated automatically
}
@Transactional(readOnly = true)
public Page<Article> getPublishedArticles(int page, int size) {
Pageable pageable = PageRequest.of(page, size,
Sort.by("publishedDate").descending());
return articleRepository.findByStatusAndPublishedDateAfter(
"PUBLISHED",
LocalDateTime.now().minusDays(30),
pageable
);
}
@Transactional(readOnly = true)
public Article getArticle(UUID id) {
return articleRepository.findById(id)
.orElseThrow(() -> new ArticleNotFoundException(id));
}
}
record CreateArticleRequest(String title, String content) {}
```
## Example 9: Index Optimization
```java
@Entity
@Table(name = "orders", indexes = {
// Single column index
@Index(name = "idx_status", columnList = "status"),
// Composite index for common query pattern
@Index(name = "idx_customer_date", columnList = "customer_id, created_date DESC"),
// For date range queries
@Index(name = "idx_created_date", columnList = "created_date DESC"),
// Unique index
@Index(name = "idx_order_number", columnList = "order_number", unique = true),
// Multi-column ordering
@Index(name = "idx_status_amount", columnList = "status ASC, total_amount DESC")
})
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, length = 50)
private String orderNumber;
@Column(length = 20)
private String status; // PENDING, PROCESSING, SHIPPED, DELIVERED
private LocalDateTime createdDate;
@Column(precision = 12, scale = 2)
private BigDecimal totalAmount;
@ManyToOne
@JoinColumn(name = "customer_id")
private Customer customer;
}
@Entity
@Table(name = "order_items", indexes = {
// Index on foreign key for JOIN performance
@Index(name = "idx_order_id", columnList = "order_id"),
// Composite index for finding items by order and status
@Index(name = "idx_order_status", columnList = "order_id, status"),
// Index on product foreign key
@Index(name = "idx_product_id", columnList = "product_id")
})
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne
@JoinColumn(name = "product_id")
private Product product;
private Integer quantity;
private String status;
}
@Entity
@Table(name = "customers", indexes = {
@Index(name = "idx_email", columnList = "email", unique = true),
@Index(name = "idx_country_city", columnList = "country, city")
})
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
@Column(unique = true)
private String email;
private String country;
private String city;
}
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
// Uses idx_status
List<Order> findByStatus(String status);
// Uses idx_customer_date
List<Order> findByCustomerOrderByCreatedDateDesc(
Customer customer,
Pageable pageable
);
// Uses idx_created_date
Page<Order> findByCreatedDateAfterOrderByCreatedDateDesc(
LocalDateTime date,
Pageable pageable
);
// Uses idx_status_amount
List<Order> findByStatusOrderByTotalAmountDesc(String status);
// Custom query using indexes
@Query("""
SELECT o FROM Order o
WHERE o.status = :status
AND o.createdDate BETWEEN :start AND :end
ORDER BY o.totalAmount DESC
""")
List<Order> findHighValueOrdersInPeriod(
@Param("status") String status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);
}
@Repository
public interface CustomerRepository extends JpaRepository<Customer, Long> {
// Uses idx_email
Optional<Customer> findByEmail(String email);
// Uses idx_country_city
List<Customer> findByCountryAndCity(String country, String city);
}
@Service
@Transactional(readOnly = true)
public class OrderAnalyticsService {
private final OrderRepository orderRepository;
public Page<Order> getRecentOrders(int page, int size) {
LocalDateTime weekAgo = LocalDateTime.now().minusDays(7);
Pageable pageable = PageRequest.of(page, size);
return orderRepository.findByCreatedDateAfterOrderByCreatedDateDesc(
weekAgo,
pageable
); // Uses idx_created_date
}
public List<Order> getPendingOrders() {
return orderRepository.findByStatus("PENDING"); // Uses idx_status
}
public List<Order> getHighValueOrders(LocalDateTime from, LocalDateTime to) {
return orderRepository.findHighValueOrdersInPeriod(
"COMPLETED",
from,
to
); // Uses idx_status_amount
}
@Transactional
public void optimizeIndexUsage() {
// These queries benefit from composite indexes
List<Order> customerOrders = orderRepository.findByCustomerOrderByCreatedDateDesc(
new Customer(),
PageRequest.of(0, 50)
); // Uses idx_customer_date
}
}
```
These examples demonstrate real-world usage patterns for Spring Data JPA, from simple CRUD operations to complex scenarios involving relationships, auditing, pagination, batch processing, UUID keys, and index optimization.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,307 @@
---
name: spring-data-neo4j
description: Expert in Spring Data Neo4j integration patterns for graph database development. Use when working with Neo4j graph databases, node entities, relationships, Cypher queries, reactive Neo4j operations, or Spring Data Neo4j repositories. Essential for graph data modeling, relationship mapping, custom queries, and Neo4j testing strategies.
version: 1.1.0
allowed-tools: Read, Write, Bash, Grep, Glob
category: backend
tags: [spring-data, neo4j, graph-database, database, java, spring-boot]
---
# Spring Data Neo4j Integration Patterns
## When to Use This Skill
To use this skill when you need to:
- Set up Spring Data Neo4j in a Spring Boot application
- Create and map graph node entities and relationships
- Implement Neo4j repositories with custom queries
- Write Cypher queries using @Query annotations
- Configure Neo4j connections and dialects
- Test Neo4j repositories with embedded databases
- Work with both imperative and reactive Neo4j operations
- Map complex graph relationships with bidirectional or unidirectional directions
- Use Neo4j's internal ID generation or custom business keys
## Overview
Spring Data Neo4j provides three levels of abstraction for Neo4j integration:
- **Neo4j Client**: Low-level abstraction for direct database access
- **Neo4j Template**: Medium-level template-based operations
- **Neo4j Repositories**: High-level repository pattern with query derivation
Key features include reactive and imperative operation modes, immutable entity mapping, custom query support via @Query annotation, Spring's Conversion Service integration, and full support for graph relationships and traversals.
## Quick Setup
### Dependencies
**Maven:**
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-neo4j</artifactId>
</dependency>
```
**Gradle:**
```groovy
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-neo4j'
}
```
### Configuration
**application.properties:**
```properties
spring.neo4j.uri=bolt://localhost:7687
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=secret
```
**Configure Neo4j Cypher-DSL Dialect:**
```java
@Configuration
public class Neo4jConfig {
@Bean
Configuration cypherDslConfiguration() {
return Configuration.newConfig()
.withDialect(Dialect.NEO4J_5).build();
}
}
```
## Basic Entity Mapping
### Node Entity with Business Key
```java
@Node("Movie")
public class MovieEntity {
@Id
private final String title; // Business key as ID
@Property("tagline")
private final String description;
private final Integer year;
@Relationship(type = "ACTED_IN", direction = Direction.INCOMING)
private List<Roles> actorsAndRoles = new ArrayList<>();
@Relationship(type = "DIRECTED", direction = Direction.INCOMING)
private List<PersonEntity> directors = new ArrayList<>();
public MovieEntity(String title, String description, Integer year) {
this.title = title;
this.description = description;
this.year = year;
}
}
```
### Node Entity with Generated ID
```java
@Node("Movie")
public class MovieEntity {
@Id @GeneratedValue
private Long id;
private final String title;
@Property("tagline")
private final String description;
public MovieEntity(String title, String description) {
this.id = null; // Never set manually
this.title = title;
this.description = description;
}
// Wither method for immutability with generated IDs
public MovieEntity withId(Long id) {
if (this.id != null && this.id.equals(id)) {
return this;
} else {
MovieEntity newObject = new MovieEntity(this.title, this.description);
newObject.id = id;
return newObject;
}
}
}
```
## Repository Patterns
### Basic Repository Interface
```java
@Repository
public interface MovieRepository extends Neo4jRepository<MovieEntity, String> {
// Query derivation from method name
MovieEntity findOneByTitle(String title);
List<MovieEntity> findAllByYear(Integer year);
List<MovieEntity> findByYearBetween(Integer startYear, Integer endYear);
}
```
### Reactive Repository
```java
@Repository
public interface MovieRepository extends ReactiveNeo4jRepository<MovieEntity, String> {
Mono<MovieEntity> findOneByTitle(String title);
Flux<MovieEntity> findAllByYear(Integer year);
}
```
**Imperative vs Reactive:**
- Use `Neo4jRepository` for blocking, imperative operations
- Use `ReactiveNeo4jRepository` for non-blocking, reactive operations
- **Do not mix imperative and reactive in the same application**
- Reactive requires Neo4j 4+ on the database side
## Custom Queries with @Query
```java
@Repository
public interface AuthorRepository extends Neo4jRepository<Author, Long> {
@Query("MATCH (b:Book)-[:WRITTEN_BY]->(a:Author) " +
"WHERE a.name = $name AND b.year > $year " +
"RETURN b")
List<Book> findBooksAfterYear(@Param("name") String name,
@Param("year") Integer year);
@Query("MATCH (b:Book)-[:WRITTEN_BY]->(a:Author) " +
"WHERE a.name = $name " +
"RETURN b ORDER BY b.year DESC")
List<Book> findBooksByAuthorOrderByYearDesc(@Param("name") String name);
}
```
**Custom Query Best Practices:**
- Use `$parameterName` for parameter placeholders
- Use `@Param` annotation when parameter name differs from method parameter
- MATCH specifies node patterns and relationships
- WHERE filters results
- RETURN defines what to return
## Testing Strategies
### Neo4j Harness for Integration Testing
**Test Configuration:**
```java
@DataNeo4jTest
class BookRepositoryIntegrationTest {
private static Neo4j embeddedServer;
@BeforeAll
static void initializeNeo4j() {
embeddedServer = Neo4jBuilders.newInProcessBuilder()
.withDisabledServer() // No HTTP access needed
.withFixture(
"CREATE (b:Book {isbn: '978-0547928210', " +
"name: 'The Fellowship of the Ring', year: 1954})" +
"-[:WRITTEN_BY]->(a:Author {id: 1, name: 'J. R. R. Tolkien'}) " +
"CREATE (b2:Book {isbn: '978-0547928203', " +
"name: 'The Two Towers', year: 1956})" +
"-[:WRITTEN_BY]->(a)"
)
.build();
}
@AfterAll
static void stopNeo4j() {
embeddedServer.close();
}
@DynamicPropertySource
static void neo4jProperties(DynamicPropertyRegistry registry) {
registry.add("spring.neo4j.uri", embeddedServer::boltURI);
registry.add("spring.neo4j.authentication.username", () -> "neo4j");
registry.add("spring.neo4j.authentication.password", () -> "null");
}
@Autowired
private BookRepository bookRepository;
@Test
void givenBookExists_whenFindOneByTitle_thenBookIsReturned() {
Book book = bookRepository.findOneByTitle("The Fellowship of the Ring");
assertThat(book.getIsbn()).isEqualTo("978-0547928210");
}
}
```
## Examples
Progress from basic to advanced examples covering complete movie database, social network patterns, e-commerce product catalogs, custom queries, and reactive operations.
See [examples](./references/examples.md) for comprehensive code examples.
## Best Practices
### Entity Design
- Use immutable entities with final fields
- Choose between business keys (@Id) or generated IDs (@Id @GeneratedValue)
- Keep entities focused on graph structure, not business logic
- Use proper relationship directions (INCOMING, OUTGOING, UNDIRECTED)
### Repository Design
- Extend `Neo4jRepository` for imperative or `ReactiveNeo4jRepository` for reactive
- Use query derivation for simple queries
- Write custom @Query for complex graph patterns
- Don't mix imperative and reactive in same application
### Configuration
- Always configure Cypher-DSL dialect explicitly
- Use environment-specific properties for credentials
- Never hardcode credentials in source code
- Configure connection pooling based on load
### Testing
- Use Neo4j Harness for integration tests
- Provide test data via `withFixture()` Cypher queries
- Use `@DataNeo4jTest` for test slicing
- Test both successful and edge-case scenarios
### Architecture
- Use constructor injection exclusively
- Separate domain entities from DTOs
- Follow feature-based package structure
- Keep domain layer framework-agnostic
### Security
- Use Spring Boot property overrides for credentials
- Configure proper authentication and authorization
- Validate input parameters in service layer
- Use parameterized queries to prevent Cypher injection
## References
For detailed documentation including complete API reference, Cypher query patterns, and configuration options:
- [Annotations Reference](./references/reference.md#annotations-reference)
- [Cypher Query Language](./references/reference.md#cypher-query-language)
- [Configuration Properties](./references/reference.md#configuration-properties)
- [Repository Methods](./references/reference.md#repository-methods)
- [Projections and DTOs](./references/reference.md#projections-and-dtos)
- [Transaction Management](./references/reference.md#transaction-management)
- [Performance Tuning](./references/reference.md#performance-tuning)
### External Resources
- [Spring Data Neo4j Official Documentation](https://docs.spring.io/spring-data/neo4j/reference/)
- [Neo4j Developer Guide](https://neo4j.com/developer/)
- [Spring Data Commons Documentation](https://docs.spring.io/spring-data/commons/reference/)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,860 @@
# Spring Data Neo4j - Reference Guide
This document provides detailed reference information for Spring Data Neo4j, including annotations, query language syntax, configuration options, and API documentation.
## Table of Contents
1. [Annotations Reference](#annotations-reference)
2. [Cypher Query Language](#cypher-query-language)
3. [Configuration Properties](#configuration-properties)
4. [Repository Methods](#repository-methods)
5. [Projections and DTOs](#projections-and-dtos)
6. [Transaction Management](#transaction-management)
7. [Performance Tuning](#performance-tuning)
## Annotations Reference
### Entity Annotations
#### @Node
Marks a class as a Neo4j node entity.
```java
@Node // Label defaults to class name
@Node("CustomLabel") // Explicit label
@Node({"Label1", "Label2"}) // Multiple labels
public class MyEntity {
// ...
}
```
**Properties:**
- `value` or `labels`: String or String array for node labels
- `primaryLabel`: Specify which label is primary (when using multiple labels)
#### @Id
Marks a field as the entity identifier.
```java
@Id
private String businessKey; // Custom business key
@Id @GeneratedValue
private Long id; // Auto-generated internal ID
```
**Important:**
- Required on every @Node entity
- Can be used with business keys or generated values
- Must be unique within the node type
#### @GeneratedValue
Configures ID generation strategy.
```java
@Id @GeneratedValue
private Long id; // Uses Neo4j internal ID
@Id @GeneratedValue(generatorClass = UUIDStringGenerator.class)
private String uuid; // Custom UUID generator
@Id @GeneratedValue(generatorClass = MyCustomGenerator.class)
private String customId;
```
**Built-in Generators:**
- `InternalIdGenerator` (default for Long): Uses Neo4j's internal ID
- `UUIDStringGenerator`: Generates UUID strings
#### @Property
Maps a field to a different property name in Neo4j.
```java
@Property("graph_property_name")
private String javaFieldName;
```
**When to use:**
- Field name differs from graph property name
- Property names contain special characters
- Following different naming conventions
#### @Relationship
Defines relationships between nodes.
```java
@Relationship(type = "RELATIONSHIP_TYPE", direction = Direction.OUTGOING)
private RelatedEntity related;
@Relationship(type = "RELATED_TO", direction = Direction.INCOMING)
private List<RelatedEntity> incoming;
@Relationship(type = "CONNECTED", direction = Direction.UNDIRECTED)
private Set<RelatedEntity> connections;
```
**Properties:**
- `type` (required): Relationship type in Neo4j
- `direction`: OUTGOING, INCOMING, or UNDIRECTED
- Default direction is OUTGOING if not specified
**Direction Guidelines:**
- `OUTGOING`: This node → target node
- `INCOMING`: Target node → this node
- `UNDIRECTED`: Ignores direction when querying
#### @RelationshipProperties
Marks a class as relationship properties container.
```java
@RelationshipProperties
public class ActedIn {
@Id @GeneratedValue
private Long id;
@TargetNode
private Movie movie;
private List<String> roles;
private Integer screenTime;
}
```
**Required Fields:**
- `@Id` field (can be generated)
- `@TargetNode` field pointing to target entity
### Repository Annotations
#### @Query
Defines custom Cypher query for a repository method.
```java
@Query("MATCH (n:Node) WHERE n.property = $param RETURN n")
List<Node> customQuery(@Param("param") String param);
@Query("MATCH (n:Node) WHERE n.id = $0 RETURN n")
Node findById(String id); // Positional parameter
```
**Parameter Binding:**
- Use `$paramName` for named parameters with `@Param`
- Use `$0`, `$1`, etc. for positional parameters
- SpEL expressions supported: `#{#entityName}`
#### @Param
Binds method parameter to query parameter.
```java
@Query("MATCH (n) WHERE n.name = $customName RETURN n")
List<Node> find(@Param("customName") String name);
```
**When required:**
- Parameter name in query differs from method parameter
- Making intent explicit and clear
### Configuration Annotations
#### @EnableNeo4jRepositories
Enables Neo4j repository support.
```java
@Configuration
@EnableNeo4jRepositories(basePackages = "com.example.repositories")
public class Neo4jConfiguration {
// ...
}
```
**Properties:**
- `basePackages`: Packages to scan for repositories
- `basePackageClasses`: Type-safe package specification
- `repositoryImplementationPostfix`: Custom implementation suffix (default: "Impl")
**Note:** Auto-enabled by Spring Boot starter, manual configuration rarely needed.
#### @DataNeo4jTest
Test slice annotation for Neo4j tests.
```java
@DataNeo4jTest
class MyRepositoryTest {
@Autowired
private MyRepository repository;
}
```
**What it does:**
- Configures test slice for Spring Data Neo4j
- Loads only Neo4j-related beans
- Configures embedded test database when available
- Enables transaction rollback for tests
## Cypher Query Language
### Basic Patterns
#### MATCH - Find Patterns
```cypher
// Find all nodes with label
MATCH (n:Label) RETURN n
// Find node with property
MATCH (n:Label {property: 'value'}) RETURN n
// Find nodes with WHERE clause
MATCH (n:Label) WHERE n.property > 100 RETURN n
// Multiple labels
MATCH (n:Label1:Label2) RETURN n
```
#### Relationship Patterns
```cypher
// Outgoing relationship
MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a, b
// Incoming relationship
MATCH (a:Person)<-[:KNOWS]-(b:Person) RETURN a, b
// Undirected relationship
MATCH (a:Person)-[:KNOWS]-(b:Person) RETURN a, b
// Relationship with properties
MATCH (a)-[r:KNOWS {since: 2020}]->(b) RETURN a, r, b
// Variable length relationships
MATCH (a)-[:KNOWS*1..3]->(b) RETURN a, b
```
#### CREATE - Create Patterns
```cypher
// Create single node
CREATE (n:Person {name: 'John', age: 30})
// Create node and relationship
CREATE (a:Person {name: 'Alice'})-[:KNOWS]->(b:Person {name: 'Bob'})
// Create relationship between existing nodes
MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
CREATE (a)-[:KNOWS {since: 2020}]->(b)
```
#### MERGE - Find or Create
```cypher
// Find or create node
MERGE (n:Person {email: 'john@example.com'})
ON CREATE SET n.created = timestamp()
ON MATCH SET n.accessed = timestamp()
// Find or create relationship
MATCH (a:Person {name: 'Alice'}), (b:Person {name: 'Bob'})
MERGE (a)-[r:KNOWS]->(b)
ON CREATE SET r.since = 2020
```
#### SET - Update Properties
```cypher
// Set single property
MATCH (n:Person {name: 'John'})
SET n.age = 31
// Set multiple properties
MATCH (n:Person {name: 'John'})
SET n.age = 31, n.city = 'London'
// Set from map
MATCH (n:Person {name: 'John'})
SET n += {age: 31, city: 'London'}
// Add label
MATCH (n:Person {name: 'John'})
SET n:Premium
```
#### DELETE and REMOVE
```cypher
// Delete node (must have no relationships)
MATCH (n:Person {name: 'John'})
DELETE n
// Delete node and relationships
MATCH (n:Person {name: 'John'})
DETACH DELETE n
// Delete relationship
MATCH (a)-[r:KNOWS]->(b)
WHERE a.name = 'Alice' AND b.name = 'Bob'
DELETE r
// Remove property
MATCH (n:Person {name: 'John'})
REMOVE n.age
// Remove label
MATCH (n:Person {name: 'John'})
REMOVE n:Premium
```
### Advanced Patterns
#### Collections and List Functions
```cypher
// Collect results
MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
RETURN p.name, collect(m.title) AS movies
// Unwind collection
UNWIND [1, 2, 3] AS number
RETURN number
// List comprehension
MATCH (p:Person)
RETURN [x IN p.skills WHERE x STARTS WITH 'Java'] AS javaSkills
// Size of collection
MATCH (p:Person)
RETURN p.name, size(p.skills) AS skillCount
```
#### Aggregation Functions
```cypher
// Count
MATCH (p:Person) RETURN count(p)
// Sum
MATCH (p:Product) RETURN sum(p.price)
// Average
MATCH (p:Product) RETURN avg(p.price)
// Min/Max
MATCH (p:Product) RETURN min(p.price), max(p.price)
// Group by with aggregation
MATCH (p:Person)-[:LIVES_IN]->(c:City)
RETURN c.name, count(p) AS population
ORDER BY population DESC
```
#### Conditional Logic
```cypher
// CASE expression
MATCH (p:Person)
RETURN p.name,
CASE
WHEN p.age < 18 THEN 'Minor'
WHEN p.age < 65 THEN 'Adult'
ELSE 'Senior'
END AS category
// COALESCE - first non-null value
MATCH (p:Person)
RETURN coalesce(p.nickname, p.name) AS displayName
```
#### Pattern Comprehension
```cypher
// Pattern comprehension
MATCH (p:Person)
RETURN p.name,
[(p)-[:KNOWS]->(friend) | friend.name] AS friends
// With filtering
MATCH (p:Person)
RETURN p.name,
[(p)-[:KNOWS]->(friend) WHERE friend.age > 30 | friend.name] AS olderFriends
```
### Query Optimization
#### Using Indexes
```cypher
// Create index (admin query, not in @Query)
CREATE INDEX person_name FOR (n:Person) ON (n.name)
// Composite index
CREATE INDEX person_name_age FOR (n:Person) ON (n.name, n.age)
// Use index hint
MATCH (p:Person)
USING INDEX p:Person(name)
WHERE p.name = 'John'
RETURN p
```
#### PROFILE and EXPLAIN
```cypher
// Analyze query performance
PROFILE
MATCH (p:Person)-[:KNOWS*1..3]->(friend)
WHERE p.name = 'Alice'
RETURN friend.name
// Dry run without execution
EXPLAIN
MATCH (p:Person)-[:KNOWS]->(friend)
RETURN p, friend
```
#### Limiting Results
```cypher
// Limit results
MATCH (n:Person) RETURN n LIMIT 10
// Skip and limit (pagination)
MATCH (n:Person)
RETURN n
ORDER BY n.name
SKIP 20 LIMIT 10
```
## Configuration Properties
### Connection Properties
```properties
# Neo4j URI
spring.neo4j.uri=bolt://localhost:7687
spring.neo4j.uri=neo4j://localhost:7687
spring.neo4j.uri=neo4j+s://production.server:7687
# Authentication
spring.neo4j.authentication.username=neo4j
spring.neo4j.authentication.password=secret
spring.neo4j.authentication.realm=native
spring.neo4j.authentication.kerberos-ticket=...
# Connection pool
spring.neo4j.pool.max-connection-pool-size=50
spring.neo4j.pool.idle-time-before-connection-test=PT30S
spring.neo4j.pool.max-connection-lifetime=PT1H
spring.neo4j.pool.connection-acquisition-timeout=PT60S
spring.neo4j.pool.metrics-enabled=true
```
### Advanced Configuration
```properties
# Logging
spring.neo4j.logging.level=WARN
spring.neo4j.logging.log-leaked-sessions=true
# Connection timeout
spring.neo4j.connection-timeout=PT30S
# Max transaction retry time
spring.neo4j.max-transaction-retry-time=PT30S
# Encrypted connection
spring.neo4j.security.encrypted=true
spring.neo4j.security.trust-strategy=TRUST_ALL_CERTIFICATES
spring.neo4j.security.hostname-verification-enabled=true
```
### Neo4j Driver Configuration Bean
```java
@Configuration
public class Neo4jConfiguration {
@Bean
org.neo4j.driver.Config neo4jDriverConfig() {
return org.neo4j.driver.Config.builder()
.withMaxConnectionPoolSize(50)
.withConnectionAcquisitionTimeout(60, TimeUnit.SECONDS)
.withConnectionLivenessCheckTimeout(30, TimeUnit.SECONDS)
.withMaxConnectionLifetime(1, TimeUnit.HOURS)
.withLogging(Logging.slf4j())
.withEncryption()
.build();
}
@Bean
Configuration cypherDslConfiguration() {
return Configuration.newConfig()
.withDialect(Dialect.NEO4J_5)
.build();
}
}
```
## Repository Methods
### Query Derivation Keywords
| Keyword | Cypher Equivalent |
|---------|------------------|
| `findBy` | `MATCH ... RETURN` |
| `existsBy` | `MATCH ... RETURN count(*) > 0` |
| `countBy` | `MATCH ... RETURN count(*)` |
| `deleteBy` | `MATCH ... DETACH DELETE` |
| `And` | `AND` |
| `Or` | `OR` |
| `Between` | `>= $lower AND <= $upper` |
| `LessThan` | `<` |
| `LessThanEqual` | `<=` |
| `GreaterThan` | `>` |
| `GreaterThanEqual` | `>=` |
| `Before` | `<` (for dates) |
| `After` | `>` (for dates) |
| `IsNull` | `IS NULL` |
| `IsNotNull` | `IS NOT NULL` |
| `Like` | `=~ '.*pattern.*'` |
| `NotLike` | `NOT =~ '.*pattern.*'` |
| `StartingWith` | `STARTS WITH` |
| `EndingWith` | `ENDS WITH` |
| `Containing` | `CONTAINS` |
| `In` | `IN` |
| `NotIn` | `NOT IN` |
| `True` | `= true` |
| `False` | `= false` |
| `OrderBy...Asc` | `ORDER BY ... ASC` |
| `OrderBy...Desc` | `ORDER BY ... DESC` |
### Method Return Types
| Return Type | Description |
|------------|-------------|
| `Entity` | Single result or null |
| `Optional<Entity>` | Single result wrapped in Optional |
| `List<Entity>` | Multiple results |
| `Stream<Entity>` | Results as Java Stream |
| `Page<Entity>` | Paginated results |
| `Slice<Entity>` | Slice of results |
| `Mono<Entity>` | Reactive single result |
| `Flux<Entity>` | Reactive stream of results |
| `boolean` | Existence check |
| `long` | Count query |
### Examples
```java
public interface UserRepository extends Neo4jRepository<User, String> {
// Simple query derivation
Optional<User> findByEmail(String email);
List<User> findByAgeGreaterThan(Integer age);
List<User> findByAgeBetween(Integer minAge, Integer maxAge);
List<User> findByNameStartingWith(String prefix);
// Boolean queries
boolean existsByEmail(String email);
// Count queries
long countByAgeGreaterThan(Integer age);
// Delete queries
long deleteByAgeLessThan(Integer age);
// Sorting
List<User> findByAgeGreaterThanOrderByNameAsc(Integer age);
// Pagination
Page<User> findByAgeGreaterThan(Integer age, Pageable pageable);
// Stream
Stream<User> findByAgeBetween(Integer min, Integer max);
// Multiple conditions
List<User> findByNameAndAge(String name, Integer age);
List<User> findByNameOrEmail(String name, String email);
// Null checks
List<User> findByNicknameIsNull();
List<User> findByNicknameIsNotNull();
// Collection queries
List<User> findByRolesContaining(String role);
List<User> findByIdIn(Collection<String> ids);
}
```
## Projections and DTOs
### Interface-based Projections
```java
// Closed projection - only declared properties
public interface UserSummary {
String getUsername();
String getEmail();
}
// Open projection - with SpEL
public interface UserWithFullName {
@Value("#{target.firstName + ' ' + target.lastName}")
String getFullName();
}
// Nested projection
public interface UserWithPosts {
String getUsername();
List<PostSummary> getPosts();
interface PostSummary {
String getTitle();
LocalDateTime getCreatedAt();
}
}
// Usage
public interface UserRepository extends Neo4jRepository<User, String> {
List<UserSummary> findAllBy();
Optional<UserWithFullName> findByUsername(String username);
}
```
### Class-based DTOs
```java
public record UserDTO(
String username,
String email,
LocalDateTime joinedAt
) {}
// Repository usage
public interface UserRepository extends Neo4jRepository<User, String> {
List<UserDTO> findAllBy();
}
```
### Dynamic Projections
```java
public interface UserRepository extends Neo4jRepository<User, String> {
<T> T findByUsername(String username, Class<T> type);
}
// Usage
UserSummary summary = repository.findByUsername("john", UserSummary.class);
UserDTO dto = repository.findByUsername("john", UserDTO.class);
User full = repository.findByUsername("john", User.class);
```
## Transaction Management
### Declarative Transactions
```java
@Service
public class UserService {
private final UserRepository userRepository;
@Transactional
public User createUser(CreateUserRequest request) {
User user = new User(request.username(), request.email());
return userRepository.save(user);
}
@Transactional(readOnly = true)
public Optional<User> getUser(String username) {
return userRepository.findByUsername(username);
}
@Transactional(
propagation = Propagation.REQUIRES_NEW,
isolation = Isolation.READ_COMMITTED,
timeout = 30
)
public void complexOperation() {
// Multiple repository calls in single transaction
// ...
}
}
```
### Programmatic Transactions
```java
@Service
public class TransactionalService {
private final Neo4jTransactionManager transactionManager;
public void executeInTransaction() {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.execute(status -> {
try {
// Your transactional code here
return someResult;
} catch (Exception e) {
status.setRollbackOnly();
throw e;
}
});
}
}
```
### Reactive Transactions
```java
@Service
public class ReactiveUserService {
private final ReactiveUserRepository repository;
private final ReactiveNeo4jTransactionManager transactionManager;
public Mono<User> createUser(CreateUserRequest request) {
return transactionManager.getReactiveTransaction()
.flatMap(status -> {
User user = new User(request.username(), request.email());
return repository.save(user)
.doOnError(e -> status.setRollbackOnly());
});
}
}
```
## Performance Tuning
### Index Creation
Create indexes on frequently queried properties:
```cypher
// Single property index
CREATE INDEX user_email FOR (u:User) ON (u.email);
// Composite index
CREATE INDEX user_name_age FOR (u:User) ON (u.name, u.age);
// Full-text index
CREATE FULLTEXT INDEX user_search FOR (u:User) ON EACH [u.name, u.bio];
// Show indexes
SHOW INDEXES;
// Drop index
DROP INDEX user_email;
```
### Query Optimization Tips
1. **Use specific labels:**
```cypher
// Good
MATCH (u:User {email: $email}) RETURN u
// Bad
MATCH (n {email: $email}) RETURN n
```
2. **Filter early:**
```cypher
// Good
MATCH (u:User)
WHERE u.age > 18
MATCH (u)-[:POSTED]->(p:Post)
RETURN p
// Bad
MATCH (u:User)-[:POSTED]->(p:Post)
WHERE u.age > 18
RETURN p
```
3. **Use projections to fetch only needed data:**
```java
// Good
List<UserSummary> findAllBy();
// Bad (when you only need summary)
List<User> findAll();
```
4. **Limit result sets:**
```java
// Use pagination
Page<User> findAll(Pageable pageable);
// Or explicit limits
@Query("MATCH (u:User) RETURN u LIMIT $limit")
List<User> findTopUsers(@Param("limit") int limit);
```
### Connection Pooling
```java
@Bean
org.neo4j.driver.Config driverConfig() {
return org.neo4j.driver.Config.builder()
.withMaxConnectionPoolSize(50)
.withConnectionAcquisitionTimeout(60, TimeUnit.SECONDS)
.withIdleTimeBeforeConnectionTest(30, TimeUnit.SECONDS)
.build();
}
```
### Batch Operations
```java
// Save in batches
@Service
public class BatchService {
private final UserRepository repository;
public void saveUsersInBatches(List<User> users) {
int batchSize = 1000;
for (int i = 0; i < users.size(); i += batchSize) {
int end = Math.min(i + batchSize, users.size());
List<User> batch = users.subList(i, end);
repository.saveAll(batch);
}
}
}
```
### Monitoring and Metrics
```properties
# Enable driver metrics
spring.neo4j.pool.metrics-enabled=true
# Log slow queries (if using Neo4j Enterprise)
# Set in neo4j.conf:
# dbms.logs.query.enabled=true
# dbms.logs.query.threshold=1s
```
## Additional Resources
- [Neo4j Cypher Manual](https://neo4j.com/docs/cypher-manual/current/)
- [Spring Data Neo4j Reference](https://docs.spring.io/spring-data/neo4j/reference/)
- [Neo4j Java Driver Documentation](https://neo4j.com/docs/java-manual/current/)
- [Graph Data Modeling Guide](https://neo4j.com/developer/data-modeling/)