Initial commit
This commit is contained in:
190
skills/spring-boot/spring-boot-actuator/SKILL.md
Normal file
190
skills/spring-boot/spring-boot-actuator/SKILL.md
Normal 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 250 ms 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.
|
||||
|
||||
519
skills/spring-boot/spring-boot-actuator/references/auditing.md
Normal file
519
skills/spring-boot/spring-boot-actuator/references/auditing.md
Normal 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
390
skills/spring-boot/spring-boot-actuator/references/endpoints.md
Normal file
390
skills/spring-boot/spring-boot-actuator/references/endpoints.md
Normal 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.
|
||||
1000
skills/spring-boot/spring-boot-actuator/references/examples.md
Normal file
1000
skills/spring-boot/spring-boot-actuator/references/examples.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
```
|
||||
582
skills/spring-boot/spring-boot-actuator/references/jmx.md
Normal file
582
skills/spring-boot/spring-boot-actuator/references/jmx.md
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
385
skills/spring-boot/spring-boot-actuator/references/loggers.md
Normal file
385
skills/spring-boot/spring-boot-actuator/references/loggers.md
Normal 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
578
skills/spring-boot/spring-boot-actuator/references/metrics.md
Normal file
578
skills/spring-boot/spring-boot-actuator/references/metrics.md
Normal 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
|
||||
318
skills/spring-boot/spring-boot-actuator/references/monitoring.md
Normal file
318
skills/spring-boot/spring-boot-actuator/references/monitoring.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
557
skills/spring-boot/spring-boot-actuator/references/tracing.md
Normal file
557
skills/spring-boot/spring-boot-actuator/references/tracing.md
Normal 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
|
||||
191
skills/spring-boot/spring-boot-cache/SKILL.md
Normal file
191
skills/spring-boot/spring-boot-cache/SKILL.md
Normal 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)
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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 Boot’s Jakarta EE 9 baseline, Infinispan’s
|
||||
`-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
|
||||
Guava’s 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"
|
||||
@@ -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.
|
||||
108
skills/spring-boot/spring-boot-crud-patterns/SKILL.md
Normal file
108
skills/spring-boot/spring-boot-crud-patterns/SKILL.md
Normal 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`
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
# Spring Docs Pointers
|
||||
|
||||
- Spring Boot Reference Guide
|
||||
- Spring Data JPA Reference
|
||||
- Validation (Jakarta Validation)
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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" } }
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package $package.presentation.dto;
|
||||
|
||||
$extra_imports
|
||||
|
||||
public record $EntityRequest($dto_request_components) { }
|
||||
@@ -0,0 +1,5 @@
|
||||
package $package.presentation.dto;
|
||||
|
||||
$extra_imports
|
||||
|
||||
public record $EntityResponse($dto_response_components) { }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package $package.presentation.dto;
|
||||
|
||||
public record ErrorResponse(
|
||||
int status,
|
||||
String error,
|
||||
String message,
|
||||
String path
|
||||
) { }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) { }
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 team’s 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
|
||||
@@ -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> { }
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
150
skills/spring-boot/spring-boot-dependency-injection/SKILL.md
Normal file
150
skills/spring-boot/spring-boot-dependency-injection/SKILL.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
520
skills/spring-boot/spring-boot-event-driven-patterns/SKILL.md
Normal file
520
skills/spring-boot/spring-boot-event-driven-patterns/SKILL.md
Normal 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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
624
skills/spring-boot/spring-boot-openapi-documentation/SKILL.md
Normal file
624
skills/spring-boot/spring-boot-openapi-documentation/SKILL.md
Normal 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/)
|
||||
@@ -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/)
|
||||
@@ -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
|
||||
```
|
||||
391
skills/spring-boot/spring-boot-resilience4j/SKILL.md
Normal file
391
skills/spring-boot/spring-boot-resilience4j/SKILL.md
Normal 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
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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
|
||||
315
skills/spring-boot/spring-boot-rest-api-standards/SKILL.md
Normal file
315
skills/spring-boot/spring-boot-rest-api-standards/SKILL.md
Normal 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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
```
|
||||
200
skills/spring-boot/spring-boot-saga-pattern/SKILL.md
Normal file
200
skills/spring-boot/spring-boot-saga-pattern/SKILL.md
Normal 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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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);
|
||||
```
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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);
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
1537
skills/spring-boot/spring-boot-saga-pattern/references/examples.md
Normal file
1537
skills/spring-boot/spring-boot-saga-pattern/references/examples.md
Normal file
File diff suppressed because it is too large
Load Diff
1264
skills/spring-boot/spring-boot-saga-pattern/references/reference.md
Normal file
1264
skills/spring-boot/spring-boot-saga-pattern/references/reference.md
Normal file
File diff suppressed because it is too large
Load Diff
626
skills/spring-boot/spring-boot-test-patterns/SKILL.md
Normal file
626
skills/spring-boot/spring-boot-test-patterns/SKILL.md
Normal 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.
|
||||
@@ -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 |
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
184
skills/spring-boot/spring-data-jpa/SKILL.md
Normal file
184
skills/spring-boot/spring-data-jpa/SKILL.md
Normal 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
|
||||
946
skills/spring-boot/spring-data-jpa/references/examples.md
Normal file
946
skills/spring-boot/spring-data-jpa/references/examples.md
Normal 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.
|
||||
3200
skills/spring-boot/spring-data-jpa/references/reference.md
Normal file
3200
skills/spring-boot/spring-data-jpa/references/reference.md
Normal file
File diff suppressed because it is too large
Load Diff
307
skills/spring-boot/spring-data-neo4j/SKILL.md
Normal file
307
skills/spring-boot/spring-data-neo4j/SKILL.md
Normal 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/)
|
||||
1005
skills/spring-boot/spring-data-neo4j/references/examples.md
Normal file
1005
skills/spring-boot/spring-data-neo4j/references/examples.md
Normal file
File diff suppressed because it is too large
Load Diff
860
skills/spring-boot/spring-data-neo4j/references/reference.md
Normal file
860
skills/spring-boot/spring-data-neo4j/references/reference.md
Normal 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/)
|
||||
Reference in New Issue
Block a user