18. Spring Boot and Production Applications
Chapter 17 covered what Spring does; this chapter covers what Spring Boot adds on top, which is almost entirely about removing the setup work between an idea and a running, deployable service. It walks from auto-configuration and starter dependencies through configuration management, REST APIs, security, and the testing and operational concerns that separate a demo from something you can put in front of users.
<a id="181-spring-boot-fundamentals"></a>
18.1 Spring Boot Fundamentals
This subchapter explains what Boot actually automates, how it does it, and where the automation stops, so that the framework stays a tool you direct rather than a black box you hope behaves.
Convention Over Configuration
Setting up a Spring application before Boot was a project in itself. A basic web service with a database needed a dispatcher servlet registration, a view resolver, a component scan declaration, a data source, a transaction manager, an entity manager factory, a Jackson converter, and a servlet container to deploy into. None of it was hard. All of it was necessary, repetitive, and easy to get subtly wrong.
The observation behind Spring Boot is that the overwhelming majority of those decisions are the same in every project. Almost everyone wants JSON, a connection pool, a transaction manager matching their persistence technology, and an HTTP server on port 8080.
Convention over configuration means the framework assumes the common answer and asks you only about the parts that differ. The convention is not a restriction: every default can be overridden, and the framework's job is to be right often enough that you rarely bother.
Configuration-first Convention-first
------------------- ----------------
declare everything declare the differences
200 lines of XML or Java application.properties:
before the first endpoint server.port=8081
every project repeats it the rest is assumed
The difference is visible in the smallest possible application.
// A complete, runnable web service. There is no other file.
@SpringBootApplication
@RestController
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@GetMapping("/hello")
public String hello() {
return "hello";
}
}
Running that starts an embedded Tomcat on port 8080, configures Jackson for JSON, registers the dispatcher servlet, enables component scanning from this package down, and sets up sensible logging. None of it was requested.
Conventions Boot applies by default:
| Area | Convention |
|---|---|
| Server port | 8080 |
| Component scan | The package containing the main class, and below |
| Configuration file | application.properties or application.yml |
| Static content | src/main/resources/static |
| Templates | src/main/resources/templates |
| Database | An in-memory one if a driver is present and no URL set |
| JSON | Jackson, if it is on the classpath |
| Logging | Logback, with a readable console pattern |
Overriding is always a property away, which is the property that makes the convention acceptable rather than constraining.
server.port=8081
spring.jackson.property-naming-strategy=SNAKE_CASE
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
The risk is real and worth naming. A developer who has only used Boot may not know that a connection pool exists, what the transaction manager is, or why the server returns JSON. When something breaks, the convention becomes invisible machinery rather than a decision someone made. This is precisely why chapter 17 came first: Boot configures Spring, and diagnosing Boot means understanding what it configured.
The healthy stance is to treat conventions as defaults you have accepted
rather than magic. Knowing that server.port exists, and that a
DataSourceAutoConfiguration class made the decision about your pool, turns the
framework back into something you can reason about.
Key Takeaways
- Most Spring configuration decisions are identical across projects.
- Convention over configuration means the framework assumes the common answer.
- A single annotated class is a complete, runnable web service.
- Defaults cover the port, scanning, JSON, logging, static content, and the database.
- Every convention can be overridden with a property.
- The cost is invisible machinery, which is why understanding plain Spring matters.
🧪 Practice
- Create a Boot application with one controller and run it with no configuration file at all.
- Change the port, the context path, and the JSON naming strategy using only properties.
- List five things the framework configured that you never declared.
- Interview: What is the difference between a framework with good defaults and a framework that is inflexible? (Hint: what must be true about every default for the first description to apply?)
Starter Dependencies and Version Management
Before Boot, adding web support to a Spring project meant selecting perhaps a dozen dependencies by hand, each with its own version, and then resolving the conflicts between them. Spring Web needed a compatible Spring Core, which needed a compatible Jackson, which had to agree with whatever the JSON test library pulled in.
That failure mode has a name.
Dependency hell
---------------
spring-webmvc 5.3.20 --needs--> spring-core 5.3.20
spring-data-jpa 2.7.0 --needs--> spring-core 5.3.21
some-library 1.4 --needs--> jackson 2.12
spring-webmvc --needs--> jackson 2.13
Result: NoSuchMethodError at runtime, discovered in production.
Starters solve the first half. A starter is a dependency that contains no code, only a curated list of other dependencies that are known to work together for one purpose.
<!-- One dependency brings the entire web stack, already consistent. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
That single line pulls in Spring MVC, an embedded Tomcat, Jackson, validation, and logging, at versions chosen to agree with each other.
| Starter | Provides |
|---|---|
spring-boot-starter-web | Spring MVC, embedded Tomcat, Jackson |
spring-boot-starter-data-jpa | Spring Data JPA, Hibernate, a connection pool |
spring-boot-starter-jdbc | JdbcTemplate and HikariCP |
spring-boot-starter-security | Spring Security and its filter chain |
spring-boot-starter-validation | Jakarta Bean Validation and Hibernate Validator |
spring-boot-starter-test | JUnit 5, Mockito, AssertJ, Spring Test |
spring-boot-starter-actuator | Production endpoints, covered in 18.5 |
spring-boot-starter-webflux | The reactive web stack instead of MVC |
The parent POM solves the second half: it declares a managed version for every dependency Boot knows about, so your own declarations omit versions entirely.
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version> <!-- the only version you choose -->
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- no version: inherited from the parent -->
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<!-- no version: Boot manages this one too -->
</dependency>
</dependencies>
Upgrading Boot's version therefore upgrades a hundred coordinated libraries in one edit, which is the main reason this arrangement matters.
Overriding a managed version is supported, for when you need a fix Boot has not picked up yet.
<properties>
<!-- Overrides the version the parent would otherwise choose. -->
<jackson-bom.version>2.17.1</jackson-bom.version>
</properties>
Do this deliberately and sparingly: the whole guarantee is that the managed set was tested together.
When you cannot use the parent POM, because your organisation has its own, import the dependency management instead.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.3.0</version>
<type>pom</type>
<scope>import</scope> <!-- versions only, no parent -->
</dependency>
</dependencies>
</dependencyManagement>
Inspecting what a starter pulled in is a skill worth having, because "where did this class come from" is a routine question.
# Print the full dependency tree, or filter it to one library.
mvn dependency:tree
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind
# Gradle equivalent
./gradlew dependencies --configuration runtimeClasspath
Excluding a transitive dependency is how you swap one component for another, most commonly replacing Tomcat with Jetty or Undertow.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
That exclusion also matters for auto-configuration, which the next topic covers: Boot decides what to configure largely by looking at what is on the classpath, so removing Tomcat and adding Jetty changes the decision automatically.
Key Takeaways
- A starter is a dependency containing no code, only a curated set of others.
- Starters solve which libraries you need; the parent POM solves which versions.
- Declaring dependencies without versions is the normal, intended style.
- Upgrading the Boot version coordinates an upgrade of everything it manages.
- Versions can be overridden with a property, at the cost of the tested guarantee.
spring-boot-dependenciesimports version management without the parent.- Excluding a transitive dependency is how components such as the server are swapped.
- The dependency tree is the tool for answering where a class came from.
🧪 Practice
- Create a project with only the web starter and list its transitive dependencies.
- Add the JPA starter and identify which connection pool and ORM arrived.
- Replace Tomcat with Jetty via exclusion and confirm the change at startup.
- Override one managed version with a property and verify it in the dependency tree.
- Interview: Why does declaring dependencies without version numbers make upgrades safer rather than riskier? (Hint: who decides the versions, and what have they done with that combination?)
Auto-Configuration Mechanics
Auto-configuration is the part of Boot that feels like magic, and it is worth dismantling precisely because it is not. It is 17.5's conditional annotations applied systematically, and understanding the mechanism turns startup problems into a five-minute diagnosis.
The principle: Boot ships hundreds of configuration classes, each declaring beans for one technology, and each guarded by conditions. At startup every one is evaluated, and the ones whose conditions match contribute their beans.
Startup
|
+-- find every auto-configuration class on the classpath
|
+-- for each, evaluate its conditions:
| is the class present?
| is a property set?
| has the user already defined this bean?
|
+-- register the beans from those that matched
|
+-- user-defined beans always take precedence
Here is a simplified version of a real one, which shows every idea at once.
@AutoConfiguration
@ConditionalOnClass(DataSource.class) // only if JDBC is on the classpath
@ConditionalOnProperty(prefix = "spring.datasource", name = "url")
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {
@Bean
@ConditionalOnMissingBean(DataSource.class) // yield to a user's own bean
public DataSource dataSource(DataSourceProperties properties) {
return properties.initializeDataSourceBuilder().build();
}
}
@ConditionalOnMissingBean is the line that makes the whole design work.
Defining your own DataSource bean means Boot's is never created, so overriding
a default requires no configuration flag and no framework edit.
Ordering matters, because "is this bean missing" depends on when you ask. Boot guarantees that user configuration is processed first, and auto- configuration classes declare their own relative order.
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
public class JdbcTemplateAutoConfiguration { ... } // needs the DataSource first
How the classes are discovered. Each library contributing auto-configuration ships a file listing its classes, which Boot reads from every JAR on the classpath.
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
...
This is the same service-loader idea as JDBC driver registration in 16.1: a JAR announces what it provides, and the framework finds it without being told.
The condition evaluation report is the diagnostic tool, and knowing it exists is the difference between guessing and reading the answer.
# Prints every auto-configuration, matched and unmatched, with the reason.
java -jar app.jar --debug
Positive matches:
-----------------
DataSourceAutoConfiguration matched:
- @ConditionalOnClass found 'javax.sql.DataSource' (OnClassCondition)
Negative matches:
-----------------
RedisAutoConfiguration:
Did not match:
- @ConditionalOnClass did not find 'redis.clients.jedis.Jedis'
When a bean you expected is missing, that report names the condition that failed.
Disabling an auto-configuration is occasionally necessary, most often when you want to configure something entirely yourself.
@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
public class Application { }
spring.autoconfigure.exclude=\
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
Writing your own is how shared libraries inside an organisation ship ready-configured components.
@AutoConfiguration
@ConditionalOnClass(AuditClient.class)
@ConditionalOnProperty(prefix = "audit", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(AuditProperties.class)
public class AuditAutoConfiguration {
@Bean
@ConditionalOnMissingBean // always allow an override
public AuditClient auditClient(AuditProperties properties) {
return new AuditClient(properties.endpoint(), properties.apiKey());
}
}
Listing that class in the imports file makes it apply automatically to any project that adds the library.
Key Takeaways
- Auto-configuration is conditional bean registration applied systematically.
- Conditions test the classpath, properties, and beans already defined.
@ConditionalOnMissingBeanis what lets your own bean silently win.- User configuration is processed before auto-configuration, so overriding works.
- Classes are discovered from an imports file in each JAR, like JDBC driver registration.
- The
--debugcondition report names exactly which condition failed.- Auto-configurations can be excluded by class or by property.
- Custom auto-configuration is how internal libraries ship preconfigured beans.
🧪 Practice
- Run an application with
--debugand find three positive and three negative matches.- Define your own
DataSourcebean and confirm from the report that Boot's no longer applies.- Exclude one auto-configuration and identify what stops working.
- Write an auto-configuration class for a small component, guarded by a property and
@ConditionalOnMissingBean.- Interview: You add a library and a bean you did not declare appears in the context. Explain the mechanism. (Hint: what file does the framework look for inside every JAR?)
The Application Entry Point and @SpringBootApplication
A Boot application starts from an ordinary main method, which is a meaningful
departure from the deployment model that preceded it. There is no container to
install and no artifact to drop into a server directory; the application is a
program that happens to serve HTTP.
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
@SpringBootApplication is three annotations in one, composed exactly as
17.5 described.
// Spring's actual definition, simplified.
@SpringBootConfiguration // a @Configuration, marked as the app's primary one
@EnableAutoConfiguration // switch on the mechanism from the previous topic
@ComponentScan // scan this package and everything below it
public @interface SpringBootApplication { }
The @ComponentScan with no arguments is why the main class's package
matters. It scans its own package downward, so a class outside that tree is
invisible.
com.shop <-- ShopApplication lives here
com.shop.order scanned
com.shop.order.web scanned
com.shop.billing scanned
com.other.util NOT scanned: no beans found here
Placing the main class in the root package is the convention, and "my bean is not being found" is nearly always a violation of it.
What SpringApplication.run actually does, in order:
1. decide the application type servlet, reactive, or none
2. load ApplicationContextInitializers and listeners from the classpath
3. prepare the Environment properties, profiles, command-line args
4. print the banner
5. create the ApplicationContext the type chosen in step 1
6. run auto-configuration and register all beans
7. instantiate every singleton eagerly, as 17.1 described
8. start the embedded server
9. call ApplicationRunner and CommandLineRunner beans
Step 1 is inferred from the classpath: the web starter yields a servlet application, WebFlux yields a reactive one, and neither yields a plain application that runs and exits.
Customising the startup is done with a builder when the defaults do not fit.
public static void main(String[] args) {
new SpringApplicationBuilder(ShopApplication.class)
.bannerMode(Banner.Mode.OFF) // no banner in logs
.web(WebApplicationType.SERVLET) // force the type
.profiles("prod") // 17.5's profiles
.listeners(new StartupTimingListener())
.run(args);
}
Runners execute code after startup, which is the correct place for work that needs a fully built context.
@Component
public class DataSeeder implements ApplicationRunner {
private final ProductRepository repository;
@Override
public void run(ApplicationArguments args) {
// The context is fully started; the server is accepting requests.
if (args.containsOption("seed")) { // --seed on the command line
repository.saveAll(sampleProducts());
}
}
}
ApplicationRunner receives parsed arguments; CommandLineRunner receives the
raw String[]. Use @Order when several must run in sequence.
Command-line arguments become properties, which is how the same jar is configured per environment without a rebuild.
java -jar shop.jar --server.port=9090 --spring.profiles.active=prod --seed
A non-web application is written the same way, and is the right shape for a batch job or a scheduled task.
@SpringBootApplication
public class ReportJob implements CommandLineRunner {
public static void main(String[] args) {
// exit() returns the code the runner produced, for the shell to read.
System.exit(SpringApplication.exit(
SpringApplication.run(ReportJob.class, args)));
}
@Override
public void run(String... args) {
reportService.generateMonthly();
}
}
Graceful shutdown matters in production, because a container that is stopped mid-request drops it.
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=30s
With this set, a shutdown signal stops accepting new requests, waits for
in-flight ones to finish, and then closes the context, running the
@PreDestroy callbacks from 17.1.
Key Takeaways
- A Boot application is an ordinary program with a
mainmethod.@SpringBootApplicationcombines configuration, auto-configuration, and component scanning.- Scanning starts at the main class's package, so it belongs in the root package.
- The application type is inferred from the classpath.
SpringApplication.runprepares the environment, builds the context, and starts the server.SpringApplicationBuildercustomises startup when defaults do not fit.ApplicationRunnerandCommandLineRunnerexecute code once the context is ready.- Command-line arguments override properties, so one artifact serves every environment.
- Graceful shutdown drains in-flight requests before the context closes.
🧪 Practice
- Move the main class into a sub-package and observe which beans stop being found.
- Write two runners with
@Orderand confirm the execution sequence.- Override the port with a command-line argument without editing any file.
- Build a non-web Boot application that performs one task and exits with a status code.
- Enable graceful shutdown and verify an in-flight request completes after a stop signal.
- Interview: Why does the main class's package location affect which beans exist? (Hint: one of the three composed annotations takes no arguments.)
Embedded Servers and Executable JARs
The traditional Java web deployment model was to build a WAR file and deploy it into a servlet container that someone had installed and configured separately. That split caused a specific class of problem: the application was tested against one container version and deployed onto another, and the container's configuration lived outside the project's version control.
Boot inverts the relationship. The server is a library the application
depends on, started by the application's own main method.
Deployed WAR Embedded server
------------ ---------------
install Tomcat on the server the jar contains Tomcat
configure server.xml separately configuration is a property
deploy app.war into webapps/ java -jar app.jar
container version is environmental container version is a dependency
"works on my machine" is plausible the artifact is the same everywhere
Configuration that used to live in the container's own files becomes ordinary application properties.
server.port=8080
server.servlet.context-path=/api
server.tomcat.threads.max=200
server.tomcat.accept-count=100
server.compression.enabled=true
server.shutdown=graceful
Three servers are supported, and switching is a dependency change.
| Server | Model | Notes |
|---|---|---|
| Tomcat | Thread per request | The default; the most familiar and documented |
| Jetty | Thread per request | Lighter footprint, common in embedded uses |
| Undertow | Non-blocking core | Good throughput, smaller memory use |
The executable JAR is the second half of the model. java -jar app.jar must
work, and that requires solving a problem the JVM does not solve by itself: the
standard class loader cannot load classes from a JAR nested inside another JAR.
Boot's solution is a custom layout and a custom loader.
app.jar
|
+-- META-INF/MANIFEST.MF
| Main-Class: org.springframework.boot.loader.JarLauncher <-- Boot's
| Start-Class: com.shop.ShopApplication <-- yours
|
+-- org/springframework/boot/loader/... the launcher itself
|
+-- BOOT-INF/classes/ your compiled code
| com/shop/ShopApplication.class
|
+-- BOOT-INF/lib/ every dependency, as jars
spring-core.jar
tomcat-embed-core.jar
...
The JVM starts JarLauncher, which installs a class loader that can read the
nested JARs, and then invokes your Start-Class. This is why the manifest's
main class is not the one you wrote.
Building it requires the Boot plugin, which repackages the plain JAR.
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
mvn package # produces target/shop-1.0.jar, executable
java -jar target/shop-1.0.jar
Layered JARs exist for container builds and are covered again in 18.5. The idea is that dependencies change far less often than application code, so separating them into layers lets a container image reuse cached layers.
# List the layers the plugin defined, then extract them.
java -Djarmode=layertools -jar app.jar list
# dependencies
# spring-boot-loader
# snapshot-dependencies
# application <-- the only layer that changes on most builds
WAR deployment is still possible, and occasionally required by an organisation's existing infrastructure.
// Extending this class lets an external container find and start the app.
@SpringBootApplication
public class ShopApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder b) {
return b.sources(ShopApplication.class);
}
}
With the packaging set to war and the embedded server marked provided, the
same code builds a deployable WAR. Treat it as a compatibility path rather than
a target.
Key Takeaways
- Boot embeds the server as a dependency instead of deploying into an external one.
- Server configuration becomes application properties under version control.
- Tomcat, Jetty, and Undertow are interchangeable by dependency.
- An executable JAR needs a custom loader because nested JARs are not standard.
- The manifest's main class is Boot's launcher; yours is the
Start-Class.- The Maven or Gradle plugin repackages the artifact into that layout.
- Layered JARs separate slow-changing dependencies from application code for image caching.
- WAR deployment remains available through
SpringBootServletInitializer.
🧪 Practice
- Package an application and inspect the JAR's structure and manifest.
- Replace Tomcat with Undertow and confirm the startup log names the new server.
- Configure the thread pool and context path through properties, then verify both.
- Extract the layers with layertools and identify which changes on a code-only rebuild.
- Interview: Why does
java -jaron a Boot application not run your main class directly? (Hint: what limitation of the standard class loader does the packaging work around?)
Spring Boot versus Plain Spring
It is easy to treat Boot as a different framework. It is not: Boot is Spring plus opinions. Every bean, every proxy, and every transaction in a Boot application is the Spring machinery chapter 17 described. Boot contributes defaults, dependency curation, packaging, and operational endpoints.
Spring Boot
+------------------------------------------------+
| auto-configuration starters executable jar |
| actuator externalised config |
+------------------------------------------------+
| Spring Framework |
| IoC container AOP MVC transactions data |
+------------------------------------------------+
Setting the same application up both ways makes the boundary concrete.
// PLAIN SPRING: everything declared, deployed as a WAR.
@Configuration
@EnableWebMvc
@ComponentScan("com.shop")
@EnableTransactionManagement
@EnableJpaRepositories("com.shop.repository")
public class WebConfig implements WebMvcConfigurer {
@Bean
public DataSource dataSource() {
var config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://localhost:5432/shop");
return new HikariDataSource(config);
}
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(
DataSource ds) { ... } // roughly 15 more lines
@Bean
public PlatformTransactionManager transactionManager(
EntityManagerFactory emf) {
return new JpaTransactionManager(emf);
}
}
// plus a DispatcherServlet registration class, plus a deployment descriptor
// SPRING BOOT: the same capabilities.
@SpringBootApplication
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
| Concern | Plain Spring | Spring Boot |
|---|---|---|
| Dependency versions | Chosen and reconciled by you | Managed by the parent POM |
| Infrastructure beans | Declared explicitly | Auto-configured, overridable |
| Server | External, installed separately | Embedded, a dependency |
| Artifact | WAR deployed into a container | Executable JAR |
| Configuration | XML or Java configuration classes | Properties and YAML |
| Operational endpoints | Written by you | Actuator, covered in 18.5 |
| Startup failures | Often at first use | At startup, with a report |
What Boot does not change: the programming model. Constructor injection,
@Transactional and its proxy limitations, @ControllerAdvice, the persistence
context, and the N+1 problem all behave exactly as chapters 16 and 17 described.
A Boot-specific bug is almost always a configuration question; everything else
is a Spring question.
When plain Spring is still the answer. Organisations with a mandated application server, or libraries that must embed into a host application, cannot use the executable JAR model. Boot's starters can still be avoided while using the framework underneath.
The honest criticism of Boot is that it lets people build systems they
cannot debug. A developer who has never declared a DataSource may not realise
a pool exists when connectionTimeout fires, and one who has never registered a
DispatcherServlet has no model for why a request returned 415. The defence is
not to avoid Boot; it is to know what it configured. The --debug report, the
actuator's bean and configuration endpoints, and the startup log are all
designed to make the invisible visible.
Key Takeaways
- Boot is not a separate framework; it configures Spring and packages it.
- Auto-configuration, starters, embedded servers, and actuator are Boot's additions.
- The programming model, including proxies and transactions, is unchanged.
- Boot moves infrastructure decisions from code into properties.
- Failures surface at startup with a report instead of at first use.
- Plain Spring remains appropriate for mandated servers and embedded libraries.
- The real risk is using defaults you cannot explain, which the diagnostic tools address.
🧪 Practice
- Write the explicit
@Configurationthat would replace one auto-configured bean and compare the line counts.- Find three beans in a Boot application that you never declared and name the auto-configuration responsible for each.
- Disable one auto-configuration, supply the bean yourself, and confirm the application still works.
- Take a Boot application and list which of its behaviours come from chapter 17 rather than from Boot.
- Interview: A colleague says Spring Boot replaced Spring. Correct the statement precisely. (Hint: what executes a
@Transactionalmethod in a Boot application?)
<a id="182-configuration-and-profiles"></a>
18.2 Configuration and Profiles
This subchapter covers how settings reach a running application: the file formats, binding them to typed objects, separating environments, keeping secrets out of the repository, and the precedence rules that decide which value wins.
Properties and YAML Configuration Files
Any setting that differs between environments, or that you might reasonably want to change without recompiling, belongs in configuration rather than in code. A database URL, a thread pool size, a feature toggle, and a log level are all configuration; a business rule is not.
Boot reads application.properties or application.yml from a set of standard
locations, and either format works identically.
spring.application.name=shop-service
server.port=8080
spring.datasource.url=jdbc:postgresql://localhost:5432/shop
spring.datasource.username=app
spring.datasource.hikari.maximum-pool-size=10
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
logging.level.com.shop=DEBUG
spring:
application:
name: shop-service
datasource:
url: jdbc:postgresql://localhost:5432/shop
username: app
hikari:
maximum-pool-size: 10 # nesting removes the repeated prefix
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
server:
port: 8080
logging:
level:
com.shop: DEBUG
| Aspect | Properties | YAML |
|---|---|---|
| Repetition | Full key on every line | Nested, each prefix written once |
| Lists | Indexed keys | Native list syntax |
| Readability at size | Degrades | Better, if indentation is correct |
| Failure mode | Rare; the format is trivial | Indentation errors, silent type surprises |
| Multiple documents | Not supported | Supported with --- |
YAML wins for anything beyond a handful of settings, with one genuine caution: it infers types, so unquoted values can surprise you.
version: 1.10 # parsed as the number 1.1, not the string "1.10"
version: "1.10" # correct
enabled: no # older parsers read this as false; quote it
password: 0123456 # leading zero, may be read as an octal number
Lists and maps appear constantly in real configuration.
app:
allowed-origins: # a list
- https://shop.example.com
- https://admin.example.com
rate-limits: # a map
default: 100
premium: 1000
app.allowed-origins[0]=https://shop.example.com
app.allowed-origins[1]=https://admin.example.com
app.rate-limits.default=100
Placeholders let one value reference another, which avoids repeating a host or a base path.
app.host=shop.example.com
app.api-url=https://${app.host}/api # composed from another property
app.timeout=${APP_TIMEOUT:5000} # environment variable with a default
Multi-document YAML keeps environment differences in one file, using the profile activation covered two topics on.
# Settings shared by every environment.
spring:
application:
name: shop-service
---
spring:
config:
activate:
on-profile: dev # applies only when "dev" is active
datasource:
url: jdbc:h2:mem:shop
---
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:postgresql://db.internal:5432/shop
Where Boot looks for the files, in increasing order of precedence:
1. classpath:/application.yml packaged in the jar
2. classpath:/config/application.yml
3. ./application.yml beside the jar
4. ./config/application.yml the usual production location
A file next to the jar overrides the packaged one, which is how the same artifact runs with different settings without being rebuilt.
# Point at a specific location, or add one to the search path.
java -jar app.jar --spring.config.location=file:/etc/shop/application.yml
java -jar app.jar --spring.config.additional-location=file:/etc/shop/
Reading a value in code uses 17.5's @Value, though the next topic argues
for a better alternative.
@Component
public class RateLimiter {
public RateLimiter(@Value("${app.rate-limits.default:100}") int limit) { ... }
}
A practical habit: add spring-boot-configuration-processor to the build.
It generates metadata from your own configuration classes, which gives
autocompletion and inline documentation for your properties in an IDE.
Key Takeaways
- Configuration holds anything that varies by environment or might change without a rebuild.
application.propertiesandapplication.ymlare equivalent in capability.- YAML removes prefix repetition and handles lists and maps natively.
- YAML infers types, so version numbers and boolean-looking values need quoting.
- Placeholders compose values and supply defaults from environment variables.
- Multi-document YAML keeps per-profile settings in one file.
- Files beside the jar override packaged ones, so one artifact serves all environments.
- The configuration processor gives IDE support for your own properties.
🧪 Practice
- Convert a properties file with twenty entries into YAML and compare the readability.
- Define a list and a map in YAML and inject both into a component.
- Demonstrate the
1.10type-inference surprise and fix it.- Override one setting by placing a file beside the jar without rebuilding.
- Interview: Why does YAML's type inference cause bugs that properties files do not have? (Hint: what is the type of every value in a properties file?)
Type-Safe Configuration Properties
Injecting individual values with @Value works and stops scaling quickly. A
component with eight settings has eight annotations, eight string keys that the
compiler cannot check, and no single place where the configuration for that
component is described.
// The problem: keys as strings, no validation, no grouping.
@Component
public class PaymentGateway {
public PaymentGateway(
@Value("${payment.endpoint}") String endpoint,
@Value("${payment.api-key}") String apiKey,
@Value("${payment.timeout:5000}") int timeoutMillis,
@Value("${payment.retries:3}") int retries,
@Value("${payment.sandbox:false}") boolean sandbox) { ... }
}
A typo in any key fails at startup with a message about a missing placeholder,
and nothing checks that retries is positive.
@ConfigurationProperties binds a whole group to a typed object. The prefix
is declared once, and the fields are the keys.
@ConfigurationProperties(prefix = "payment")
@Validated // 17.4's Bean Validation
public record PaymentProperties(
@NotBlank
String endpoint,
@NotBlank
String apiKey, // binds from payment.api-key
@DefaultValue("5s")
Duration timeout, // "5s" converts to a Duration
@Min(0) @Max(10)
@DefaultValue("3")
int retries,
@DefaultValue("false")
boolean sandbox) { }
payment:
endpoint: https://pay.example.com
api-key: ${PAYMENT_API_KEY} # from the environment, see the secrets topic
timeout: 5s
retries: 3
Relaxed binding is what lets api-key in YAML reach apiKey in Java. Boot
accepts several spellings of the same property, which matters because
environment variables cannot contain dots or hyphens.
| Form | Example | Typical source |
|---|---|---|
| Kebab case | payment.api-key | YAML and properties files (preferred) |
| Camel case | payment.apiKey | Properties files |
| Underscore | payment.api_key | Properties files |
| Upper case underscore | PAYMENT_API_KEY | Environment variables |
That last row is the one that matters in containers: any property can be set as an environment variable by upper-casing it and replacing punctuation with underscores.
Registering the class is done one of two ways.
// Either: list them where they are enabled.
@SpringBootApplication
@EnableConfigurationProperties(PaymentProperties.class)
public class ShopApplication { }
// Or: scan for them, which suits many properties classes.
@SpringBootApplication
@ConfigurationPropertiesScan("com.shop.config")
public class ShopApplication { }
Using them is ordinary injection, and the component now has one dependency instead of five.
@Component
public class PaymentGateway {
private final PaymentProperties properties;
public PaymentGateway(PaymentProperties properties) {
this.properties = properties;
}
public Receipt charge(Card card, BigDecimal amount) {
return client.post(properties.endpoint())
.timeout(properties.timeout())
.send(...);
}
}
Nested and collection types bind naturally, which is where the approach really pays off.
@ConfigurationProperties(prefix = "app")
public record AppProperties(
Security security, // a nested group
List<String> allowedOrigins, // a list
Map<String, RateLimit> rateLimits) { // a map of nested objects
public record Security(Duration tokenTtl, String issuer) { }
public record RateLimit(int perMinute, int burst) { }
}
app:
security:
token-ttl: 15m
issuer: shop-service
allowed-origins:
- https://shop.example.com
rate-limits:
default:
per-minute: 100
burst: 20
premium:
per-minute: 1000
burst: 200
Type conversion is richer than it looks. Boot converts durations, data sizes, enums, and resource paths from strings.
Duration timeout; // "5s", "500ms", "PT1M30S"
DataSize maxUpload; // "10MB", "512KB"
OrderStatus status; // enum name, case-insensitive
Resource template; // "classpath:template.html"
The decisive advantage is validation at startup. With @Validated, an
invalid value stops the application immediately with a message naming the
property, rather than producing a failure at first use.
Binding to target failed:
Property: payment.retries
Value: 50
Reason: must be less than or equal to 10
| Aspect | @Value | @ConfigurationProperties |
|---|---|---|
| Keys | A string per injection | One prefix per group |
| Grouping | None | One class describes the group |
| Validation | None | Bean Validation at startup |
| Nested and complex | Awkward | Natural |
| Relaxed binding | No | Yes |
| SpEL expressions | Supported | Not supported |
| Best for | One or two ad-hoc values | Any real group of settings |
Key Takeaways
@Valuedoes not scale past a couple of settings and offers no validation.@ConfigurationPropertiesbinds a prefixed group to a typed object.- Relaxed binding maps kebab case, camel case, and environment variable spellings to one field.
- Any property can be supplied as an upper-case underscore environment variable.
- Register with
@EnableConfigurationPropertiesor@ConfigurationPropertiesScan.- Nested records, lists, and maps bind without extra code.
- Durations, data sizes, enums, and resources convert from strings automatically.
@Validatedturns bad configuration into a startup failure naming the property.
🧪 Practice
- Replace five
@Valueannotations in one class with a properties record.- Add validation constraints and start the application with an invalid value.
- Bind a map of nested objects and iterate it in a component.
- Set one property through an environment variable using the upper-case form.
- Bind a
Durationand aDataSizefrom human-readable strings.- Interview: Why is a configuration error better discovered at startup than at first use? (Hint: consider a deploy at 2am and a code path used once a week.)
Profiles for Environment Separation
17.5 introduced profiles as conditional bean registration. In Boot they do more: they also select which configuration files and which property blocks apply, and they are the standard mechanism for running one artifact in several environments.
Profile-specific files are found automatically by name.
application.yml always loaded, the shared baseline
application-dev.yml loaded when "dev" is active
application-test.yml loaded when "test" is active
application-prod.yml loaded when "prod" is active
Profile-specific values override the baseline, so each file contains only what differs.
# application.yml -- shared by every environment
spring:
application:
name: shop-service
jpa:
open-in-view: false
server:
shutdown: graceful
# application-dev.yml
spring:
datasource:
url: jdbc:h2:mem:shop
jpa:
hibernate:
ddl-auto: create-drop # acceptable locally, never in production
logging:
level:
org.hibernate.SQL: DEBUG # see the generated SQL while developing
# application-prod.yml
spring:
datasource:
url: jdbc:postgresql://db.internal:5432/shop
hikari:
maximum-pool-size: 20
jpa:
hibernate:
ddl-auto: validate # 17.3: migrations own the schema
logging:
level:
root: WARN
com.shop: INFO
Activating a profile is done from outside the artifact, which is the whole point.
java -jar shop.jar --spring.profiles.active=prod
SPRING_PROFILES_ACTIVE=prod java -jar shop.jar
spring.profiles.active=dev # a default for local development only
Profile-specific beans work exactly as 17.5 described, and the two mechanisms combine.
@Configuration
@Profile("!prod") // everything except production
public class DevToolsConfig {
@Bean
public EmailSender emailSender() {
return message -> log.info("would send: {}", message); // never real mail
}
}
Profile groups activate several profiles together, which keeps deployment commands simple.
spring:
profiles:
group:
prod: prod-db, prod-cache, metrics # one name activates three
@ActiveProfiles sets them in tests, which 18.5 returns to.
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceTest { }
Three rules keep profiles from becoming a mess.
Name profiles after environments, not technologies. A postgres profile
that is only ever active with prod should be a property, not a profile. The
exception is a genuine deployment variant such as cloud versus on-premise.
Keep the number small. Each additional profile multiplies the combinations that are theoretically possible and never tested. Three or four is typical.
Never let production depend on a profile being absent. Configuration that
only works because nobody activated dev is fragile. Make production explicit.
Dangerous Safe
--------- ----
@Profile("!prod") on a security @Profile("prod") on production beans
bypass bean and an explicit prod activation
forgetting to set the profile forgetting to set the profile
disables security fails to start
Checking which profiles are active at runtime is a one-line diagnostic and is exposed by actuator, covered in 18.5.
@Component
public class ProfileLogger implements ApplicationRunner {
private final Environment environment;
@Override
public void run(ApplicationArguments args) {
log.info("active profiles: {}",
Arrays.toString(environment.getActiveProfiles()));
}
}
When none are set, Boot activates default, which is why a missing activation
silently produces the baseline configuration rather than an error.
Key Takeaways
application-<profile>.ymlfiles are loaded automatically and override the baseline.- Each profile file should contain only what differs from the shared configuration.
- Profiles are activated from outside the artifact, by argument, property, or environment variable.
- Profile-specific beans and profile-specific files work together.
- Profile groups activate several names at once.
- Name profiles after environments and keep their number small.
- Never make a safe configuration depend on a profile being inactive.
- With no activation, the
defaultprofile applies silently.
🧪 Practice
- Create dev and prod profile files differing in data source and log level, and switch between them from the command line.
- Define a bean that exists only outside production and prove it is absent when
prodis active.- Set up a profile group that activates three profiles from one name.
- Log the active profiles at startup and run with none set.
- Interview: Why is
@Profile("!prod")on a security-relevant bean risky? (Hint: what happens if someone forgets to set any profile at all?)
Externalized Configuration and Secrets
Configuration that lives inside the artifact cannot differ between environments without rebuilding, and a secret that lives inside the artifact is not a secret. Both problems are solved by externalizing: the running process obtains its settings from its environment rather than from its own contents.
Internalised Externalised
------------ ------------
one jar per environment one jar, many environments
build for prod -> deploy build once -> deploy anywhere
credentials in the repository credentials supplied at run time
a config change needs a rebuild a config change needs a restart
This is one of the twelve-factor principles, and the practical form is that environment variables and mounted files, not the packaged file, carry the real values.
Environment variables are the most portable mechanism and work everywhere.
export SPRING_DATASOURCE_URL=jdbc:postgresql://db.internal:5432/shop
export SPRING_DATASOURCE_USERNAME=app
export SPRING_DATASOURCE_PASSWORD='...'
export SPRING_PROFILES_ACTIVE=prod
java -jar shop.jar
The upper-case underscore form is the relaxed binding from two topics back, so
SPRING_DATASOURCE_URL sets spring.datasource.url with no configuration.
Placeholders make the expectation explicit, which is better than relying on an invisible override.
spring:
datasource:
url: ${DB_URL} # required: startup fails if absent
username: ${DB_USER:app} # optional, with a default
password: ${DB_PASSWORD} # required
A missing required placeholder produces a startup failure naming the property, which is exactly the fail-fast behaviour you want.
What must never be committed:
| Never in the repository | Where it belongs |
|---|---|
| Database passwords | Secret manager or environment |
| API keys and tokens | Secret manager or environment |
| Private keys and certificates | Mounted file with restricted permissions |
| Encryption keys | Secret manager or a key management service |
| Production hostnames (arguably) | Environment, to avoid disclosing topology |
Committing a secret is not fixed by deleting it in a later commit: git keeps history, so the value must be rotated, not just removed.
Secret managers are the production answer, and the pattern is the same regardless of which one an organisation uses.
Application startup
|
+-- authenticate to the secret store (instance identity, not a password)
|
+-- fetch secrets
|
+-- bind them as properties
|
+-- proceed with normal startup
| Mechanism | Typical context |
|---|---|
| Environment variables | Anywhere; the baseline |
| Docker or Kubernetes secrets | Mounted as files or environment variables |
| HashiCorp Vault | Self-managed, with dynamic credentials |
| Cloud provider secret managers | Managed cloud deployments |
| Spring Cloud Config | Centralised configuration for many services |
Kubernetes is worth showing concretely, because it is the most common target and it uses both forms.
env:
- name: SPRING_PROFILES_ACTIVE
value: prod
- name: SPRING_DATASOURCE_PASSWORD
valueFrom:
secretKeyRef: # injected from a Secret, not in the image
name: shop-db
key: password
volumeMounts:
- name: config
mountPath: /config # Boot reads ./config/application.yml
A layered arrangement that works well in practice:
application.yml defaults, safe to commit, no secrets
application-prod.yml production structure, still no secrets
environment variables the actual credentials, injected at run time
command-line arguments one-off overrides during an incident
Avoid printing secrets. A logged configuration dump, an error message containing a JDBC URL with credentials, or an actuator endpoint exposing the environment are all common leaks. Boot sanitises known-sensitive keys in actuator output, and 18.5 covers securing those endpoints.
# Extra keys to sanitise in actuator output.
management.endpoint.env.show-values=never
Key Takeaways
- Externalized configuration lets one artifact run in every environment.
- Environment variables map to properties through relaxed binding with no setup.
- Declaring placeholders makes required settings explicit and fails fast when absent.
- Secrets must never be committed; a committed secret must be rotated, not deleted.
- Secret managers authenticate the workload and supply values at startup.
- Kubernetes supplies secrets as environment variables or mounted files.
- Keep structure in committed files and values in the environment.
- Guard against leaking secrets through logs, errors, and actuator endpoints.
🧪 Practice
- Configure a data source entirely through environment variables with no values in any file.
- Use a required placeholder and confirm the startup failure when it is unset.
- Run the same jar twice with different environment variables and observe both behaviours.
- Mount a configuration file beside the jar and override one packaged value.
- Find a project that logs its configuration at startup and check whether a secret would appear.
- Interview: A password was committed and removed in the next commit. Is the problem solved? (Hint: what does a version control system keep by design?)
Configuration Property Precedence
With files, profiles, environment variables, and command-line arguments all able to set the same property, one question decides behaviour: which wins? Boot defines a strict order, and knowing it turns "the setting is being ignored" into a solved problem.
The rule is simple to state: later sources override earlier ones, and the ordering runs from packaged defaults up to explicit runtime overrides.
LOWEST precedence
|
| 1. default properties (SpringApplication.setDefaultProperties)
| 2. @PropertySource on a @Configuration class
| 3. application.yml packaged in the jar
| 4. application-<profile>.yml packaged in the jar
| 5. application.yml outside the jar (./config/ or ./)
| 6. application-<profile>.yml outside the jar
| 7. OS environment variables
| 8. Java system properties (-D)
| 9. command-line arguments (--key=value)
| 10. SPRING_APPLICATION_JSON
| 11. @TestPropertySource and test properties
|
HIGHEST precedence
The shape of that list is deliberate. Anything you can change without rebuilding beats anything baked into the artifact, and anything typed at the moment of launch beats everything else.
# All four set the same property. The command-line argument wins.
# application.yml: server.port: 8080
export SERVER_PORT=8081
java -Dserver.port=8082 -jar app.jar --server.port=8083
# The application listens on 8083.
Profile-specific files beat the baseline at the same level, which is why the arrangement in the previous topic works.
application.yml server.port: 8080
application-prod.yml server.port: 9090 <-- wins when prod is active
SPRING_APPLICATION_JSON sets many properties from one variable, which is
useful where a platform allows only a few environment variables.
SPRING_APPLICATION_JSON='{"server":{"port":9000},"logging":{"level":{"root":"WARN"}}}' \
java -jar app.jar
Diagnosing a property is best done by asking the application rather than reasoning about the list. The actuator environment endpoint, covered in 18.5, reports every source and which one supplied the effective value.
curl localhost:8080/actuator/env/server.port
{
"property": { "source": "commandLineArgs", "value": "8083" },
"activeProfiles": ["prod"],
"propertySources": [
{ "name": "commandLineArgs", "property": { "value": "8083" } },
{ "name": "systemProperties", "property": { "value": "8082" } },
{ "name": "systemEnvironment", "property": { "value": "8081" } },
{ "name": "applicationConfig: [classpath:/application.yml]",
"property": { "value": "8080" } }
]
}
The source field answers the question directly. Reading this once is faster
than memorising the ordering.
In code, the Environment exposes the same information.
@Component
public class ConfigDiagnostics implements ApplicationRunner {
private final ConfigurableEnvironment environment;
@Override
public void run(ApplicationArguments args) {
// Sources are iterated highest precedence first.
environment.getPropertySources()
.forEach(source -> log.info("source: {}", source.getName()));
log.info("effective server.port = {}",
environment.getProperty("server.port"));
}
}
Three precedence mistakes account for most confusion.
Editing the packaged file and not rebuilding. The jar contains a copy; the source tree is not what runs.
An environment variable left over from an earlier experiment. It outranks every file, so the file looks broken. Checking the effective source finds it immediately.
Assuming a profile file replaces the baseline. It does not; it overrides key
by key. A key present only in application.yml still applies when a profile is
active, which is the intended design but surprises people who expect the files
to be alternatives.
Key Takeaways
- Later sources override earlier ones, from packaged defaults to command-line arguments.
- Anything external to the artifact beats anything packaged inside it.
- Command-line arguments outrank system properties, which outrank environment variables.
- Profile-specific files override the baseline key by key, not wholesale.
SPRING_APPLICATION_JSONsets many properties through one variable.- The actuator environment endpoint names the winning source for any property.
- Stale environment variables are a common cause of apparently ignored configuration.
🧪 Practice
- Set the same property in a file, an environment variable, and a command-line argument, and confirm which applies.
- Query the actuator environment endpoint for one property and read its source.
- Set a property in both the baseline and a profile file, and verify the override.
- Leave a stale environment variable set and diagnose why a file change has no effect.
- Interview: A developer changes
application.yml, rebuilds nothing, and the running jar ignores it. Explain both reasons this could happen. (Hint: one concerns the artifact, one concerns what else might set the same key.)
<a id="183-building-rest-services"></a>
18.3 Building REST Services
This subchapter assembles a complete, well-behaved HTTP API from the pieces chapters 16 and 17 established: endpoint design, the types that cross the boundary, serialization, validation and errors, persistence, documentation, and keeping clients working as the API changes.
Designing Resource Endpoints
17.4 covered the mechanics of mapping a request to a method. Design is the separate question of which endpoints should exist, and it is worth treating deliberately because an API's shape is the hardest thing to change later.
The organising idea is that a REST API exposes resources, which are nouns with identity, and that HTTP methods supply the verbs. Everything else follows.
Start by naming the resources, not the operations. A useful test: write the list of things your system manages, then check that each has a URL.
Resources URLs
--------- ----
orders /orders /orders/{id}
customers /customers /customers/{id}
products /products /products/{id}
an order's lines /orders/{id}/lines
Nest only to express ownership, and only one level deep. Deep nesting produces URLs that are long, rigid, and awkward when a sub-resource needs to be addressed on its own.
Good /orders/42/lines lines belong to an order
Good /lines/1001 a line is also addressable directly
Poor /customers/7/orders/42/lines/1001 four levels; brittle and redundant
A practical rule: nest for listing and creating within a parent, and use a flat URL for addressing an individual item.
Filtering, sorting, and pagination belong in the query string, not in the path, because they select from a collection rather than identifying a resource.
GET /orders?status=NEW&customerId=7&placedAfter=2026-01-01
GET /orders?sort=placedAt,desc&page=2&size=20
Pagination should be the default on every collection, because an unbounded list endpoint is a production incident waiting for the table to grow.
@GetMapping
public Page<OrderResponse> list(
@RequestParam(required = false) OrderStatus status,
@PageableDefault(size = 20, sort = "placedAt",
direction = Sort.Direction.DESC) Pageable pageable) {
return service.find(status, pageable).map(OrderResponse::from);
}
@PageableDefault supplies the bound even when the client omits it, which is
the part that prevents the incident. Capping the maximum page size matters too.
spring.data.web.pageable.max-page-size=100
Actions that are not state replacements are the case REST purism handles
badly. Cancelling an order is not a PUT of a new representation; it is a
transition with its own rules. Model it as a sub-resource.
POST /orders/42/cancel an action with a clear meaning
POST /orders/42/refunds creates a refund; also a resource
POST /users/7/password-reset an action, not a field update
This is widely accepted and far clearer than contorting the model to avoid a verb.
Partial updates need a decision, because PUT means replace.
| Method | Semantics | Consequence for omitted fields |
|---|---|---|
PUT | Replace the whole representation | Cleared or reset to default |
PATCH | Apply a partial change | Left untouched |
Accepting a PUT and treating absent fields as "leave alone" is the common bug:
a client that sends a subset silently wipes the rest.
A worked endpoint set for one resource, with the status codes 17.4 covered:
| Method and path | Purpose | Success | Notable failures |
|---|---|---|---|
GET /orders | List, paged | 200 | 400 on bad filter |
GET /orders/{id} | Fetch one | 200 | 404 |
POST /orders | Create | 201 | 400, 409 on duplicate |
PUT /orders/{id} | Replace | 200 | 404, 409 on conflict |
PATCH /orders/{id} | Partial update | 200 | 404, 400 |
DELETE /orders/{id} | Remove | 204 | 404 |
POST /orders/{id}/cancel | Transition | 200 | 404, 409 if not cancellable |
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService service;
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody @Valid CreateOrderRequest request) {
Order order = service.place(request.toCommand());
URI location = URI.create("/api/orders/" + order.id());
return ResponseEntity.created(location) // 201 with a Location header
.body(OrderResponse.from(order));
}
@PostMapping("/{id}/cancel")
public OrderResponse cancel(@PathVariable long id,
@RequestBody @Valid CancelRequest request) {
// Throws OrderNotCancellableException, mapped to 409 by the advice.
return OrderResponse.from(service.cancel(id, request.reason()));
}
}
Consistency across the API matters more than any individual choice. Pick one convention for plurals, one for date formats, one for pagination, and one for error bodies, and apply them everywhere. A client integrating with an API that is uniformly imperfect has an easier time than one integrating with an API that is inconsistently ideal.
Key Takeaways
- Model resources as nouns with identity; HTTP methods supply the verbs.
- Nest one level to express ownership, and address individual items flatly.
- Filtering, sorting, and pagination belong in the query string.
- Every collection endpoint should be paged by default with a capped maximum.
- Model genuine actions as sub-resources rather than distorting the model.
PUTreplaces andPATCHamends; treatingPUTas partial silently loses data.- Return 201 with a
Locationheader from creation endpoints.- Consistency across the API is worth more than perfection in any one endpoint.
🧪 Practice
- Design the full endpoint set for two related resources, including one action endpoint.
- Add default and maximum pagination to a list endpoint and verify both bounds.
- Implement
PUTandPATCHfor one resource and demonstrate the difference with an omitted field.- Take an API that uses verbs in URLs and redesign it around resources.
- Interview: Why is
POST /orders/42/cancelacceptable in an otherwise resource-oriented API? (Hint: what would thePUTequivalent require the client to know and send?)
Data Transfer Objects and Mapping
17.4 stated the rule: do not expose entities through the API. This topic explains why thoroughly and covers how to organise the alternative without drowning in boilerplate.
A data transfer object (DTO) is a type whose only job is to cross a boundary. Request DTOs describe what a client may send; response DTOs describe what it receives. Neither is a persistence concern.
Four reasons entities must not be exposed:
Coupling. The API contract becomes the database schema. Renaming a column breaks every client, so the schema can never be refactored.
Over-exposure. Every field is serialised, including ones a client should
never see. A User entity with a password hash, internal flags, and audit
columns leaks all of them by default.
Mass assignment. Deserialising a request directly into an entity lets a
client set any field it names, including role, id, or accountBalance.
// DANGEROUS: the client controls every field of the entity.
@PostMapping("/users")
public User create(@RequestBody User user) { // {"role":"ADMIN",...}
return repository.save(user); // privilege escalation
}
Lazy loading failures. 16.4's LazyInitializationException fires during
serialisation, because Jackson touches an association after the transaction
ended. The stack trace points at the serialiser, not at the real cause.
// The request DTO accepts only what a client may legitimately set.
public record CreateOrderRequest(
@NotNull Long customerId,
@NotEmpty @Valid List<LineRequest> lines,
@Size(max = 500) String note) {
public PlaceOrderCommand toCommand() {
return new PlaceOrderCommand(customerId,
lines.stream().map(LineRequest::toCommand).toList(), note);
}
}
// The response DTO exposes only what a client should see.
public record OrderResponse(
long id,
String reference,
String status,
BigDecimal total,
OffsetDateTime placedAt,
List<LineResponse> lines) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.id(),
order.reference().value(),
order.status().name(),
order.total(),
order.placedAt(),
order.lines().stream().map(LineResponse::from).toList());
}
}
Records are an excellent fit: immutable, concise, and with Jackson support built in.
Separate DTOs per operation is usually right, because the fields differ.
CreateOrderRequest customerId, lines, note (no id, no status)
UpdateOrderRequest lines, note (customer is fixed)
OrderResponse id, reference, status, total (never a note draft)
OrderSummaryResponse id, status, total (for list endpoints)
The summary type matters: a list endpoint returning full representations of fifty orders, each with its lines, is slow and usually wasteful.
Where mapping code belongs. Three options, in order of preference for most codebases.
| Approach | Pros | Cons |
|---|---|---|
| Static factory on the DTO | Explicit, no dependency, greppable | Manual, verbose for large types |
| A dedicated mapper class | Testable, keeps DTOs passive | An extra class per resource |
| A generated mapper such as MapStruct | No boilerplate, compile-time checked | A build plugin and generated code to understand |
MapStruct generates the implementation at compile time, so there is no reflection cost and mismatches are compile errors.
@Mapper(componentModel = "spring") // produces a Spring bean
public interface OrderMapper {
// Fields with matching names map automatically.
@Mapping(target = "reference", source = "reference.value")
OrderResponse toResponse(Order order);
List<OrderResponse> toResponses(List<Order> orders);
}
A rule that prevents the most common leak: map inside the transaction, or ensure everything the DTO needs was fetched. This is 16.4's join fetch applied at the API boundary.
@Transactional(readOnly = true) // 17.2: the mapping happens inside
public OrderResponse getOrder(long id) {
Order order = repository.findWithLines(id) // JOIN FETCH, no N+1
.orElseThrow(() -> new OrderNotFoundException(id));
return OrderResponse.from(order); // lazy associations already loaded
}
The objection to DTOs is boilerplate, and it is fair for a small service where the entity and the representation genuinely coincide. The counter is that the coincidence rarely survives the first schema change, and the cost of retrofitting DTOs to a public API is far higher than the cost of writing them initially.
Key Takeaways
- DTOs decouple the API contract from the persistence model.
- Exposing entities enables mass assignment, over-exposure, and schema coupling.
- Deserialising into an entity lets a client set fields it should not control.
- Serialising an entity triggers lazy loading failures outside the transaction.
- Records are a natural fit for both request and response types.
- Use different DTOs per operation, including a lighter summary for lists.
- Static factories, dedicated mappers, and MapStruct are the three mapping options.
- Map inside the transaction or fetch eagerly, or lazy loading will fail.
🧪 Practice
- Expose an entity with a sensitive field directly and confirm it appears in the JSON.
- Demonstrate mass assignment by posting a field the client should not control, then fix it with a request DTO.
- Serialise an entity with a lazy association outside a transaction and read the failure.
- Write a summary response type for a list endpoint and compare payload sizes.
- Replace manual mapping with MapStruct for one resource and inspect the generated code.
- Interview: Name two distinct risks of accepting a JPA entity as a
@RequestBody. (Hint: one is about what the client can set, one is about what the database expects.)
Serialization with Jackson
Jackson turns Java objects into JSON and back. Boot configures it automatically, and most of the time you never think about it. The exceptions, dates, nulls, naming, and polymorphism, account for a large share of real API defects, so they are worth knowing explicitly.
The default behaviour: public getters or record components become JSON fields with matching names, and JSON fields bind back to constructor parameters or setters.
public record OrderResponse(long id, String status, BigDecimal total) { }
{ "id": 42, "status": "NEW", "total": 199.99 }
Global configuration is preferable to per-field annotations, because consistency across an API matters more than any individual choice.
spring:
jackson:
property-naming-strategy: SNAKE_CASE # orderId becomes order_id
default-property-inclusion: non_null # omit null fields entirely
serialization:
write-dates-as-timestamps: false # ISO-8601 strings, not epoch numbers
indent-output: false
deserialization:
fail-on-unknown-properties: false # tolerate extra fields from clients
time-zone: UTC
Two of those deserve explanation.
write-dates-as-timestamps: false should always be set. The default
serialises a java.time value as a number of seconds, which is unreadable and
loses the offset.
{ "placedAt": 1758844800.000000000 } // default: an epoch number
{ "placedAt": "2026-09-26T10:00:00Z" } // with the setting: readable, unambiguous
fail-on-unknown-properties: false is the tolerant reader principle. A
client sending a field your version does not know should not get a 400; this is
what allows an API to add fields without breaking older clients, and it matters
again in the versioning topic.
Per-field annotations cover the cases global settings cannot.
public record OrderResponse(
long id,
@JsonProperty("order_status") // an explicit name
String status,
@JsonFormat(pattern = "yyyy-MM-dd") // date only, no time
LocalDate deliveryDate,
@JsonInclude(JsonInclude.Include.NON_EMPTY) // omit when empty
List<String> tags,
@JsonIgnore // never serialised
String internalNote) { }
| Annotation | Effect |
|---|---|
@JsonProperty | Rename, or mark a field required for binding |
@JsonIgnore | Exclude a field entirely |
@JsonInclude | Omit null or empty values |
@JsonFormat | Control date, time, and number formatting |
@JsonAlias | Accept alternative incoming names |
@JsonAnySetter | Collect unmapped fields into a map |
@JsonView | Serialise different subsets for different contexts |
@JsonIgnore is not a security control. It prevents serialisation but, by
default, also blocks deserialisation of that field, and relying on it to protect
a sensitive attribute is fragile compared with simply not putting the field in
the DTO.
Write-only fields are the correct pattern for passwords and similar inputs.
public record CreateUserRequest(
String email,
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
String password) { } // accepted in requests, never in responses
Polymorphic types need explicit configuration, because JSON has no notion of a Java subclass.
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
property = "type") // a discriminator field
@JsonSubTypes({
@JsonSubTypes.Type(value = CardPayment.class, name = "card"),
@JsonSubTypes.Type(value = TransferPayment.class, name = "transfer")
})
public sealed interface PaymentRequest permits CardPayment, TransferPayment { }
{ "type": "card", "cardNumber": "...", "expiry": "2028-05" }
Never enable Jackson's default typing, which embeds arbitrary class names in the JSON and has produced a long series of deserialisation vulnerabilities. Use an explicit discriminator as above.
Custom serializers handle domain types that should not appear as nested objects.
public class MoneySerializer extends JsonSerializer<Money> {
@Override
public void serialize(Money value, JsonGenerator gen, SerializerProvider p)
throws IOException {
gen.writeString(value.amount() + " " + value.currency()); // "19.99 EUR"
}
}
@Configuration
public class JacksonConfig {
@Bean
public Module moneyModule() {
var module = new SimpleModule();
module.addSerializer(Money.class, new MoneySerializer());
return module; // Boot picks up Module beans automatically
}
}
A debugging note: when a response is unexpectedly empty or a field is missing, the cause is almost always an absent accessor or a mismatched name. Logging the serialised output during development finds it faster than reasoning about it.
Key Takeaways
- Boot configures Jackson automatically; the defaults are mostly right.
- Prefer global configuration over per-field annotations for consistency.
- Always disable timestamp date serialisation so dates are readable ISO-8601.
- Tolerating unknown properties lets clients and servers evolve independently.
- Use write-only access for inputs such as passwords rather than relying on
@JsonIgnore.- Polymorphic types need an explicit discriminator; never enable default typing.
- Custom serializers keep domain value types from leaking their structure.
- A missing field is nearly always a missing accessor or a naming mismatch.
🧪 Practice
- Serialise an
OffsetDateTimewith and without the timestamp setting and compare.- Configure snake case globally and verify every endpoint changes without editing DTOs.
- Make a password field write-only and confirm it is accepted but never returned.
- Implement a polymorphic request with a discriminator and deserialise both subtypes.
- Write a custom serializer for a value type and register it as a module.
- Interview: Why does tolerating unknown JSON properties make an API easier to evolve? (Hint: consider a server that adds a field and a client that has not been updated.)
Validation and Consistent Error Responses
17.4 covered Bean Validation and @ControllerAdvice separately. In a real
service they are one concern: every invalid input, every domain rule violation,
and every unexpected failure must produce a response a client can act on, in a
format that never varies.
Design the error contract first. A client integrating with your API writes one piece of error-handling code, and it should work for every endpoint.
{
"type": "https://api.shop.com/errors/validation-failed",
"title": "Validation failed",
"status": 400,
"detail": "The request contains 2 invalid fields",
"instance": "/api/orders",
"traceId": "b4f2a1c8e9d34567",
"errors": [
{ "field": "customerId", "message": "must not be null" },
{ "field": "lines[0].quantity", "message": "must be greater than 0" }
]
}
That shape is RFC 7807 problem details, extended with a field list and a trace
identifier. Spring's ProblemDetail supports it directly, which saves inventing
a format.
Three layers produce errors, and each needs mapping.
1. Binding and validation malformed JSON, missing fields, constraint failures
| -> 400, with field detail
2. Domain rules order not cancellable, insufficient stock
| -> 409 or 422, with a domain error code
3. Unexpected failures bugs, database outages
-> 500, with a trace id and no detail
A complete handler covering all three, building on 17.4's advice class:
@RestControllerAdvice
public class ApiExceptionHandler {
// 1. Request body validation failures.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException e) {
var problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Validation failed");
problem.setType(URI.create("https://api.shop.com/errors/validation-failed"));
problem.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of("field", fe.getField(),
"message", fe.getDefaultMessage()))
.toList());
return problem;
}
// Query and path parameter constraints, which throw a different exception.
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleConstraint(ConstraintViolationException e) { ... }
// Malformed JSON, a wrong type, or an unparseable body.
@ExceptionHandler(HttpMessageNotReadableException.class)
public ProblemDetail handleUnreadable(HttpMessageNotReadableException e) {
var problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Malformed request body");
return problem; // deliberately no parser detail: it leaks structure
}
// 2. Domain rules, carrying a stable machine-readable code.
@ExceptionHandler(DomainException.class)
public ProblemDetail handleDomain(DomainException e) {
var problem = ProblemDetail.forStatusAndDetail(e.status(), e.getMessage());
problem.setTitle(e.title());
problem.setProperty("code", e.code()); // clients branch on this
return problem;
}
// 16.3 and 17.3: an optimistic locking conflict is a 409.
@ExceptionHandler(OptimisticLockingFailureException.class)
public ProblemDetail handleConflict(OptimisticLockingFailureException e) {
var problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
problem.setTitle("The record was modified by someone else");
return problem;
}
// 3. Everything else: log fully, disclose nothing.
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception e) {
String traceId = MDC.get("traceId"); // 18.5's correlation id
log.error("unhandled error, traceId={}", traceId, e);
var problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problem.setTitle("Internal error");
problem.setDetail("An unexpected error occurred");
problem.setProperty("traceId", traceId); // the client quotes this to support
return problem;
}
}
A machine-readable code is what makes errors usable. A client cannot branch
on an English sentence, and translating a message breaks it. A stable code
field lets clients handle specific cases and display their own text.
public abstract class DomainException extends RuntimeException {
public abstract String code(); // e.g. "ORDER_NOT_CANCELLABLE"
public abstract HttpStatus status();
public abstract String title();
}
Choosing between 400, 409, and 422:
| Status | Meaning | Example |
|---|---|---|
| 400 | The request is malformed or fails a constraint | Missing field, negative quantity |
| 422 | Well-formed and valid, but semantically rejected | Order total below the minimum |
| 409 | Conflicts with the current state | Already cancelled, version stale |
Using 400 for everything is defensible and common; the distinction pays off when clients need to retry differently.
Validation groups handle the case where rules differ by operation.
public interface OnCreate { }
public interface OnUpdate { }
public record ProductRequest(
@Null(groups = OnCreate.class) // must be absent on create
@NotNull(groups = OnUpdate.class) // required on update
Long id,
@NotBlank(groups = { OnCreate.class, OnUpdate.class })
String name) { }
@PostMapping
public ProductResponse create(
@RequestBody @Validated(OnCreate.class) ProductRequest request) { ... }
Turn off the error detail Boot exposes by default in production.
server:
error:
include-message: never
include-stacktrace: never
include-binding-errors: never
Leaving these on leaks internal structure to anyone who sends a bad request.
Key Takeaways
- Define one error format and use it for every endpoint and every failure type.
- RFC 7807 problem details is a standard shape with framework support.
- Binding failures, domain rule violations, and unexpected errors need separate handling.
- Include a stable machine-readable code so clients can branch without parsing text.
- Include a trace identifier on server errors and disclose nothing else.
- 400 is malformed, 422 is semantically rejected, 409 conflicts with current state.
- Validation groups express different rules for create and update.
- Disable Boot's default error detail in production.
🧪 Practice
- Implement a handler returning problem details for validation, domain, and unexpected errors.
- Return a field-level error list for a request with three invalid fields.
- Add a stable error code to one domain exception and branch on it from a test client.
- Trigger malformed JSON and confirm the response leaks no parser detail.
- Use validation groups so one DTO enforces different rules on create and update.
- Interview: Why should an error body carry a code as well as a message? (Hint: what happens to client logic when someone improves the wording?)
Persistence Layer Integration
Chapters 16 and 17 covered persistence in depth. This topic is about the seam: how the web layer and the data layer meet without each one's problems leaking into the other.
The layering, and what each layer may not do:
Controller HTTP only. No transactions, no repositories, no entities.
| Binds a request DTO, calls one service method, maps the result.
v
Service The transaction boundary (16.3). Domain logic. Entities live here.
| Accepts and returns domain types or commands, never DTOs.
v
Repository Data access only. No business rules.
|
v
Database
Two rules do most of the work. The transaction boundary is in the service, as 16.3 established, and entities do not cross into the controller, as the DTO topic argued.
@Service
public class OrderService {
private final OrderRepository orders;
private final ProductRepository products;
@Transactional // one business operation
public Order place(PlaceOrderCommand command) {
Customer customer = customers.findById(command.customerId())
.orElseThrow(() -> new CustomerNotFoundException(command.customerId()));
Order order = Order.forCustomer(customer);
for (var line : command.lines()) {
Product product = products.findById(line.productId())
.orElseThrow(() -> new ProductNotFoundException(line.productId()));
product.reserve(line.quantity()); // domain rule, may throw
order.addLine(product, line.quantity()); // 16.4's both-sides helper
}
return orders.save(order);
}
@Transactional(readOnly = true) // 17.2: skips dirty checking
public Page<Order> find(OrderStatus status, Pageable pageable) {
return status == null ? orders.findAll(pageable)
: orders.findByStatus(status, pageable);
}
}
open-in-view must be disabled, and this is the single most important
setting in this topic.
spring:
jpa:
open-in-view: false # Boot defaults to true, which is wrong for APIs
With it enabled, Boot keeps the persistence context open for the whole request, so lazy associations load during serialisation. That sounds helpful and is harmful: queries fire from the view layer, outside any transaction, producing unpredictable N+1 behaviour and holding a connection for the entire request including the time spent writing bytes to a slow client.
open-in-view: true open-in-view: false
------------------ -------------------
connection held for the whole request connection held only in the service
lazy loads succeed anywhere lazy loads fail outside the service
N+1 hides in the serializer N+1 surfaces immediately as an error
pool exhausted by slow clients pool sized by actual database work
Disabling it converts a silent performance problem into a loud
LazyInitializationException during development, which is exactly the trade you
want.
Fetch what the response needs, explicitly. With the view open no longer rescuing you, each query must load what the DTO will read. This is 16.4's join fetch, now load-bearing.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.lines WHERE o.id = :id")
Optional<Order> findWithLines(@Param("id") Long id);
@EntityGraph(attributePaths = { "lines", "customer" }) // declarative fetch plan
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
}
Projections keep list endpoints cheap, which matters most at exactly the endpoints called most often.
public interface OrderSummaryView { // 17.3's interface projection
Long getId();
String getStatus();
BigDecimal getTotal();
}
Page<OrderSummaryView> findAllBy(Pageable pageable); // selects three columns
Batching and pool sizing are the two settings worth applying from the start.
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50 # 16.2's batching, via JPA
order_inserts: true
order_updates: true
datasource:
hikari:
maximum-pool-size: 10 # 16.2: small pools outperform large ones
leak-detection-threshold: 20000
Batching requires sequence-based identifiers, as 16.4 noted: IDENTITY
generation defeats it.
Verify the query count in tests, so the N+1 problem cannot return quietly.
@Test
void listingOrdersIssuesOneQuery() {
SQLStatementCountValidator.reset();
orderService.find(null, PageRequest.of(0, 20));
SQLStatementCountValidator.assertSelectCount(2); // rows plus the page count
}
Key Takeaways
- Controllers handle HTTP, services own transactions, repositories access data.
- Entities stay inside the service layer; DTOs cross the web boundary.
- Disable
open-in-view, whose default hides N+1 queries and holds connections.- With it disabled, each query must explicitly fetch what the response reads.
- Join fetches and entity graphs express those fetch plans.
- Projections keep frequently called list endpoints cheap.
- Configure JDBC batching and a small connection pool from the start.
- Assert query counts in tests so N+1 regressions fail the build.
🧪 Practice
- Disable
open-in-viewand find every endpoint that then throws a lazy initialisation error.- Fix one of them with a join fetch and confirm the query count.
- Replace a list endpoint's full entities with a projection and compare timings.
- Enable Hibernate batching and verify from the SQL log that inserts are grouped.
- Write a test asserting the query count for one endpoint and then deliberately break it.
- Interview: Why is
open-in-viewenabled by default yet recommended against? (Hint: what does it make easy for a beginner, and what does it hide from everyone else?)
API Documentation with OpenAPI
An API that is not documented is an API that people integrate with by reading your source code or by trial and error. Hand-written documentation solves this briefly and then drifts, because nothing forces it to change when the code does.
OpenAPI is a machine-readable specification format for HTTP APIs. Generating it from the code means the documentation cannot describe an endpoint that does not exist.
Hand-written docs Generated from code
----------------- -------------------
a wiki page, edited manually derived from controllers and DTOs
drifts silently from reality changes when the code changes
no client generation clients generated in any language
no interactive testing a browsable, executable UI
Setup is one dependency. Springdoc scans the controllers and DTOs and serves both the specification and a browser UI.
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
/v3/api-docs the OpenAPI document, as JSON
/swagger-ui.html an interactive UI for exploring and calling endpoints
Much of the document is inferred: paths and methods from the mappings, request and response schemas from the DTOs, required fields and bounds from the validation annotations of 17.4. Well-typed code is already most of a specification.
Annotations fill in what types cannot express: prose, examples, and the error responses that do not appear in a method signature.
@RestController
@RequestMapping("/api/orders")
@Tag(name = "Orders", description = "Placing and managing customer orders")
public class OrderController {
@Operation(summary = "Place a new order",
description = "Reserves stock and creates an order in NEW status.")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Order created"),
@ApiResponse(responseCode = "400", description = "Validation failed",
content = @Content(schema = @Schema(
implementation = ProblemDetail.class))),
@ApiResponse(responseCode = "409", description = "Insufficient stock",
content = @Content(schema = @Schema(
implementation = ProblemDetail.class)))
})
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody @Valid CreateOrderRequest request) { ... }
}
public record CreateOrderRequest(
@Schema(description = "Identifier of the ordering customer",
example = "42", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull Long customerId,
@Schema(description = "At least one line is required")
@NotEmpty @Valid List<LineRequest> lines) { }
Examples are the highest-value addition. A realistic request body in the documentation saves every integrator an hour of guessing.
Global information goes in a bean.
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI api() {
return new OpenAPI()
.info(new Info()
.title("Shop API")
.version("v1")
.description("Order and catalogue management"))
.servers(List.of(
new Server().url("https://api.shop.com").description("prod"),
new Server().url("http://localhost:8080").description("local")))
// Declares the auth scheme used in 18.4.
.components(new Components().addSecuritySchemes("bearer",
new SecurityScheme().type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")))
.addSecurityItem(new SecurityRequirement().addList("bearer"));
}
}
Client generation is the payoff that justifies keeping the specification accurate.
# Generate a typed client from the running service's specification.
openapi-generator-cli generate \
-i http://localhost:8080/v3/api-docs \
-g typescript-fetch \
-o ./client
Contract testing goes one step further: commit the specification and fail the build when it changes unexpectedly, which turns an accidental breaking change into a failed pull request.
@Test
void apiContractHasNotChangedUnexpectedly() throws Exception {
String current = mockMvc.perform(get("/v3/api-docs"))
.andReturn().getResponse().getContentAsString();
assertThatJson(current).isEqualTo(readResource("openapi-baseline.json"));
}
Do not expose the UI in production unless the API is genuinely public.
springdoc:
swagger-ui:
enabled: false # in the prod profile
api-docs:
enabled: false
Key Takeaways
- Generated documentation cannot drift from the code the way a wiki page does.
- Springdoc derives paths, schemas, and constraints from mappings, DTOs, and validation annotations.
- Annotations add descriptions, examples, and the error responses types cannot express.
- Realistic examples are the single most useful thing to add.
- A configuration bean supplies titles, servers, and security schemes.
- The specification generates typed clients in any language.
- Committing the specification turns breaking changes into build failures.
- Disable the UI and the document in production unless the API is public.
🧪 Practice
- Add springdoc to a service and explore the generated UI without writing any annotations.
- Document one endpoint's error responses and confirm they appear.
- Add examples to a request DTO and verify them in the UI.
- Generate a client in another language from the specification and call one endpoint.
- Commit the specification and write a test that fails when it changes.
- Interview: Why is generated documentation more trustworthy than hand-written documentation? (Hint: what has to happen for each one to become wrong?)
Versioning and Backward Compatibility
The moment a client you do not control depends on your API, you lose the ability to change it freely. Versioning is how you keep changing it anyway, and the first thing to understand is that most changes do not need a new version at all.
Compatible changes can be made in place, provided clients follow the tolerant reader principle from the Jackson topic.
| Change | Compatible? |
|---|---|
| Adding a new optional request field | Yes |
| Adding a new response field | Yes |
| Adding a new endpoint | Yes |
| Adding a new enum value in a response | Risky: clients may not handle it |
| Making a required request field optional | Yes |
| Making an optional request field required | No |
| Removing or renaming a response field | No |
| Changing a field's type | No |
| Changing a status code for an existing case | No |
| Tightening validation | No |
The asymmetry is the rule worth memorising: adding to a response is safe, removing from it is not; relaxing a request is safe, tightening it is not.
When a breaking change is unavoidable, three versioning schemes are in common use, as 17.4 introduced.
| Scheme | Example | Assessment |
|---|---|---|
| URL path | /api/v1/orders, /api/v2/orders | Most practical; visible in logs, testable in a browser |
| Header | X-Api-Version: 2 | Clean URLs; harder to test and to cache |
| Media type | Accept: application/vnd.shop.v2+json | Purest; least familiar to most teams |
URL versioning wins on operability. You can see the version in an access log, route it at a load balancer, and test it with a browser.
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
// V2 splits the single 'name' field into given and family names.
@GetMapping("/{id}")
public OrderResponseV2 get(@PathVariable long id) {
return OrderResponseV2.from(service.findById(id));
}
}
Version the contract, not the implementation. Two controllers and two response types are fine; two services and two repositories are a maintenance disaster.
OrderControllerV1 --> OrderResponseV1 --+
+--> OrderService --> repository
OrderControllerV2 --> OrderResponseV2 --+
(mapping differs) (one implementation)
Deprecate before removing, and give the schedule in the response itself.
@Deprecated
@GetMapping("/api/v1/orders/{id}")
public ResponseEntity<OrderResponseV1> getV1(@PathVariable long id) {
return ResponseEntity.ok()
.header("Deprecation", "true") // RFC 8594
.header("Sunset", "Sat, 31 Oct 2026 23:59:59 GMT") // removal date
.header("Link", "</api/v2/orders>; rel=\"successor-version\"")
.body(OrderResponseV1.from(service.findById(id)));
}
Logging usage of deprecated endpoints is what turns the removal from a guess into a decision: when the count reaches zero, removal is safe.
@Component
public class DeprecationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res,
Object handler) {
if (handler instanceof HandlerMethod method
&& method.hasMethodAnnotation(Deprecated.class)) {
// Who is still calling this, and how often?
log.warn("deprecated endpoint {} called by {}",
req.getRequestURI(), req.getHeader("User-Agent"));
meterRegistry.counter("api.deprecated",
"path", req.getRequestURI()).increment();
}
return true;
}
}
The expand-and-contract pattern applies to APIs exactly as 17.3 applied it to database schemas.
Step 1 add the new field alongside the old one, populate both
Step 2 document the old field as deprecated
Step 3 wait, measuring usage
Step 4 remove the old field in a new version
Practical guidance. Keep the number of live versions very small, ideally two. Every version is code that must be tested and kept working, and three versions means every change is made three times. Publish a sunset date when a version is deprecated, and hold to it.
Key Takeaways
- Most changes are compatible and need no new version.
- Adding to responses and relaxing requests is safe; the reverse is not.
- New enum values in responses can break strict clients.
- URL path versioning is the most operable of the three schemes.
- Version the contract with separate controllers and DTOs, sharing one service.
- Announce deprecation with
DeprecationandSunsetheaders.- Measure usage of deprecated endpoints so removal is evidence-based.
- Expand and contract applies to APIs as it does to schemas.
- Keep live versions to two; each one multiplies the maintenance cost.
🧪 Practice
- Classify ten hypothetical changes to an endpoint as compatible or breaking.
- Add a second version of one endpoint sharing the same service layer.
- Add deprecation and sunset headers to the old version and verify them.
- Write an interceptor that counts calls to deprecated endpoints.
- Plan the four steps to rename a response field without breaking clients.
- Interview: Why can adding a field to a response break a client, even though it is usually safe? (Hint: what does a strict deserialiser do with a field it does not recognise?)
<a id="184-security-essentials"></a>
18.4 Security Essentials
This subchapter covers how Spring Security decides who a caller is and what they may do: the filter chain that intercepts every request, the authentication and authorization model, storing passwords safely, stateless tokens, and enforcing rules below the web layer.
Spring Security Filter Chain
Security cannot be a concern that each controller handles, for the reason 17.2 gave about cross-cutting concerns: a check that must be written in two hundred places will be missing from one of them, and that one is the vulnerability.
Spring Security therefore sits in front of the application, as a chain of
servlet filters that run before DispatcherServlet sees the request. This is
17.4's filter position, used deliberately: a filter runs even for URLs that map
to no controller, so there is no path around it.
HTTP request
|
v
+--------------------------------------------------+
| FilterChainProxy ("springSecurityFilterChain") |
| |
| SecurityContextPersistenceFilter | restore any existing auth
| HeaderWriterFilter | security response headers
| CsrfFilter | CSRF token check
| LogoutFilter |
| UsernamePasswordAuthenticationFilter | form login
| BearerTokenAuthenticationFilter | JWT, see later topic
| ExceptionTranslationFilter | 401 and 403 decisions
| AuthorizationFilter | the actual access check
+--------------------------------------------------+
|
v
DispatcherServlet -> controller
Each filter has one job, and the order is fixed and meaningful: authentication filters must run before the authorization filter, and the exception translator must wrap both so it can convert their failures into responses.
The SecurityContext is where the result of authentication lives. It is
held in a ThreadLocal, which is why 17.4's warning about clearing thread state
matters here too.
// Available anywhere in the request's thread, including services.
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = auth.getName();
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
Configuration is a bean, replacing the older WebSecurityConfigurerAdapter
that appears in pre-2022 examples.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// Rules are evaluated in order; the FIRST match wins.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()) // the essential catch-all
.csrf(csrf -> csrf.disable()) // safe only for stateless APIs
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(Customizer.withDefaults())
.build();
}
}
Rule order is the most common source of security bugs here. The first matching rule decides, so a broad rule placed early makes every later rule unreachable.
// BROKEN: everything matches the first rule, so the admin rule never applies.
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN") // unreachable
// CORRECT: most specific first.
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
anyRequest().authenticated() must be present. Without a catch-all, a path
nobody wrote a rule for is unprotected, and new endpoints are unprotected by
default. Deny by default, permit explicitly.
Several chains can coexist, which is how an API and a browser-facing application live in one service with different rules.
@Bean
@Order(1) // evaluated first
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**") // this chain handles only /api
.csrf(csrf -> csrf.disable()) // stateless, token-authenticated
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(a -> a.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
@Bean
@Order(2)
public SecurityFilterChain webChain(HttpSecurity http) throws Exception {
return http // everything else: browser-facing
.authorizeHttpRequests(a -> a
.requestMatchers("/", "/login").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults()) // sessions and CSRF stay ON
.build();
}
CSRF deserves a precise rule rather than the habit of disabling it. Cross-site request forgery exploits the browser automatically attaching cookies to requests triggered by another site. If your API authenticates with a header that a browser does not attach automatically, such as a bearer token, CSRF does not apply and disabling it is correct. If it authenticates with a cookie or a session, CSRF protection must stay on.
| Authentication style | CSRF protection |
|---|---|
| Session cookie | Required |
| Cookie-stored token | Required |
Authorization header | Not applicable; may disable |
Security response headers are written by default and are worth knowing.
.headers(headers -> headers
.frameOptions(f -> f.deny()) // clickjacking
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'"))
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000))) // force HTTPS
Debugging is best done by reading the chain rather than guessing.
logging.level.org.springframework.security=DEBUG
The log names each filter the request passed and the rule that decided the outcome.
Key Takeaways
- Security runs as servlet filters before the dispatcher, so no request bypasses it.
- Each filter has one responsibility, and the order is significant.
- The authenticated principal lives in a thread-local
SecurityContext.- Configuration is a
SecurityFilterChainbean; the adapter class is obsolete.- Authorization rules are evaluated in order and the first match wins, so specific rules come first.
- Always end with
anyRequest().authenticated()so new endpoints are protected by default.- Multiple chains with
securityMatcherlet an API and a web UI coexist.- Disable CSRF only when authentication does not rely on cookies.
- Enable debug logging to see which filter and which rule decided.
🧪 Practice
- Configure a chain with public, authenticated, and admin-only paths, and test all three.
- Place a broad rule before a specific one and demonstrate that the specific rule never applies.
- Remove the catch-all rule, add a new endpoint, and confirm it is unprotected.
- Define two chains with different matchers and verify each handles its own paths.
- Enable security debug logging and trace one denied request through the filters.
- Interview: Why is security implemented as a filter rather than as an aspect on controllers? (Hint: what happens to a request whose URL matches no controller at all?)
Authentication and Authorization Basics
Two questions look similar and are answered by entirely different machinery. Authentication asks who the caller is. Authorization asks what that caller may do. Confusing them produces both security holes and misleading error responses.
Authentication Authorization
-------------- -------------
"Who are you?" "May you do this?"
credentials -> identity identity -> permission
fails with 401 fails with 403
runs once per request runs per resource or method
The status codes encode the difference: 401 means "I do not know who you are", and 403 means "I know who you are and the answer is no". Returning 403 to an anonymous caller hides the fact that authenticating would help.
The authentication flow in Spring Security has four participants.
request with credentials
|
v
AuthenticationFilter extracts credentials into an Authentication
|
v
AuthenticationManager delegates to the providers
|
v
AuthenticationProvider verifies; e.g. DaoAuthenticationProvider
| |
| UserDetailsService loads the user
| PasswordEncoder compares the password
v
SecurityContext holds the authenticated principal
UserDetailsService is the single method you implement to connect Spring
Security to wherever your users live.
@Service
public class DatabaseUserDetailsService implements UserDetailsService {
private final UserRepository users;
@Override
public UserDetails loadUserByUsername(String username) {
User user = users.findByEmail(username)
// Deliberately the same exception for "no such user" as the
// framework produces for a bad password, so responses do not
// reveal which accounts exist.
.orElseThrow(() -> new UsernameNotFoundException("bad credentials"));
return org.springframework.security.core.userdetails.User
.withUsername(user.email())
.password(user.passwordHash()) // the stored hash, never plain
.authorities(toAuthorities(user.roles()))
.accountLocked(user.isLocked())
.disabled(!user.isActive())
.build();
}
private List<GrantedAuthority> toAuthorities(Set<Role> roles) {
return roles.stream()
// "ROLE_" is the prefix hasRole() expects; hasAuthority() does not.
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()))
.toList();
}
}
Roles versus authorities is a naming convention that trips people up
constantly. There is one concept, GrantedAuthority; a "role" is simply an
authority whose name starts with ROLE_.
| Expression | Matches the authority |
|---|---|
hasRole("ADMIN") | ROLE_ADMIN |
hasAuthority("ROLE_ADMIN") | ROLE_ADMIN |
hasAuthority("order:write") | order:write |
hasRole("order:write") | ROLE_order:write, almost certainly a bug |
A useful distinction in practice: roles describe who someone is, and authorities describe what they may do. Coarse roles with fine-grained permissions derived from them scales better than proliferating roles.
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("order:read")
.requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("order:write")
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
Authorization expressions support more than a single check.
.requestMatchers("/api/reports/**").hasAnyRole("ADMIN", "ANALYST")
.requestMatchers("/api/internal/**").access(
new WebExpressionAuthorizationManager("hasRole('ADMIN') and isFullyAuthenticated()"))
.requestMatchers("/api/**").access(AuthorizationManagers.allOf(
AuthenticatedAuthorizationManager.authenticated(),
AuthorityAuthorizationManager.hasAuthority("api:access")))
Reading the current user inside application code is usually done by injecting it rather than reaching for the holder.
@GetMapping("/me")
public UserResponse currentUser(@AuthenticationPrincipal UserDetails principal) {
return UserResponse.from(userService.findByEmail(principal.getUsername()));
}
This is preferable to SecurityContextHolder in a controller because it is
explicit, testable, and does not depend on thread-local state.
Customising the failure responses matters for an API, because the defaults are designed for browsers.
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, res, e) -> { // no credentials: 401
res.setStatus(HttpStatus.UNAUTHORIZED.value());
res.setContentType("application/problem+json");
res.getWriter().write(problemJson(401, "Authentication required"));
})
.accessDeniedHandler((req, res, e) -> { // known user, denied: 403
res.setStatus(HttpStatus.FORBIDDEN.value());
res.setContentType("application/problem+json");
res.getWriter().write(problemJson(403, "Access denied"));
}))
Without this, an unauthenticated API call can receive a redirect to a login page that does not exist, which is confusing for every client.
Never leak which accounts exist. Distinct messages for "unknown user" and "wrong password" let an attacker enumerate valid accounts. Use one message, and apply the same timing where practical.
Key Takeaways
- Authentication establishes identity; authorization decides permission.
- 401 means unauthenticated; 403 means authenticated but not permitted.
UserDetailsServiceis the single integration point for your user store.- A role is an authority prefixed with
ROLE_;hasRoleadds the prefix andhasAuthoritydoes not.- Roles describe identity and authorities describe permissions; prefer fine-grained authorities.
- Inject
@AuthenticationPrincipalrather than reading the thread-local holder.- Customise the entry point and denied handler so APIs return JSON, not redirects.
- Use one failure message so responses cannot be used to enumerate accounts.
🧪 Practice
- Implement a
UserDetailsServicebacked by a repository and authenticate against it.- Demonstrate the difference between
hasRole("ADMIN")andhasAuthority("ADMIN").- Return problem details for both 401 and 403 and verify each from a client.
- Inject the authenticated principal into a controller and return the current user.
- Design an authority scheme for three user types and four operations.
- Interview: Why should a login endpoint give the same response for an unknown username and a wrong password? (Hint: what can an attacker learn from the difference?)
Password Storage and Hashing
A password database is the highest-value target in most applications, because people reuse passwords. The design goal is that a stolen database is not a stolen set of passwords, and meeting it requires understanding why the obvious approaches fail.
Plain text needs no explanation: a database breach is total.
A general-purpose hash such as SHA-256 is the approach that feels right and is not. The hash is one-way, but it is also extremely fast, which is exactly wrong. Modern hardware computes billions of SHA-256 hashes per second, so an attacker with the hashes simply tries every plausible password.
Attacking fast hashes
---------------------
SHA-256 on a GPU rig ~10,000,000,000 guesses/second
every 8-character password exhausted in hours
every common password exhausted in seconds
Unsalted hashes add a second failure: identical passwords produce identical hashes, so one cracked hash reveals every account using that password, and precomputed rainbow tables work against the whole database at once.
Unsalted Salted
-------- ------
alice: 5e884898da280471... alice: $2a$10$N9qo8uLO... (salt inside)
bob: 5e884898da280471... bob: $2a$10$kJ3mZq1P...
^ identical: same password ^ different, despite the same password
The correct approach is a deliberately slow, salted, adaptive hash. Three properties matter.
| Property | Purpose |
|---|---|
| Salted | A unique random salt per password defeats rainbow tables and identical-hash leaks |
| Slow | A cost measured in hundreds of milliseconds makes bulk guessing impractical |
| Adaptive | A tunable work factor is raised as hardware improves |
| Algorithm | Resists GPU attack | Resists custom hardware | Notes |
|---|---|---|---|
| BCrypt | Good | Moderate | The safe default; widely supported |
| SCrypt | Good | Good | Memory-hard |
| Argon2id | Good | Good | Current recommendation; needs more tuning |
| PBKDF2 | Moderate | Poor | Acceptable where required by compliance |
| SHA-256 | No | No | Never for passwords |
Spring Security's PasswordEncoder is the abstraction, and BCrypt is the
sensible default.
@Configuration
public class PasswordConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// The strength is a log2 cost factor: 12 means 2^12 rounds.
// Aim for roughly 250ms on your production hardware.
return new BCryptPasswordEncoder(12);
}
}
@Service
public class UserService {
private final PasswordEncoder encoder;
private final UserRepository users;
public User register(String email, String rawPassword) {
// encode() generates a fresh random salt and embeds it in the output.
String hash = encoder.encode(rawPassword);
return users.save(new User(email, hash));
}
public void changePassword(User user, String current, String replacement) {
// matches() re-hashes the candidate with the stored salt and compares
// in constant time. Never compare hashes with equals().
if (!encoder.matches(current, user.passwordHash())) {
throw new BadCredentialsException("bad credentials");
}
user.setPasswordHash(encoder.encode(replacement));
}
}
The stored value carries everything needed to verify it.
$2a$12$N9qo8uLOickgx2ZMRZoMye IjZAgcfl7p92ldGxad68LJZdL17lhWy
^^^ ^^ ^-------------------^ ^-------------------------------^
alg cost salt (22 chars) hash
DelegatingPasswordEncoder handles migration, which matters because you
will eventually need to move off whatever you chose.
@Bean
public PasswordEncoder passwordEncoder() {
// Stored values are prefixed with {bcrypt}, {argon2}, and so on, so old
// and new formats coexist. New passwords use the default.
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
Upgrading on successful login is the standard pattern: when a user authenticates with an old format, re-hash the password they just supplied with the new one.
Rules that surround the hashing:
- Never log a password, including in debug statements and exception messages.
- Never email a password, and never offer to recover one. An account that can recover a password is storing it reversibly.
- Reset by expiring token, single-use and short-lived.
- Rate limit login attempts, per account and per source. Slow hashing protects the database; rate limiting protects the live endpoint.
- Check against known-breached password lists rather than imposing complex composition rules, which push people toward predictable substitutions.
- Require length over complexity. A long passphrase beats eight characters with a symbol.
Choosing the work factor should be measured, not guessed.
@Test
void bcryptCostIsAppropriate() {
var encoder = new BCryptPasswordEncoder(12);
long start = System.nanoTime();
encoder.encode("a-test-password");
long millis = (System.nanoTime() - start) / 1_000_000;
// Slow enough to deter bulk guessing, fast enough for a login endpoint.
assertThat(millis).isBetween(200L, 500L);
}
Key Takeaways
- Passwords must be stored so that a database breach does not reveal them.
- Fast general-purpose hashes such as SHA-256 are unsuitable; speed is the attacker's advantage.
- Unsalted hashes expose identical passwords and enable rainbow tables.
- Use a salted, slow, adaptive algorithm: BCrypt, SCrypt, or Argon2id.
PasswordEncoder.encodegenerates the salt;matchescompares safely.- The stored string contains the algorithm, cost, and salt.
DelegatingPasswordEncoderlets formats coexist and supports migration.- Never log, email, or recover a password; reset with a short-lived token.
- Rate limit logins, and prefer length and breach checks over composition rules.
🧪 Practice
- Hash the same password twice with BCrypt and explain why the outputs differ.
- Measure encoding time at cost factors 10, 12, and 14 and choose one.
- Implement registration and password change using
PasswordEncodercorrectly.- Configure a delegating encoder and verify a value in an older format still authenticates.
- Add rate limiting to a login endpoint and test it.
- Interview: Why is BCrypt's slowness a feature rather than a performance defect? (Hint: compare the cost of one login with the cost of ten billion guesses.)
Token-Based Authentication with JWT
Session-based authentication stores state on the server: a cookie holds an identifier, and the server keeps the session data. This works well for a single application and causes friction for APIs serving mobile clients, single-page applications, and other services, where sticky sessions or a shared session store become the constraint.
Token-based authentication moves the state into the token itself. The client presents a signed token on each request, and the server verifies the signature rather than looking anything up.
Session-based Token-based
------------- -----------
client holds a session id client holds a signed token
server holds the session data server holds only a key
requires sticky sessions or a store any instance can verify
revocation is immediate revocation needs extra machinery
A JSON Web Token has three base64url-encoded parts separated by dots.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsInJvbGVzIjpbIlVTRVIiXX0.dBjftJeZ4CVP...
\_________________/ \_______________________________________/ \___________/
header payload signature
header {"alg":"HS256","typ":"JWT"}
payload {"sub":"42","roles":["USER"],"iat":1758844800,"exp":1758845700}
signature HMAC-SHA256(base64(header) + "." + base64(payload), secret)
The payload is encoded, not encrypted. Anyone holding the token can read it. The signature proves it has not been altered; it does not hide anything. Never put a password, a card number, or personal data in a token.
| Claim | Meaning |
|---|---|
sub | Subject: who the token is about |
iss | Issuer: who created it |
aud | Audience: who it is intended for |
exp | Expiry timestamp; verification must check it |
iat | Issued-at timestamp |
jti | A unique token id, useful for revocation lists |
Spring Security's resource server support handles validation, so writing a filter by hand is unnecessary and error-prone.
@Configuration
@EnableWebSecurity
public class JwtSecurityConfig {
@Bean
public SecurityFilterChain chain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // header auth: CSRF not applicable
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(jwt ->
jwt.jwtAuthenticationConverter(authoritiesConverter())))
.build();
}
// Maps a claim in the token onto Spring Security authorities.
private JwtAuthenticationConverter authoritiesConverter() {
var authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("ROLE_"); // matches hasRole()
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
@Bean
public JwtDecoder jwtDecoder(@Value("${jwt.secret}") String secret) {
var key = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
return NimbusJwtDecoder.withSecretKey(key).build(); // verifies signature and exp
}
}
Issuing a token after a successful authentication:
@Service
public class TokenService {
private final JwtEncoder encoder;
public String issue(UserDetails user) {
Instant now = Instant.now();
var claims = JwtClaimsSet.builder()
.issuer("shop-service")
.issuedAt(now)
.expiresAt(now.plus(15, ChronoUnit.MINUTES)) // SHORT lifetime
.subject(user.getUsername())
.claim("roles", user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(a -> a.replaceFirst("^ROLE_", ""))
.toList())
.build();
return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
Revocation is the hard problem, and it is the reason token lifetimes must be short. A signed token is valid until it expires; there is no server-side record to delete. Logging a user out, or disabling a compromised account, does not invalidate tokens already issued.
The standard answer is a two-token scheme.
Access token 15 minutes sent with every request; not checked against storage
Refresh token 7 days stored server-side; exchanged for a new access token
revocable by deleting the stored record
login --> access token (15m) + refresh token (7d)
|
request -------+--> verify signature and exp, no lookup
|
access expires --> POST /auth/refresh with the refresh token
|
look it up, check it is not revoked
|
issue a new access token
The window of exposure is therefore bounded by the access token's lifetime, which is why fifteen minutes is typical and hours are not.
Where to store tokens in a browser is a genuine trade-off.
| Storage | Vulnerable to | Notes |
|---|---|---|
localStorage | Cross-site scripting | Any injected script can read it |
| In-memory variable | Lost on reload | Safest against theft; needs a refresh flow |
HttpOnly cookie | CSRF | Script cannot read it; requires CSRF protection |
An HttpOnly, Secure, SameSite=Strict cookie with CSRF protection is the
strongest common arrangement, at the cost of reintroducing the CSRF concern the
earlier topic set aside.
Rules that prevent the known failure modes:
- Reject
alg: noneand never let the token choose the algorithm. Pin it in the decoder configuration; this class of bug has broken many implementations. - Use a long, random secret for HMAC, at least 256 bits, supplied from the environment as 18.2 described. For multiple services, prefer asymmetric RS256 so verifiers hold only the public key.
- Always validate expiry, issuer, and audience. The library does this only if configured to.
- Keep tokens out of URLs, where they land in access logs and browser history.
- Use HTTPS without exception. A bearer token on a plain connection is a password in plain text.
Key Takeaways
- Tokens move session state to the client, so any instance can verify a request.
- A JWT is header, payload, and signature; the payload is readable by anyone.
- Never place secrets or personal data in a token.
- Spring Security's resource server support validates tokens; do not write the filter yourself.
- A signed token cannot be revoked, which forces short access token lifetimes.
- Pair a short access token with a stored, revocable refresh token.
- Browser storage is a trade-off between cross-site scripting and CSRF exposure.
- Pin the algorithm, use a strong secret, validate expiry and issuer, and require HTTPS.
🧪 Practice
- Decode a JWT by hand and read its payload, confirming it is not encrypted.
- Configure a resource server chain and authenticate a request with a bearer token.
- Map a custom claim onto Spring Security authorities and enforce a rule with it.
- Implement access and refresh tokens and demonstrate revoking the refresh token.
- Set a token to expire in ten seconds and observe the failure after it lapses.
- Interview: A user is disabled but keeps accessing the API for several minutes. Explain why, and what limits the window. (Hint: what does the server check on each request, and what does it not check?)
Method-Level Security
URL-based rules protect the web layer, and that is not the whole application. A service method called by a scheduled job, a message consumer, or another service never passes through the filter chain, so a rule expressed as a path pattern does not apply to it.
Method-level security places the check on the method, where the operation actually happens. It is implemented as an aspect, which means every limitation from 17.2 applies: proxied beans only, public methods only, and no self-invoked calls.
@Configuration
@EnableMethodSecurity // prePostEnabled is true by default
public class MethodSecurityConfig { }
@PreAuthorize evaluates before the method runs, which is what you want for
anything with an effect.
@Service
public class DocumentService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteAll() { ... }
// SpEL can reference method arguments by name.
@PreAuthorize("#ownerId == authentication.principal.id or hasRole('ADMIN')")
public List<Document> listFor(long ownerId) { ... }
// And can call a bean to express a domain rule.
@PreAuthorize("@documentPermissions.canEdit(#id, authentication)")
public Document update(long id, DocumentUpdate update) { ... }
}
@Component("documentPermissions")
public class DocumentPermissions {
public boolean canEdit(long documentId, Authentication authentication) {
// Arbitrary logic, unit-testable in isolation from the web layer.
return repository.findById(documentId)
.map(doc -> doc.ownerEmail().equals(authentication.getName()))
.orElse(false);
}
}
Delegating to a bean is the pattern that keeps expressions readable. A SpEL string longer than about eighty characters is hard to review and impossible to unit test.
@PostAuthorize runs the method and then decides, which is appropriate only
when the decision depends on the result.
@PostAuthorize("returnObject.ownerEmail == authentication.name")
public Document findById(long id) { ... } // loaded, then possibly discarded
Be aware of what this means: the method's work is done and any side effects have happened. Never use it on a method that writes.
Filtering collections applies a rule per element.
// Removes elements the caller may not see, after the method returns.
@PostFilter("filterObject.ownerEmail == authentication.name")
public List<Document> findAll() { ... }
// Removes elements the caller may not submit, before the method runs.
@PreFilter("filterObject.ownerEmail == authentication.name")
public void saveAll(List<Document> documents) { ... }
@PostFilter is convenient and can be a performance trap: loading ten thousand
rows to discard nine thousand is worse than filtering in the query. Prefer a
repository method that filters in SQL, and reserve @PostFilter for small
collections.
| Annotation | Runs | Use for |
|---|---|---|
@PreAuthorize | Before | Nearly everything, especially writes |
@PostAuthorize | After | Decisions depending on the return value |
@PreFilter | Before | Filtering an incoming collection |
@PostFilter | After | Filtering a small returned collection |
@Secured | Before | Simple role checks; no SpEL |
@RolesAllowed | Before | The Jakarta standard equivalent |
A custom annotation applies 17.5's composition and makes intent clearer than a repeated expression.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasRole('ADMIN')")
public @interface AdminOnly { }
@Service
public class ReportService {
@AdminOnly // reads better, and is greppable
public Report generateFinancials() { ... }
}
Layering matters. URL rules and method rules are complementary, not redundant.
Filter chain coarse, fast, covers every path including unmapped ones
| /api/admin/** requires ROLE_ADMIN
v
Method security fine, contextual, covers non-web callers too
"this document belongs to you"
The proxy limitations are the trap, exactly as with @Transactional.
@Service
public class ReportService {
public void generateAll() {
for (var id : ids) {
generate(id); // self-invocation: @PreAuthorize DOES NOT RUN
}
}
@PreAuthorize("hasRole('ADMIN')")
public void generate(long id) { ... }
}
A security check that silently does not run is worse than one that is absent, because the code reads as protected. Test the rules rather than assuming them.
@Test
@WithMockUser(roles = "USER") // not an admin
void nonAdminCannotDeleteAll() {
assertThrows(AccessDeniedException.class, () -> documentService.deleteAll());
}
Key Takeaways
- URL rules do not cover service methods invoked outside the web layer.
- Method security places the check where the operation happens.
@PreAuthorizeis the default choice and runs before any effect occurs.@PostAuthorizeruns the method first, so never use it on writes.- Delegate complex expressions to a bean so they are readable and testable.
@PostFilterdiscards rows after loading them; filter in the query instead.- Custom annotations make repeated rules clearer.
- Method security is an aspect, so self-invoked and non-public calls are unprotected.
- Test authorization rules; a silently skipped check reads as protection.
🧪 Practice
- Protect a service method with
@PreAuthorizeand call it as two different users.- Write an expression referencing a method argument and enforce ownership.
- Delegate a rule to a permission bean and unit test that bean directly.
- Demonstrate that a self-invoked call bypasses
@PreAuthorize, then fix it.- Create an
@AdminOnlycomposed annotation and apply it.- Interview: Why is URL-based security insufficient on its own? (Hint: name two callers of a service method that never send an HTTP request.)
<a id="185-testing-and-operating-spring-boot-applications"></a>
18.5 Testing and Operating Spring Boot Applications
This subchapter covers what happens after the code works: proving it keeps working with tests at the right granularity, and running it somewhere real with enough visibility to diagnose problems without a debugger.
Slice Tests and Full Context Tests
Chapter 15 established the test pyramid: many fast unit tests, fewer integration
tests, few end-to-end tests. Spring Boot adds a specific hazard to that picture,
because starting an application context is slow, and @SpringBootTest makes it
trivially easy to start one for every test.
A suite that ignores the pyramid
--------------------------------
400 tests, each with @SpringBootTest
context starts in ~4 seconds
contexts differ, so caching does not help
-> 20+ minutes, and nobody runs it before pushing
Most tests need no Spring at all. A service with constructor-injected
collaborators, as 17.1 argued for, is testable with plain new.
// No context, no annotations, milliseconds.
class OrderServiceTest {
@Test
void rejectsOrderBelowMinimum() {
var repository = new InMemoryOrderRepository();
var service = new OrderService(repository, new RecordingEmailSender());
assertThrows(OrderTooSmallException.class,
() -> service.place(orderTotalling("1.00")));
}
}
Slice tests load only the part of the context a layer needs. They are the middle of the pyramid and the most under-used tool in Boot testing.
| Annotation | Loads | Excludes |
|---|---|---|
@WebMvcTest | Controllers, advice, converters, filters | Services, repositories |
@DataJpaTest | Repositories, entities, a test database | Controllers, services |
@JdbcTest | JdbcTemplate and a data source | Everything else |
@JsonTest | Jackson configuration and mappers | Everything else |
@RestClientTest | REST client infrastructure | Everything else |
@SpringBootTest | The whole application context | Nothing |
A controller slice tests binding, validation, status codes, and JSON without a database.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired private MockMvc mockMvc;
@MockitoBean // replaces the real bean in the slice
private OrderService orderService;
@Test
void returnsNotFoundForUnknownOrder() throws Exception {
when(orderService.findById(99L))
.thenThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/orders/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Order not found"));
}
@Test
void rejectsOrderWithNoLines() throws Exception {
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "customerId": 7, "lines": [] }
"""))
.andExpect(status().isBadRequest())
// Confirms 18.3's error contract, not just the status code.
.andExpect(jsonPath("$.errors[0].field").value("lines"));
}
}
This is the right place to test the web contract: status codes, the error format, validation rules, and serialisation. The service is mocked because its behaviour is tested elsewhere.
A repository slice tests queries against a real database engine.
@DataJpaTest
class OrderRepositoryTest {
@Autowired private OrderRepository repository;
@Autowired private TestEntityManager entityManager;
@Test
void findWithLinesFetchesLinesInOneQuery() {
Order order = entityManager.persistFlushFind(orderWithThreeLines());
entityManager.clear(); // force a genuine load, not the cache
Order found = repository.findWithLines(order.getId()).orElseThrow();
// 16.4: this must not trigger a second query.
assertThat(found.getLines()).hasSize(3);
}
}
@DataJpaTest is transactional and rolls back per test, as 17.3 described, and
by default replaces the configured data source with an embedded one. That
default is worth overriding, because an in-memory database does not enforce what
your production database enforces.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest { } // runs against the real engine, see Testcontainers
Full context tests are for the paths that must be verified end to end.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderApiIntegrationTest {
@Autowired private TestRestTemplate restTemplate; // a real HTTP client
@Test
void placesAnOrderAndReadsItBack() {
var created = restTemplate.postForEntity(
"/api/orders", new CreateOrderRequest(7L, List.of(line())),
OrderResponse.class);
assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(created.getHeaders().getLocation()).isNotNull();
var fetched = restTemplate.getForEntity(
created.getHeaders().getLocation(), OrderResponse.class);
assertThat(fetched.getBody().status()).isEqualTo("NEW");
}
}
Context caching is what keeps a Boot suite fast. Spring caches a context and reuses it across test classes with identical configuration. Anything that makes a configuration unique, a different property, a different mocked bean, a different active profile, creates a new context and another startup.
Five test classes, identical configuration -> 1 context, 4s total
Five test classes, each with one @TestPropertySource difference
-> 5 contexts, 20s total
The practical advice is to standardise: use one test profile and one set of mocked beans wherever possible, and resist per-class property tweaks.
Key Takeaways
- Starting a context is slow, so reach for it only when the test needs one.
- Constructor injection makes most service tests plain JUnit with no Spring.
- Slice tests load one layer and are the most under-used option.
@WebMvcTestis the right place to test status codes, validation, and the error contract.@DataJpaTestis transactional and rolls back, but defaults to an embedded database.- Override that default so queries run against the real engine.
@SpringBootTestwith a random port exercises the full stack over real HTTP.- Contexts are cached by configuration; per-class differences multiply startup time.
🧪 Practice
- Write the same test three ways: plain JUnit, a slice, and a full context, and compare run times.
- Test a controller's validation and error format with
@WebMvcTestand no database.- Write a repository test asserting that a join fetch avoids a second query.
- Add a unique
@TestPropertySourceto three test classes and measure the suite's slowdown.- Interview: Why does mocking a different bean in two test classes make a suite slower? (Hint: what does Spring use as the cache key for a context?)
Mocking Beans and Test Configuration
A test needs collaborators it can control. In a slice or full-context test, the container supplies the beans, so replacing one requires telling the container rather than calling a constructor.
@MockitoBean replaces a bean in the context with a Mockito mock. It is the
current name; @MockBean is the deprecated predecessor that appears in most
existing code and examples.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired private MockMvc mockMvc;
@MockitoBean // replaces or adds this bean in the context
private OrderService orderService;
@MockitoSpyBean // wraps the REAL bean, recording calls
private AuditService auditService;
@Test
void createReturnsLocationHeader() throws Exception {
when(orderService.place(any())).thenReturn(anOrder(42L));
mockMvc.perform(post("/api/orders").contentType(APPLICATION_JSON)
.content(validOrderJson()))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/orders/42"));
verify(auditService).record(argThat(e -> e.type().equals("ORDER_PLACED")));
}
}
The distinction matters: a mock has no behaviour until stubbed, so an unstubbed method returns null or zero. A spy delegates to the real object unless stubbed, which suits a collaborator whose real behaviour is wanted but whose calls must be observed.
Be aware of the caching cost. Each distinct set of mocked beans produces a distinct context, so scattering different mocks across many classes is the slowdown the previous topic described. Grouping tests that share a configuration is worth doing deliberately.
Test configuration classes supply replacements that are real objects rather than mocks, which is often better: a fake repository backed by a map behaves consistently without stubbing every call.
@TestConfiguration // NOT @Configuration: not picked up by scanning
public class TestClockConfig {
@Bean
public Clock clock() {
// A fixed clock makes every time-dependent assertion deterministic.
return Clock.fixed(Instant.parse("2026-09-26T10:00:00Z"), ZoneOffset.UTC);
}
}
@SpringBootTest
@Import(TestClockConfig.class) // opt in explicitly
class OrderServiceIntegrationTest { }
@TestConfiguration is deliberately not found by component scanning, so it
affects only the tests that import it.
Overriding a bean from the main configuration needs the class to be considered first, or the property that permits overriding.
@TestConfiguration
public class StubPaymentConfig {
@Bean
@Primary // 17.1: wins over the real bean
public PaymentGateway paymentGateway() {
return (card, amount) -> new Receipt("TEST-RECEIPT");
}
}
Properties can be set per test, though each distinct set costs a context.
@SpringBootTest(properties = {
"app.feature.newPricing=true",
"spring.jpa.show-sql=true"
})
class NewPricingTest { }
@SpringBootTest
@TestPropertySource(locations = "classpath:test-overrides.properties")
class OverriddenTest { }
@ActiveProfiles selects the profile, which is the cleanest way to swap a
whole set of beans as 18.2 described.
@SpringBootTest
@ActiveProfiles("test") // loads application-test.yml and @Profile("test") beans
class OrderServiceIntegrationTest { }
Security in tests needs its own handling, since 18.4's filter chain will
otherwise reject every request in a @WebMvcTest.
@Test
@WithMockUser(username = "alice", roles = { "USER" })
void userCanReadOwnOrders() throws Exception {
mockMvc.perform(get("/api/orders")).andExpect(status().isOk());
}
@Test
@WithAnonymousUser
void anonymousIsRejected() throws Exception {
mockMvc.perform(get("/api/orders")).andExpect(status().isUnauthorized());
}
@Test
void postRequiresCsrfTokenWhenEnabled() throws Exception {
mockMvc.perform(post("/api/orders").with(csrf()) // supplies a valid token
.contentType(APPLICATION_JSON).content(json()))
.andExpect(status().isCreated());
}
Injecting a Clock rather than calling Instant.now() deserves emphasis,
because time is the most common source of flaky tests, as chapter 15 noted.
Making it a dependency turns an untestable method into a deterministic one.
@Service
public class OrderService {
private final Clock clock; // injected, not static
public Order place(PlaceOrderCommand command) {
return new Order(command, OffsetDateTime.now(clock)); // controllable
}
}
The same reasoning applies to randomness, the default locale, and the default time zone: each is a hidden input that a test cannot control unless it is injected.
Key Takeaways
@MockitoBeanreplaces a bean in the context;@MockBeanis its deprecated predecessor.- A mock returns defaults until stubbed; a spy delegates to the real object.
- Each distinct set of mocks creates a new cached context, so group tests that share one.
@TestConfigurationsupplies test beans and is not picked up by scanning.@Primaryon a test bean overrides the production one.@ActiveProfilesswaps whole sets of beans and configuration files.@WithMockUserand the CSRF request post-processor make secured endpoints testable.- Inject a
Clockrather than callingnow(), and do the same for randomness.
🧪 Practice
- Test a controller slice with a mocked service and assert on the response body.
- Replace a mock with a hand-written fake and compare the readability of the test.
- Write a
@TestConfigurationproviding a fixed clock and assert a timestamp exactly.- Test a secured endpoint as an anonymous user, a regular user, and an admin.
- Convert a service that calls
Instant.now()into one that takes aClockand test it deterministically.- Interview: Why is a hand-written fake sometimes better than a mock? (Hint: what happens to a mock-based test when the collaborator gains a new method the code under test calls?)
Integration Testing with Testcontainers
Repository tests commonly run against H2 in memory because it is fast and needs no setup. The problem is that H2 is not the database you deploy against, and the differences are exactly where bugs hide.
Tested on H2 Deployed on PostgreSQL
------------ ----------------------
no JSONB, no arrays, no ENUM the schema uses all three
different function names to_char, date_trunc behave differently
different locking behaviour 16.3's SELECT ... FOR UPDATE differs
looser constraint enforcement a constraint fires only in production
migrations pass in compatibility mode the real migration fails
A test suite that passes on H2 and fails in production is worse than no test, because it produced confidence that was not warranted.
Testcontainers starts a real database in a Docker container for the test run, and stops it afterwards. The test runs against the same engine and version as production.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
@ServiceConnection wires it automatically, which removes the property
plumbing that older examples show.
@SpringBootTest
@Testcontainers
class OrderRepositoryIntegrationTest {
@Container
@ServiceConnection // sets spring.datasource.* automatically
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
// 'static' matters: one container for the whole class, not per test
@Autowired private OrderRepository repository;
@Test
void enforcesTheUniqueReferenceConstraint() {
repository.save(orderWithReference("ORD-00000001"));
// 17.3's translated exception, from the real engine's real constraint.
assertThrows(DataIntegrityViolationException.class,
() -> repository.saveAndFlush(orderWithReference("ORD-00000001")));
}
}
A shared container across the whole suite avoids paying the startup cost per class. The singleton pattern is the standard approach.
public abstract class AbstractIntegrationTest {
// Started once per JVM; Ryuk removes it when the JVM exits.
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
static { POSTGRES.start(); }
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
Every integration test class extends this, so the configuration is identical and the context cache from two topics back actually works.
Other services containerise the same way, which is what makes the approach worth the setup.
@Container @ServiceConnection
static RedisContainer redis = new RedisContainer("redis:7-alpine");
@Container @ServiceConnection
static KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
// A stub HTTP server for an external API, so tests do not call the real one.
@Container
static GenericContainer<?> wiremock =
new GenericContainer<>("wiremock/wiremock:3.5.4").withExposedPorts(8080);
Testing migrations is a major benefit. With Flyway from 17.3 and a real engine, the test proves the migrations apply cleanly to an empty database.
@Test
void migrationsApplyToACleanDatabase() {
// The container starts empty and Flyway runs during context startup.
// If any migration is invalid, the context fails and so does this test.
assertThat(jdbcTemplate.queryForObject(
"SELECT count(*) FROM flyway_schema_history WHERE success = false",
Integer.class)).isZero();
}
Data isolation between tests has two options.
| Approach | Speed | Caveat |
|---|---|---|
@Transactional rollback | Fast | Never exercises a real commit, as 17.3 noted |
| Truncate tables after each | Slower | Exercises commits; needs careful ordering |
For tests that must verify commit behaviour, such as anything involving
REQUIRES_NEW or deferred constraints, truncation is the honest choice.
Local development benefits too. Boot's @ServiceConnection support works in
a development-mode launcher, so ./mvnw spring-boot:test-run starts the
application with real dependencies and no local installation.
@TestConfiguration(proxyBeanMethods = false)
public class LocalDevContainers {
@Bean @ServiceConnection
public PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
}
The costs are real: Docker must be available, including in the continuous integration environment, and startup adds seconds. Both are worth paying for tests that mean something. The pyramid still applies; these belong in the integration layer, not in place of unit tests.
Key Takeaways
- H2 is not the production database, and the differences are where bugs hide.
- Testcontainers runs the real engine and version for the duration of a test run.
@ServiceConnectionconfigures the data source automatically.- A static or singleton container avoids restarting it per test class.
- Sharing one base class keeps configuration identical so context caching works.
- Databases, caches, brokers, and stub HTTP servers all containerise the same way.
- Real engines let you test migrations and genuine constraint enforcement.
- Rollback isolation is fast but never exercises a commit; truncation does.
- Docker availability and startup time are the costs.
🧪 Practice
- Convert an H2-based repository test to PostgreSQL with Testcontainers.
- Write a test that passes on H2 and fails on PostgreSQL, using a vendor-specific feature.
- Share one container across three test classes and measure the time saved.
- Add a test that proves the Flyway migrations apply to an empty database.
- Containerise a second dependency and use it in an integration test.
- Interview: Why can a test suite passing against H2 be worse than having no integration tests? (Hint: what decision does a green suite lead a team to make?)
Actuator Endpoints and Health Checks
A running application is opaque. Something outside it, a load balancer, an orchestrator, a monitoring system, needs to answer questions the process cannot be asked directly: is it alive, is it ready for traffic, what version is it, why is it slow.
Actuator exposes those answers over HTTP, and it is one dependency.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
| Endpoint | Provides |
|---|---|
/actuator/health | Liveness and readiness status |
/actuator/info | Build and version information |
/actuator/metrics | Metric values, as covered in the next topic |
/actuator/prometheus | Metrics in Prometheus format |
/actuator/env | Every property and its source, as 18.2 described |
/actuator/beans | Every bean in the context |
/actuator/configprops | Bound @ConfigurationProperties values |
/actuator/mappings | Every request mapping, useful for 404 diagnosis |
/actuator/loggers | Read and change log levels at runtime |
/actuator/threaddump | A thread dump, for diagnosing a hang |
/actuator/heapdump | A heap dump, for diagnosing a leak |
Only health and info are exposed by default, which is the right default.
Exposing everything publicly leaks configuration, bean names, and dependency
versions.
management:
endpoints:
web:
exposure:
include: health,info,prometheus # be explicit about what is public
base-path: /internal # move off the predictable path
endpoint:
health:
show-details: when-authorized # never 'always' on a public endpoint
server:
port: 9090 # a separate port, not routed publicly
Running actuator on a separate port is the strongest arrangement: the management port is reachable from the cluster and the monitoring system, never from the internet.
Health indicators compose. Boot contributes one per detected dependency, and the overall status is the worst of them.
{
"status": "UP",
"components": {
"db": { "status": "UP", "details": { "database": "PostgreSQL" } },
"diskSpace": { "status": "UP", "details": { "free": 21474836480 } },
"redis": { "status": "UP" }
}
}
A custom indicator covers a dependency Boot does not know about.
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
private final PaymentGateway gateway;
@Override
public Health health() {
try {
var response = gateway.ping(); // must be fast and cheap
return Health.up()
.withDetail("latencyMs", response.latencyMillis())
.build();
} catch (Exception e) {
// Degraded, not down: the application still serves reads.
return Health.status("DEGRADED")
.withDetail("error", e.getMessage())
.build();
}
}
}
Two cautions. A health check is called frequently, so it must be cheap; a check that runs a real query every second becomes load. And a dependency being down does not always mean the application is down, which is why the degraded distinction matters.
Liveness and readiness are different questions, and conflating them causes restart loops.
Liveness "is this process broken beyond recovery?"
false -> the orchestrator KILLS and restarts the container
Readiness "can this instance serve traffic right now?"
false -> the orchestrator stops ROUTING to it, but leaves it running
A database outage should fail readiness, not liveness. Failing liveness restarts every instance, which does not fix the database and removes the application as well.
management:
endpoint:
health:
probes:
enabled: true # adds /health/liveness and /health/readiness
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
# Kubernetes probes, pointed at the right endpoint each.
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 9090 }
initialDelaySeconds: 30
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 9090 }
periodSeconds: 5
/actuator/loggers is the operational endpoint worth knowing, because it
changes log levels on a running instance without a restart.
# Turn on debug logging for one package during an incident, then turn it off.
curl -X POST localhost:9090/actuator/loggers/com.shop.payment \
-H 'Content-Type: application/json' \
-d '{"configuredLevel":"DEBUG"}'
Build information in /actuator/info answers "which version is deployed",
which is the first question in most incidents.
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution><goals><goal>build-info</goal></goals></execution>
</executions>
</plugin>
Secure the endpoints with 18.4's filter chain if they share the application port.
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers("/actuator/**").hasRole("OPS")
Key Takeaways
- Actuator exposes operational state over HTTP from one dependency.
- Only health and info are exposed by default; expose others deliberately.
- Run the management endpoints on a separate port where possible.
- Health is composed from indicators, and the worst status wins.
- Custom indicators must be cheap, since they are polled frequently.
- Liveness failure restarts the process; readiness failure stops routing to it.
- A dependency outage belongs in readiness, not liveness.
- The loggers endpoint changes log levels without a restart.
- Build info answers which version is deployed, the first incident question.
🧪 Practice
- Enable actuator and list which endpoints are exposed by default.
- Write a custom health indicator that reports degraded when a dependency is slow.
- Configure liveness and readiness probes separately and explain what each failure causes.
- Change a package's log level at runtime through the loggers endpoint.
- Move actuator to a separate port and secure the remaining endpoints.
- Interview: Why should a database outage fail readiness rather than liveness? (Hint: what does the orchestrator do in each case, and does it help?)
Logging, Metrics, and Tracing
When a request is slow or fails in production, you cannot attach a debugger. Three kinds of signal answer three different questions, and a system needs all three.
Metrics "is something wrong, and how bad?" aggregated numbers over time
Logs "what happened in this specific case?" discrete events with detail
Traces "where did the time go, across services?" one request end to end
Logging is configured by Boot with Logback and a readable console pattern. Two changes matter for production.
Structured output, because a log line that a machine can parse is queryable.
<!-- logback-spring.xml: JSON in production, human-readable locally -->
<configuration>
<springProfile name="!prod">
<include resource="org/springframework/boot/logging/logback/base.xml"/>
</springProfile>
<springProfile name="prod">
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
</encoder>
</appender>
<root level="INFO"><appender-ref ref="JSON"/></root>
</springProfile>
</configuration>
Correlation, so every line from one request can be found together. This is 17.4's filter, and modern Boot adds the identifiers automatically when tracing is present.
{ "timestamp":"2026-09-26T10:00:00Z", "level":"ERROR",
"logger":"com.shop.OrderService", "message":"payment declined",
"traceId":"b4f2a1c8e9d34567", "spanId":"1a2b3c4d", "orderId":42 }
Three logging rules worth stating plainly:
- Never log secrets or personal data. Logs are copied, shipped, and retained far more widely than a database.
- Log at the right level.
ERRORmeans someone should look; a validation failure isDEBUGat most, or the signal is drowned. - Include context, not prose.
orderId=42 status=DECLINEDis greppable; "something went wrong with the order" is not.
Metrics are aggregated numbers, and Boot uses Micrometer, a facade over monitoring systems in the same way SLF4J is a facade over logging backends.
@Service
public class OrderService {
private final Counter placed;
private final Timer placeTimer;
public OrderService(MeterRegistry registry) {
this.placed = Counter.builder("orders.placed")
.description("Orders successfully placed")
.tag("service", "shop") // tags become query dimensions
.register(registry);
this.placeTimer = registry.timer("orders.place.duration");
}
public Order place(PlaceOrderCommand command) {
return placeTimer.record(() -> { // records count and latency
Order order = doPlace(command);
placed.increment();
return order;
});
}
}
| Meter type | Measures | Example |
|---|---|---|
| Counter | A monotonically increasing count | Orders placed |
| Gauge | A value that goes up and down | Queue depth, pool usage |
| Timer | Count and duration distribution | Request latency |
| Distribution summary | A distribution of values | Payload sizes |
Boot already publishes HTTP request timings, connection pool usage, JVM memory and garbage collection, and thread counts, so a lot is available before you add anything.
Cardinality is the trap. Every distinct combination of tag values creates a separate time series, so tagging with a user id or an order id can generate millions and overwhelm the monitoring system.
// WRONG: unbounded cardinality.
registry.counter("orders.placed", "orderId", String.valueOf(order.id()));
// RIGHT: a bounded set of values.
registry.counter("orders.placed", "channel", order.channel().name());
The four signals worth alerting on, for any request-serving service:
| Signal | Question |
|---|---|
| Latency | How long do requests take? |
| Traffic | How many requests are arriving? |
| Errors | What proportion fail? |
| Saturation | How close to a limit is it? |
Tracing follows one request across service boundaries, which logs and metrics cannot do.
trace b4f2a1c8
|
+-- api-gateway [=========================] 340ms
+-- order-service [==================] 280ms
+-- postgres [===] 40ms
+-- payment [============] 210ms <-- the time is here
+-- bank [==========] 190ms
Micrometer Tracing propagates the context automatically, and Boot instruments web requests and clients.
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
management:
tracing:
sampling:
probability: 0.1 # 10% of requests; 100% is expensive at volume
@Observed(name = "order.place") // one annotation produces a span,
public Order place(PlaceOrderCommand command) { ... } // a timer, and a counter
The three signals reinforce each other. A metric alert says error rate rose at 10:04. A trace shows the time is in the payment call. The logs for that trace id show the gateway returned a specific error. Each alone is much less useful.
Key Takeaways
- Metrics detect problems, logs explain individual cases, traces locate time across services.
- Use structured JSON logging in production and human-readable output locally.
- Correlate logs with a trace identifier so one request's lines are findable.
- Never log secrets or personal data, and reserve
ERRORfor actionable failures.- Micrometer is a facade over monitoring backends, as SLF4J is over logging.
- Counters, gauges, timers, and summaries cover the measurement types.
- Tag cardinality must be bounded; identifiers as tags overwhelm the backend.
- Alert on latency, traffic, errors, and saturation.
- Sample traces rather than recording every request at high volume.
🧪 Practice
- Switch to JSON logging under a production profile and confirm both formats work.
- Add a trace identifier to the logging context and correlate several lines from one request.
- Register a counter and a timer, then read both from the metrics endpoint.
- Add a high-cardinality tag deliberately and count the series produced.
- Enable tracing across two services and follow one request through both.
- Interview: You have metrics showing a latency spike. What do traces and logs each add that metrics cannot? (Hint: one tells you where, one tells you what.)
Containerizing and Deploying the Application
An executable JAR still depends on a JVM of the right version, on operating system libraries, and on whatever configuration the host happens to have. A container image packages all of it, so the artifact that was tested is the artifact that runs.
JAR deployment Container image
-------------- ---------------
install the right JDK on the host the JDK is in the image
host libraries must match libraries are in the image
configuration lives on the host configuration is injected at run time
"works on my machine" is possible the image is byte-identical everywhere
A naive Dockerfile works and is wasteful.
FROM eclipse-temurin:21-jre-alpine
COPY target/shop.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
The problem is layering. The JAR is one layer of perhaps sixty megabytes, and changing one line of code replaces all of it, so every deployment pushes and pulls the whole thing.
A layered build uses 18.1's layertools so that dependencies, which rarely change, are cached separately from application code, which changes constantly.
# Stage 1: build and split the jar into layers.
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
COPY . .
RUN ./mvnw -q package -DskipTests
RUN java -Djarmode=layertools -jar target/shop.jar extract
# Stage 2: assemble the runtime image, most stable layer first.
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# Never run as root: a container escape then starts from an unprivileged user.
RUN addgroup -S app && adduser -S app -G app
USER app
COPY --from=builder /build/dependencies/ ./
COPY --from=builder /build/spring-boot-loader/ ./
COPY --from=builder /build/snapshot-dependencies/ ./
COPY --from=builder /build/application/ ./ # the only layer that usually changes
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Or skip the Dockerfile entirely. Boot builds an optimised image with buildpacks, already layered and running as a non-root user.
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=shop:1.0
JVM settings inside a container are the detail that catches people. Modern JVMs are container-aware and read the cgroup limit, but the default heap fraction is conservative.
ENV JAVA_TOOL_OPTIONS="\
-XX:MaxRAMPercentage=75.0 \
-XX:+UseG1GC \
-XX:+ExitOnOutOfMemoryError"
MaxRAMPercentage rather than a fixed -Xmx lets the same image run correctly
under different memory limits. ExitOnOutOfMemoryError matters because a JVM
thrashing after an out-of-memory error is worse than a restarted one: the
orchestrator can replace a dead container but cannot fix a sick one.
Set the container memory limit above the heap, because the JVM also uses metaspace, thread stacks, and direct buffers. A container limited to exactly the heap size is killed by the operating system.
A deployment manifest ties together the topics of this chapter.
spec:
containers:
- name: shop
image: registry.example.com/shop:1.4.2 # never 'latest' in production
ports: [{ containerPort: 8080 }, { containerPort: 9090 }]
env:
- name: SPRING_PROFILES_ACTIVE # 18.2
value: prod
- name: SPRING_DATASOURCE_PASSWORD # 18.2: never in the image
valueFrom:
secretKeyRef: { name: shop-db, key: password }
resources:
requests: { memory: "512Mi", cpu: "250m" }
limits: { memory: "1Gi", cpu: "1000m" }
livenessProbe: # 18.5
httpGet: { path: /actuator/health/liveness, port: 9090 }
initialDelaySeconds: 30
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 9090 }
periodSeconds: 5
securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
Graceful shutdown must be configured for rolling deploys to be seamless, as 18.1 introduced. Without it, terminating a pod drops its in-flight requests.
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
The orchestrator's termination grace period must exceed that timeout, or it kills the process mid-drain.
Startup time matters when scaling to meet load. Two options exist when it becomes a constraint.
| Approach | Startup | Trade-off |
|---|---|---|
| Standard JVM | 2 to 8 seconds | None; the default |
| Class data sharing (CDS) | 30 to 50% faster | A build step, little downside |
| GraalVM native image | Under 100ms | Long builds, reflection needs configuration, no JIT peak performance |
A deployment checklist drawn from this chapter:
[ ] profiles set explicitly, never relying on a default 18.2
[ ] secrets injected at run time, absent from the image 18.2
[ ] actuator on a separate port, endpoints restricted 18.5
[ ] liveness and readiness probes distinguished 18.5
[ ] graceful shutdown enabled, grace period longer 18.1
[ ] structured logging with correlation identifiers 18.5
[ ] connection pool sized deliberately 16.2
[ ] database migrations run and tested 17.3
[ ] error responses disclose no internals 18.3
[ ] image runs as a non-root user with a pinned tag 18.5
Key Takeaways
- A container image packages the JVM, libraries, and application as one artifact.
- Layer the image so dependencies are cached separately from application code.
- Buildpacks produce an optimised, non-root image with no Dockerfile.
- Use
MaxRAMPercentagerather than a fixed heap so one image fits several limits.- Set the container limit above the heap, since the JVM uses memory outside it.
- Exit on out-of-memory so the orchestrator can replace the instance.
- Inject profiles and secrets as environment variables, never bake them in.
- Graceful shutdown plus a longer termination grace period gives seamless rolling deploys.
- Pin image tags, run as non-root, and keep the root filesystem read-only.
🧪 Practice
- Write a layered Dockerfile and compare image push times against a single-layer one after a code change.
- Build the same application with buildpacks and inspect the resulting layers.
- Run the image with two different memory limits and confirm the heap adapts.
- Configure graceful shutdown and verify an in-flight request completes during a rolling restart.
- Deploy with liveness and readiness probes and observe the behaviour when a dependency is stopped.
- Work through the checklist above against a project you have and note what is missing.
- Interview: Why should a container be limited to more memory than the JVM heap? (Hint: name three things the JVM allocates that are not on the heap.)
<a id="19-capstone-and-interview-preparation"></a>