📚 Series Navigation:
← Previous: Part 1 - The Blueprint Before the Build
👉 You are here: Part 2 - Spring Boot Alchemy
Next: Part 3 - REST Assured
📋 Introduction
There's a particular kind of magic that happens when you press "Run" on a Spring Boot application. One moment you have a YAML file and some annotated classes. The next, you have a fully running web server with connection pooling, transaction management, caching, security, metrics, and more — all wired together without you writing a single line of plumbing code.
Developers new to Spring Boot often take this for granted. Of course my REST endpoints just work. Of course Hibernate is configured. Obviously my cache is working. But under the hood, there's an intricate dance of auto-configuration, condition evaluation, property binding, and bean lifecycle management that makes it all possible.
Understanding this magic is the difference between a developer who uses Spring Boot and one who masters it. When something goes wrong — and it will — the developer who understands auto-configuration can diagnose the issue in minutes. The one who doesn't will spend hours on Stack Overflow wondering why their beans aren't being created.
In this article, we'll peel back the curtain on the Weather Microservice's configuration. We'll see how a 243-line YAML file, a handful of configuration classes, and a carefully curated pom.xml come together to create a production-ready application. Let's get started. ☕
⚙️ The PROPS Framework: Five Pillars of Spring Boot Configuration
Let me introduce the PROPS framework — five principles that guide how we think about configuring Spring Boot applications:
| Letter | Principle | What It Means |
|---|---|---|
| P | Profile-driven | Different environments get different configurations |
| R | Runtime Binding | Properties bind to typed Java objects at startup |
| O | Overridable Defaults | Sensible defaults that can be overridden via env vars |
| P | Property Precedence | Clear hierarchy from defaults → YAML → env vars → CLI args |
| S | Starter Dependencies | One dependency brings in everything you need for a feature |
Let's explore each one through the lens of the Weather Microservice.
🚀 The Entry Point: Where It All Begins
Every Spring Boot journey starts with a single class:
// WeatherApplication.java
@SpringBootApplication
@EnableCaching
public class WeatherApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(WeatherApplication.class);
Runtime.getRuntime()
.addShutdownHook(
new Thread(
() -> System.out.println("\nShutting down WeatherSpring gracefully...")));
app.run(args);
}
}
That @SpringBootApplication annotation is doing more work than your most productive team member. It's actually three annotations in one:
| Annotation | What It Does |
|---|---|
@SpringBootConfiguration |
Marks this as a Spring configuration class |
@EnableAutoConfiguration |
Triggers Spring Boot's auto-configuration magic |
@ComponentScan |
Scans com.weatherspring and all sub-packages for beans |
The @EnableCaching annotation activates Spring's cache abstraction. Without it, all those @Cacheable annotations on our services would be decorative.
Notice the shutdown hook — it provides a clean console message when the application stops. Small touch, big impact when you're watching logs during a deployment.
📄 The Configuration File: 243 Lines of Power
The application.yml file is the nerve center of the Weather Microservice. Let's walk through it section by section.
Application Identity & Virtual Threads
spring:
application:
name: weather-service
threads:
virtual:
enabled: true
profiles:
active: dev
Three lines. That's all it takes to:
- Name the application — Used in metrics tags, logging, and service discovery
- Enable virtual threads — Handles 10,000+ concurrent requests vs ~200 with platform threads
- Set the default profile —
devfor local development
🔥 Critical Insight: spring.threads.virtual.enabled: true is arguably the single most impactful line in this entire YAML file. It switches Spring Boot's embedded Tomcat to use virtual threads (Project Loom), fundamentally changing the concurrency model. We'll deep-dive into this in Part 9.
MVC & Problem Details
mvc:
throw-exception-if-no-handler-found: true
problemdetails:
enabled: true
Two settings worth understanding:
throw-exception-if-no-handler-found: true— Ensures that requests to unmapped URLs throw aNoHandlerFoundExceptioninstead of silently returning a blank 404.
This lets ourGlobalExceptionHandlercatch it and return a proper RFC 7807 response.problemdetails.enabled: true— Activates Spring's built-in RFC 7807 ProblemDetail support, which standardizes error responses across the entire application.
JPA & Hibernate
jpa:
show-sql: false
hibernate:
ddl-auto: validate
properties:
hibernate:
format_sql: true
dialect: org.hibernate.dialect.H2Dialect
The critical setting here is ddl-auto: validate. This tells Hibernate to check that the database schema matches the entity mappings, but never modify the schema. Schema changes go through Flyway migrations — always.
ddl-auto Value |
What It Does | Safe for Production? |
|---|---|---|
none |
Does nothing | ✅ Yes |
validate |
Checks schema matches entities | ✅ Yes |
update |
Modifies schema to match entities | ❌ Never |
create |
Drops and recreates on every startup | ❌ Never |
create-drop |
Creates on start, drops on stop | ❌ Never |
💡 Pro Tip: Always use validate in development and validate or none in production. Using update in production is how you get mysterious column additions and data loss.
Flyway Database Migrations
flyway:
enabled: true
baseline-on-migrate: true
baseline-version: 0
locations: classpath:db/migration
validate-on-migrate: true
Flyway manages all database schema changes through versioned migration scripts. baseline-on-migrate: true lets Flyway work with existing databases by creating a baseline version. validate-on-migrate: true verifies that migration checksums haven't been tampered with — protecting against accidental modifications to applied migrations.
Datasource with Environment Variable Overrides
datasource:
url: ${DATABASE_URL:jdbc:h2:file:./data/weatherdb;AUTO_SERVER=TRUE}
driver-class-name: org.h2.Driver
username: ${DATABASE_USERNAME:sa}
password: ${DATABASE_PASSWORD:}
This is the Overridable Defaults principle in action. The syntax ${DATABASE_URL:jdbc:h2:file:./data/weatherdb} means:
- Use the
DATABASE_URLenvironment variable if it exists - Fall back to
jdbc:h2:file:./data/weatherdbif it doesn't
For local development, you get an H2 file-based database with zero setup. In production, you set DATABASE_URL to your PostgreSQL connection string. Same code, different environments, no code changes.
Server Configuration
server:
port: 8080
shutdown: graceful
compression:
enabled: true
mime-types: application/json,application/xml,text/html,text/xml,text/plain
min-response-size: 1024
Three important settings:
shutdown: graceful— When the app receives SIGTERM, it stops accepting new requests but finishes processing in-flight requests.
Combined withspring.lifecycle.timeout-per-shutdown-phase: 30s, this gives requests up to 30 seconds to complete.- Response compression — JSON responses larger than 1KB are automatically gzip-compressed, reducing bandwidth by 60-80% for typical API responses.
Management & Actuator
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,circuitbreakers,circuitbreakerevents,shutdown
enabled-by-default: false
endpoint:
health:
enabled: true
show-details: when-authorized
roles: ACTUATOR_ADMIN
prometheus:
enabled: true
shutdown:
enabled: true
The actuator configuration follows the principle of least privilege:
- All endpoints disabled by default (
enabled-by-default: false) - Only specific endpoints are enabled — health, metrics, prometheus, etc.
- Health details require authorization — Only users with
ACTUATOR_ADMINrole see full health details - Shutdown is enabled — But protected by security (Part 7)
This is security-conscious configuration. In production, you don't want random users hitting /actuator/env and seeing your environment variables.
Resilience4j Configuration
resilience4j:
circuitbreaker:
configs:
default:
failureRateThreshold: 50
minimumNumberOfCalls: 5
waitDurationInOpenState: 10s
slidingWindowSize: 10
instances:
weatherApiCurrent:
baseConfig: default
failureRateThreshold: 50
waitDurationInOpenState: 15s
minimumNumberOfCalls: 10
retry:
configs:
default:
maxAttempts: 3
waitDuration: 1s
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
ratelimiter:
instances:
weatherApi:
limitForPeriod: 50
limitRefreshPeriod: 1m
This is a masterclass in hierarchical configuration:
- Default configs define the baseline behavior for all instances
- Named instances override specific settings (e.g., forecast circuit breaker has a longer wait because forecast calls are slower)
baseConfig: defaultinherits from the default template
We'll explore Resilience4j deeply in Part 5, but notice how configuration-driven this approach is. No code changes needed to tune retry counts or failure thresholds.
Weather API & Thread Pool Configuration
weather:
api:
base-url: https://api.weatherapi.com/v1
key: ${WEATHER_API_KEY}
timeout: 5000
cache:
current-weather-ttl: 300
forecast-ttl: 3600
location-ttl: 900
thread-pool:
platform:
core-pool-size: ${PLATFORM_EXECUTOR_CORE_POOL_SIZE:10}
max-pool-size: ${PLATFORM_EXECUTOR_MAX_POOL_SIZE:50}
queue-capacity: ${PLATFORM_EXECUTOR_QUEUE_CAPACITY:500}
await-termination-seconds: ${PLATFORM_EXECUTOR_AWAIT_TERMINATION:30}
Custom configuration properties for the application's own concerns. The thread pool config uses environment variable overrides so production can tune pool sizes without code changes.
🔀 Profile Power: One Codebase, Multiple Environments
The Weather Microservice uses Spring profiles to manage environment-specific configuration. The base application.yml uses H2 and dev-friendly settings. The production profile (application-prod.yml) overrides what needs to change:
# application-prod.yml
spring:
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
jdbc:
batch_size: 20
order_inserts: true
order_updates: true
datasource:
url: ${DATABASE_URL:jdbc:postgresql://localhost:5432/weatherspring}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: ${DB_POOL_SIZE:20}
minimum-idle: ${DB_POOL_MIN_IDLE:5}
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
pool-name: WeatherServicePool
server:
error:
include-stacktrace: never
include-message: never
logging:
level:
root: INFO
com.weatherspring: INFO
org.springframework: WARN
Key differences from dev:
| Aspect | Dev (default) | Production |
|---|---|---|
| Database | H2 (file-based) | PostgreSQL |
| Dialect | H2Dialect | PostgreSQLDialect |
| Connection Pool | Default HikariCP | Tuned with leak detection |
| Stack traces in errors | Shown | never |
| Error messages | Shown | never |
| Logging level | DEBUG for app |
INFO for app, WARN for framework |
| Batch inserts | Disabled | Enabled (batch_size=20) |
🔥 Critical Insight: include-stacktrace: never and include-message: never in production prevent leaking internal details to API consumers. Never expose stack traces in production — they reveal class names, line numbers, and library versions that attackers can exploit.
Activating Profiles
# Local development (default)
./mvnw spring-boot:run
# Production
SPRING_PROFILES_ACTIVE=prod java -jar weather-service.jar
# Docker
docker run -e SPRING_PROFILES_ACTIVE=prod weather-service
# Kubernetes (via Helm values)
env:
- name: SPRING_PROFILES_ACTIVE
value: prod
⚙️ Type-Safe Configuration with @ConfigurationProperties
Rather than scattering @Value annotations throughout the codebase, the Weather Microservice uses type-safe configuration binding. Here's the AsyncConfig class:
@Configuration
@EnableAsync
@ConfigurationProperties(prefix = "thread-pool.platform")
@Validated
@Getter
@Setter
public class AsyncConfig {
@Min(1) @Max(100)
private int corePoolSize = 10;
@Min(1) @Max(500)
private int maxPoolSize = 50;
@Min(0) @Max(10000)
private int queueCapacity = 500;
@Min(1) @Max(300)
private int awaitTerminationSeconds = 30;
@PostConstruct
public void validateConfig() {
if (maxPoolSize < corePoolSize) {
throw new IllegalStateException(
String.format("max-pool-size (%d) must be >= core-pool-size (%d)",
maxPoolSize, corePoolSize));
}
}
}
This binds directly to the YAML configuration:
thread-pool:
platform:
core-pool-size: 10
max-pool-size: 50
queue-capacity: 500
The benefits over @Value:
| Feature | @Value |
@ConfigurationProperties |
|---|---|---|
| Type safety | ❌ String at runtime | ✅ Compiled types |
| Validation | ❌ Manual | ✅ Bean Validation annotations |
| IDE support | ❌ No autocomplete | ✅ Full IDE support |
| Grouped properties | ❌ Scattered | ✅ Logical grouping |
| Default values | ✅ Via ${prop:default} |
✅ Via field initializers |
| Relaxed binding | ❌ Exact match | ✅ core-pool-size = corePoolSize |
The @Validated annotation enables Bean Validation on the config properties. If someone sets core-pool-size: -5 in YAML, the application fails fast at startup with a clear validation error — not at runtime when the first async task runs.
The @PostConstruct validation adds business logic that Bean Validation can't express: maxPoolSize must be >= corePoolSize. This runs at startup, failing immediately with a descriptive error.
📦 Starter Dependencies: The Curated Menu
The Weather Microservice's pom.xml tells a story about what the application does. Each starter dependency brings in a curated set of libraries:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.7</version>
</parent>
<properties>
<java.version>25</java.version>
</properties>
The parent POM manages dependency versions centrally. You declare spring-boot-starter-web without a version, and the parent ensures compatibility with all other Spring Boot dependencies.
The Starter Lineup
| Starter | What It Brings |
|---|---|
spring-boot-starter-web |
Embedded Tomcat, Spring MVC, Jackson JSON |
spring-boot-starter-data-jpa |
Hibernate, Spring Data JPA, HikariCP |
spring-boot-starter-validation |
Hibernate Validator, Jakarta Bean Validation |
spring-boot-starter-cache |
Spring Cache abstraction |
spring-boot-starter-actuator |
Health checks, metrics, monitoring endpoints |
spring-boot-starter-security |
Spring Security, auth infrastructure |
spring-boot-starter-aop |
AspectJ support (needed for Resilience4j) |
Beyond Starters: Specialized Libraries
<!-- Resilience4j: Circuit breaker, retry, rate limiting -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<!-- Caffeine: High-performance cache -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>${caffeine.version}</version>
</dependency>
<!-- Distributed tracing -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<!-- Structured JSON logging -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>${logstash-logback.version}</version>
</dependency>
Each dependency triggers specific auto-configuration:
- Caffeine on classpath → Spring auto-configures
CaffeineCacheManager - Micrometer on classpath → Spring auto-configures metrics collection
- Resilience4j on classpath → Spring auto-configures circuit breaker registry
This is the magic of auto-configuration: presence on the classpath implies intent. Add the jar, get the feature.
🏗️ Configuration Classes: The Custom Wiring
When auto-configuration isn't enough, the Weather Microservice uses explicit @Configuration classes.
RestClient Configuration
@Configuration
public class RestClientConfig {
@Value("${weather.api.timeout:5000}")
private long timeout;
@Value("${weather.api.base-url}")
private String baseUrl;
@Bean
public RestClient weatherRestClient() {
HttpClient httpClient =
HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(timeout))
.executor(Executors.newVirtualThreadPerTaskExecutor())
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofMillis(timeout));
return RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.defaultHeader("Accept", "application/json")
.build();
}
}
This creates a RestClient bean that:
- Uses Java's modern
HttpClient(not Apache HttpClient) - Configures virtual threads for the HTTP executor — HTTP calls don't block platform threads
- Sets connect and read timeouts from configuration
- Sets the base URL so API calls can use relative paths
Async Configuration with Three Executors
@Configuration
@EnableAsync
@ConfigurationProperties(prefix = "thread-pool.platform")
@Validated
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
@Bean(name = "compositeExecutor", destroyMethod = "close")
public ExecutorService compositeExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
@Bean(name = "platformExecutor")
public Executor platformExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix("platform-async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(awaitTerminationSeconds);
executor.initialize();
return executor;
}
}
Three executors for different purposes:
| Executor | Type | Purpose |
|---|---|---|
taskExecutor |
Virtual threads | Default @Async executor |
compositeExecutor |
Virtual threads | Parallel weather data fetching |
platformExecutor |
Platform threads | CPU-bound fallback tasks |
The compositeExecutor uses destroyMethod = "close" because virtual thread executor services implement AutoCloseable, not the traditional ExecutorService.shutdown() method.
🔄 Property Precedence: Who Wins?
Spring Boot has a well-defined property precedence order. For the Weather Microservice, the practical hierarchy is:
1. Command-line arguments (highest priority)
java -jar app.jar --server.port=9090
2. Environment variables
SERVER_PORT=9090
3. application-{profile}.yml
application-prod.yml
4. application.yml
application.yml
5. @ConfigurationProperties defaults
private int corePoolSize = 10; (lowest priority)
This means a Kubernetes environment variable will always override a YAML setting, which will override a default. The Weather Microservice exploits this heavily:
# application.yml - sensible defaults
datasource:
url: ${DATABASE_URL:jdbc:h2:file:./data/weatherdb}
# Kubernetes deployment - env var overrides
env:
- name: DATABASE_URL
value: jdbc:postgresql://db:5432/weather
No code changes. No recompilation. Just environment variables.
🔧 Build Configuration: The Maven Setup
The pom.xml isn't just a dependency list — it's a quality gate:
Compiler Configuration
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>25</release>
<compilerArgs>
<arg>-parameters</arg>
</compilerArgs>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
The -parameters flag is critical — it preserves method parameter names in bytecode, which Spring uses for:
@PathVariableand@RequestParamautomatic name matching- Constructor parameter name resolution for
@ConfigurationProperties - Better error messages in validation failures
Quality Tools
<!-- JaCoCo: 80% line coverage gate -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</plugin>
<!-- Spotless: Code formatting -->
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
</plugin>
<!-- Checkstyle: Style enforcement -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
</plugin>
Every build enforces:
- 80% line coverage — JaCoCo fails the build if coverage drops below 80%
- Consistent formatting — Spotless auto-formats code on build
- Style rules — Checkstyle enforces Google Java Style
The JaCoCo configuration also wisely excludes classes that don't need testing:
<excludes>
<exclude>**/*Dto.class</exclude> <!-- Records - just data -->
<exclude>**/model/**/*.class</exclude> <!-- JPA entities - Lombok -->
<exclude>**/config/**/*.class</exclude> <!-- Config - tested via integration -->
</excludes>
🎯 Auto-Configuration in Action
Here's what happens when the Weather Microservice starts:
- Component scan discovers all
@Component,@Service,@Controller,@Repository,@Configurationclasses - Auto-configuration evaluates 100+
@Conditionalconditions:- H2 on classpath + datasource config →
DataSourceAutoConfiguration - Hibernate + JPA properties →
HibernateJpaAutoConfiguration - Caffeine on classpath +
spring.cache.type=caffeine→CaffeineCacheConfiguration - Spring Security on classpath →
SecurityAutoConfiguration - Actuator + Prometheus on classpath →
PrometheusMetricsExportAutoConfiguration
- H2 on classpath + datasource config →
- Property binding maps YAML to
@ConfigurationPropertiesobjects - Bean validation runs
@Validatedon config classes - Flyway runs migrations before Hibernate validates the schema
- Custom beans from
@Configurationclasses are created and wired
You can see exactly what auto-configuration was applied by running:
java -jar weather-service.jar --debug
This prints the "Conditions Evaluation Report" — which auto-configurations were applied and which were skipped, and why.
✅ Configuration Checklist
- [ ] Single
application.ymlfor base configuration with sensible defaults - [ ] Profile-specific overrides for production (database, logging, security)
- [ ] Environment variable placeholders with fallback defaults:
${VAR:default} - [ ]
@ConfigurationPropertiesfor type-safe, validated configuration binding - [ ]
@Validatedon config classes to fail fast on invalid configuration - [ ]
hibernate.ddl-auto: validate— neverupdatein production - [ ] Graceful shutdown enabled with timeout
- [ ] Actuator endpoints secured and selectively enabled
- [ ] Error details hidden in production (
include-stacktrace: never) - [ ] JaCoCo coverage gates in the build pipeline
- [ ] Code formatting enforced automatically (Spotless/Checkstyle)
🎓 Conclusion: Configuration Is Architecture
Spring Boot's configuration system does a lot of heavy lifting. The key takeaways:
@SpringBootApplicationcombines component scanning, auto-configuration, and configuration source marking in a single annotation- Auto-configuration evaluates classpath presence and property values to wire together hundreds of beans automatically
- The PROPS framework (Profile-driven, Runtime Binding, Overridable defaults, Property precedence, Starter dependencies) guides configuration decisions
- Profile-specific YAML files let you run the same code in dev (H2) and production (PostgreSQL) with zero code changes
@ConfigurationPropertieswith@Validatedprovides type-safe, validated configuration that fails fast at startup- Environment variable overrides (
${VAR:default}) enable twelve-factor app configuration - Starter dependencies curate compatible libraries — add the jar, get the feature
- Build plugins (JaCoCo, Spotless, Checkstyle) enforce quality as part of the build process
Configuration isn't an afterthought — it's architecture. The choices you make in YAML files and configuration classes determine how your service behaves in production, how it fails, and how quickly you can diagnose issues.
Coming Next Week:
Part 3: REST Assured - Designing APIs Developers Actually Want to Use 🌐
📚 Series Progress
✅ Part 1: The Blueprint Before the Build
✅ Part 2: Spring Boot Alchemy ← You just finished this!
⬜ Part 3: REST Assured
⬜ Part 4: The Data Foundation
⬜ Part 5: When the World Breaks
⬜ Part 6: Cache Me If You Can
⬜ Part 7: Guarding the Gates
⬜ Part 8: Fail Gracefully
⬜ Part 9: 10,000 Threads and a Dream
⬜ Part 10: Can You See Me Now?
⬜ Part 11: Trust, But Verify
⬜ Part 12: Ship It
⬜ Part 13: To Production and Beyond
Happy coding, and remember — the best configuration is the one that makes the right thing easy and the wrong thing hard. ☕