Configuration & Profiles in Boot 4
The same VehicleValuationService code should point at a sandbox Black Book endpoint in dev and the real one in prod — without a single line of code changing.
Every piece of wiring covered so far assumed the classes involved already knew their own configuration — BlackBookClient just had a baseUrl, VehicleRepository just had a database to talk to. This lesson is about where that configuration actually comes from, and why the answer matters more than it looks like it should. The wrong answer, which shows up constantly in real codebases under deadline pressure, is hardcoding: a provider API key typed directly into BlackBookClient's source, a Postgres connection string baked into application code, a Redis host name that's fine on a laptop and wrong everywhere else. Every one of those is a production incident waiting for the day someone forgets it's there, or the day the value needs to change and the only way to change it is a redeploy — or the day that hardcoded API key gets committed to a public repository and needs to be rotated under pressure.
Spring Boot's answer is externalized configuration: values live in application.yml (or environment variables, or a secrets manager, all of which Spring merges together by a well-defined precedence order) and get bound into your code rather than typed into it. The simplest binding mechanism is @Value("\${providers.black-book.base-url}") on a single field or constructor parameter, and you've already seen it used that way for the @Bean factory method in lesson 2. It works, but it scales poorly: as the number of configured values grows, @Value annotations get scattered across every class that needs one, there's no single place to see what configuration the app expects, and a typo in the property key just silently produces null (or a startup failure with a stack trace that doesn't point at the typo directly) rather than a compile error.
@ConfigurationProperties is the better default for anything beyond one or two stray values: a single data class, typically named something like ProviderProperties, whose fields mirror a nested block of YAML, bound and validated once at startup. For our three providers you'd model a ProvidersProperties class with a nested property per vendor -- blackBook, carfax, vis -- each carrying its own baseUrl, apiKey, and timeout. Spring Boot 4's constructor-binding support means this class can be an ordinary Kotlin data class with a val per property and no setters, no default no-arg constructor, and no reflection-unfriendly mutable state -- the same immutability discipline as constructor-injected beans, applied to configuration.
Profiles are how the same YAML structure produces different values in different environments. application.yml holds settings common to every environment; application-dev.yml, application-test.yml, and application-prod.yml hold the overrides specific to each, and Spring Boot picks which extra file to layer on top based on the active profile (set via the spring.profiles.active property, an environment variable, or a launch flag). In this course's scenario, dev might point providers.black-book.base-url at a vendor-provided sandbox that returns canned responses and costs nothing to call repeatedly; test might point it at a WireMock server so integration tests are hermetic and fast; prod points it at the real Black Book endpoint with a real, billable API key. VehicleValuationService and BlackBookClient never see any of this branching -- they're handed a ProvidersProperties instance and use whatever values it holds, unaware that the value came from a different file depending on which profile activated.
The same pattern applies to the polyglot persistence in this scenario, and it's exactly where the incident-waiting-to-happen becomes concrete. The Postgres connection string, the Redis host and port, and the MongoDB URI all differ between a developer's laptop, a CI pipeline running Testcontainers, and the production cluster -- and all of them belong in profile-specific YAML (or, for genuine secrets like passwords and API keys, in environment variables or a secrets manager that YAML merely references via a placeholder like ${DB_PASSWORD}), never typed as a literal in a .kt file. A hardcoded prod database URL that quietly also gets used when someone runs the app locally against test data is exactly the kind of mistake that looks fine in every pull request and then deletes real rows on someone's first local run.
Boot 4 sharpens all of this without changing the mental model: configuration metadata processing has moved further into the AOT (Ahead-of-Time) pipeline, meaning tools like your IDE's autocomplete for property keys in application.yml, and the validation that a @ConfigurationProperties class's bound values are well-formed, can happen with less runtime reflection than in previous versions -- part of the same startup-time and native-image push discussed in lesson 2. From your side of the fence, you still write a data class annotated @ConfigurationProperties(prefix = "providers"), still enable it with @EnableConfigurationProperties or component scanning, and still author one YAML file per profile; Boot 4 just gets you there faster and validates it earlier.
The through-line across all five lessons in this module is the same idea seen from five angles: keep each concern in exactly one place, and let the pieces meet only at clean, explicit boundaries. Layering keeps HTTP, business logic, and data access from tangling. Constructor injection keeps a class's dependencies visible and swappable. Suspend keeps I/O from silently blocking a shared resource. The valuation walkthrough showed all three cooperating on one request. And externalized, profiled configuration keeps environment-specific values out of the code that shouldn't need to know about them. Module 2 picks up from here and goes deep on the HTTP client underneath BlackBookClient, CarfaxClient, and VisClient.
// DO NOT DO THIS.class BlackBookClientBad {private val baseUrl = "https://api.blackbook.com/v2"private val apiKey = "sk_live_4f9a2b7c1e8d" // a real key, committed to git history foreverprivate val dbUrl = "jdbc:postgresql://prod-db.internal:5432/valuations"}
The anti-pattern this lesson exists to prevent: secrets and environment-specific values typed directly into source. Requires nothing to run, but is shown only as a counter-example, so it does not run in-browser.
@ConfigurationProperties(prefix = "providers")data class ProvidersProperties(val blackBook: ProviderConfig,val carfax: ProviderConfig,val vis: ProviderConfig,) {data class ProviderConfig(val baseUrl: String,val apiKey: String,val timeoutMs: Long = 5_000,)}@Configuration@EnableConfigurationProperties(ProvidersProperties::class)class ProviderConfigConfiguration
A typed, immutable @ConfigurationProperties class bound from YAML, replacing scattered @Value fields. Requires Spring Boot's configuration-binding machinery to construct, so it does not run in-browser.
# application.yml (shared across all profiles)providers:black-book:timeout-ms: 5000carfax:timeout-ms: 5000vis:timeout-ms: 5000# application-dev.yml (active when spring.profiles.active=dev)providers:black-book:base-url: https://sandbox.blackbook.example.comapi-key: ${BLACK_BOOK_DEV_KEY}# application-prod.yml (active when spring.profiles.active=prod)providers:black-book:base-url: https://api.blackbook.com/v2api-key: ${BLACK_BOOK_PROD_KEY} # resolved from a secrets manager at deploy time
The matching application.yml layers: shared defaults plus a dev override that points at a sandbox instead of the real vendor. Requires Spring Boot's YAML loading and profile activation, so it does not run in-browser.
🧠 Check your understanding
0/1 · 0/1 answered1. A developer needs the app to call a sandbox VIS endpoint on their laptop but the real VIS endpoint in production, without maintaining two copies of BlackBookClient or VisClient. What is the correct Spring Boot mechanism, and why does it satisfy the requirement without touching provider client code?