references/ai.md
# AI - Spring AI 2.x
Spring AI 2.0.x supports Spring Boot 4.0.x and 4.1.x. Use it only when the app calls an LLM, an embedding model or a vector store. Check the version-specific details below against the [Spring AI reference](https://docs.spring.io/spring-ai/reference/) before relying on them: the API moves fast and 1.x snippets from blogs often no longer compile.
## Dependencies
Import the BOM, then add starters without versions. Releases come from Maven Central.
```xml
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.2</version> <!-- use the latest 2.0.x release -->
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId> <!-- spring-ai-starter-model-<provider> -->
</dependency>
</dependencies>
```
Starters are named `spring-ai-starter-model-<provider>`. Plain `spring-ai-<provider>` artifacts are modules without autoconfiguration. Take a milestone or RC (for example 2.1.0-RC2) only when asked, and then add the Spring snapshot/milestone repositories.
## Configuration
Secrets come from the environment, never from the repo (see `conventions`):
```yaml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
```
Model, temperature and other options differ per provider: take the property names from the provider page. In 2.0 the `.options` segment is dropped for non-chat models (`spring.ai.openai.embedding.model`, not `...embedding.options.model`).
## ChatClient
Inject the autoconfigured `ChatClient.Builder`. Do not call `ChatClient.create(chatModel)` or `ChatClient.builder(chatModel)` in application code: they skip observability and `ChatClientBuilderCustomizer` beans.
```java
@Service
public class AssistantService {
private final ChatClient chatClient;
public AssistantService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("You answer questions about our product catalog.")
.build();
}
public String ask(String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
```
With several models, build one `ChatClient` bean per model from `ChatClientBuilderConfigurer` and mark one `@Primary`.
`call()` only selects synchronous execution; the model runs on `content()`, `chatResponse()` or `entity()`. `chatResponse()` gives token usage metadata for metrics.
### Structured output
```java
public record Recommendation(String title, List<String> reasons) {}
Recommendation result = chatClient.prompt()
.user("Recommend one book about Spring")
.call()
.entity(Recommendation.class);
List<Recommendation> list = chatClient.prompt()
.user("Recommend three books about Spring")
.call()
.entity(new ParameterizedTypeReference<List<Recommendation>>() {});
```
Use records for output types. For flaky models add `.entity(Recommendation.class, spec -> spec.useProviderStructuredOutput().validateSchema())`. `entity()` is not supported on `stream()`: collect the text and convert with `BeanOutputConverter`.
### Streaming
`stream().content()` returns a `Flux<String>` and needs the reactive stack (`spring-boot-starter-webflux`) on the classpath. Keep blocking `call()` for ordinary MVC endpoints.
```java
@GetMapping(value = "/api/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(@RequestParam String message) {
return chatClient.prompt().user(message).stream().content();
}
```
## Tool calling
The model asks, your application executes. Describe every tool: without `description` the model has no hint when to call it.
```java
@Component
public class OrderTools {
private final OrderService orderService;
public OrderTools(OrderService orderService) {
this.orderService = orderService;
}
@Tool(description = "Get the status of an order by its id")
public String orderStatus(@ToolParam(description = "Order id") Long orderId) {
return orderService.status(orderId);
}
}
String answer = chatClient.prompt()
.user("Where is order 42?")
.tools(orderTools)
.call()
.content();
```
- `tools(...)` per call appends to `defaultTools(...)`. Put read-only tools in the defaults, destructive ones on the single call that needs them.
- Tool parameters are required by default; a model asked for a value it cannot know invents one. Mark real optionals with `@ToolParam(required = false)`.
- A `RuntimeException` from a tool is sent back to the model as the error message so it can recover; checked exceptions are rethrown. Do not put secrets or stack traces in exception messages. `spring.ai.tools.throw-exception-on-error=true` throws to the caller instead.
- `MethodToolCallback` does not support `Optional`, `Future`, `Mono`/`Flux` or `Function`/`Supplier` parameters and return types.
- Tool names must be unique. `ChatClient` fails on duplicates; a hand-written loop over `ChatModel` does not, so a remote MCP tool could shadow a trusted local one.
- The model decides which tool runs and with what arguments, so authorize inside the tool method (the current user's rights), not only at the endpoint.
## RAG
Add `spring-ai-vector-store-advisor` for `QuestionAnswerAdvisor`, or `spring-ai-rag` for `RetrievalAugmentationAdvisor`, plus a vector store starter.
```java
var qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().similarityThreshold(0.8d).topK(6).build())
.build();
String answer = chatClient.prompt()
.advisors(qaAdvisor)
.user(question)
.call()
.content();
```
- Per-request metadata filter, portable across stores: `.advisors(a -> a.param(QuestionAnswerAdvisor.FILTER_EXPRESSION, "tenant == 'acme'"))`. Derive the value from the authenticated user on the server, never from request text; it is the multi-tenant boundary.
- `RetrievalAugmentationAdvisor` with `VectorStoreDocumentRetriever` is the modular variant (query rewriting, post-processing). By default an empty retrieval makes the model decline to answer; allow it with `ContextualQueryAugmenter.builder().allowEmptyContext(true)`.
- Ingest documents with `vectorStore.add(List<Document>)` from a startup runner or a job, not in the request path.
## Chat memory
```java
ChatMemory chatMemory = MessageWindowChatMemory.builder().maxMessages(20).build();
ChatClient client = builder
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
client.prompt()
.user(message)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
```
- `ChatMemory.CONVERSATION_ID` is required on every call and has no default (`IllegalArgumentException` otherwise). Derive it on the server from the authenticated user (plus a conversation id), never from a client-supplied value alone, or users read each other's history.
- `PromptChatMemoryAdvisor` is gone: use `MessageChatMemoryAdvisor`.
- Persistent memory: `spring-ai-starter-model-chat-memory-repository-jdbc`. Tool call messages are not persisted. With Flyway owning the schema, set `spring.ai.chat.memory.repository.jdbc.initialize-schema=never` and add the `SPRING_AI_CHAT_MEMORY` table in a migration (copy the vendor script from the starter). The 2.0 table has a `sequence_id` column used for ordering; existing tables need it added.
## MCP server
Expose tools to MCP clients with the server starter for your stack: `spring-ai-starter-mcp-server-webmvc` or `-webflux` (Streamable HTTP), `spring-ai-starter-mcp-server` (stdio).
```yaml
spring:
ai:
mcp:
server:
protocol: STREAMABLE # STREAMABLE | STATELESS; SSE is deprecated since 2.0
type: SYNC
```
```java
@Component
public class CatalogTools {
@McpTool(name = "find-product", description = "Find a product by name")
public String findProduct(@McpToolParam(description = "Product name", required = true) String name) {
return "...";
}
}
```
The HTTP transport exposes an unauthenticated endpoint (`POST /mcp`) that lists and runs every registered tool. Put Spring Security in front of it before it is reachable beyond localhost.
## Testing
- Test your own code (prompt assembly, tool methods, controllers) without a model: unit-test `@Tool` methods as plain methods, and stub `ChatModel` for the `ChatClient`. A plain Mockito mock can return `null` from methods the builder calls (for example `getOptions()`); stub them or write a small `ChatModel` implementation.
- Run vector stores against a real container with `@ServiceConnection` (for example a PostgreSQL container with a pgvector image), as in `references/testing.md`.
- Do not call a paid provider from the default test run. Evaluator tests (`RelevancyEvaluator`, `FactCheckingEvaluator`) need a real model: keep them in a separate profile or disable them unless an API key is present.
## Upgrading from 1.x
| 1.x | 2.0 |
|-----|-----|
| `FunctionCallback`, `FunctionCallback.builder().function(...)` | `ToolCallback`, `FunctionToolCallback.builder(name, fn)` |
| `defaultFunctions(...)`, `functions(...)` | `defaultTools(...)`, `tools(...)` |
| `ToolCallAdvisor` | `ToolCallingAdvisor` (auto-registered by `ChatClient`; remove explicit registrations) |
| `spring-ai-advisors-vector-store` | `spring-ai-vector-store-advisor` |
| `PromptChatMemoryAdvisor`, `ChatMemory.DEFAULT_CONVERSATION_ID` | `MessageChatMemoryAdvisor`, explicit `ChatMemory.CONVERSATION_ID` |
| `internalToolExecutionEnabled(...)`, `SpringBeanToolCallbackResolver`, `toolNames(...)` | removed: `ChatClient` + `ToolCallingAdvisor`, or `ToolCallback` beans / `@Tool` methods |
| `ChatClient.options(builtOptions)` | `options(optionsBuilder)`; copy with `mutate().build()` instead of `copy()` |
| `org.springaicommunity.mcp.annotation.*` | `org.springframework.ai.mcp.annotation.*` |
| `spring.ai.<provider>.embedding.options.model` | `spring.ai.<provider>.embedding.model` |
The JSON API moved to Jackson 3 (`tools.jackson`), matching Spring Boot 4. The full list is in the [upgrade notes](https://docs.spring.io/spring-ai/reference/upgrade-notes.html).
## Rules
- `ChatClient.Builder` is injected through the constructor; no `ChatClient.create(...)` in application code.
- API keys and tenant/user identifiers come from the environment and the security context, not from code or request text.
- Every `@Tool` has a description, and authorization is checked inside the tool.
- Conversation ids are derived on the server.
- Default tests run without a real model or API key.
references/cloud.md
# Cloud Native - Spring Cloud
## Spring Cloud Config Server
```java
// Config Server
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
public static void main(String[] args) {
SpringApplication.run(ConfigServerApplication.class, args);
}
}
// application.yml
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: https://github.com/example/config-repo
default-label: main
search-paths: '{application}'
# credentials: configure via environment variables
native:
search-locations: classpath:/config
// Config Client
@SpringBootApplication
public class ClientApplication {
public static void main(String[] args) {
SpringApplication.run(ClientApplication.class, args);
}
}
// application.yml (Config Client)
spring:
application:
name: user-service
config:
import: "configserver:http://localhost:8888"
cloud:
config:
# credentials: configure via environment variables
fail-fast: true
retry:
max-attempts: 6
initial-interval: 1000
```
## Dynamic Configuration Refresh
```java
@RestController
@RefreshScope
public class ConfigController {
@Value("${app.feature.enabled:false}")
private boolean featureEnabled;
@Value("${app.max-connections:100}")
private int maxConnections;
@GetMapping("/config")
public Map<String, Object> getConfig() {
return Map.of(
"featureEnabled", featureEnabled,
"maxConnections", maxConnections
);
}
}
// Refresh configuration via Actuator endpoint:
// POST /actuator/refresh
```
## Service Discovery - Eureka
```java
// Eureka Server
@SpringBootApplication
@EnableEurekaServer
public class EurekaServerApplication {
public static void main(String[] args) {
SpringApplication.run(EurekaServerApplication.class, args);
}
}
// application.yml (Eureka Server)
server:
port: 8761
eureka:
instance:
hostname: localhost
client:
register-with-eureka: false
fetch-registry: false
service-url:
defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
// Eureka Client
@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);
}
}
// application.yml (Eureka Client)
spring:
application:
name: user-service
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
registry-fetch-interval-seconds: 5
instance:
prefer-ip-address: true
lease-renewal-interval-in-seconds: 10
lease-expiration-duration-in-seconds: 30
```
## Spring Cloud Gateway
```java
@SpringBootApplication
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("user-service", r -> r
.path("/api/users/**")
.filters(f -> f
.rewritePath("/api/users/(?<segment>.*)", "/users/${segment}")
.addRequestHeader("X-Gateway", "Spring-Cloud-Gateway")
.circuitBreaker(config -> config
.setName("userServiceCircuitBreaker")
.setFallbackUri("forward:/fallback/users")
)
.retry(config -> config
.setRetries(3)
.setStatuses(HttpStatus.SERVICE_UNAVAILABLE)
)
)
.uri("lb://user-service")
)
.route("order-service", r -> r
.path("/api/orders/**")
.filters(f -> f
.rewritePath("/api/orders/(?<segment>.*)", "/orders/${segment}")
.requestRateLimiter(config -> config
.setRateLimiter(redisRateLimiter())
.setKeyResolver(userKeyResolver())
)
)
.uri("lb://order-service")
)
.build();
}
@Bean
public RedisRateLimiter redisRateLimiter() {
return new RedisRateLimiter(10, 20); // replenishRate, burstCapacity
}
@Bean
public KeyResolver userKeyResolver() {
return exchange -> Mono.justOrEmpty(
exchange.getRequest().getHeaders().getFirst("X-User-Id")
).defaultIfEmpty("anonymous");
}
}
// application.yml (Gateway)
spring:
cloud:
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Origin
globalcors:
cors-configurations:
'[/**]':
allowed-origins: "*"
allowed-methods:
- GET
- POST
- PUT
- DELETE
allowed-headers: "*"
```
## Circuit Breaker - Resilience4j
```java
@Service
public class ExternalApiService {
private static final Logger log = LoggerFactory.getLogger(ExternalApiService.class);
private final RestClient restClient;
public ExternalApiService(RestClient.Builder builder) {
this.restClient = builder
.baseUrl("https://api.external.com")
.build();
}
@CircuitBreaker(name = "externalApi", fallbackMethod = "getFallbackData")
@Retry(name = "externalApi")
@RateLimiter(name = "externalApi")
public ExternalData getData(String id) {
return restClient
.get()
.uri("/data/{id}", id)
.retrieve()
.body(ExternalData.class);
}
private ExternalData getFallbackData(String id, Exception e) {
log.warn("Fallback triggered for id: {}, error: {}", id, e.getMessage());
return new ExternalData(id, "Fallback data", LocalDateTime.now());
}
}
// application.yml
resilience4j:
circuitbreaker:
instances:
externalApi:
register-health-indicator: true
sliding-window-size: 10
minimum-number-of-calls: 5
permitted-number-of-calls-in-half-open-state: 3
automatic-transition-from-open-to-half-open-enabled: true
wait-duration-in-open-state: 5s
failure-rate-threshold: 50
event-consumer-buffer-size: 10
retry:
instances:
externalApi:
max-attempts: 3
wait-duration: 1s
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
ratelimiter:
instances:
externalApi:
limit-for-period: 10
limit-refresh-period: 1s
timeout-duration: 0s
```
## Distributed Tracing - Micrometer + OpenTelemetry
### OTLP Exporter (Preferred)
```yaml
# application.yml - OpenTelemetry Protocol (recommended over Zipkin)
management:
tracing:
sampling:
probability: 1.0
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces
logging:
pattern:
level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]"
```
```xml
<!-- pom.xml -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
```
### Zipkin Exporter (Alternative)
```yaml
management:
tracing:
sampling:
probability: 1.0
zipkin:
tracing:
endpoint: http://localhost:9411/api/v2/spans
```
### Custom Spans with Observation API
```java
@Service
public class OrderService {
private final ObservationRegistry observationRegistry;
private final OrderRepository orderRepository;
public OrderService(ObservationRegistry observationRegistry, OrderRepository orderRepository) {
this.observationRegistry = observationRegistry;
this.orderRepository = orderRepository;
}
public Order processOrder(OrderRequest request) {
return Observation.createNotStarted("order.process", observationRegistry)
.lowCardinalityKeyValue("order.type", request.type())
.observe(() -> {
Order order = createOrder(request);
return order;
});
}
}
```
## Load Balancing with Spring Cloud LoadBalancer
```java
@Configuration
@LoadBalancerClient(name = "user-service", configuration = UserServiceLoadBalancerConfig.class)
public class LoadBalancerConfiguration {
}
@Configuration
public class UserServiceLoadBalancerConfig {
@Bean
public ReactorLoadBalancer<ServiceInstance> randomLoadBalancer(
LoadBalancerClientFactory clientFactory,
ObjectProvider<LoadBalancerProperties> properties) {
return new RandomLoadBalancer(
clientFactory.getLazyProvider("user-service", ServiceInstanceListSupplier.class),
"user-service"
);
}
}
@Service
public class UserClientService {
private final RestClient restClient;
public UserClientService(RestClient.Builder builder) {
this.restClient = builder
.baseUrl("http://user-service")
.build();
}
public User getUser(Long id) {
return restClient
.get()
.uri("/users/{id}", id)
.retrieve()
.body(User.class);
}
}
```
## Health Checks, Actuator & Prometheus
```xml
<!-- pom.xml - Prometheus metrics endpoint -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
```
```yaml
# application.yml
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-components: always # each component's status (db, diskSpace, ...) for anonymous callers
show-details: when-authorized # details (host names, versions) only with a token
probes:
enabled: true
group:
readiness:
include: readinessState,db
health:
livenessState:
enabled: true
readinessState:
enabled: true
metrics:
tags:
application: ${spring.application.name}
```
Status endpoints. Permitting `/actuator/health/**` (with the probes) applies to every app; the rest is **required only when the API is built for a UI** that shows the API status and version (`conventions` skill):
- `GET /actuator/health` answers `{"status": "UP", "components": {...}}`, and a non-2xx status with `DOWN`/`OUT_OF_SERVICE`. Components without details tell a client or a dashboard what is down without leaking infrastructure.
- `GET /actuator/info` answers `{"build": {"version": ...}}` once the `spring-boot-maven-plugin` runs the `build-info` goal (see `ui-contract.md`). It is the running version, the same as the OpenAPI `info.version`.
- `SecurityConfig` permits `/actuator/health/**` (including the `liveness`/`readiness` probes) and `/actuator/info`, before any other rule. Every other Actuator endpoint (`env`, `metrics`, `prometheus`, ...) needs a token or a separate management port.
- An integration test asserts that both answer without a token, that `health` lists `db`, that `info` `build.version` equals the contract's `info.version`, and that `/actuator/env` returns 401.
```java
@Component
public class CustomHealthIndicator implements HealthIndicator {
@Override
public Health health() {
boolean serviceUp = checkExternalService();
if (serviceUp) {
return Health.up()
.withDetail("externalService", "Available")
.withDetail("timestamp", LocalDateTime.now())
.build();
} else {
return Health.down()
.withDetail("externalService", "Unavailable")
.withDetail("error", "Connection timeout")
.build();
}
}
private boolean checkExternalService() {
// Check external dependency
return true;
}
}
```
## Kubernetes Deployment
### ConfigMap and Secret
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: user-service-config
data:
SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres-service:5432/mydb"
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: "health,info,metrics,prometheus"
SERVER_SHUTDOWN: "graceful"
```
Configure datasource credentials via Kubernetes Secrets mounted as environment variables or using Config Tree (see below).
### Config Tree for Mounted Secrets
Instead of env vars, read secrets from mounted files using Config Tree (more secure — secrets don't leak via `/proc/environ`):
```yaml
# application.yml
spring:
config:
import: "optional:configtree:/run/secrets/"
```
With a Kubernetes Secret mounted at `/run/secrets/`, each key becomes a file. Spring Boot reads the file content as the property value.
```yaml
# Kubernetes volume mount for Config Tree
spec:
containers:
- name: app
volumeMounts:
- name: secrets
mountPath: /run/secrets
readOnly: true
volumes:
- name: secrets
secret:
secretName: user-service-secrets
```
### Deployment Manifest
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 2
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/actuator/prometheus"
spec:
containers:
- name: user-service
image: user-service:1.0.0
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: user-service-config
- secretRef:
name: user-service-secrets
env:
- name: SPRING_PROFILES_ACTIVE
value: "kubernetes"
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75.0"
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 15
periodSeconds: 5
startupProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 20
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
securityContext:
runAsNonRoot: true
runAsUser: 1000
---
apiVersion: v1
kind: Service
metadata:
name: user-service
spec:
selector:
app: user-service
ports:
- port: 80
targetPort: 8080
type: ClusterIP
```
### Ingress TLS with cert-manager
cert-manager automatically provisions and renews TLS certificates on Kubernetes.
```yaml
# ClusterIssuer for Let's Encrypt
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@example.com
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: nginx
```
```yaml
# Ingress with automatic TLS
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: user-service-ingress
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
ingressClassName: nginx
tls:
- hosts:
- api.example.com
secretName: user-service-tls
rules:
- host: api.example.com
http:
paths:
- path: /api/users
pathType: Prefix
backend:
service:
name: user-service
port:
number: 80
```
cert-manager creates and renews the `user-service-tls` Secret automatically. For end-to-end TLS (not just ingress termination), mount the same Secret into the pod and configure Spring Boot SSL Bundles with `reload-on-update: true` (see security reference).
### Certificate for mTLS between services
```yaml
# cert-manager Certificate for pod-level TLS
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: user-service-cert
spec:
secretName: user-service-tls-pod
issuerRef:
name: internal-ca
kind: ClusterIssuer
dnsNames:
- user-service
- user-service.default.svc.cluster.local
duration: 720h
renewBefore: 48h
```
```yaml
# Mount into pod for Spring Boot SSL Bundle
spec:
containers:
- name: user-service
volumeMounts:
- name: tls-certs
mountPath: /etc/ssl/certs
readOnly: true
volumes:
- name: tls-certs
secret:
secretName: user-service-tls-pod
```
## Virtual Threads
```yaml
# application.yml - one line enables virtual threads for all request handling
spring:
threads:
virtual:
enabled: true
```
Virtual threads eliminate the need for WebFlux in most cases. With `spring.threads.virtual.enabled=true`:
- Tomcat handles each request on a virtual thread
- Blocking I/O (JDBC, RestClient, file I/O) no longer wastes platform threads
- `@Async` tasks run on virtual threads automatically
- No code changes required - existing blocking code scales like reactive
**When to still use WebFlux:** streaming responses, SSE, WebSocket, or when you need backpressure.
## HTTP Interface Clients (Spring 6+)
```java
// Declarative HTTP client - replaces OpenFeign
public interface UserClient {
@GetExchange("/users/{id}")
UserResponse getUser(@PathVariable Long id);
@GetExchange("/users")
List<UserResponse> getAllUsers();
@PostExchange("/users")
UserResponse createUser(@RequestBody UserCreateRequest request);
}
// Configuration
@Configuration
public class ClientConfig {
@Bean
public UserClient userClient(RestClient.Builder builder) {
RestClient restClient = builder
.baseUrl("http://user-service")
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(UserClient.class);
}
}
```
## GraalVM Native Image
```xml
<!-- pom.xml - add native profile support -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version> <!-- use the latest Spring Boot 4.x release -->
</parent>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
```
```bash
# Build native image
./mvnw -Pnative native:compile
# Build native Docker image with Buildpacks
./mvnw -Pnative spring-boot:build-image
```
Benefits: ~50ms startup, ~50MB RSS memory. Trade-offs: longer build time, reflection requires hints.
## Graceful Shutdown
```yaml
# application.yml - required for zero-downtime K8s rolling updates
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
```
```yaml
# Kubernetes - add preStop hook to allow deregistration
spec:
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
```
## Docker Configuration
### Multi-stage Build
```dockerfile
FROM eclipse-temurin:21-jdk-alpine AS build
WORKDIR /workspace/app
COPY mvnw .
COPY .mvn .mvn
COPY pom.xml .
RUN ./mvnw dependency:go-offline -B
COPY src src
RUN ./mvnw package -DskipTests
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S app && adduser -S app -G app && apk add --no-cache curl
WORKDIR /app
COPY --from=build /workspace/app/target/*.jar app.jar
USER app
HEALTHCHECK --interval=30s --timeout=3s CMD curl -sf http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
```
### Paketo Buildpacks (Recommended)
Paketo Buildpacks build OCI images without a Dockerfile. Integrated into Spring Boot Maven plugin.
```bash
# Build image (uses Paketo by default)
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=myapp:1.0.0
# Build native image with Paketo
./mvnw -Pnative spring-boot:build-image \
-Dspring-boot.build-image.imageName=myapp:1.0.0-native
```
```xml
<!-- pom.xml - Paketo configuration -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>${project.artifactId}:${project.version}</name>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
<BPE_JAVA_TOOL_OPTIONS>-XX:MaxRAMPercentage=75.0</BPE_JAVA_TOOL_OPTIONS>
</env>
</image>
</configuration>
</plugin>
```
Advantages over Dockerfile:
- Reproducible builds, no Dockerfile to maintain
- Automatic security patching of base image layers
- Optimized layering (dependencies cached separately from app code)
- Supported by Spring Boot Maven plugin out of the box
## Local K8s Development with Skaffold
Skaffold automates the build → push → deploy loop.
```yaml
# skaffold.yaml
apiVersion: skaffold/v4beta11
kind: Config
metadata:
name: user-service
build:
artifacts:
- image: user-service
custom:
buildCommand: |
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=$IMAGE -q
dependencies:
paths:
- src/**
- pom.xml
deploy:
kubectl:
manifests:
- k8s/*.yaml
```
```bash
# Continuous development - rebuild and redeploy on code changes
skaffold dev
# One-time build + deploy
skaffold run
# Deploy to specific profile/environment
skaffold run -p production
```
## Quick Reference
| Component | Purpose |
|-----------|---------|
| **Config Server** | Centralized configuration management |
| **Eureka** | Service discovery and registration |
| **Gateway** | API gateway with routing, filtering, load balancing |
| **Circuit Breaker** | Fault tolerance and fallback patterns |
| **Load Balancer** | Client-side load balancing |
| **Tracing (OTLP)** | Distributed tracing with OpenTelemetry |
| **Actuator** | Production-ready monitoring and management |
| **Virtual Threads** | High-throughput blocking I/O without WebFlux |
| **HTTP Interfaces** | Declarative HTTP clients (replaces Feign) |
| **Native Image** | Fast startup, low memory with GraalVM |
| **Paketo Buildpacks** | Build OCI images without Dockerfile (Spring Boot plugin) |
| **Skaffold** | Local Kubernetes development loop (build → deploy → watch) |
| **Config Tree** | Read secrets from mounted files instead of env vars |
| **cert-manager** | Automatic TLS certificate provisioning and renewal on K8s |
| **SSL Bundles** | Unified SSL config with hot reload (see security reference) |
| **Kubernetes** | Container orchestration, ConfigMap/Secret, probes, Prometheus |
references/data.md
# Data Access - Spring Data JPA
## JPA Entity Pattern
```java
@Entity
@Table(name = "users", indexes = {
@Index(name = "idx_email", columnList = "email", unique = true),
@Index(name = "idx_username", columnList = "username")
})
@EntityListeners(AuditingEntityListener.class)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 100)
private String email;
@Column(nullable = false, length = 100)
private String password;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false)
private Boolean active = true;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Address> addresses = new ArrayList<>();
@ManyToMany
@JoinTable(
name = "user_roles",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
@Version
private Long version;
// Constructors
public User() {}
public User(Long id, String email, String password, String username, Boolean active,
List<Address> addresses, Set<Role> roles, Instant createdAt,
Instant updatedAt, Long version) {
this.id = id;
this.email = email;
this.password = password;
this.username = username;
this.active = active != null ? active : true;
this.addresses = addresses != null ? addresses : new ArrayList<>();
this.roles = roles != null ? roles : new HashSet<>();
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.version = version;
}
// Getters and Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public Boolean getActive() { return active; }
public void setActive(Boolean active) { this.active = active; }
public List<Address> getAddresses() { return addresses; }
public void setAddresses(List<Address> addresses) { this.addresses = addresses; }
public Set<Role> getRoles() { return roles; }
public void setRoles(Set<Role> roles) { this.roles = roles; }
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
// Helper methods for bidirectional relationships
public void addAddress(Address address) {
addresses.add(address);
address.setUser(this);
}
public void removeAddress(Address address) {
addresses.remove(address);
address.setUser(null);
}
}
```
## Spring Data JPA Repository
```java
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {
Optional<User> findByEmail(String email);
Optional<User> findByUsername(String username);
boolean existsByEmail(String email);
boolean existsByUsername(String username);
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
Optional<User> findByEmailWithRoles(@Param("email") String email);
@Query("SELECT u FROM User u WHERE u.active = true AND u.createdAt >= :since")
List<User> findActiveUsersSince(@Param("since") LocalDateTime since);
@Modifying
@Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :threshold")
int deactivateInactiveUsers(@Param("threshold") LocalDateTime threshold);
// Projection for read-only DTOs
@Query("SELECT new pl.piomin.services.dto.UserSummary(u.id, u.username, u.email) " +
"FROM User u WHERE u.active = true")
List<UserSummary> findAllActiveSummaries();
}
```
## Repository with Specifications
```java
public class UserSpecifications {
public static Specification<User> hasEmail(String email) {
return (root, query, cb) ->
email == null ? null : cb.equal(root.get("email"), email);
}
public static Specification<User> isActive() {
return (root, query, cb) -> cb.isTrue(root.get("active"));
}
public static Specification<User> createdAfter(LocalDateTime date) {
return (root, query, cb) ->
date == null ? null : cb.greaterThanOrEqualTo(root.get("createdAt"), date);
}
public static Specification<User> hasRole(String roleName) {
return (root, query, cb) -> {
Join<User, Role> roles = root.join("roles", JoinType.INNER);
return cb.equal(roles.get("name"), roleName);
};
}
/** Case-insensitive "contains" on any of the attributes; LIKE wildcards in {@code q} match literally. */
public static Specification<User> search(String q, String... attributes) {
if (q == null || q.isBlank()) {
return null;
}
String like = "%" + q.toLowerCase().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + "%";
return (root, query, cb) -> cb.or(Arrays.stream(attributes)
.map(attribute -> cb.like(cb.lower(root.get(attribute)), like, '\\'))
.toArray(Predicate[]::new));
}
}
// Usage in service
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Page<User> searchUsers(UserSearchCriteria criteria, Pageable pageable) {
Specification<User> spec = Specification
.where(UserSpecifications.hasEmail(criteria.email()))
.and(UserSpecifications.isActive())
.and(UserSpecifications.createdAfter(criteria.createdAfter()));
return userRepository.findAll(spec, pageable);
}
}
```
- Escape `%`, `_` and `\` in user input passed to `LIKE`, otherwise `q=%` matches everything and `q=a_c` matches `abc`.
- Bind query filters to a record (`@ParameterObject @Valid UserFilter filter` in the controller) that builds the `Specification`; filters combine with AND, and an invalid value returns 400 with a field error named after the parameter.
- A `Specification` query that maps a relation into the response loads it with `@EntityGraph` on `findAll(Specification, Pageable)` (see `jpa-patterns`).
## Transaction Management
```java
@Service
@Transactional(readOnly = true)
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final OrderRepository orderRepository;
private final OrderEventRepository orderEventRepository;
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final NotificationService notificationService;
public OrderService(OrderRepository orderRepository, OrderEventRepository orderEventRepository,
PaymentService paymentService, InventoryService inventoryService,
NotificationService notificationService) {
this.orderRepository = orderRepository;
this.orderEventRepository = orderEventRepository;
this.paymentService = paymentService;
this.inventoryService = inventoryService;
this.notificationService = notificationService;
}
@Transactional
public Order createOrder(OrderCreateRequest request) {
// All operations in single transaction
Order order = new Order();
order.setCustomerId(request.customerId());
order.setStatus(OrderStatus.PENDING);
request.items().forEach(item -> {
inventoryService.reserveStock(item.productId(), item.quantity());
order.addItem(item);
});
order = orderRepository.save(order);
try {
paymentService.processPayment(order);
order.setStatus(OrderStatus.PAID);
} catch (PaymentException e) {
order.setStatus(OrderStatus.PAYMENT_FAILED);
throw e; // Transaction will rollback
}
return orderRepository.save(order);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logOrderEvent(Long orderId, String event) {
// Separate transaction - will commit even if parent rolls back
OrderEvent orderEvent = new OrderEvent(orderId, event);
orderEventRepository.save(orderEvent);
}
@Transactional(noRollbackFor = NotificationException.class)
public void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new ResourceNotFoundException("Order not found"));
order.setStatus(OrderStatus.COMPLETED);
orderRepository.save(order);
// Won't rollback transaction if notification fails
try {
notificationService.sendCompletionEmail(order);
} catch (NotificationException e) {
log.error("Failed to send notification for order {}", orderId, e);
}
}
}
```
## Auditing Configuration
```java
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> {
Authentication authentication = SecurityContextHolder
.getContext()
.getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
return Optional.of("system");
}
return Optional.of(authentication.getName());
};
}
}
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@CreatedBy
@Column(nullable = false, updatable = false, length = 100)
private String createdBy;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
@LastModifiedBy
@Column(nullable = false, length = 100)
private String updatedBy;
public Instant getCreatedAt() { return createdAt; }
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
public String getCreatedBy() { return createdBy; }
public void setCreatedBy(String createdBy) { this.createdBy = createdBy; }
public Instant getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
public String getUpdatedBy() { return updatedBy; }
public void setUpdatedBy(String updatedBy) { this.updatedBy = updatedBy; }
}
```
## Projections
```java
// Interface-based projection
public interface UserSummary {
Long getId();
String getUsername();
String getEmail();
@Value("#{target.firstName + ' ' + target.lastName}")
String getFullName();
}
// Class-based projection (DTO)
public record UserSummaryDto(
Long id,
String username,
String email
) {}
// Usage
public interface UserRepository extends JpaRepository<User, Long> {
List<UserSummary> findAllBy();
<T> List<T> findAllBy(Class<T> type);
}
// Service usage
List<UserSummary> summaries = userRepository.findAllBy();
List<UserSummaryDto> dtos = userRepository.findAllBy(UserSummaryDto.class);
```
## Query Optimization
```java
// Entity-level optimization: batch fetching on collections
@Entity
public class User {
// ...
@BatchSize(size = 25)
@OneToMany(mappedBy = "user")
private List<Order> orders;
}
// Repository-level optimization: JOIN FETCH, EntityGraph, native queries
public interface UserRepository extends JpaRepository<User, Long> {
@Query("SELECT DISTINCT u FROM User u " +
"LEFT JOIN FETCH u.addresses " +
"LEFT JOIN FETCH u.roles " +
"WHERE u.active = true")
List<User> findAllActiveWithAssociations();
@EntityGraph(attributePaths = {"addresses", "roles"})
List<User> findAllByActiveTrue();
@Query(value = """
SELECT u.* FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= :since
GROUP BY u.id
HAVING COUNT(o.id) >= :minOrders
""", nativeQuery = true)
List<User> findFrequentBuyers(@Param("since") LocalDateTime since,
@Param("minOrders") int minOrders);
}
// Service-level: use repository with pagination
@Service
@Transactional(readOnly = true)
public class UserQueryService {
private final UserRepository userRepository;
public UserQueryService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public Page<User> findAllUsers(Pageable pageable) {
return userRepository.findAll(pageable);
}
}
```
## Database Migrations (Flyway)
Use Flyway only when the application persists data to a relational database that Flyway supports (PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, DB2, H2, ...). Skip it entirely - no dependency, no `db/migration`, no `spring.flyway.*` - when the API has no persistence, uses only NoSQL stores (MongoDB, Redis, Cassandra, Elasticsearch), or the project already uses Liquibase. Rules are in the `conventions` skill.
### Dependencies
```xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<!-- Database-specific Flyway module (see table below) -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
```
Versions come from the Spring Boot BOM.
| Database | Flyway module |
|----------|---------------|
| PostgreSQL | `flyway-database-postgresql` |
| MySQL / MariaDB | `flyway-mysql` |
| SQL Server | `flyway-sqlserver` |
| Oracle | `flyway-database-oracle` |
| H2 | none (included in core) |
### Configuration
```yaml
spring:
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/orders}
username: ${SPRING_DATASOURCE_USERNAME:orders}
password: ${SPRING_DATASOURCE_PASSWORD:orders}
jpa:
hibernate:
ddl-auto: validate # Flyway owns the schema; Hibernate only validates it
open-in-view: false
management:
endpoint:
health:
probes:
enabled: true
group:
readiness:
include: readinessState,db
```
Flyway runs on startup and reads `classpath:db/migration` by default, so no `spring.flyway.*` keys are needed. Any mismatch between entities and migrations fails startup.
### Migration Files
- Location: `src/main/resources/db/migration`
- Naming: `V<n>__<description>.sql` (e.g. `V1__create_users.sql`, `V2__add_user_status.sql`)
- Never edit an applied migration; every change is one new file
- Every entity change ships with its migration in the same commit
- Name constraints and indexes explicitly (`pk_`, `uk_`, `ck_`, `fk_`, `ix_`) and index every foreign key column
- A new `NOT NULL` column gets a `DEFAULT` or a backfill, so existing rows keep working
- Column `DEFAULT`s match entity defaults; validation rules (`@Size`, enums) get a matching `CHECK`
```sql
-- V1__create_users.sql
CREATE TABLE users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
email VARCHAR(100) NOT NULL,
username VARCHAR(50) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
version BIGINT NOT NULL,
CONSTRAINT pk_users PRIMARY KEY (id),
CONSTRAINT uk_users_email UNIQUE (email),
CONSTRAINT uk_users_username UNIQUE (username)
);
-- V2__create_addresses.sql
CREATE TABLE addresses (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
user_id BIGINT NOT NULL,
street VARCHAR(200) NOT NULL,
city VARCHAR(100) NOT NULL,
country VARCHAR(2) NOT NULL,
CONSTRAINT pk_addresses PRIMARY KEY (id),
CONSTRAINT fk_addresses_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT,
CONSTRAINT ck_addresses_country CHECK (country ~ '^[A-Z]{2}$')
);
CREATE INDEX ix_addresses_user_id ON addresses (user_id);
-- V3__add_user_status.sql
ALTER TABLE users
ADD COLUMN status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
ADD CONSTRAINT ck_users_status CHECK (status IN ('ACTIVE', 'SUSPENDED'));
```
### Existing Database
For a database created before Flyway, export the current schema into `V1__initial_schema.sql`, or set `spring.flyway.baseline-on-migrate: true` (with `baseline-version`) so Flyway marks the existing schema as baseline and applies only newer migrations.
### Testing
Run integration tests against the production engine with Testcontainers and `@ServiceConnection`, so Flyway applies the real migrations and Hibernate validates them. Assert that all migrations were applied - see `testing.md`.
## Quick Reference
| Annotation | Purpose |
|------------|---------|
| `@Entity` | Marks class as JPA entity |
| `@Table` | Specifies table details and indexes |
| `@Id` | Marks primary key field |
| `@GeneratedValue` | Auto-generated primary key strategy |
| `@Column` | Column constraints and mapping |
| `@OneToMany/@ManyToOne` | One-to-many/many-to-one relationships |
| `@ManyToMany` | Many-to-many relationships |
| `@JoinColumn/@JoinTable` | Join column/table configuration |
| `@Transactional` | Declares transaction boundaries |
| `@Query` | Custom JPQL/native queries |
| `@Modifying` | Marks query as UPDATE/DELETE |
| `@EntityGraph` | Defines fetch graph for associations |
| `@Version` | Optimistic locking version field |
references/security.md
# Security - Spring Security 6
## Security Configuration
```java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/api/auth/**")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint())
.accessDeniedHandler(accessDeniedHandler())
)
.addFilterBefore(jwtAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(List.of("http://localhost:3000"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
}
```
## JWT Authentication Filter
```java
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtService jwtService, UserDetailsService userDetailsService) {
this.jwtService = jwtService;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
final String authHeader = request.getHeader("Authorization");
final String jwt;
final String username;
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
jwt = authHeader.substring(7);
try {
username = jwtService.extractUsername(jwt);
if (username != null && SecurityContextHolder.getContext()
.getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtService.isTokenValid(jwt, userDetails)) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
} catch (JwtException e) {
log.error("JWT validation failed", e);
}
filterChain.doFilter(request, response);
}
}
```
## JWT Service
```java
@Service
public class JwtService {
// Inject via @ConfigurationProperties — never hardcode credentials
private final JwtProperties jwtProperties;
public JwtService(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
}
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
public String generateToken(UserDetails userDetails) {
Map<String, Object> extraClaims = new HashMap<>();
extraClaims.put("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));
return generateToken(extraClaims, userDetails);
}
public String generateToken(
Map<String, Object> extraClaims,
UserDetails userDetails) {
return buildToken(extraClaims, userDetails, jwtProperties.expiration());
}
public String generateRefreshToken(UserDetails userDetails) {
return buildToken(new HashMap<>(), userDetails, jwtProperties.refreshExpiration());
}
private String buildToken(
Map<String, Object> extraClaims,
UserDetails userDetails,
long expiration) {
return Jwts
.builder()
.claims(extraClaims)
.subject(userDetails.getUsername())
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getSignInKey())
.compact();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
private boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
private Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
private Claims extractAllClaims(String token) {
return Jwts
.parser()
.verifyWith(getSignInKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
private SecretKey getSignInKey() {
byte[] keyBytes = Decoders.BASE64.decode(jwtProperties.secret());
return Keys.hmacShaKeyFor(keyBytes);
}
}
```
## UserDetailsService Implementation
```java
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
@Transactional(readOnly = true)
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByEmailWithRoles(username)
.orElseThrow(() -> new UsernameNotFoundException(
"User not found with email: " + username));
return org.springframework.security.core.userdetails.User
.builder()
.username(user.getEmail())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList()))
.accountExpired(false)
.accountLocked(!user.getActive())
.credentialsExpired(false)
.disabled(!user.getActive())
.build();
}
}
```
## Authentication Controller
```java
@RestController
@RequestMapping("/api/auth")
public class AuthenticationController {
private final AuthenticationService authenticationService;
public AuthenticationController(AuthenticationService authenticationService) {
this.authenticationService = authenticationService;
}
@PostMapping("/register")
public ResponseEntity<AuthenticationResponse> register(
@Valid @RequestBody RegisterRequest request) {
AuthenticationResponse response = authenticationService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/login")
public ResponseEntity<AuthenticationResponse> login(
@Valid @RequestBody LoginRequest request) {
AuthenticationResponse response = authenticationService.login(request);
return ResponseEntity.ok(response);
}
@PostMapping("/refresh")
public ResponseEntity<AuthenticationResponse> refreshToken(
@RequestBody RefreshTokenRequest request) {
AuthenticationResponse response = authenticationService.refreshToken(request);
return ResponseEntity.ok(response);
}
@PostMapping("/logout")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<Void> logout() {
SecurityContextHolder.clearContext();
return ResponseEntity.noContent().build();
}
}
```
## Authentication Service
```java
@Service
@Transactional
public class AuthenticationService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final AuthenticationManager authenticationManager;
public AuthenticationService(UserRepository userRepository,
PasswordEncoder passwordEncoder,
JwtService jwtService,
AuthenticationManager authenticationManager) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
this.authenticationManager = authenticationManager;
}
public AuthenticationResponse register(RegisterRequest request) {
if (userRepository.existsByEmail(request.email())) {
throw new DuplicateResourceException("Email already registered");
}
User user = new User();
user.setEmail(request.email());
user.setPassword(passwordEncoder.encode(request.password()));
user.setUsername(request.username());
user.setActive(true);
Role userRole = new Role();
userRole.setName("USER");
user.setRoles(Set.of(userRole));
user = userRepository.save(user);
String accessToken = jwtService.generateToken(convertToUserDetails(user));
String refreshToken = jwtService.generateRefreshToken(convertToUserDetails(user));
return new AuthenticationResponse(accessToken, refreshToken);
}
public AuthenticationResponse login(LoginRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.email(),
request.password()
)
);
User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
String accessToken = jwtService.generateToken(convertToUserDetails(user));
String refreshToken = jwtService.generateRefreshToken(convertToUserDetails(user));
return new AuthenticationResponse(accessToken, refreshToken);
}
public AuthenticationResponse refreshToken(RefreshTokenRequest request) {
String username = jwtService.extractUsername(request.refreshToken());
User user = userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
UserDetails userDetails = convertToUserDetails(user);
if (!jwtService.isTokenValid(request.refreshToken(), userDetails)) {
throw new InvalidTokenException("Invalid refresh token");
}
String accessToken = jwtService.generateToken(userDetails);
return new AuthenticationResponse(accessToken, request.refreshToken());
}
private UserDetails convertToUserDetails(User user) {
return org.springframework.security.core.userdetails.User
.builder()
.username(user.getEmail())
.password(user.getPassword())
.authorities(user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList()))
.build();
}
}
```
## Method Security
```java
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@PreAuthorize("hasRole('ADMIN')")
public List<User> getAllUsers() {
return userRepository.findAll();
}
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public User getUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
}
@PreAuthorize("isAuthenticated()")
@PostAuthorize("returnObject.email == authentication.principal.username")
public User updateProfile(Long userId, UserUpdateRequest request) {
User user = getUserById(userId);
// Update logic
return userRepository.save(user);
}
@Secured({"ROLE_ADMIN", "ROLE_MANAGER"})
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
}
```
## OAuth2 Resource Server (JWT)
```java
@Configuration
@EnableWebSecurity
public class OAuth2ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public JwtDecoder jwtDecoder() {
return JwtDecoders.fromIssuerLocation("https://auth.example.com");
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthenticationConverter =
new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
}
```
## SSL Bundles & Certificate Hot Reload
Spring Boot 3.1+ provides unified SSL configuration via SSL Bundles, with automatic reload when certificates change on disk.
### PEM-based SSL Bundle (e.g., cert-manager on Kubernetes)
```yaml
# application.yml
spring:
ssl:
bundle:
pem:
server:
reload-on-update: true
keystore:
certificate: /etc/ssl/certs/tls.crt
private-key: /etc/ssl/certs/tls.key
truststore:
certificate: /etc/ssl/certs/ca.crt
server:
port: 8443
ssl:
bundle: server
```
### Applying SSL Bundles to RestClient
```java
@Configuration
public class RestClientSslConfig {
@Bean
public RestClient secureRestClient(RestClient.Builder builder, SslBundles sslBundles) {
return builder
.baseUrl("https://internal-service")
.apply(b -> b.requestFactory(
ClientHttpRequestFactories.get(
ClientHttpRequestFactorySettings.DEFAULTS
.withSslBundle(sslBundles.getBundle("server"))
)))
.build();
}
}
```
### JKS-based SSL Bundle
```yaml
spring:
ssl:
bundle:
jks:
server:
reload-on-update: true
keystore:
location: classpath:keystore.p12
# credentials: configure via environment variables
type: PKCS12
```
When `reload-on-update: true` is set, Spring Boot watches the certificate files and automatically reloads the SSL context when they change — no application restart required. This works seamlessly with Kubernetes cert-manager, which writes renewed certificates to the mounted Secret volume.
## Quick Reference
| Annotation | Purpose |
|------------|---------|
| `@EnableWebSecurity` | Enables Spring Security |
| `@EnableMethodSecurity` | Enables method-level security annotations |
| `@PreAuthorize` | Checks authorization before method execution |
| `@PostAuthorize` | Checks authorization after method execution |
| `@Secured` | Role-based method security |
| `@WithMockUser` | Mock authenticated user in tests |
| `@AuthenticationPrincipal` | Inject current user in controller |
## Security Best Practices
- Always use HTTPS in production
- Use SSL Bundles with `reload-on-update: true` for zero-downtime certificate rotation
- Store JWT secret in environment variables or mounted secrets
- Use strong password encoding (BCrypt with strength 12+)
- Implement token refresh mechanism
- Add rate limiting to authentication endpoints
- Validate all user inputs
- Log security events
- Keep dependencies updated
- Use CSRF protection for state-changing operations
- Implement proper session timeout
references/testing.md
# Testing - Spring Boot Test
## Unit Testing with JUnit 5
```java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@InjectMocks
private UserService userService;
@Test
@DisplayName("Should create user successfully")
void shouldCreateUser() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
User user = new User();
user.setId(1L);
user.setEmail(request.email());
user.setUsername(request.username());
when(userRepository.existsByEmail(request.email())).thenReturn(false);
when(passwordEncoder.encode(request.password())).thenReturn("encodedPassword");
when(userRepository.save(any(User.class))).thenReturn(user);
// When
UserResponse response = userService.create(request);
// Then
assertThat(response).isNotNull();
assertThat(response.email()).isEqualTo(request.email());
verify(userRepository).existsByEmail(request.email());
verify(passwordEncoder).encode(request.password());
verify(userRepository).save(any(User.class));
}
@Test
@DisplayName("Should throw exception when email already exists")
void shouldThrowExceptionWhenEmailExists() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
when(userRepository.existsByEmail(request.email())).thenReturn(true);
// When & Then
assertThatThrownBy(() -> userService.create(request))
.isInstanceOf(DuplicateResourceException.class)
.hasMessageContaining("Email already registered");
verify(userRepository, never()).save(any(User.class));
}
}
```
## Integration Testing with @SpringBootTest
```java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@ActiveProfiles("test")
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class UserIntegrationTest {
@Autowired
private RestTestClient restTestClient;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Test
@Order(1)
@DisplayName("Should create user via API")
void shouldCreateUserViaApi() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
// When & Then
restTestClient.post()
.uri("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.exchange()
.expectStatus().isCreated()
.expectHeader().exists("Location")
.expectBody(UserResponse.class)
.value(response -> {
assertThat(response.email()).isEqualTo(request.email());
});
}
@Test
@Order(2)
@DisplayName("Should return validation error for invalid request")
void shouldReturnValidationError() {
// Given
UserCreateRequest request = new UserCreateRequest(
"invalid-email",
"short",
"u",
15
);
// When & Then
restTestClient.post()
.uri("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.exchange()
.expectStatus().isBadRequest()
.expectBody()
.jsonPath("$.errors").isNotEmpty();
}
}
```
## Web Layer Testing with MockMvc
```java
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
@WithMockUser(roles = "ADMIN")
@DisplayName("Should get all users")
void shouldGetAllUsers() throws Exception {
// Given
Page<UserResponse> users = new PageImpl<>(List.of(
new UserResponse(1L, "user1@example.com", "user1", 25, true, null, null),
new UserResponse(2L, "user2@example.com", "user2", 30, true, null, null)
));
when(userService.findAll(any(Pageable.class))).thenReturn(users);
// When & Then
mockMvc.perform(get("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content.length()").value(2))
.andExpect(jsonPath("$.content[0].email").value("user1@example.com"))
.andDo(print());
}
@Test
@WithMockUser(roles = "ADMIN")
@DisplayName("Should create user")
void shouldCreateUser() throws Exception {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
UserResponse response = new UserResponse(
1L,
request.email(),
request.username(),
request.age(),
true,
LocalDateTime.now(),
LocalDateTime.now()
);
when(userService.create(any(UserCreateRequest.class))).thenReturn(response);
// When & Then
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.email").value(request.email()))
.andExpect(jsonPath("$.username").value(request.username()))
.andDo(print());
}
@Test
@WithMockUser(roles = "USER")
@DisplayName("Should return 403 for non-admin user")
void shouldReturn403ForNonAdmin() throws Exception {
mockMvc.perform(get("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden());
}
}
```
## Data JPA Testing
```java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private TestEntityManager entityManager;
@Test
@DisplayName("Should find user by email")
void shouldFindUserByEmail() {
// Given
User user = new User();
user.setEmail("test@example.com");
user.setPassword("password");
user.setUsername("testuser");
user.setActive(true);
entityManager.persistAndFlush(user);
// When
Optional<User> found = userRepository.findByEmail("test@example.com");
// Then
assertThat(found).isPresent();
assertThat(found.get().getEmail()).isEqualTo("test@example.com");
}
@Test
@DisplayName("Should check if email exists")
void shouldCheckIfEmailExists() {
// Given
User user = new User();
user.setEmail("test@example.com");
user.setPassword("password");
user.setUsername("testuser");
user.setActive(true);
entityManager.persistAndFlush(user);
// When
boolean exists = userRepository.existsByEmail("test@example.com");
// Then
assertThat(exists).isTrue();
}
@Test
@DisplayName("Should fetch user with roles")
void shouldFetchUserWithRoles() {
// Given
Role adminRole = new Role();
adminRole.setName("ADMIN");
entityManager.persist(adminRole);
User user = new User();
user.setEmail("admin@example.com");
user.setPassword("password");
user.setUsername("admin");
user.setActive(true);
user.setRoles(Set.of(adminRole));
entityManager.persistAndFlush(user);
entityManager.clear();
// When
Optional<User> found = userRepository.findByEmailWithRoles("admin@example.com");
// Then
assertThat(found).isPresent();
assertThat(found.get().getRoles()).hasSize(1);
assertThat(found.get().getRoles()).extracting(Role::getName).contains("ADMIN");
}
}
```
## Testcontainers with @ServiceConnection
```java
// ✅ Preferred: @ServiceConnection auto-configures datasource properties
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
class UserServiceIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
userRepository.deleteAll();
}
@Autowired
private Flyway flyway;
@Test
@DisplayName("Should apply all Flyway migrations")
void migrationsApplied() {
// Only when the app uses Flyway: migrations ran and Hibernate validated the schema
assertThat(flyway.info().current()).isNotNull();
assertThat(flyway.info().pending()).isEmpty();
}
@Test
@DisplayName("Should create and find user in real database")
void shouldCreateAndFindUser() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
// When
UserResponse created = userService.create(request);
UserResponse found = userService.findById(created.id());
// Then
assertThat(found).isNotNull();
assertThat(found.email()).isEqualTo(request.email());
}
}
```
### Legacy approach (Spring Boot < 3.1, not recommended)
```java
// @DynamicPropertySource - still works, but @ServiceConnection is simpler
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
```
### Reusable Container Configuration
```java
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
}
// Use in tests
@SpringBootTest
@Import(TestcontainersConfig.class)
class OrderServiceIntegrationTest {
// Container shared across tests in this class
}
```
## Testing Reactive Endpoints with WebTestClient
```java
@WebFluxTest(UserReactiveController.class)
class UserReactiveControllerTest {
@Autowired
private WebTestClient webTestClient;
@MockitoBean
private UserReactiveService userService;
@Test
@DisplayName("Should get user reactively")
void shouldGetUserReactively() {
// Given
UserResponse user = new UserResponse(
1L,
"test@example.com",
"testuser",
25,
true,
LocalDateTime.now(),
LocalDateTime.now()
);
when(userService.findById(1L)).thenReturn(Mono.just(user));
// When & Then
webTestClient.get()
.uri("/api/v1/users/{id}", 1L)
.accept(MediaType.APPLICATION_JSON)
.exchange()
.expectStatus().isOk()
.expectBody(UserResponse.class)
.value(response -> {
assertThat(response.id()).isEqualTo(1L);
assertThat(response.email()).isEqualTo("test@example.com");
});
}
@Test
@DisplayName("Should create user reactively")
void shouldCreateUserReactively() {
// Given
UserCreateRequest request = new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
UserResponse response = new UserResponse(
1L,
request.email(),
request.username(),
request.age(),
true,
LocalDateTime.now(),
LocalDateTime.now()
);
when(userService.create(any(UserCreateRequest.class))).thenReturn(Mono.just(response));
// When & Then
webTestClient.post()
.uri("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.body(Mono.just(request), UserCreateRequest.class)
.exchange()
.expectStatus().isCreated()
.expectHeader().exists("Location")
.expectBody(UserResponse.class)
.value(user -> {
assertThat(user.email()).isEqualTo(request.email());
});
}
}
```
## Testing Configuration
With a relational database, prefer Testcontainers with the production engine (see above): Flyway applies the real migrations and `ddl-auto: validate` checks the entities, so tests need no datasource or `ddl-auto` overrides. The H2 config below is a fallback only for projects without Flyway.
```java
// application-test.yml
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop # only without Flyway; with Flyway keep validate
show-sql: true
properties:
hibernate:
format_sql: true
security:
user:
name: test
password: test
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE
// Test Configuration Class
@TestConfiguration
public class TestConfig {
@Bean
@Primary
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(4); // Faster for tests
}
@Bean
public Clock fixedClock() {
return Clock.fixed(
Instant.parse("2024-01-01T00:00:00Z"),
ZoneId.of("UTC")
);
}
}
```
## Test Fixtures with @DataJpaTest
```java
@Component
public class TestDataFactory {
public static User createUser(String email, String username) {
User user = new User();
user.setEmail(email);
user.setPassword("encodedPassword");
user.setUsername(username);
user.setActive(true);
user.setCreatedAt(LocalDateTime.now());
user.setUpdatedAt(LocalDateTime.now());
return user;
}
public static UserCreateRequest createUserRequest() {
return new UserCreateRequest(
"test@example.com",
"Password123",
"testuser",
25
);
}
}
```
## Quick Reference
| Annotation | Purpose |
|------------|---------|
| `@SpringBootTest` | Full application context integration test |
| `@AutoConfigureRestTestClient` | Auto-configure `RestTestClient` for integration tests |
| `@WebMvcTest` | Test MVC controllers with mocked services |
| `@WebFluxTest` | Test reactive controllers |
| `@DataJpaTest` | Test JPA repositories with in-memory database |
| `@MockitoBean` | Add mock bean to Spring context (replaces `@MockBean`, removed in Spring Boot 4) |
| `@MockitoSpyBean` | Wrap a Spring bean in a Mockito spy (replaces `@SpyBean`) |
| `@WithMockUser` | Mock authenticated user for security tests |
| `@Testcontainers` | Enable Testcontainers support |
| `@ActiveProfiles` | Activate specific Spring profiles for test |
## Testing Best Practices
- Write tests following AAA pattern (Arrange, Act, Assert)
- Use descriptive test names with @DisplayName
- Mock external dependencies, use real DB with Testcontainers
- Achieve 85%+ code coverage
- Test happy path and edge cases
- Use @Transactional for test data cleanup
- Separate unit tests from integration tests
- Use parameterized tests for multiple scenarios
- Test security rules and validation
- Keep tests fast and independent
references/ui-contract.md
# REST API Built for a UI (OpenAPI Contract)
Rules for an API whose web UI (or other client) is **generated from its OpenAPI contract**, for example a UI made from [crud-ui-blueprint](https://github.com/mmartofel/crud-ui-blueprint). The UI builds its client code, forms, validation, error messages and login from the contract and from the values listed here. The UI never works around the contract, so every rule below is a requirement.
**When it applies** (see the `conventions` skill, "REST API Built for a UI"): the user says the API backs a UI or generated clients; the project's `CLAUDE.md` names a UI; or the repo has `contract/openapi.json` or `docs/API-CONTRACT.md`. A contract document in the project (`docs/API-CONTRACT.md`) wins over this file. A plain API does not load this file.
Examples use a resource `Person` (tag `Persons`, path `/api/persons`, realm `person`) and a `Task` with an optional `assignee` (a Person). Replace them with your resources.
## 1. Endpoints
- Base path `/api` (not `/api/v1`). The UI calls relative URLs and production serves UI and API from one origin: **no CORS configuration**.
- Five operations per resource; the method names become the operationIds and the names of the UI's generated hooks:
| Method and path | Controller method | Success |
|---|---|---|
| `GET /api/persons` | `listPersons` | 200 `PagedModel` |
| `GET /api/persons/{id}` | `getPerson` | 200 record |
| `POST /api/persons` | `createPerson` | 201 record, `@ResponseStatus(HttpStatus.CREATED)` |
| `PUT /api/persons/{id}` | `updatePerson` | 200 record |
| `DELETE /api/persons/{id}` | `deletePerson` | 204, `@ResponseStatus(HttpStatus.NO_CONTENT)` |
- `{id}` is `Long` (`int64`). One `@Tag(name = "Persons")` per controller; `@SecurityRequirement(name = "bearer")`.
- Schemas per direction: `PersonResponse`, `PersonCreateRequest`, `PersonUpdateRequest`. Entities never appear.
## 2. OpenAPI Contract (springdoc 3)
```xml
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version> <!-- 3.x for Spring Boot 4 -->
</dependency>
<!-- spring-boot-maven-plugin with the build-info goal: BuildProperties and /actuator/info build.version -->
```
```yaml
springdoc:
default-produces-media-type: application/json # otherwise success responses are */*
writer-with-default-pretty-printer: true
writer-with-order-by-keys: true # stable file, readable diffs
```
```java
@Configuration
@SecurityScheme(name = "bearer", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "JWT")
public class OpenApiConfig {
/** Resources other records point to; deleting them returns 409 {@code in-use} while referenced. */
private static final Set<String> REFERENCED_PATHS = Set.of("/api/persons/{id}");
/** The contract version is the Maven project version; the server URL is relative, so the file is stable. */
@Bean
OpenAPI openApi(BuildProperties build) {
return new OpenAPI().info(new Info().title("Person API").version(build.getVersion()))
.servers(List.of(new Server().url("/")));
}
/**
* Problem Details error responses by convention: 400 and 409 on POST/PUT, 400 on list GETs, 404 on {id} paths,
* 409 on DELETE of a referenced resource. @ApiResponse on methods would drop the generated success response.
*/
@Bean
OpenApiCustomizer problemResponses() {
return openApi -> {
openApi.getComponents().getSchemas().putAll(ModelConverters.getInstance(true).readAll(Problem.class));
openApi.getPaths().forEach((path, item) -> item.readOperationsMap().forEach((method, operation) -> {
if (method == HttpMethod.POST || method == HttpMethod.PUT) {
addProblem(operation, "400", "Invalid request; field errors in `errors` (code `not-found` for an unknown reference)");
addProblem(operation, "409", "Value already in use (`errors` with code `unique`) or stale `version` (no `errors`)");
}
if (method == HttpMethod.GET && !path.contains("{id}")) {
addProblem(operation, "400", "Invalid filter (field errors in `errors`) or sort");
}
if (path.contains("{id}")) {
addProblem(operation, "404", "Not found");
}
if (method == HttpMethod.DELETE && REFERENCED_PATHS.contains(path)) {
addProblem(operation, "409", "Still referenced by other records (`errors` with code `in-use`, no `field`)");
}
}));
};
}
private static void addProblem(Operation operation, String status, String description) {
Schema<?> problem = new Schema<>().$ref("#/components/schemas/Problem");
operation.getResponses().addApiResponse(status, new ApiResponse().description(description)
.content(new Content().addMediaType("application/problem+json", new MediaType().schema(problem))));
}
}
```
Schema rules (the UI generates TypeScript types and Zod validation from them):
| Rule | How |
|------|-----|
| Always-present response fields are required (at least `id`, `version`) | `@Schema(requiredMode = REQUIRED)` |
| Server-set fields only in responses | `createdAt`/`updatedAt` as `Instant` (`@CreationTimestamp`/`@UpdateTimestamp`), `@Schema(accessMode = READ_ONLY)` |
| Enums | `UPPER_CASE` Java enum constants → `type: string, enum: [...]`; the UI shows translated labels |
| Dates | `LocalDate` → `format: date`, `Instant` → `format: date-time` |
| Required text | `@NotBlank @Size(min = 1, max = N)`: `@Size(max = N)` alone overwrites the `minLength: 1` of `@NotBlank` with `0`, and `@Schema(minLength = 1)` does not win |
| Patterns | `@Pattern` must be a valid JavaScript regex (the UI runs `new RegExp`): no `(?s)`, `(?i)`, possessive quantifiers, atomic groups, `\A`, `\Z`, `\z`, `\p{javaLowerCase}` |
| A rule Bean Validation cannot express | Custom constraint + matching `@Schema` keyword (e.g. `@MultipleOf("0.5")` with `@Schema(multipleOf = 0.5)`) + the same DB `CHECK` |
| Field with a default | `@Schema(defaultValue = "AVAILABLE")` in the request, the entity setter maps null to it (`Objects.requireNonNullElse`), the migration has the same `DEFAULT` |
| Version | `info.version` from `BuildProperties`; every contract change bumps the project version, a breaking change bumps MAJOR |
springdoc 3 pitfalls:
- Any `@ApiResponse` on a controller method (also through a meta-annotation) replaces the generated responses and the success response disappears; that is why errors are added in the `OpenApiCustomizer`.
- Manually registered schemas need `ModelConverters.getInstance(true)` (OpenAPI 3.1), or `"type": "object"` is missing.
- The success status comes from `@ResponseStatus`, not from the returned `ResponseEntity`: a create without `@ResponseStatus(CREATED)` is documented as 200.
- `Pageable` and filter records without `@ParameterObject` are documented as one object parameter.
- Without a fixed `servers` URL, a contract exported from a `RANDOM_PORT` test contains the random port and changes on every build.
**Export and CI.** An integration test writes `contract/openapi.json` on every `mvn clean verify`, first writing and then asserting, so a failing assertion still leaves the file to inspect:
```java
@Test
void exportOpenApiContract() throws Exception {
String json = restTestClient.get().uri("/v3/api-docs").exchange()
.expectStatus().isOk()
.expectBody(String.class).returnResult().getResponseBody();
Path file = Path.of("contract/openapi.json");
Files.createDirectories(file.getParent());
Files.writeString(file, json + System.lineSeparator());
DocumentContext contract = JsonPath.parse(json);
assertThat(contract.read("$.info.version", String.class)).isEqualTo(buildProperties.getVersion());
assertThat(contract.read("$.paths['/api/persons'].post.operationId", String.class)).isEqualTo("createPerson");
assertThat(contract.read("$.paths['/api/persons'].post.responses", Map.class)).containsKey("201");
assertThat(contract.read("$.components.schemas.PersonCreateRequest.properties.firstName.minLength", Integer.class))
.isEqualTo(1);
assertThat(contract.read("$.components.schemas.PersonResponse.required", List.class)).contains("id", "version");
}
```
`SecurityConfig` permits `/v3/api-docs/**`. CI fails when the committed contract is out of date (`mvn -B verify`, then `test -z "$(git status --porcelain contract/)"`). The test needs Testcontainers: without Docker/Podman it is skipped and the contract is not regenerated.
## 3. Errors
RFC 9457 Problem Details (`application/problem+json`, schema `Problem` with `status` and `title` required), from the `ApiExceptionHandler` in `web.md`. Field errors go into `errors: [{field, code, message}]`, schema `FieldError` with `code` and `message` required.
- `code` is a documented string enum the UI translates. Use exactly this vocabulary and do not invent synonyms (`too-long`, `pattern`, ...); a constraint without its own code maps to `invalid`:
| Code | Source |
|---|---|
| `required` | `@NotBlank`, `@NotNull` (an empty required text breaks `@NotBlank` and `@Size(min = 1)`: report only `required`) |
| `email` | `@Email` |
| `past` | `@Past` |
| `unique` | unique constraint violation, by constraint name (409) |
| `invalid` | every other constraint (`@Size`, `@Pattern`, `@Min`, `@Max`, ...), bad filter values |
| `date-range` | cross-field rule, reported on the later field (`@DateRange` in `web.md`) |
| `not-found` | unknown `<relation>Id` in a request (400) |
| `in-use` | delete of a record that is still referenced (409, no `field`) |
List them in `@Schema(allowableValues = ...)` on `FieldError.code`. A new code is a contract change: add it to the mapping, the enum, the README and the tests, and tell the UI team (it needs a translation).
- `message` is an English fallback only. `field` is absent only for record-level errors (`in-use`).
- Status codes the UI handles: 400 (field errors on the form), 401 (re-login), 403 ("no access"), 404 ("not found" page), **409 with `errors`** (field conflict, e.g. `unique`, or `in-use`), **409 without `errors`** (stale `version`: the UI offers a reload). Never return a stale-version 409 with `errors`.
Prepared errors for relations, rendered by `ResponseEntityExceptionHandler` without an extra `@ExceptionHandler`:
```java
/** 400: {@code field} references a record that does not exist. */
public static ErrorResponseException notFound(String field) {
return problem(HttpStatus.BAD_REQUEST, "Referenced record does not exist", new FieldMessage(field, NOT_FOUND, "does not exist"));
}
/** 409: the record is still referenced by other records and cannot be deleted. */
public static ErrorResponseException inUse(String detail, String message) {
return problem(HttpStatus.CONFLICT, detail, new FieldMessage(null, IN_USE, message));
}
private static ErrorResponseException problem(HttpStatus status, String detail, FieldMessage error) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setProperty("errors", List.of(error));
return new ErrorResponseException(status, problem, null);
}
```
## 4. Updates and Concurrency
- Responses include `version`. `<Resource>CreateRequest` has no `id` and no `version`.
- `PUT` replaces the whole record: `<Resource>UpdateRequest` has the same fields and constraints as the create request plus a required `version` from the last read. Optional fields sent as `null` are cleared; a field with a default gets the default again.
- The service compares the request's `version` with the loaded entity and throws `ObjectOptimisticLockingFailureException` on mismatch (409 without `errors`); see `jpa-patterns`, Optimistic Locking. `PUT` uses `saveAndFlush`, so `version` and `updatedAt` in the response are current.
## 5. Lists, Search and Filters
- `GET /api/persons?q=&page=&size=&sort=` plus optional filters, each a separate, typed query parameter (enum, `format: date`, boolean, `int64` id). Never one generic filter expression. Response: `PagedModel` `{content, page: {number, size, totalElements, totalPages}}`.
- `q` is a free-text search over the fields the API chooses, with `LIKE` wildcards escaped (`data.md`, Specifications). An empty or missing filter means "no filter"; filters combine with AND; an invalid value returns 400 with a field error named after the parameter.
```java
/** Optional list filters, combined with AND. */
public record TaskFilter(
@Parameter(description = "Case-insensitive search in title and description") String q,
@Parameter(description = "Done or open tasks") Boolean done,
@Parameter(description = "Exact priority") TaskPriority priority,
@Parameter(description = "Id of the assigned person") Long assigneeId) {
public Specification<Task> toSpecification() {
List<Specification<Task>> specs = new ArrayList<>();
if (q != null && !q.isBlank()) {
specs.add(TaskSpecifications.search(q, "title", "description"));
}
if (done != null) {
specs.add((root, query, cb) -> cb.equal(root.get("done"), done));
}
if (priority != null) {
specs.add((root, query, cb) -> cb.equal(root.get("priority"), priority));
}
if (assigneeId != null) {
specs.add((root, query, cb) -> cb.equal(root.get("assignee").get("id"), assigneeId));
}
return Specification.allOf(specs);
}
}
// Controller
@GetMapping
public PagedModel<TaskResponse> listTasks(@ParameterObject @Valid TaskFilter filter, @ParameterObject Pageable pageable) {
return new PagedModel<>(taskService.list(filter.toSpecification(), pageable));
}
```
## 6. Relations
- **Requests** reference a related record by id: `<relation>Id` (e.g. `assigneeId`), nullable when the relation is optional. An unknown id returns 400 with a field error on `<relation>Id`, code `not-found`.
- **Responses** embed a summary `<relation>: {id, label}` (both required), where `label` is the display name, so a list never needs one request per row:
```java
/** Summary of a related record embedded in a response. */
public record Reference(@Schema(requiredMode = REQUIRED) Long id,
@Schema(requiredMode = REQUIRED, description = "Display name") String label) {
public static Reference of(Person p) {
return p == null ? null : new Reference(p.getId(), p.getDisplayName());
}
}
```
- **Filter** on a relation: `<relation>Id` (the UI lists "tasks of this person" with it).
- **Delete** of a record that is still referenced: count the references and throw `inUse(...)` (409, `errors: [{code: "in-use", message}]`, no `field`); never cascade. The foreign key is `ON DELETE RESTRICT` as a backstop. Add the path to `REFERENCED_PATHS`.
- **Loading:** `@EntityGraph(attributePaths = "assignee")` on the repository's `findAll(Specification, Pageable)` and `findById` (`open-in-view` is off). Writes are `@Transactional` and map the response inside the transaction (`jpa-patterns`, "Writing a Record with a Relation").
- **Tests:** none → A, A → B and A → none, unknown id (400 `not-found`) and delete in use (409 `in-use`), in integration tests against the real database.
## 7. Security and Keycloak
The UI logs in against a Keycloak realm with Authorization Code + PKCE, and its setup (`crud-ui-init`) expects this shape. For realm `<realm>`:
- **Roles:** realm roles `<realm>-viewer` (read) and `<realm>-editor` (read and write). Reads need either role; writes need `<realm>-editor`.
- **Clients:** `<realm>-ui`, public, standard flow only, PKCE `S256`, redirect URI `http://localhost:5173/*`, web origin `http://localhost:5173`. `<realm>-api`, confidential, service account with `<realm>-editor`, secret `<realm>-api-secret` (dev only), for `client_credentials` smoke tests.
- **Demo users** (password = username): `viewer` with `<realm>-viewer`, `editor` with `<realm>-editor`. The UI's e2e tests log in as them.
- **Internationalization** on: `en` (default) and `pl`; the login page follows the UI's `ui_locales`.
- **One realm file** `k8s/realm.json`, imported with `--import-realm` by Docker Compose (Keycloak `start-dev`, volume `./k8s/realm.json:/opt/keycloak/data/import/realm.json:ro`) and by Kubernetes (`configMapGenerator` in `k8s/kustomization.yaml`, so a changed file rolls a new pod). `--import-realm` skips an existing realm: after a change run `docker compose up -d --force-recreate keycloak`.
- **Ports:** Keycloak 8080, API 8081 (`server.port: ${SERVER_PORT:8081}`), PostgreSQL 5432.
```java
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**", "/actuator/info", "/v3/api-docs/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/**").hasAnyRole(VIEWER, EDITOR)
.requestMatchers("/api/**").hasRole(EDITOR)
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(realmRoles())))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable())
.build();
}
/** Maps Keycloak realm roles ({@code realm_access.roles}) to {@code ROLE_<role>} authorities. */
private static JwtAuthenticationConverter realmRoles() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !(realmAccess.get("roles") instanceof List<?> roles)) {
return List.of();
}
return roles.stream().map(role -> (GrantedAuthority) new SimpleGrantedAuthority("ROLE_" + role)).toList();
});
return converter;
}
```
The JWT is validated with `spring.security.oauth2.resourceserver.jwt.jwk-set-uri: ${JWK_SET_URI:http://localhost:8080/realms/<realm>/protocol/openid-connect/certs}`.
## 8. Status Endpoints
The UI footer polls `/actuator/health` for the API status and reads `/actuator/info` for its running version, both without a token:
```yaml
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
show-components: always # components (db, ...) without details
probes:
enabled: true
group:
readiness:
include: readinessState,db
```
`build-info` fills `/actuator/info` `build.version`, equal to the contract's `info.version`. An integration test asserts both answer without a token, `health` lists `db`, `build.version` equals `info.version`, and `/actuator/env` returns 401 (`testing-patterns`).
## 9. README "Connect a UI"
The README keeps a section with the values the UI's `crud-ui-init` asks for. Update it whenever a port, realm, client, user or role changes:
````markdown
## Connect a UI
`.env.local` of a crud-ui-blueprint UI for this API's local stack:
```
VITE_OIDC_AUTHORITY=http://localhost:8080/realms/<realm>
VITE_OIDC_CLIENT_ID=<realm>-ui
VITE_OIDC_ROLES_CLAIM=realm_access.roles
API_PROXY_TARGET=http://localhost:8081
CONTRACT_SOURCE=../<project>/contract/openapi.json
E2E_WRITER_USER=editor
E2E_WRITER_PASSWORD=editor
E2E_READER_USER=viewer
E2E_READER_PASSWORD=viewer
```
Read role `<realm>-viewer`, write role `<realm>-editor` (realm roles in `realm_access.roles`). The UI footer reads the
public `/actuator/health` (components) and `/actuator/info` (`build.version`).
````
## 10. Build Order and Definition of Done
Build order for a new API: skeleton (`pom.xml` with `build-info` and actuator, `docker-compose.yml` with PostgreSQL and Keycloak, `k8s/` with `realm.json`, `application.yml`, CI with the contract check), then the shared infrastructure (`SecurityConfig`, `OpenApiConfig`, `ApiExceptionHandler` with `Problem`/`FieldError`/`notFound`/`inUse`, `Reference`, the contract export test), then one resource at a time, referenced resources first (migration, entity, DTOs, filter, repository, service, controller, tests, README).
Done means:
1. `mvn clean verify` passes with the integration tests running, not skipped.
2. `contract/openapi.json` is regenerated, its `info.version` bumped, and part of the change.
3. Positive and negative tests; a relation change has an integration test.
4. The local API was smoke-tested with a `client_credentials` token from `<realm>-api` and the smoke data deleted.
5. README updated: changelog, API and error tables, "Connect a UI".
references/web.md
# Web Layer - Controllers & REST APIs
## REST Controller Pattern
```java
@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping
public PagedModel<UserResponse> listUsers(
@ParameterObject @PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
return new PagedModel<>(userService.findAll(pageable));
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
UserResponse user = userService.findById(id);
return ResponseEntity.ok(user);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // springdoc documents a ResponseEntity return as 200 without it
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody UserCreateRequest request) {
UserResponse user = userService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(user.id())
.toUri();
return ResponseEntity.created(location).body(user);
}
@PutMapping("/{id}")
public ResponseEntity<UserResponse> updateUser(
@PathVariable Long id,
@Valid @RequestBody UserUpdateRequest request) {
UserResponse user = userService.update(id, request);
return ResponseEntity.ok(user);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
}
```
## Paged Lists
- Return Spring Data's `PagedModel` (`{content, page: {number, size, totalElements, totalPages}}`), never `Page`/`PageImpl`: their JSON is not a stable format and Spring Data logs a warning when serializing them.
- Annotate `Pageable` with springdoc's `@ParameterObject`, so the contract shows `page`, `size` and `sort` as separate query parameters instead of one `pageable` object.
- Do not put `@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)` on the application class to get the same shape: `@WebMvcTest` then registers the web support twice and fails.
- Cap the page size (`spring.data.web.pageable.max-page-size: 100`).
- An unknown `sort` property throws `PropertyReferenceException` (package `org.springframework.data.core` in Spring Data 4); map it to 400 (see Exception Handling).
## Request DTOs with Validation
```java
public record UserCreateRequest(
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
String email,
@NotBlank(message = "Password is required")
@Size(min = 8, max = 100, message = "Password must be 8-100 characters")
@Pattern(regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d).*$",
message = "Password must contain uppercase, lowercase, and digit")
String password,
@NotBlank(message = "Username is required")
@Size(min = 3, max = 50)
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "Username must be alphanumeric")
String username,
@Min(value = 18, message = "Must be at least 18")
@Max(value = 120, message = "Must be at most 120")
Integer age
) {}
// PUT replaces the whole record: same fields and constraints as the create request,
// plus the version from the last read (409 on mismatch). Optional fields sent as null are cleared.
// Required when the API is built for a UI; a plain API may use another update model, but still sends the version.
public record UserUpdateRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 3, max = 50) @Pattern(regexp = "^[a-zA-Z0-9_]+$") String username,
@Min(18) @Max(120) Integer age,
@NotNull Long version
) {}
```
Rules that keep the constraints usable by API clients (the OpenAPI contract carries them as JSON Schema, see `ui-contract.md`). Required when the API is built for a UI (`conventions` skill); recommended otherwise:
- `@Pattern` must also be a valid JavaScript (ECMA-262) regex: no inline flags (`(?s)`, `(?i)`), possessive quantifiers (`a++`), atomic groups (`(?>…)`), `\A`, `\Z`, `\z` or `\p{javaLowerCase}`.
- A required text field is `@NotBlank @Size(min = 1, max = N)`: `@Size(max = N)` alone overwrites the `minLength: 1` that `@NotBlank` produces with `0`.
- A rule Bean Validation cannot express gets a custom constraint, the matching `@Schema` keyword (e.g. `@Schema(multipleOf = 0.5)`) and the same DB `CHECK`.
- A field with a default (e.g. `status`) sent as `null` on PUT gets its default again: the entity setter maps null to it (`Objects.requireNonNullElse`), the request documents it with `@Schema(defaultValue = "ACTIVE")`, and the migration has the same `DEFAULT`.
## Response DTOs
```java
public record UserResponse(
@Schema(requiredMode = REQUIRED) Long id,
@Schema(requiredMode = REQUIRED) String email,
@Schema(requiredMode = REQUIRED) String username,
Integer age,
@Schema(requiredMode = REQUIRED) boolean active,
@Schema(requiredMode = REQUIRED, accessMode = READ_ONLY) Instant createdAt,
@Schema(requiredMode = REQUIRED, accessMode = READ_ONLY) Instant updatedAt,
@Schema(requiredMode = REQUIRED) Long version
) {
public static UserResponse from(User user) {
return new UserResponse(
user.getId(),
user.getEmail(),
user.getUsername(),
user.getAge(),
user.isActive(),
user.getCreatedAt(),
user.getUpdatedAt(),
user.getVersion()
);
}
}
```
When the API is built for a UI, mark every always-present field `requiredMode = REQUIRED`; otherwise clients generated from the contract (TypeScript, Kotlin) see every field as optional. Include `version`, so a client can send it back on PUT.
## Exception Handling (Problem Details)
Every error is an RFC 9457 Problem Details body (`application/problem+json`). When the API is built for a UI (`conventions` skill), field errors go into an `errors` extension, one entry per field with a stable, documented `code` that clients can translate; `message` is an English fallback only. `field` is left out for errors about the whole record.
```json
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Invalid request content.",
"instance": "/api/users",
"errors": [
{"field": "email", "code": "email", "message": "must be a well-formed email address"},
{"field": "username", "code": "required", "message": "must not be blank"}
]
}
```
```java
/** All errors are RFC 9457 Problem Details; field-level errors are listed in {@code errors}. */
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
public static final String REQUIRED = "required";
public static final String EMAIL = "email";
public static final String UNIQUE = "unique";
public static final String INVALID = "invalid";
/** {@code code} is stable for clients; {@code message} is an English fallback. */
@Schema(name = "FieldError")
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FieldMessage(String field,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {REQUIRED, EMAIL, UNIQUE, INVALID})
String code,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String message) {
}
/** Documents the error body in the OpenAPI contract (see ui-contract.md); keep it in sync with the handler. */
@Schema(name = "Problem")
public record Problem(URI type,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int status,
String detail, URI instance, List<FieldMessage> errors) {
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
List<FieldMessage> errors = ex.getFieldErrors().stream()
.map(e -> new FieldMessage(e.getField(), code(e.getCode()), e.getDefaultMessage()))
.toList();
// An empty required text breaks both @NotBlank and @Size(min = 1); report only "required" for that field.
Set<String> missing = errors.stream().filter(e -> REQUIRED.equals(e.code())).map(FieldMessage::field)
.collect(Collectors.toSet());
ex.getBody().setProperty("errors", errors.stream()
.filter(e -> REQUIRED.equals(e.code()) || !missing.contains(e.field()))
.toList());
return super.handleMethodArgumentNotValid(ex, headers, status, request);
}
/** Maps a Bean Validation constraint name ({@code FieldError#getCode()}) to a stable error code. */
private static String code(String constraint) {
return switch (constraint) {
case "NotBlank", "NotNull" -> REQUIRED;
case "Email" -> EMAIL;
case null, default -> INVALID;
};
}
/** 409 with a field error for a known unique constraint, 409 without {@code errors} otherwise. */
@ExceptionHandler
ProblemDetail handleDataIntegrity(DataIntegrityViolationException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
if (ex.getCause() instanceof ConstraintViolationException cve
&& User.UNIQUE_EMAIL.equals(cve.getConstraintName())) {
problem.setDetail("Email is already in use");
problem.setProperty("errors", List.of(new FieldMessage("email", UNIQUE, "already in use")));
}
return problem;
}
/** 409 without {@code errors}: the client sent a stale version and has to reload. */
@ExceptionHandler
ProblemDetail handleOptimisticLock(ObjectOptimisticLockingFailureException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "Record was changed by someone else; reload and retry");
}
/** 400 for {@code ?sort=unknownProperty}. */
@ExceptionHandler
ProblemDetail handleBadSort(PropertyReferenceException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
}
/** 500 for anything not handled above; the cause is logged, never returned to the client. */
@ExceptionHandler
ProblemDetail handleUnexpected(Exception ex) {
logger.error("Unexpected error", ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected server error");
}
}
```
- Extend `ResponseEntityExceptionHandler`: it already renders Spring MVC's exceptions (`MethodArgumentNotValidException`, `ResponseStatusException`, `ErrorResponseException`, ...) as Problem Details.
- `ConstraintViolationException` here is Hibernate's (`org.hibernate.exception`), which carries the database constraint name; name constraints explicitly in migrations (`uk_users_email`) and keep the name in a constant on the entity.
- A new constraint or code goes into `code(...)`, the `allowableValues` of `FieldError`, the README and the tests at the same time. Clients translate codes, never `message`.
- The catch-all has no `@ResponseStatus`, so springdoc does not add a 500 response to every operation.
- Never put `ex.getMessage()` of an unexpected exception into the response.
- A missing record throws `ResponseStatusException(NOT_FOUND, ...)` or a prepared `ErrorResponseException`; `ResponseEntityExceptionHandler` renders both, no extra `@ExceptionHandler` needed.
- Deleting a record that other records still reference returns 409 with a `detail`; it never cascades (the foreign key is `ON DELETE RESTRICT` as a backstop).
## Cross-Field Validation
A rule that involves two fields is a class-level constraint that reports the error on one field (the later one), so a client can show it next to an input:
```java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRange.Validator.class)
public @interface DateRange {
String message() default "must not be before the start date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class Validator implements ConstraintValidator<DateRange, DatePeriod> {
@Override
public boolean isValid(DatePeriod value, ConstraintValidatorContext context) {
if (value.from() == null || value.to() == null || !value.to().isBefore(value.from())) {
return true;
}
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
.addPropertyNode("to").addConstraintViolation();
return false;
}
}
}
/** Implemented by the create and update requests that carry the period. */
public interface DatePeriod {
LocalDate from();
LocalDate to();
}
```
Map `DateRange` to its own code (e.g. `date-range`) in the exception handler.
## Custom Validation
```java
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
public @interface UniqueEmail {
String message() default "Email already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
public UniqueEmailValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null) return true;
return !userRepository.existsByEmail(email);
}
}
```
A lookup like this gives an early, friendly error but races with concurrent inserts and rejects a PUT that keeps its own email. The database unique constraint stays the source of truth, mapped to 409 `unique` in `ApiExceptionHandler`.
## RestClient for External APIs
```java
@Configuration
public class RestClientConfig {
private static final Logger log = LoggerFactory.getLogger(RestClientConfig.class);
@Bean
public RestClient restClient(RestClient.Builder builder) {
return builder
.baseUrl("https://api.example.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.requestInterceptor((request, body, execution) -> {
log.info("Request: {} {}", request.getMethod(), request.getURI());
return execution.execute(request, body);
})
.build();
}
}
@Service
public class ExternalApiService {
private final RestClient restClient;
public ExternalApiService(RestClient restClient) {
this.restClient = restClient;
}
public ExternalDataResponse fetchData(String id) {
return restClient
.get()
.uri("/data/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
throw new ResourceNotFoundException("External resource not found");
})
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new ServiceUnavailableException("External service unavailable");
})
.body(ExternalDataResponse.class);
}
}
```
Use `RestClient` for blocking calls (default with virtual threads). Use `WebClient` only for reactive/streaming scenarios.
## CORS Configuration
```java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000", "https://example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
```
## Problem Details (RFC 9457)
```yaml
# application.yml - enables standard error response format
spring:
mvc:
problemdetails:
enabled: true
```
With this enabled, Spring Boot returns RFC 9457 responses (which replaced RFC 7807) for standard exceptions, also without a custom `@RestControllerAdvice`:
```json
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "User with id 42 not found",
"instance": "/api/v1/users/42"
}
```
Custom Problem Detail exceptions:
```java
public class UserNotFoundException extends ErrorResponseException {
public UserNotFoundException(Long userId) {
super(HttpStatus.NOT_FOUND,
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
"User with id " + userId + " not found"),
null);
setTitle("User Not Found");
setType(URI.create("https://api.example.com/errors/user-not-found"));
}
}
```
## Quick Reference
| Annotation | Purpose |
|------------|---------|
| `@RestController` | Marks class as REST controller (combines @Controller + @ResponseBody) |
| `@RequestMapping` | Maps HTTP requests to handler methods |
| `@GetMapping/@PostMapping` | HTTP method-specific mappings |
| `@PathVariable` | Extracts values from URI path |
| `@RequestParam` | Extracts query parameters |
| `@RequestBody` | Binds request body to method parameter |
| `@Valid` | Triggers validation on request body |
| `@RestControllerAdvice` | Global exception handling for REST controllers |
| `@ResponseStatus` | Sets HTTP status code for method |
| `@ParameterObject` | (springdoc) Documents `Pageable` or a filter record as separate query parameters |
| `PagedModel` | Stable JSON for a page: `{content, page}` |
SKILL.md
---
name: spring-boot
description: Spring Boot 4.x development - REST APIs, JPA, Security, Testing, and Cloud-native patterns. Use for building enterprise Java applications with Spring Boot.
metadata:
version: "2.0.0"
domain: backend
triggers: Spring Boot, Spring Framework, Spring Security, Spring Data JPA, Spring WebFlux, Java REST API, Microservices Java
role: specialist
scope: implementation
output-format: code
---
# Spring Boot Skill
Enterprise Spring Boot 4.x development with focus on clean architecture and production-ready code.
## Core Workflow
1. **Analyze** - Understand requirements, identify service boundaries, APIs, data models
2. **Design** - Plan architecture, confirm design before coding
3. **Implement** - Build with constructor injection and layered architecture; for a relational database, write Flyway migrations alongside entities (`ddl-auto: validate`)
4. **Secure** - Add Spring Security, OAuth2, method security; verify tests pass
5. **Test** - Write unit, integration tests; run `./mvnw clean verify` and confirm all pass, with integration tests running (not skipped)
6. **Deploy** - Configure health checks via Actuator; validate `/actuator/health` returns UP and `/actuator/info` shows `build.version`
## Quick Start Templates
### Entity
```java
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank
private String name;
@DecimalMin("0.0")
private BigDecimal price;
// Getters/Setters (no Lombok)
}
```
### Repository
```java
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByNameContainingIgnoreCase(String name);
}
```
### Service
```java
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) {
this.repo = repo;
}
public List<ProductResponse> search(String name) {
return repo.findByNameContainingIgnoreCase(name).stream()
.map(ProductResponse::from)
.toList();
}
@Transactional
public ProductResponse create(ProductRequest request) {
var product = new Product();
product.setName(request.name());
product.setPrice(request.price());
return ProductResponse.from(repo.save(product));
}
}
```
### REST Controller
```java
@RestController
@RequestMapping("/api/v1/products")
@Validated
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping
public List<ProductResponse> search(@RequestParam(defaultValue = "") String name) {
return service.search(name);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProductResponse create(@Valid @RequestBody ProductRequest request) {
return service.create(request);
}
}
```
### DTOs (Records)
```java
public record ProductRequest(
@NotBlank String name,
@DecimalMin("0.0") BigDecimal price
) {}
public record ProductResponse(Long id, String name, BigDecimal price) {
public static ProductResponse from(Product product) {
return new ProductResponse(product.getId(), product.getName(), product.getPrice());
}
}
```
### Global Exception Handler (Problem Details)
```java
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
public record FieldMessage(String field, String code, String message) {}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
ex.getBody().setProperty("errors", ex.getFieldErrors().stream()
.map(e -> new FieldMessage(e.getField(), code(e.getCode()), e.getDefaultMessage()))
.toList());
return super.handleMethodArgumentNotValid(ex, headers, status, request);
}
private static String code(String constraint) {
return switch (constraint) {
case "NotBlank", "NotNull" -> "required";
case null, default -> "invalid";
};
}
@ExceptionHandler
ProblemDetail handleOptimisticLock(ObjectOptimisticLockingFailureException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "Record was changed by someone else; reload and retry");
}
@ExceptionHandler
ProblemDetail handleUnexpected(Exception ex) {
logger.error("Unexpected error", ex);
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected server error");
}
}
```
Every error is RFC 9457 Problem Details with field errors in `errors: [{field, code, message}]`. Full handler (unique constraints, bad sort, documented codes): `references/web.md`.
### Test Slice
```java
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean ProductService service;
@Test
void createProduct_validRequest_returns201() throws Exception {
var response = new ProductResponse(1L, "Widget", new BigDecimal("10.00"));
when(service.create(any())).thenReturn(response);
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"Widget","price":10.0}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Widget"));
}
}
```
## Reference Guide
Load detailed patterns based on context:
| Topic | Reference | When to Load |
|-------|-----------|-------------|
| Web/REST | `references/web.md` | Controllers, paged lists, validation, PUT and `version`, Problem Details (RFC 9457) with `errors[]` |
| API built for a UI | `references/ui-contract.md` | Only when a UI or generated client consumes the OpenAPI contract (see `conventions`): `/api`, springdoc 3 contract export, error codes, filters, relations, Keycloak, "Connect a UI" |
| Data Access | `references/data.md` | JPA, repositories, transactions, queries, Flyway migrations |
| Security | `references/security.md` | Spring Security 6, OAuth2, JWT, auth |
| Cloud/Config | `references/cloud.md` | Config server, discovery, resilience, virtual threads, HTTP interfaces, GraalVM native, OTLP tracing |
| AI | `references/ai.md` | Only when the app calls an LLM: Spring AI 2.x `ChatClient`, structured output, tool calling, RAG, chat memory, MCP server, testing, 1.x to 2.0 renames |
| Testing | `references/testing.md` | Unit, integration, slice tests, Testcontainers @ServiceConnection |
## Constraints
### MUST DO
- Constructor injection (no field injection)
- `@Valid` on all request bodies
- `@Transactional` for multi-step writes
- `@Transactional(readOnly = true)` for reads
- Type-safe config with `@ConfigurationProperties`
- Global exception handling with `@RestControllerAdvice`
- Externalize secrets (use env vars, not properties files)
- Flyway migrations + `ddl-auto: validate` when persisting to a relational database (skip Flyway otherwise)
- Errors as Problem Details; list endpoints return `PagedModel`
- Permit `/actuator/health/**` so the liveness and readiness probes work behind security
- **Only when the API is built for a UI** (see `conventions`, "REST API Built for a UI"): follow `references/ui-contract.md`
### MUST NOT DO
- Field injection (`@Autowired` on fields)
- Skip input validation on endpoints
- Mix blocking and reactive code
- Store secrets in application.properties
- Use deprecated Spring Boot 2.x/3.x patterns
- Hardcode URLs, credentials, environment values
- Use `ddl-auto: update`/`create` outside tests, or edit an already-applied migration
- Return `Page`/`PageImpl` from a controller, or put `@EnableSpringDataWebSupport` on the application class (`@WebMvcTest` registers it twice)
- Put `@ApiResponse` on controller methods (springdoc 3 then drops the success response; use an `OpenApiCustomizer`)
- Return an exception's message from the catch-all handler
### Spring Boot 4 / Spring Data 4 Notes
- `PropertyReferenceException` and `TypeInformation` moved to `org.springframework.data.core`
- `@MockBean`/`@SpyBean` are removed: use `@MockitoBean`/`@MockitoSpyBean`
- `@WebMvcTest` creates no repositories: every repository a controller injects needs a `@MockitoBean` in its test
- Build with `./mvnw clean verify`: classes compiled by the IDE in `target` can hide `javac` errors
## Architecture Patterns
**Project Structure:**
```
src/main/java/pl/piomin/services/
├── controller/ # REST endpoints
├── service/ # Business logic
├── repository/ # Data access
├── model/ # Entities
├── dto/ # Request/Response DTOs
├── config/ # Configuration
└── exception/ # Custom exceptions + handler
```
**Layering:**
- Controller → Service → Repository
- Controller handles HTTP, validation
- Service handles business logic, transactions
- Repository handles data persistence
**Clean Architecture Principles:**
- Domain models independent of frameworks
- Use case driven design
- Dependency inversion (interfaces)
- Clear boundaries between layers
## Common Annotations
| Annotation | Purpose |
|------------|---------|
| `@RestController` | REST controller (combines @Controller + @ResponseBody) |
| `@Service` | Business logic component |
| `@Repository` | Data access component |
| `@Transactional` | Transaction management |
| `@Valid` | Trigger validation |
| `@ConfigurationProperties` | Bind properties to class |
| `@EnableMethodSecurity` | Enable method security |
## Reactive WebFlux Endpoint
```java
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public Mono<ResponseEntity<OrderDto>> getOrder(@PathVariable UUID id) {
return orderService.findById(id)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.notFound().build());
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Mono<OrderDto> createOrder(@Valid @RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
}
```
## Spring Security JWT
```java
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll() // + "/actuator/info" when built for a UI
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
}
```
## Knowledge Base
Spring Boot 4.x, Java 21+, Spring WebFlux, Project Reactor, Spring Data JPA, Spring Security, OAuth2/JWT, Hibernate, R2DBC, Spring Cloud, Resilience4j, Micrometer, JUnit 5, Testcontainers, Mockito, Maven, Paketo Buildpacks, Skaffold