Written for someone who already knows what a transaction, a proxy and an isolation level are, and wants the Spring-specific facts: the module split and the starter renames, exactly how auto-configuration decides, the fifteen-deep property precedence, what the proxy silently declines to do, which defaults are wrong for production, and what Boot 4 broke. Pages 1–3 are the guide; the rest index every annotation you are likely to meet, grouped by what it does. Type in the filter box — or press / — to narrow the index; hover any annotation for its full description and where it comes from.
Sources: spring.io/projects/spring-boot and docs.spring.io (Spring Boot 4.1.1 reference, Spring Framework 7, Spring Data JPA), the Spring Boot 4.0 release notes and migration guide, endoflife.date/spring-boot, the JRebel Spring annotations cheat sheet, dev.to/burakboduroglu, codingshuttle.com, github.com/RohanVishwakarma001/Spring-Boot-Complete-Cheat-sheet, and Mark Heckler, Spring Boot: Up and Running (O’Reilly). Hover any annotation for its description.Not a framework — a packaging of one. Spring Boot removes two jobs from a Spring application: resolving a mutually compatible dependency set, and writing the configuration that wires it together. Four mechanisms do all of it.
| Mechanism | What it removes |
|---|---|
| Starters | A curated dependency (spring-boot-starter-*) pulls in a technology and everything it needs. A parent BOM pins every version, so you name artifacts without versions. |
| Auto-configuration | On startup Boot inspects the classpath, the existing beans and the properties, then defines the beans you did not. Put Hibernate on the path and you get a DataSource, an EntityManagerFactory and a transaction manager. |
| Embedded server | Tomcat (or Jetty, or Netty) is a library inside your jar. java -jar app.jar is the deployment. There is no container to install and no WAR to drop. |
| Actuator | Health, metrics, environment, mappings, loggers and thread dumps over HTTP or JMX, without writing any of it. |
A fifth, Spring Initializr, is not part of the runtime — it is the generator that writes the first commit for you.
Every auto-configuration backs off the moment you define the bean yourself. @ConditionalOnMissingBean is on almost all of it. That is why Boot feels like magic and behaves like a library: your bean always wins.
Two releases a year, in May/June and November. Each gets 13 months of free OSS support from GA and a further year commercially; 2.7 and 3.5 are the long-lived exceptions.
| Branch | GA | OSS ends | Commercial | Java |
|---|---|---|---|---|
| 4.1 | Jun 2026 | Jul 2027 | Jul 2028 | 17–26 |
| 4.0 | Nov 2025 | Dec 2026 | Dec 2027 | 17–25 |
| 3.5 | May 2025 | Jun 2026 | Jun 2032 | 17–25 |
| 3.4 | Nov 2024 | Dec 2025 | Dec 2026 | 17–24 |
| 3.0 | Nov 2022 | Dec 2023 | Dec 2024 | 17–21 |
| 2.7 | May 2022 | Jun 2023 | Jun 2029 | 8–21 |
| Thing | Version |
|---|---|
| Java | 17 minimum, 26 maximum |
| Spring Framework | 7.0.9 or later |
| Maven | 3.6.3+ |
| Gradle | 8.14+ (8.x line) or 9.x |
| Servlet containers | Tomcat 11.0.x, Jetty 12.1.x — Servlet 6.1; any Servlet 6.1+ container for a WAR |
| GraalVM | Community 25, Native Build Tools 1.1.8 |
Boot 3.x is the Jakarta EE line (javax.* → jakarta.*); Boot 2.x is not. Boot 4 adds Spring Framework 7, Spring Security 7 and Jackson 3 on top.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
</dependencies>
Can’t inherit from the parent? Import spring-boot-dependencies as a BOM with <scope>import</scope> in dependencyManagement instead — you keep version management, you lose the plugin defaults.
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.1'
id 'io.spring.dependency-management' version '1.1.7'
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
}
cloud.geocam.demo ├── DemoApplication.java ← @SpringBootApplication ├── web/ controllers, DTOs, advice ├── service/ business logic ├── repository/ Spring Data interfaces └── domain/ entities src/main/resources ├── application.yaml ├── application-dev.yaml ├── static/ css, js, images → served at / └── templates/ Thymeleaf, Mustache…
Component scanning starts at the package of the class carrying @SpringBootApplication and goes down. Put that class at the root of your package tree, or nothing is found.
package cloud.geocam.demo;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
| Meta-annotation | Effect |
|---|---|
@SpringBootConfiguration | A @Configuration class, and the marker tests use to find the context root |
@EnableAutoConfiguration | Turns on classpath-driven configuration |
@ComponentScan | Scans this package and below for stereotypes |
var app = new SpringApplication(DemoApplication.class);
app.setBannerMode(Banner.Mode.OFF);
app.setWebApplicationType(WebApplicationType.NONE);
app.setDefaultProperties(Map.of("server.port", "9090"));
app.run(args);
// or the fluent builder
new SpringApplicationBuilder(DemoApplication.class)
.profiles("dev").web(WebApplicationType.SERVLET).run(args);
Order several runners with @Order. An exception thrown from a runner aborts startup.
A starter is a POM with no code: it names a technology and drags in a consistent set of dependencies. Boot 4 split the old megajars into one module per technology, so the names changed — and the “test” half of every starter is now its own artifact.
| Starter | Gets you |
|---|---|
| spring-boot-starter | Core: auto-configuration, logging, YAML. Everything else builds on it. |
| -webmvc | Spring MVC + embedded Tomcat. Replaces -web, now deprecated. |
| -webflux | Reactive stack: WebFlux + Reactor Netty |
| -data-jpa | Spring Data JPA with Hibernate, plus HikariCP |
| -jdbc | JdbcClient/JdbcTemplate and HikariCP, no ORM |
| -data-jdbc / -data-r2dbc | Spring Data JDBC; R2DBC for reactive SQL |
| -data-mongodb, -data-redis, -data-elasticsearch, -data-cassandra | NoSQL, each with a -reactive twin where one exists |
| -security | Spring Security: filter chain, form login, HTTP Basic |
| -security-oauth2-client / -resource-server / -authorization-server | OAuth2 and OIDC. The old names without security- are deprecated. |
| -validation | Bean Validation via Hibernate Validator |
| -actuator | Production endpoints |
| -restclient / -webclient | Blocking (RestClient) or reactive (WebClient) HTTP clients, plus HTTP Service Clients |
| -flyway / -liquibase | Migrations. New in Boot 4 — the bare third-party dependency is no longer enough. |
| -cache, -quartz, -mail, -batch, -integration, -graphql, -kafka, -amqp, -websocket, -thymeleaf, -freemarker, -mustache, -hateoas, -jooq, -pulsar, -rsocket, -grpc-client/-server | One each, doing what the name says |
| -opentelemetry, -micrometer-metrics, -zipkin | Metrics and tracing export |
<dependency>
<artifactId>spring-boot-starter-webmvc</artifactId>
<exclusions><exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion></exclusions>
</dependency>
<dependency>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
spring-boot-starter-test (JUnit Jupiter, AssertJ, Hamcrest, Mockito, JSONassert, JsonPath) still exists, but Boot 4 wants you to list the per-technology ones instead: -webmvc-test, -data-jpa-test, -security-test, -restclient-test. spring-boot-starter-classic and -test-classic restore the old all-in-one behaviour while you migrate.
Two ways to get a definition into the BeanFactory: scanning, or a factory method. Everything else — qualifiers, scopes, proxies — is refinement on top.
@Service // stereotype: scanned
class OrderService { }
@Configuration // factory method: explicit
class AppConfig {
@Bean
RestClient restClient(RestClient.Builder b) {
return b.baseUrl("https://api.example.com").build();
}
}
@Service
public class OrderService {
private final OrderRepository repo; // final: immutable
private final PaymentClient payments;
OrderService(OrderRepository repo, PaymentClient payments) {
this.repo = repo; this.payments = payments;
}
}
A single constructor is autowired implicitly — no @Autowired. Fields stay final, no half-constructed state is reachable, and the test constructs it with new. Field injection needs reflection to substitute, and removes the pressure that makes a nine-dependency constructor look as wrong as it is. It also cannot express a circular dependency as a compile-time impossibility, which is why circular-reference failures surface at startup with constructor injection and at first use without it.
| Annotation | Means |
|---|---|
@Component | Generic bean; the other three are specialisations of it |
@Service | Business logic. Semantic only — no extra behaviour |
@Repository | Data access. Does add behaviour: translates vendor exceptions into Spring’s DataAccessException hierarchy |
@Controller / @RestController | Web endpoint; @RestController = @Controller + @ResponseBody |
| Scope | One instance per |
|---|---|
singleton | container (the default) |
prototype | injection point — container does not manage its destruction |
request / session / application | web request / HTTP session / ServletContext |
Injecting a prototype into a singleton gives you one instance forever. Use ObjectProvider, @Lookup, or a scoped proxy.
@EnableAutoConfiguration reads every META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports file on the classpath — one line per class — and evaluates each class’s conditions. Classes whose conditions match contribute beans; the rest are skipped and never loaded.
| Condition | Matches when |
|---|---|
@ConditionalOnClass | a class is on the classpath (the usual first gate) |
@ConditionalOnMissingClass | it is not |
@ConditionalOnBean | a bean of that type is already defined |
@ConditionalOnMissingBean | it is not — the back-off that lets your bean win |
@ConditionalOnProperty | prefix+name has havingValue; matchIfMissing decides the default |
@ConditionalOnBooleanProperty | the same, for a plain boolean |
@ConditionalOnResource | a resource exists, e.g. classpath:banner.txt |
@ConditionalOnWebApplication | servlet or reactive web context (type=SERVLET|REACTIVE|ANY) |
@ConditionalOnNotWebApplication | plain context |
@ConditionalOnWarDeployment / Not | deployed as a WAR / running embedded |
@ConditionalOnExpression | a SpEL expression is true |
@AutoConfiguration(before=, after=, beforeName=, afterName=), or the standalone @AutoConfigureBefore / @AutoConfigureAfter / @AutoConfigureOrder. Ordering affects bean definition order only; creation order still follows dependencies.
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
@ConditionalOnClass(AcmeClient.class)
@EnableConfigurationProperties(AcmeProperties.class)
public class AcmeAutoConfiguration {
@Bean @ConditionalOnMissingBean
AcmeClient acmeClient(AcmeProperties p) {
return new AcmeClient(p.url(), p.token());
}
}
Register the class in src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Use $ for nested classes. Never put auto-configuration classes in a package that gets component-scanned.
Everything configurable is a property, and properties come from many places. Later sources in this list win.
| # | Source |
|---|---|
| 1 | SpringApplication.setDefaultProperties |
| 2 | @PropertySource on a @Configuration class |
| 3 | Config data — application.properties / .yaml and their profile variants |
| 4 | random.* |
| 5 | OS environment variables |
| 6 | Java system properties (-D) |
| 7–9 | JNDI java:comp/env, ServletContext and ServletConfig init params |
| 10 | SPRING_APPLICATION_JSON |
| 11 | Command-line arguments (--server.port=9090) |
| 12–14 | Test-only: @SpringBootTest(properties=), @DynamicPropertySource, @TestPropertySource |
| 15 | DevTools global settings in $HOME/.config/spring-boot |
Classpath root, classpath /config, the current directory, ./config/, and immediate children of ./config/ — in that order, each overriding the last. Inside a location, .properties beats .yaml, and a profile-specific file always beats the plain one.
@ConfigurationProperties(prefix = "acme")
public record AcmeProperties(
URI url, String token, Duration timeout,
@DefaultValue("3") int retries) { }
// switch it on, once:
@ConfigurationPropertiesScan // on the app class
// or @EnableConfigurationProperties(AcmeProperties.class)
Records get constructor binding automatically. Prefer this to scattering @Value: it is typed, validatable (@Validated + constraints), relocatable, and shows up in /actuator/configprops and in your IDE’s completion if you add spring-boot-configuration-processor.
One property, many spellings. acme.api-key in a file, acme.apiKey in code, ACME_APIKEY in the environment, acme.api_key in a system property — all bind to the same field. Kebab-case in files is the canonical form; use it in ${…} placeholders too.
; separates groupsserver.port=8080 #--- spring.config.activate.on-profile=prod spring.config.activate.on-cloud-platform=kubernetes server.port=80
In YAML the separator is ---; in .properties it is a #--- comment line. Multi-document files do not work through @PropertySource.
A profile is a named slice of configuration and beans. Nothing about it is environment-specific until you say so.
// beans
@Component @Profile("!prod")
class InMemoryPaymentGateway implements PaymentGateway { }
# files, loaded on top of application.yaml
application-dev.yaml
application-prod.yaml
Expressions work too: @Profile("prod | staging"), @Profile("!test"). With several active profiles it is last-wins, so active=prod,live lets application-live override application-prod.
The trap: spring.profiles.active cannot be set inside a profile-specific file. Set it outside the application entirely — environment, argument, or the plain application.yaml.
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService service;
OrderController(OrderService service) { this.service = service; }
@GetMapping // GET /api/orders?page=0&size=20
Page<OrderDto> list(Pageable pageable) { … }
@GetMapping("/{id}") // GET /api/orders/42
OrderDto one(@PathVariable long id) { … }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
OrderDto create(@Valid @RequestBody NewOrder body) { … }
@PutMapping("/{id}")
ResponseEntity<OrderDto> replace(@PathVariable long id,
@Valid @RequestBody OrderDto body) {
return ResponseEntity.ok(service.replace(id, body));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
void delete(@PathVariable long id) { … }
}
| Annotation | Reads |
|---|---|
@PathVariable | a {placeholder} in the URL template |
@RequestParam | query string or form field; required, defaultValue |
@RequestBody | the body, deserialised by Jackson |
@RequestHeader / @CookieValue | one header / one cookie |
@RequestPart | one part of a multipart upload |
@ModelAttribute | a whole object bound from request params |
@MatrixVariable | ;key=value segments in the path |
| no annotation | resolved by type: Pageable, Principal, HttpServletRequest, UriComponentsBuilder… |
Also spring.mvc.apiversion.use.query-param, .path-segment or .media-type-parameter, plus ApiVersionResolver, ApiVersionParser and ApiVersionDeprecationHandler beans. Same properties under spring.webflux.*.
With Spring Security on the path, CORS must also be enabled in the filter chain (http.cors(withDefaults())) or the preflight is rejected before it reaches MVC.
public record NewOrder(
@NotBlank @Size(max = 80) String customer,
@Email String email,
@NotNull @Positive BigDecimal amount,
@Future LocalDate deliverBy,
@Valid @NotEmpty List<Line> lines) { }
@PostMapping
OrderDto create(@Valid @RequestBody NewOrder body) { … }
Needs spring-boot-starter-validation. @Valid on a nested field cascades; without it, nested objects are not checked. On a bean’s own methods use @Validated at class level to get method-parameter validation.
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalid(MethodArgumentNotValidException ex) {
var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setProperty("errors", ex.getBindingResult().getFieldErrors()
.stream().collect(toMap(FieldError::getField,
FieldError::getDefaultMessage)));
return pd;
}
@ExceptionHandler(OrderNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
ProblemDetail missing(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
ex.getMessage());
}
}
ProblemDetail is RFC 9457 — type, title, status, detail, instance, plus your own properties. Extend ResponseEntityExceptionHandler to get Spring’s own exceptions in the same shape, or set spring.mvc.problemdetails.enabled=true to have Boot do it for you.
Never turn stack traces on in production. /error is a real mapping; you can replace it with your own @Controller or an ErrorAttributes bean.
@Entity @Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 80)
private String customer;
@Enumerated(EnumType.STRING) // never ORDINAL
private Status status;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer owner;
@OneToMany(mappedBy = "order", cascade = ALL, orphanRemoval = true)
private List<Line> lines = new ArrayList<>();
@Version private long version; // optimistic locking
@CreatedDate private Instant createdAt;
}
public interface OrderRepository
extends JpaRepository<Order, Long> {
List<Order> findByCustomerIgnoreCase(String customer);
Page<Order> findByStatusOrderByCreatedAt(Status s, Pageable p);
Optional<Order> findFirstByCustomerAndStatus(String c, Status s);
boolean existsByReference(String reference);
long countByStatus(Status s);
@Query("select o from Order o join fetch o.lines where o.id = :id")
Optional<Order> findWithLines(@Param("id") Long id);
@Modifying @Transactional
@Query("update Order o set o.status = :s where o.id = :id")
int markStatus(@Param("id") Long id, @Param("s") Status s);
}
The hierarchy is Repository → CrudRepository → PagingAndSortingRepository → JpaRepository. No implementation is written; Spring Data builds a proxy from the method names at startup, and a name it cannot parse is a startup failure, not a runtime one.
| Keyword | SQL |
|---|---|
And / Or / Not | and / or / <> |
Is, Equals, or nothing | = |
LessThan(Equal), GreaterThan(Equal) | < <= > >= |
Between, Before, After | between, <, > |
(Is)Null, (Is)NotNull | is null, is not null |
Like, NotLike | like — you supply the % |
StartingWith, EndingWith, Containing | like — Spring supplies the % |
In, NotIn | in — takes a Collection, array or varargs |
True, False | = true, = false |
IgnoreCase | upper(x) = upper(?) |
Distinct, OrderBy…Asc|Desc | select distinct, order by |
findFirst5, findTop10 | limit the result |
| Property | Note |
|---|---|
spring.jpa.hibernate.ddl-auto | none | validate | update | create | create-drop. Boot defaults to create-drop for an embedded DB, none otherwise. Use validate in production and let Flyway own the schema. |
spring.jpa.show-sql | dumps SQL to stdout; prefer logging.level.org.hibernate.SQL=DEBUG |
spring.jpa.open-in-view | defaults to true and logs a warning. Set it false and fix the lazy-loading it was hiding. |
spring.jpa.properties.hibernate.* | passthrough for raw Hibernate settings |
Cure the plain case with join fetch, @EntityGraph(attributePaths = "lines"), or a projection interface that never touches the association. But join fetch on a collection plus a Pageable makes Hibernate log HHH90003004 and paginate in memory after loading the whole result set — correct answer, unbounded heap. Split it: page the ids, then fetch the graph for that id set. Count queries to diagnose; reading the code will not show you this.
@Service
class OrderService {
@Transactional
public Order place(NewOrder cmd) {
var order = repo.save(Order.from(cmd));
inventory.reserve(order); // same transaction
return order; // commit here, or roll back
}
@Transactional(readOnly = true) // no dirty checking or flush
public List<Order> recent() { return repo.findTop20ByIdDesc(); }
}
| Value | Behaviour |
|---|---|
REQUIRED | join it, or start one. The default. |
REQUIRES_NEW | suspend the outer, run in a fresh one. For audit rows that must survive a rollback. |
NESTED | savepoint inside the outer transaction |
SUPPORTS | join if there is one, otherwise run without |
NOT_SUPPORTED | suspend and run without |
MANDATORY / NEVER | throw if there is not one / if there is |
The four ANSI levels plus DEFAULT, which delegates to the driver — and DEFAULT is what you almost always want, because the level you name here is passed straight through and PostgreSQL’s REPEATABLE_READ is snapshot isolation while MySQL’s is not. Setting isolation on a @Transactional that joins an existing transaction throws rather than silently downgrading.
By default Spring rolls back on RuntimeException and Error, and commits on a checked exception. Say so if you mean otherwise:
this.otherMethod() does not go through the proxy, so its @Transactional, @Async or @Cacheable is ignored. Move the method to another bean, or inject the bean into itself.@Transactional only works on public methods of a Spring bean. On a private, protected or package-private method it silently does nothing.Three shapes, one auto-configured builder each. RestTemplate is legacy — new blocking code uses RestClient.
@Bean
RestClient orders(RestClient.Builder builder) {
return builder.baseUrl("https://api.example.com")
.defaultHeader("X-Api-Key", key)
.build();
}
Pet pet = restClient.get()
.uri("/pets/{id}", id)
.accept(APPLICATION_JSON)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
(req, res) -> { throw new NotFound(res.getStatusCode()); })
.body(Pet.class);
ResponseEntity<Void> created = restClient.post()
.uri("/pets").contentType(APPLICATION_JSON).body(pet)
.retrieve().toBodilessEntity();
@HttpExchange("/pets")
interface PetClient {
@GetExchange("/{id}") Pet byId(@PathVariable long id);
@PostExchange Pet create(@RequestBody Pet pet);
@DeleteExchange("/{id}") void delete(@PathVariable long id);
}
@Configuration
@ImportHttpServices(group = "pets", types = PetClient.class)
class ClientConfig { }
Boot 4 auto-configures the proxy factory, so the interface is injectable as a bean; group configuration goes through a RestClientHttpServiceGroupConfigurer. Method parameters take the familiar @PathVariable, @RequestParam, @RequestHeader, @RequestBody, @CookieValue, @RequestPart.
Mono<Pet> pet = webClient.get().uri("/pets/{id}", id)
.retrieve().bodyToMono(Pet.class);
Add spring-boot-starter-security and every endpoint is locked immediately, with a generated password on the console. That default is a prompt to configure, not a configuration.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // stateless API only
.cors(Customizer.withDefaults())
.authorizeHttpRequests(a -> a
.requestMatchers("/api/public/**").permitAll()
.requestMatchers(GET, "/api/orders/**").hasRole("USER")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
@Bean PasswordEncoder encoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
Several chains can coexist; each needs a securityMatcher and they are tried in @Order. The old WebSecurityConfigurerAdapter is long gone, and Security 7 removed the .and() chaining style — lambdas everywhere.
Writing your own OncePerRequestFilter to parse JWTs is the tutorial answer; the resource-server support is the maintained one. If you do write a filter, register it with addFilterBefore(…, UsernamePasswordAuthenticationFilter.class) and populate the SecurityContextHolder.
hasRole("ADMIN") tests for the authority ROLE_ADMIN; hasAuthority adds no prefix. Mixing them is why a rule that looks right denies everything.authorizeHttpRequests are evaluated in declaration order, first match wins — anyRequest() placed early makes everything after it dead code, and Security refuses to start if it can prove a matcher is unreachable.@EnableCaching // once, on a @Configuration class
@Service
class CatalogService {
@Cacheable(value = "products", key = "#id",
unless = "#result == null")
Product byId(long id) { … } // slow call, memoised
@CachePut(value = "products", key = "#p.id")
Product save(Product p) { … } // always runs, refreshes it
@CacheEvict(value = "products", key = "#id")
void delete(long id) { … }
@CacheEvict(value = "products", allEntries = true)
void reindex() { … }
}
| Attribute | Meaning |
|---|---|
key | SpEL over the arguments; default is all arguments combined |
condition | evaluated before the call — cannot see the result |
unless | evaluated after — #result is in scope |
sync = true | one caller computes, the rest wait |
beforeInvocation | on @CacheEvict: evict even if the method throws |
@CacheConfig | class-level defaults for cache names and key generator |
@Caching | several cache annotations of the same kind on one method |
| Backing store | When |
|---|---|
ConcurrentHashMap | the fallback if nothing else is present. No eviction, no size limit — a memory leak with a nice name. |
| Caffeine | single instance, real eviction. spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=5m |
| Redis | shared across instances. spring.cache.type=redis, spring.cache.redis.time-to-live=10m |
| Hazelcast, JCache, Couchbase | auto-detected when on the classpath |
Cache proxies are subject to the same self-invocation rule as transactions. And cache what is expensive and stable — caching a method that is already a primary-key lookup buys nothing and costs correctness.
@Configuration @EnableAsync
class AsyncConfig {
@Bean("mailExecutor")
ThreadPoolTaskExecutor mailExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(4); ex.setMaxPoolSize(16);
ex.setQueueCapacity(200); ex.setThreadNamePrefix("mail-");
return ex;
}
}
@Async("mailExecutor")
CompletableFuture<Receipt> send(Order o) { … }
Return void and you lose every exception unless you register an AsyncUncaughtExceptionHandler. Return CompletableFuture and the caller can join or compose. Boot’s default executor is configured under spring.task.execution.*; on Java 21+, spring.threads.virtual.enabled=true puts the whole application on virtual threads.
@Configuration @EnableScheduling
class SchedulingConfig { }
@Scheduled(fixedDelay = 30_000) // 30s after the last one ended
void poll() { … }
@Scheduled(fixedRate = 1000, initialDelay = 5000)
void tick() { … } // every 1s regardless of duration
@Scheduled(cron = "0 15 3 * * MON-FRI", zone = "Europe/London")
void nightly() { … } // 03:15 on weekdays
@Scheduled(cron = "${reports.cron:-}") // "-" disables it
void report() { … }
Spring’s cron expression is six fields, seconds first — not the five-field Unix form, so a copied crontab line is off by a factor of sixty. L and # are supported; ? is accepted and means the same as *. Macros: @hourly, @daily, @weekly, @monthly, @yearly.
Two things that surprise people. The scheduler pool is one thread by default, so a slow job delays every other job in the application — set spring.task.scheduling.pool.size. And @Scheduled fires on every instance in a cluster; if the job is not idempotent you need a distributed lock (ShedLock, or a row you SELECT … FOR UPDATE SKIP LOCKED).
Three brokers, one shape: a listener annotation on a method, a template for sending, and auto-configuration that reads a handful of properties.
@KafkaListener(topics = "orders", groupId = "billing")
void onOrder(Order order, Acknowledgment ack) { … }
kafkaTemplate.send("orders", order.id().toString(), order);
@RabbitListener(queues = "orders")
void onOrder(Order order) { … }
rabbitTemplate.convertAndSend("orders.ex", "order.created", order);
@JmsListener(destination = "orders")
void onOrder(@Payload Order order,
@Header("JMSCorrelationID") String cid) { … }
Serialisation is the recurring bug: agree a format (JSON via JsonMessageConverter, or Avro/Protobuf with a registry) rather than letting Java serialisation happen. And make every consumer idempotent — all three brokers are at-least-once.
SLF4J is the API, Logback the default implementation, and every starter pulls in spring-boot-starter-logging. You configure it with properties, not XML, until you need something Logback-specific.
private static final Logger log =
LoggerFactory.getLogger(OrderService.class);
log.info("Placed order {} for {}", order.id(), name); // parameterised
log.debug("Payload: {}", () -> expensive()); // lazily evaluated
log.error("Payment failed for {}", order.id(), ex); // throwable last
The {} form is not cosmetic: with + the concatenation and every toString() run before the level check. The Supplier overload defers even the argument evaluation. An exception passed as the last argument is treated as a throwable and gets a stack trace, not a {} substitution — which is why a message with a trailing {} and an exception loses the trace.
Drop logback-spring.xml on the classpath for appenders, filters and per-profile blocks (<springProfile name="prod">). Use the -spring suffix so Boot, not Logback, loads it — that is what enables <springProperty> and profile support. Log4j2 instead: exclude spring-boot-starter-logging, add spring-boot-starter-log4j2.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepository repo;
@InjectMocks OrderService service;
@Test void placesOrder() {
given(repo.save(any())).willReturn(anOrder());
assertThat(service.place(cmd()).id()).isEqualTo(1L);
then(repo).should().save(any());
}
}
No context, so no startup cost and no context cache to invalidate. The cache is the thing worth understanding: Spring keys a loaded context on the full set of configuration attributes — classes, profiles, property sources, slice annotations, even which beans are replaced by @MockitoBean — and reuses it across test classes that match. Every variation you introduce is another context built from scratch, which is why a suite of 400 tests can take four seconds or four minutes on the same code.
| Annotation | Loads |
|---|---|
@WebMvcTest(OrderController.class) | MVC infrastructure, controllers, advice, converters — no services or repositories. Supply them with @MockitoBean. |
@WebFluxTest | the reactive equivalent, with WebTestClient |
@DataJpaTest | entities, repositories, an embedded database, TestEntityManager. Transactional and rolled back per test. |
@JdbcTest, @DataJdbcTest, @JooqTest | the same for plain JDBC, Spring Data JDBC and jOOQ |
@DataMongoTest, @DataRedisTest, @DataR2dbcTest, @DataNeo4jTest, @DataCassandraTest, @DataElasticsearchTest, @DataLdapTest | one per store |
@JsonTest | serialisation only, with JacksonTester |
@RestClientTest, @WebClientTest | client-side, with MockRestServiceServer |
@GraphQlTest | GraphQL controllers with GraphQlTester |
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvcTester mvc;
@MockitoBean OrderService service;
@Test void returnsOrder() {
given(service.byId(1L)).willReturn(anOrderDto());
assertThat(mvc.get().uri("/api/orders/1"))
.hasStatusOk()
.bodyJson().extractingPath("$.customer").isEqualTo("Ada");
}
}
@SpringBootTest(webEnvironment = RANDOM_PORT)
@AutoConfigureRestTestClient
class OrderApiTest {
@Test void createsOrder(@Autowired RestTestClient client) {
client.post().uri("/api/orders").body(newOrder())
.exchange().expectStatus().isCreated();
}
}
| webEnvironment | Effect |
|---|---|
MOCK | default — mock servlet environment, no port |
RANDOM_PORT | real server on a free port; inject it with @LocalServerPort |
DEFINED_PORT | real server on the configured port |
NONE | context only, no web |
@SpringBootTest
@Testcontainers
class OrderIT {
@Container @ServiceConnection
static PostgreSQLContainer<?> db =
new PostgreSQLContainer<>("postgres:17");
}
@ServiceConnection wires the container’s URL, username and password into the context — no @DynamicPropertySource needed. The same containers can back spring-boot-testcontainers at development time, so ./mvnw spring-boot:test-run starts a real Postgres for local work.
Note for upgraders: Boot 4 removed @MockBean and @SpyBean. Use Spring Framework’s @MockitoBean and @MockitoSpyBean.
Add spring-boot-starter-actuator. Only health is exposed by default, over HTTP and JMX — everything else is opt-in, deliberately.
| ID | Shows |
|---|---|
health | up/down, aggregated from every HealthIndicator. Exposed by default. |
info | build, git and custom info |
metrics | Micrometer meters; /actuator/metrics/{name}?tag=uri:/api/orders |
prometheus | the same in Prometheus scrape format |
loggers | read and change log levels at runtime |
env, configprops | resolved environment and bound @ConfigurationProperties — sanitised, still sensitive |
beans, conditions, mappings | what was defined, why, and every request mapping |
threaddump, heapdump | a thread dump; an HPROF/PHD file |
httpexchanges | last 100 request/response pairs (needs an HttpExchangeRepository) |
scheduledtasks, quartz | what is scheduled, and Quartz jobs |
flyway, liquibase | migrations applied |
caches, sessions, startup, auditevents, integrationgraph, logfile | the rest |
shutdown | graceful shutdown. Disabled as well as unexposed — needs management.endpoint.shutdown.access=unrestricted. |
Micrometer is the facade: @Timed, @Counted, @Observed, or an injected MeterRegistry. Common tags for every meter come from management.metrics.tags.*. Tracing exports over OTLP (spring-boot-starter-opentelemetry) or Zipkin, sampled by management.tracing.sampling.probability.
Boot 4 renamed two of these: management.tracing.enabled → management.tracing.export.enabled, and @ConditionalOnEnabledTracing → @ConditionalOnEnabledTracingExport.
Secure it. /actuator/env and /actuator/configprops leak configuration, /actuator/heapdump leaks everything in memory. Put the whole base path behind authentication, or on a management port that only your monitoring can reach.
app.jar
├── META-INF/MANIFEST.MF Main-Class: JarLauncher
├── org/springframework/boot/loader/…
└── BOOT-INF/
├── classes/ your code and resources
├── lib/ every dependency, as a nested jar
└── classpath.idx / layers.idx
Nested jars are stored, not compressed, so the launcher can read them in place. This is why an unzip-and-run does not work and why java -jar does.
FROM eclipse-temurin:21-jre AS builder WORKDIR /app COPY target/app.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --launcher FROM eclipse-temurin:21-jre WORKDIR /app COPY --from=builder /app/app/dependencies/ ./ COPY --from=builder /app/app/spring-boot-loader/ ./ COPY --from=builder /app/app/snapshot-dependencies/ ./ COPY --from=builder /app/app/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Four layers, ordered by how often they change, so a code-only rebuild ships a few hundred kilobytes instead of a hundred megabytes. Or skip the Dockerfile entirely: spring-boot:build-image uses Cloud Native Buildpacks and does the layering for you.
Startup in tens of milliseconds and a fraction of the memory, at the cost of a long build and no runtime reflection you have not declared. AOT processing (process-aot) runs first and generates the hints.
spring-boot-devtools as an optional/developmentOnly dependency: restart on classpath change, LiveReload, template caching off, and a property defaults file at $HOME/.config/spring-boot. It disables itself in a repackaged jar, and it has no place in a production image.
4.0 (November 2025) is the largest reshaping since 2.0. If you are reading tutorials written for 3.x, these are the lines that will not compile.
| Dependency | Now |
|---|---|
| Spring Framework | 7.0 |
| Spring Security | 7.0 — lambda DSL only |
| Jackson | 3.0 (group tools.jackson). Jackson 2 ships deprecated. |
| Hibernate | 7.1, Hibernate Validator 9.0 |
| Jakarta | Servlet 6.1, Persistence 3.2, Validation 3.1, WS RS 4.0 |
| Kotlin / Gradle | 2.2.20 / Gradle 9 (8.14+ still supported) |
The old spring-boot-autoconfigure megajar is gone. Boot now ships one module per technology, on a strict naming scheme:
| Was | Is |
|---|---|
spring-boot-starter-web | spring-boot-starter-webmvc |
spring-boot-starter-web-services | spring-boot-starter-webservices |
spring-boot-starter-oauth2-client etc. | spring-boot-starter-security-oauth2-client |
bare flyway-core / liquibase-core | spring-boot-starter-flyway / -liquibase |
spring-boot-starter-test | the -test starter of each technology under test |
Stuck? spring-boot-starter-classic and spring-boot-starter-test-classic restore the old catch-all behaviour so you can upgrade in two steps rather than one.
spring.mvc.apiversion.*, and a version attribute on the mapping annotations.@HttpExchange, register it with @ImportHttpServices, inject the proxy.@SpringBootTest and @AutoConfigureMockMvc.| Was | Is |
|---|---|
@MockBean / @SpyBean | @MockitoBean / @MockitoSpyBean |
org.springframework.boot.env.EnvironmentPostProcessor | org.springframework.boot.EnvironmentPostProcessor |
@ConditionalOnEnabledTracing | @ConditionalOnEnabledTracingExport |
management.tracing.enabled | management.tracing.export.enabled |
spring.dao.exceptiontranslation.enabled | spring.persistence.exceptiontranslation.enabled |
Auto-configuration classes no longer expose public members other than constants — if you were subclassing or calling into one, you cannot any more. And certificate-validity-threshold support was dropped from the SSL info endpoint.
@Transactional, @Async, @Cacheable, @PreAuthorize and @Retryable all work through a proxy. A call from one method of a bean to another on this bypasses it entirely.@SpringBootApplication class’s package. A bean in a sibling package is never found.@Valid without a nested @Valid. Validation does not cascade into a collection or a nested object unless you annotate the field.@EnableCaching / @EnableAsync / @EnableScheduling missing. The annotations still compile; nothing happens.| Message | Cause |
|---|---|
Port 8080 was already in use | another instance, or something else. server.port=0 picks a free one. |
Failed to determine a suitable driver class | a JDBC starter is on the path but no spring.datasource.url. Either configure it or exclude DataSourceAutoConfiguration. |
LazyInitializationException | a lazy association touched after the session closed. Fetch it in the query — do not re-enable open-in-view. |
The dependencies of some beans form a cycle | A needs B needs A. Extract the shared part into C. spring.main.allow-circular-references=true is a delay, not a fix. |
No qualifying bean of type … expected single match | two candidates. @Primary, @Qualifier, or inject the List. |
Whitelabel Error Page, 500, no detail | look at the log; the response is deliberately silent. server.error.include-message=always in dev. |
ddl-auto=validate in every environment above your laptop.final fields, no @Autowired.@ConfigurationProperties, not scattered @Value. Typed, validatable, discoverable.equals/hashCode on an entity using all fields. Use the business key, or the id with a null-safe guard.application.yaml in the repository.The reference documentation’s “Common Application Properties” appendix lists every one. Add spring-boot-configuration-processor and your IDE completes them, including your own @ConfigurationProperties. At runtime, /actuator/configprops shows what was actually bound.