references/aot-and-startup.md
# AOT, CDS and startup
Start with measurement, not flags: record startup time (`Started ... in N seconds` log, or `spring.main.log-startup-info=true`) and memory on the target hardware before and after each change, with several runs (single timings are noisy: observed differences between runs of the same configuration were larger than some of the gains). Do not change JVM or Spring flags without a stated goal (startup, memory, image size). Verified on Boot 4.1.1, Maven, JDK 26 runtime (`--release 25`); JDK 21 and 25 were not available for these runs.
## Spring AOT (build-time processing)
Spring AOT turns bean definitions and reflection/proxy hints into generated code. It is required for GraalVM native images and optional on the JVM.
```bash
./mvnw -DskipTests package spring-boot:process-aot # Gradle: ./gradlew processAot
```
Verified: on the sample app (JPA, Flyway, Kafka, cache, validation, springdoc) `process-aot` succeeded and generated sources under `target/spring-aot/main/sources/` (for example `DemoApplication__BeanDefinitions.java`). Notes:
- AOT evaluates conditions and bean definitions at build time: profiles and properties that change the bean graph (`@Profile`, `@ConditionalOnProperty`) are fixed at build time. Do not rely on runtime-only conditions.
- `process-aot` starts the application context without connecting your beans to real services; if it fails, the usual causes are beans with runtime-only side effects in constructors or an ambiguous main class (set `<start-class>` in Maven, see `create-spring-boot-java-project` `references/spring-boot-4.md`).
- Custom reflection/resources: register hints with `@ImportRuntimeHints` and `RuntimeHintsRegistrar`, or `@RegisterReflectionForBinding` for DTOs serialized by Jackson.
- Using the generated code on the JVM requires running with `-Dspring.aot.enabled=true` and packaging the AOT output; that path was not verified here and gives modest gains, so prefer CDS/AOT cache below for JVM startup.
## Native image
For a native executable use the `java-add-graalvm-native-image-support` skill (build tool detection, native build, error iteration) and the native Dockerfiles of `create-spring-boot-java-project` (`references/docker.md`). Trade-offs: much faster startup and lower memory, but slower builds, closed-world assumptions (reflection hints), and some libraries need extra hints. Not verified in this skill.
## Class Data Sharing (CDS) / Application CDS
Spring Boot supports CDS through the extracted jar layout (`-Djarmode=tools`). Verified sequence on Boot 4.1.1 (the training run needs the app's dependencies, here a PostgreSQL):
```bash
./mvnw -DskipTests package
java -Djarmode=tools -jar target/app.jar extract --destination extracted
cd extracted
# training run: create the archive on exit (the app exits right after context refresh)
java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar app.jar
# production run
java -XX:SharedArchiveFile=app.jsa -jar app.jar
```
Verified: `app.jsa` (about 50 MB for the sample app) was created and used without errors. The `[warning][cds] Skipping ... Failed verification` and `NoClassDefFoundError` preload warnings in the training run concern optional classes whose libraries are not on the classpath (reactor, Apache HttpClient, Log4j) and are harmless. The archive is tied to the exact JVM and jar: regenerate it in the build or image build for each version, and run the training with the same JDK as production.
JDK 19+ can also create the archive automatically at the first run (`-XX:+AutoCreateSharedArchive -XX:SharedArchiveFile=app.jsa`; verified that the file is created).
**JDK 25+ one-step AOT cache** ([JEP 514](https://openjdk.org/jeps/514)): `java -XX:AOTCacheOutput=app.aot -Dspring.context.exit=onRefresh -jar app.jar`, then `java -XX:AOTCache=app.aot -jar app.jar`. Verified on JDK 26 (about 160 MB cache, used without errors). JDK 24 introduced AOT caches ([JEP 483](https://openjdk.org/jeps/483)) but requires separate `AOTMode=record` and `AOTMode=create` steps; it does not recognize `AOTCacheOutput`. Check `java -version` before choosing the command, and keep CDS as the fallback for older JDKs. Same rule: one cache per JDK build and jar.
In containers, create the archive during `docker build` (a `RUN` training step in the final stage) rather than at container start.
## Lazy initialization
```properties
spring.main.lazy-initialization=true
```
- Defers bean creation to first use, which lowers startup time and memory at start but moves failures and the first-request latency to runtime: misconfigured beans no longer fail fast, and health checks may pass on a half-working app. Use it for dev/test or short-lived processes; avoid it as a production default. Set `@Lazy(false)` on beans that must start eagerly (schedulers, listeners).
- Measured on the sample app: no consistent startup gain over several runs; do not promise one.
## Other cheap wins
- Remove unused starters and auto-configurations; `--debug` prints the condition evaluation report to find what starts.
- `spring.jpa.open-in-view=false` and `spring.main.banner-mode=off` cost nothing.
- Set `-XX:TieredStopAtLevel=1` only for short-lived CLI-style jobs; it hurts steady-state throughput.
- Container memory: let the JVM size heap from container limits (`-XX:MaxRAMPercentage`); do not hard-code `-Xmx` equal to the limit.
- Virtual threads do not change startup (`references/messaging-caching.md`).
references/configuration.md
# Configuration
Formats, profiles, `.env` and secret basics, and the short `@ConfigurationProperties` example already live in `create-spring-boot-java-project` (`references/configuration.md`); read it for those and keep one format (properties or YAML) as the project already does. This file adds what is needed when changing an existing application. Verified on Boot 4.1.1.
## Type-safe properties (records)
```java
@ConfigurationProperties(prefix = "app.mail")
@Validated
public record MailProperties(@NotBlank String from, @DefaultValue("3") @Positive int retries,
@DefaultValue("5s") Duration timeout) {}
```
```properties
app.mail.from=noreply@example.com
```
- Records bind through their canonical constructor (constructor binding is the default for records). Use `@DefaultValue` (`org.springframework.boot.context.properties.bind.DefaultValue`) for optional values; `Duration`, `DataSize`, enums and `List`/`Map` bind natively (`5s`, `PT5S`, `10MB`).
- Register with `@ConfigurationPropertiesScan` on the application class (or `@EnableConfigurationProperties(MailProperties.class)`). Verified: the record is a bean with `retries == 3` and `timeout == 5s` when only `from` is set.
- `@Validated` + `jakarta.validation` constraints make the application fail at startup on invalid configuration, which is what you want. Validation needs the validation starter (`spring-boot-starter-validation`).
- Prefer `@ConfigurationProperties` to scattered `@Value`; use `@Value` only for one-off values. Add `spring-boot-configuration-processor` (optional/annotation-processor path, as start.spring.io does) for IDE metadata.
- Immutability: no setters; nested config as nested records.
- Inject the record where needed; do not read `Environment` directly in business code.
## Profiles
- Keep production-safe defaults in `application.properties`; profile files (`application-dev.properties`) only override what differs. Activate with `SPRING_PROFILES_ACTIVE`.
- Group related profiles with `spring.profiles.group.prod=prod-db,prod-observability` when a deployment needs several.
- Use `@Profile` sparingly for beans; do not branch application logic on profile names.
- Do not ship a `test` profile in `src/main`; tests use `src/test/resources` or `@TestPropertySource` (see `spring-boot-testing`).
## Config import and secrets
```properties
spring.config.import=optional:file:.env[.properties],optional:configtree:/run/secrets/
```
- `optional:` prevents startup failure when the file is absent (local development); drop it for values that must exist in production.
- Kubernetes/Docker secrets mounted as files: `configtree:` maps each file to a property (file name = key).
- Environment variables override files through relaxed binding (`APP_MAIL_FROM` -> `app.mail.from`).
- Never commit `.env` or real credentials; commit `.env.sample`. Do not log property values holding secrets, and do not expose `/actuator/env` and `/actuator/configprops` publicly (`spring-observability`, `spring-security`).
- Externalized config sources like Vault or Spring Cloud Config are out of scope unless the project already uses them; follow its existing import setup.
## Dev-time services
### Docker Compose
Add `spring-boot-docker-compose` (start.spring.io adds it, optional/development-only). With a `compose.yaml` at the project root, `./mvnw spring-boot:run` starts the services and wires connection details automatically. Pin image tags (start.spring.io generates `postgres:latest`; replace it, see `create-spring-boot-java-project` `references/database.md`). Not executed in this skill's verification: only the generated files were inspected.
### Testcontainers at dev time
start.spring.io (with the `testcontainers` dependency) generates `TestcontainersConfiguration` and a `TestDemoApplication` in `src/test`:
```java
public class TestDemoApplication {
public static void main(String[] args) {
SpringApplication.from(DemoApplication::main).with(TestcontainersConfiguration.class).run(args);
}
}
```
Run it with `./mvnw spring-boot:test-run` (Gradle: `./gradlew bootTestRun`). Verified on Boot 4.1.1: PostgreSQL and Kafka containers started from the `@ServiceConnection` beans, the app reached `UP` on `/actuator/health`, and stopping the app removed the containers (Ryuk). Make `TestcontainersConfiguration` `public` if it is used from other packages (see `create-spring-boot-java-project` `references/spring-boot-4.md`). Choose either Compose or Testcontainers for dev-time services, not both, to avoid two databases fighting over the same properties.
## Boot 3 vs 4
Same property binding rules. Boot 4 changes that touch configuration: Jackson properties continue under `spring.jackson.*` for Jackson 3 (Jackson 2 compatibility lives in a separate module; consult the migration guide), modular auto-configuration (classes moved to `org.springframework.boot.<technology>.autoconfigure`, which matters only if you reference or exclude auto-configurations by class name: `spring.autoconfigure.exclude=org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration`). Use the `spring-boot-4-migration` skill for the move itself.
references/data.md
# Data access (Spring Data JPA, Spring Data JDBC)
For mapping problems (N+1 theory, equals/hashCode, cascades, locking, batching) use the `java-hibernate-jpa` skill; for PostgreSQL defaults and Docker Compose use `create-spring-boot-java-project` (`references/database.md`); for `@DataJpaTest` and Testcontainers use `spring-boot-testing`. This file covers the Spring layer on top. Verified with Spring Data JPA on Boot 4.1.1 and Boot 3.5.16 (same code).
## Repositories
```java
public interface OrderRepository extends JpaRepository<PurchaseOrder, Long> {
interface CustomerView { Long getId(); String getCustomer(); } // interface projection
Page<CustomerView> findByStatus(PurchaseOrder.Status status, Pageable pageable);
@Query("select new com.example.demo.OrderSummary(o.id, o.customer, o.total) from PurchaseOrder o where o.customer = :customer")
Page<OrderSummary> summaries(String customer, Pageable pageable); // record DTO
@EntityGraph(attributePaths = "items")
Optional<PurchaseOrder> findWithItemsById(Long id); // fetches the collection in one query
}
```
- Derived queries for simple lookups; `@Query` (JPQL) once the method name gets long; `@Query(nativeQuery = true)` only when JPQL cannot express it. Named parameters (`:customer`) bind by name when compiled with `-parameters` (the Boot parent POM and Gradle plugin enable it; verified without `@Param`).
- **Projections:** return interface projections (closed: only getters for the needed columns) or record DTOs (`select new ...` with the fully qualified name) for read endpoints.
- **Pagination:** `Page<T>` runs an extra `count` query; use `Slice<T>` when the total is not needed. Always sort on a stable, unique column (`Sort.by("id")`); cap the page size from user input (`Math.min(size, 100)`). Never combine pagination with a collection `JOIN FETCH` (in-memory paging; see `java-hibernate-jpa`).
- **N+1:** fix per query with `@EntityGraph` or `JOIN FETCH`, keep associations `LAZY`, and assert statement counts in a test (`hibernate.generate_statistics`).
- `spring.jpa.open-in-view=false`; load what the response needs inside the transaction and map to DTOs there.
- **Boot 4:** JPA slice classes moved: `org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest`, `org.springframework.boot.jpa.test.autoconfigure.TestEntityManager`, `org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase` (Boot 3: `org.springframework.boot.test.autoconfigure.orm.jpa.*` and `...autoconfigure.jdbc.*`). Starter: `spring-boot-starter-data-jpa` (+ `spring-boot-starter-data-jpa-test` for tests).
## Transactions
- `@Transactional` on **service** methods (the use-case boundary), not on controllers or repositories. Reads: `@Transactional(readOnly = true)`.
- Keep transactions short: no HTTP calls, messaging sends or file I/O inside; publish events after commit with `@TransactionalEventListener(phase = AFTER_COMMIT)` or an outbox (`references/messaging-caching.md`).
- Spring rolls back on unchecked exceptions only; use `rollbackFor` for checked ones. Self-invocation bypasses the proxy.
- Services return DTOs, so lazy associations are never touched after the transaction ends.
## Auditing
```java
@Configuration(proxyBeanMethods = false)
@EnableJpaAuditing
class JpaAuditingConfig {} // not on the @SpringBootApplication class
@Entity @EntityListeners(AuditingEntityListener.class)
class PurchaseOrder {
@CreatedDate Instant createdAt;
@LastModifiedDate Instant lastModifiedAt;
}
```
`@DataJpaTest` does not scan `@Configuration` classes: `@Import(JpaAuditingConfig.class)` in the test, otherwise `createdAt` stays `null` (verified). For `@CreatedBy`/`@LastModifiedBy` provide an `AuditorAware<String>` bean (usually from the security context; see `spring-security`).
## Schema management
Follow the schema management rule of `java-hibernate-jpa` (prototype: `update`; production: Flyway/Liquibase with `ddl-auto=validate`; externally managed: `none`). With Spring Boot:
- **Boot 4:** add `spring-boot-starter-flyway` (and `org.flywaydb:flyway-database-postgresql` for PostgreSQL) or `spring-boot-starter-liquibase`; the auto-configuration lives in the starter. **Boot 3:** `org.flywaydb:flyway-core` (+ `flyway-database-postgresql`) or `liquibase-core`.
- Migrations in `src/main/resources/db/migration/V1__init.sql`; never edit an applied migration, add a new version. Each migration is one reviewable unit; use `ddl-auto=validate` so entity/schema drift fails at startup (verified: a Flyway-created schema passes `validate` for the entities above).
- Add them only when the user wants versioned migrations or the app is headed to production; do not add Flyway to a project whose schema is managed elsewhere.
## Spring Data JDBC (alternative)
Choose it for simple aggregates without lazy loading, sessions or dirty tracking: aggregate roots are loaded and saved as a whole, and there is no persistence context. Starter `spring-boot-starter-data-jdbc` (do not put it next to `data-jpa` in the same module unless you configure repository scanning explicitly: both would try to claim repositories).
```java
public record Book(@Id Long id, String title, @MappedCollection(idColumn = "book") Set<Chapter> chapters) {
public record Chapter(String name) {}
}
public interface BookRepository extends ListCrudRepository<Book, Long>, PagingAndSortingRepository<Book, Long> {
List<Book> findByTitleStartingWith(String prefix);
}
```
- You own the schema (`schema.sql` with `spring.sql.init.mode=always`, or Flyway/Liquibase as above); there is no `ddl-auto`.
- `null` id means insert; the saved copy carries the generated id (`repo.save` returns it: use the return value with records).
- Boot 4 test slice: `org.springframework.boot.data.jdbc.test.autoconfigure.DataJdbcTest` (verified with a Testcontainers PostgreSQL, `@AutoConfigureTestDatabase(replace = NONE)`).
- Not a drop-in for JPA: no `@EntityGraph`, no lazy associations, references between aggregates are by id.
references/messaging-caching.md
# Messaging, caching, scheduling, async
**Ask before adding** a broker, a cache provider or a scheduler/async setup to an existing project: each one adds infrastructure and operational cost. When the user agrees, use the Boot-managed versions (no explicit version for Spring-managed artifacts). Verified on Boot 4.1.1 (JDK 26 runtime, `--release 25`); Boot 3 differences noted where known. Tests for these: `spring-boot-testing`; tracing/metrics for them: `spring-observability`.
## Kafka (Spring for Apache Kafka)
- **Boot 4:** `spring-boot-starter-kafka` (+ `spring-boot-starter-kafka-test`). **Boot 3:** `org.springframework.kafka:spring-kafka` (no starter; version managed by the Boot BOM).
- Producer: `KafkaTemplate<String, String>`; consumer: `@KafkaListener`.
```java
@Component
class Events {
private final KafkaTemplate<String, String> template;
Events(KafkaTemplate<String, String> template) { this.template = template; }
void publish(String key, String payload) { template.send("orders", key, payload); }
@KafkaListener(topics = "orders", groupId = "demo")
void on(String payload) { /* must be idempotent: delivery is at-least-once */ }
}
```
```properties
spring.kafka.bootstrap-servers=${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
spring.kafka.consumer.auto-offset-reset=earliest
```
- Consumers must be **idempotent** (at-least-once delivery); decide on a retry/dead-letter strategy (`DefaultErrorHandler` with `DeadLetterPublishingRecoverer`) before going to production.
- Keep payloads as versioned DTOs (records); avoid sending JPA entities.
- **Test with a real broker** through Testcontainers (verified: send then receive in a `@SpringBootTest`):
```java
import org.testcontainers.kafka.KafkaContainer;
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean @ServiceConnection
KafkaContainer kafkaContainer() { return new KafkaContainer(DockerImageName.parse("apache/kafka-native:4.0.0")); }
}
```
Boot 4 uses Testcontainers 2.x (artifact `org.testcontainers:testcontainers-kafka`); Boot 3 uses 1.x (artifact `org.testcontainers:kafka`). The example uses `org.testcontainers.kafka.KafkaContainer`, available in recent 1.x releases as well as 2.x; check that the project's BOM-managed version supports `apache/kafka-native`. Do not substitute the legacy `org.testcontainers.containers.KafkaContainer`: it requires a `confluentinc/cp-kafka` image. If the project's older Testcontainers version lacks the modern class or native-image support, use the legacy class with a compatible pinned Confluent image instead, without implicitly upgrading dependencies. Pin the image tag, never `latest`. Wait with a `CountDownLatch`/Awaitility, not `Thread.sleep`.
## RabbitMQ / AMQP (not verified in this skill's builds)
`spring-boot-starter-amqp` (Boot 3 and 4), `RabbitTemplate` to send, `@RabbitListener` to consume, `@ServiceConnection` with the Testcontainers RabbitMQ module for tests. Declare queues/exchanges as `Queue`/`TopicExchange`/`Binding` beans, use `Jackson2JsonMessageConverter`-style JSON converters only after checking the Jackson major in use (Boot 4 = Jackson 3), and configure a dead-letter exchange. Check the Spring AMQP reference for the exact converter class names for your version before generating code.
## Transactional outbox
Publishing to a broker inside a database transaction is not atomic: a crash between commit and send loses the message. When messages must not be lost, write the event to an `outbox` table in the same transaction as the state change, and have a relay (scheduled poller or CDC such as Debezium) publish and mark it sent. Consumers stay idempotent. Do not implement it unless the user needs that guarantee; for events that can be lost, `@TransactionalEventListener(phase = AFTER_COMMIT)` is simpler.
## Caching with Caffeine
- Dependencies: `spring-boot-starter-cache` (Boot 4 + test starter `spring-boot-starter-cache-test`; Boot 3 the same starter name) and `com.github.ben-manes.caffeine:caffeine` (BOM-managed).
```java
@Configuration(proxyBeanMethods = false)
@EnableCaching
class CachingConfig {} // a dedicated class, not the @SpringBootApplication class
@Service
class RateService {
@Cacheable("rates") // key = method arguments
public Rate expensive(String currency) { ... }
@CacheEvict(value = "rates", allEntries = true)
public void evict() {}
}
```
```properties
spring.cache.type=caffeine
spring.cache.cache-names=rates
spring.cache.caffeine.spec=maximumSize=500,expireAfterWrite=10m
```
- Verified on Boot 4.1.1: second call is served from cache, `evict()` forces a reload. Always set `maximumSize` and an expiry; unbounded caches are memory leaks.
- Cache immutable DTOs/records, not managed JPA entities. The cache is per JVM: with several instances use a distributed cache (Redis) only if the user asks for it, and expect stale data across nodes with Caffeine.
- `@Cacheable` is proxy-based: self-invocation and `private` methods bypass it. Do not cache methods with side effects or `null` returns you do not intend to cache.
- Enabling caching in `@SpringBootApplication` breaks slice tests that do not register a `CacheManager` (verified: `@DataJpaTest` fails with "No qualifying bean of type CacheManager"), hence the dedicated `@Configuration`.
- To expose cache statistics in Micrometer call `recordStats` (Caffeine spec `recordStats`); otherwise Boot logs a warning that the cache is not recording statistics (observed).
## Scheduling
```java
@Configuration(proxyBeanMethods = false)
@EnableScheduling
class SchedulingConfig {}
@Scheduled(fixedDelayString = "${app.tick:PT10S}") // or cron = "0 0 * * * *", zone = "UTC"
void cleanup() { ... }
```
Verified: `@Scheduled(fixedDelayString = "${...:100ms}")` runs on Boot 4.1.1. Prefer `fixedDelay` over `fixedRate` to avoid overlapping runs. `@Scheduled` runs on **every instance**: with several replicas use a lock (ShedLock) or an external scheduler; add that only after asking. Scheduled methods take no arguments and must not throw unhandled exceptions silently: log and handle.
## Async and virtual threads
```properties
spring.threads.virtual.enabled=true # Java 21+, Boot 3.2+
```
With the property set, Boot runs Tomcat/Jetty request handling, `@Async` tasks (through the auto-configured task executor) and other Boot-managed executors on virtual threads. Verified on Boot 4.1.1: an `@Async` method reports `Thread.currentThread().isVirtual() == true`.
```java
@Configuration(proxyBeanMethods = false)
@EnableAsync
class AsyncConfig {}
@Async
public CompletableFuture<Result> compute() { ... } // return void or CompletableFuture
```
- Needs Java 21+; on Boot 3 the property exists from 3.2. Do not enable it on Java 17.
- Virtual threads help blocking I/O workloads (JDBC, HTTP). They do not speed up CPU-bound work. Pool sizes become the bottleneck: size the HikariCP pool deliberately and consider limits on downstream calls. Before JDK 24, `synchronized` blocks around blocking calls pin the carrier thread; prefer `ReentrantLock` there.
- `@Async` is proxy-based like `@Transactional`: the caller must be another bean, and the transaction/security context does not follow into the async thread unless propagated.
- With `@Async` returning `void`, exceptions are lost unless an `AsyncUncaughtExceptionHandler` is configured.
references/rest-api.md
# REST API (Spring MVC)
Verified on Boot 4.1.1 (Framework 7) and, where noted, Boot 3.5.16 (Framework 6.2). Security of endpoints: `spring-security`. Test slices (`@WebMvcTest`, `MockMvcTester`, `@RestClientTest`): `spring-boot-testing`.
## Starters and packages (Boot 3 vs 4)
| | Boot 3 | Boot 4 |
| --- | --- | --- |
| Web starter | `spring-boot-starter-web` | `spring-boot-starter-webmvc` (+ `spring-boot-starter-webmvc-test`) |
| `RestClient` auto-config | Boot 3.2+ only, in the web starter; unavailable on 3.0/3.1 | add `spring-boot-starter-restclient` |
| `@WebMvcTest` | `org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest` | `org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest` (also `AutoConfigureMockMvc`) |
| Mock a bean | `@MockitoBean` (3.4+; `@MockBean` removed in 4) | `@MockitoBean` (`org.springframework.test.context.bean.override.mockito`) |
| JSON | Jackson 2 `ObjectMapper` | Jackson 3 `tools.jackson.databind.json.JsonMapper` bean |
Jackson 2 databind may still be on the classpath transitively (for example through Spring for Apache Kafka), which is not a reason to code against it in Boot 4. A temporary Jackson 2 bridge exists (`spring-boot-jackson2`, see the Boot 4 migration guide and `spring-boot-4-migration`).
## Controllers and validation
```java
public record CreateOrderRequest(@NotBlank String customer, @Positive BigDecimal total) {}
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orders;
OrderController(OrderService orders) { this.orders = orders; }
@PostMapping
ResponseEntity<OrderSummary> create(@Valid @RequestBody CreateOrderRequest req) {
var created = orders.create(req);
return ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created);
}
}
```
- Nouns for resources, plural, HTTP verbs for actions; `201` + `Location` on create, `204` on delete, `404` via exception, not `null`.
- Request/response types are records, separate from entities. Bean Validation (`jakarta.validation.constraints.*`) goes on the record components. In Framework 6.1+ (Boot 3.2+), put constraints directly on `@RequestParam`/`@PathVariable` parameters and omit class-level `@Validated`: built-in MVC method validation raises `HandlerMethodValidationException`. Class-level `@Validated` instead enables AOP validation, which raises `ConstraintViolationException` by default and needs its own error handler. On older Framework versions, use that AOP approach and map the exception to HTTP 400 explicitly.
- Never accept unbounded input: cap `size` for pagination, limit request body size at the server/proxy.
- Return `Page<T>` only if exposing Spring Data's page JSON is acceptable; for a stable public contract return your own `record PageResponse<T>(List<T> content, long total, int page, int size)`. Pagination parameters and stable sorting: `data.md`.
## Errors: ProblemDetail (RFC 9457)
```java
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail notFound(OrderNotFoundException ex) {
var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Order not found");
return pd;
}
}
```
- Extending `ResponseEntityExceptionHandler` turns Spring MVC's own exceptions (validation `MethodArgumentNotValidException` and, in Framework 6.1+, `HandlerMethodValidationException`, malformed JSON, unsupported media type, invalid API version) into `application/problem+json` (verified: validation errors give status 400 problem details; the unknown-API-version error gives 400). Test invalid query/path parameters as well as invalid request bodies; class-level `@Validated` must not divert them to an unhandled AOP exception.
- Verified the same advice works unchanged on Boot 3.5.16. Do not return exception messages that contain internals (SQL, class names) in `detail`; log them, return a stable message, and add a custom property (`pd.setProperty("code", "ORDER_NOT_FOUND")`) for clients.
- Test with `jsonPath("$.status")`, `jsonPath("$.title")` and `content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON)`.
## API versioning (Boot 4 / Framework 7 only)
Framework 7 has first-class API versioning; it does not exist in Framework 6 (verified: `@GetMapping(version = ...)` does not compile on Boot 3.5.16, use URL prefixes such as `/api/v1/...` there).
```properties
spring.mvc.apiversion.use.header=X-API-Version
spring.mvc.apiversion.default=1
spring.mvc.apiversion.supported=1,2
```
```java
@GetMapping(value = "/{id}", version = "1") OrderSummary getV1(@PathVariable Long id) { ... }
@GetMapping(value = "/{id}", version = "2") OrderSummary getV2(@PathVariable Long id) { ... }
```
Alternatives to the header are `...use.path-segment`, `...use.query-parameter` and `...use.media-type-parameter`. Verified: a request without the header uses the default version, and an unsupported version (`3`) returns a `400` problem detail ("Invalid API version"). Version only when a breaking change is unavoidable; additive changes do not need a new version.
## Calling other services
### RestClient (Boot 3.2+/4)
`RestClient` and `RestClientAdapter` require Framework 6.1+; Boot's builder auto-configuration starts in 3.2. On Boot 3.0/3.1, retain the project's existing `RestTemplate` rather than generating this example or implicitly upgrading dependencies.
On Boot 4 add `spring-boot-starter-restclient` (it was in the verified build, which injected the builder; Boot 3.2+: `RestClient.Builder` comes with the web starter). Inject the **builder** (`RestClient.Builder`), never create clients without it, so that observability and customizers apply (`spring-observability`). Set base URL and timeouts through properties/customizers, not per call. Prefer `RestClient` over `RestTemplate` (maintenance mode) and over `WebClient` unless you are reactive.
```java
@Bean
RestClient ratesClient(RestClient.Builder builder) {
return builder.baseUrl("https://rates.example.com").build();
}
// usage: ratesClient.get().uri("/rates/{c}", "EUR").retrieve().body(Rate.class);
```
`retrieve()` throws `RestClientResponseException` on 4xx/5xx; map to your own exceptions at the boundary.
### HTTP interfaces (`@HttpExchange`)
```java
@HttpExchange("/rates")
public interface RatesClient {
record Rate(String currency, double value) {}
@GetExchange("/{currency}")
Rate get(@PathVariable String currency);
}
```
- **Boot 4 / Framework 7:** register with `@ImportHttpServices` and configure the URL in properties (verified):
```java
@Configuration(proxyBeanMethods = false)
@ImportHttpServices(group = "rates", types = RatesClient.class)
class HttpClientsConfig {}
```
```properties
spring.http.serviceclient.rates.base-url=https://rates.example.com
```
then inject `RatesClient` like any bean.
- **Boot 3.2+ / Framework 6.1+:** `@ImportHttpServices` does not exist (verified). Build the proxy manually:
```java
var client = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build().createClient(RatesClient.class);
```
(`org.springframework.web.client.support.RestClientAdapter`, same on Boot 4.)
- **Boot 3.0/3.1 / Framework 6.0:** if WebClient is already available, HTTP interfaces can use `WebClientAdapter` with the Framework 6.0 `HttpServiceProxyFactory` API. Otherwise retain the existing `RestTemplate` setup; do not introduce `RestClientAdapter` or add WebFlux implicitly.
- **Tests (Boot 3.2+/4):** `MockRestServiceServer.bindTo(RestClient.Builder)` records and answers requests without a network (verified for both the manual and the Boot 4 setups); use WireMock only for contract-style tests with real HTTP.
## OpenAPI (springdoc)
springdoc is not Spring-Boot-managed: pick the line that matches the Boot major from the springdoc compatibility table and pin it. Verified: `org.springdoc:springdoc-openapi-starter-webmvc-ui` 3.1.1 on Boot 4.1.1 serves `/v3/api-docs` including controller paths (springdoc 2.x targets Boot 3). Expose Swagger UI (`/swagger-ui.html`) only in non-production profiles or behind authentication; allow the paths in `spring-security`. Document with `@Operation`/`@ApiResponse` sparingly; the controller signature and validation annotations already feed the schema.
## Null-safety with JSpecify (Framework 7 / Boot 4)
Spring Framework 7 and Boot 4 annotate their APIs with JSpecify (`org.jspecify.annotations`): everything is non-null unless marked `@Nullable`. In your code:
- Add `@NullMarked` in `package-info.java` (verified to compile on Boot 4.1.1 where `org.jspecify:jspecify` is already on the classpath) and mark nullable parameters, returns and fields with `org.jspecify.annotations.Nullable`.
- Replace `org.springframework.lang.Nullable`/`NonNull` (deprecated in Framework 7) with JSpecify when touching those files.
- JSpecify is documentation for tools (IDE, NullAway, Error Prone) and does **not** validate at runtime. For request validation keep using `jakarta.validation.constraints.NotNull`/`@NotBlank`. Optional: add NullAway to the build only if the project asks for it.
SKILL.md
---
name: java-springboot
description: 'Write and review application code in an existing Spring Boot 3/4 project: controllers, validation and ProblemDetail errors, Spring Data JPA/JDBC, RestClient and HTTP interfaces, configuration properties, Kafka/AMQP, caching, scheduling, virtual threads, AOT and startup tuning. Use when adding or changing features. To scaffold a new project use create-spring-boot-java-project instead.'
---
# Spring Boot application development
Opinionated rules plus a router to deeper references. Code samples in the references were compiled and run on Spring Boot 4.1.1 (Spring Framework 7) and, where they differ, on Boot 3.5.16 (Framework 6.2). Boot 3 vs 4 differences are marked "Boot 3" / "Boot 4" in each reference.
## Before writing code
1. **Detect, do not assume:** read `pom.xml`/`build.gradle(.kts)` for the Spring Boot version, Java version, starters in use (`spring-boot-starter-web` is Boot 3, `spring-boot-starter-webmvc` is Boot 4), persistence (JPA, JDBC), the migration tool, the test setup, and the package layout and naming conventions of the existing code. Match them.
2. **Never upgrade Spring Boot, Java or dependencies implicitly.** A Boot 3 -> 4 move is a deliberate task (`spring-boot-4-migration`).
3. **Ask before adding infrastructure:** a broker (Kafka, RabbitMQ), a cache provider, a new database, or a scheduler/async setup. Add only what the user requested, with the Boot-managed version (no explicit version for BOM-managed artifacts, never `latest`).
4. **Verify every change** with the project's own build: `./mvnw verify` or `./gradlew build` (plain `mvn`/`gradle` if no wrapper). Integration tests need Docker when they use Testcontainers.
## Router
| Task | Read |
| --- | --- |
| Repositories, projections, pagination, N+1, `@Transactional`, auditing, Flyway/Liquibase, Spring Data JDBC | `references/data.md` |
| Controllers, validation, `ProblemDetail`, `RestClient`, `@HttpExchange`, API versioning, OpenAPI, JSpecify | `references/rest-api.md` |
| Kafka/AMQP, `@Cacheable`/Caffeine, `@Scheduled`, `@Async`, virtual threads, outbox | `references/messaging-caching.md` |
| `@ConfigurationProperties`, profiles, secrets, config import, Docker Compose / Testcontainers at dev time | `references/configuration.md` |
| AOT, CDS/AppCDS, lazy initialization, native image | `references/aot-and-startup.md` |
If these standalone skills are installed, also use them for their topic; otherwise rely on the official docs: `java-hibernate-jpa` (JPA mapping), `spring-boot-testing` (tests), `spring-security` (authentication, authorization, CSRF/CORS), `spring-observability` (metrics, tracing, Actuator, logs), `create-spring-boot-java-project` (project defaults, Boot 3 vs 4 build changes), `java-add-graalvm-native-image-support` (native executable).
## Core rules
- **Structure and layers:** package by feature; constructor injection; controllers return DTOs/records, never entities; services own the transaction.
- **Validation and errors:** Bean Validation on request records with `@Valid`; one `@RestControllerAdvice` returning `ProblemDetail`. JSpecify annotations document nullability only and do not validate.
- **Jackson:** Boot 4 uses Jackson 3 (`tools.jackson.databind.*`, `JsonMapper`; annotations stay in `com.fasterxml.jackson.annotation`); Boot 3 uses Jackson 2 (`com.fasterxml.jackson.databind.*`, `ObjectMapper`).
- **Slice-test gotcha:** put `@EnableJpaAuditing`, `@EnableCaching`, `@EnableAsync`, `@EnableScheduling` on dedicated `@Configuration` classes, not on the `@SpringBootApplication` class, or slices such as `@DataJpaTest` fail to start (for example, no `CacheManager`). Slices do not scan `@Configuration` classes: `@Import` the one a test needs.
- **Proxied beans:** `@Cacheable`, `@Async` and `@Transactional` work through proxies: they do nothing on self-invocation or on `private`/`final` methods; test them through the injected bean.
## Checklist
- [ ] Versions, build tool and conventions detected; starters match the Boot major
- [ ] No new infrastructure or dependency without the user's agreement
- [ ] `./mvnw verify` / `./gradlew build` passes; secrets absent from code, properties and logs
See also: Spring Boot reference <https://docs.spring.io/spring-boot/reference/>, Spring Framework reference <https://docs.spring.io/spring-framework/reference/>, Spring Data <https://docs.spring.io/spring-data/commons/reference/>.