1. Goal: CatalogProperties as a boundary
By this point we already have the whole chain assembled piece by piece: @Value everywhere showed the pain of string keys, @ConfigurationProperties gave the model shape, registration turned it into an ordinary bean, and the binder learned how to bind complex fields. Now it is time to put all of that into a living catalog-service.
When you start refactoring configuration, it is easy to accidentally make one place “pretty” and five others ugly. So first let us agree on the target result. We do not just need a class with fields, but a clear architectural boundary: the entire app.catalog.* branch inside the Java code is read through one object. This reduces the number of places where mistakes can happen and makes the service behavior predictable.
Think of @Value as buying groceries one item at a time while running between shelves. Today you quickly found bread, tomorrow butter, the day after tomorrow you forgot that cheese exists at all, because it is in another corner of the store. CatalogProperties is when you take one basket, and it already contains everything related to “our catalog”: the title, behavior flags, limits, and, most importantly, the list of courses as data.
Practically speaking, in catalog-service this means three things.
First, we stop injecting separate values in different places through @Value. Second, we stop dragging Environment around the project as a “universal remote control for everything” (it is useful, but as the main style it also quickly spreads everywhere). Third, we build the data flow so that YAML → binder → CatalogProperties → bootstrap (course loading) → repository/service → controller → JSON.
Below is the diagram of what we want to end up with.
flowchart TD
YAML["application.yaml + catalog-data.yaml app.catalog.*"] --> Binder["Spring Boot Binder @ConfigurationProperties"]
Binder --> Props["CatalogProperties bean type-safe model"]
Props --> Seeder["CourseCatalogSeeder config -> domain mapping"]
Seeder --> Repo["InMemoryCourseCatalogRepository stores CourseCard"]
Repo --> Service["CourseCatalogService filters + limits + flags"]
Service --> Controller["CourseCatalogController thin web layer"]
Controller --> JSON["JSON responses"]
From this moment on, CatalogProperties is not “just another class in the config package”, but part of the application skeleton: like CourseCatalogService or CourseCatalogRepository, only for configuration.
2. The CatalogProperties model without logic
You may want to write the ideal configuration model right away, but in a learning project something else matters more: the class should read like a simple data structure. Today we deliberately choose a transparent POJO style with fields and getter/setter, because it makes it easier to understand exactly how Spring binds values from YAML to Java. At this step, transparency matters more than the strictest possible model variant.
Let us start with the root class. It lives in the config package and describes the whole app.catalog.* branch. Pay attention to two practical things: the course list is initialized as an empty ArrayList so we do not get a NullPointerException, and the field names mirror the meaning of the settings instead of being “just values”.
package com.example.catalogservice.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
// This class is the "boundary" for the entire app.catalog.* branch.
// Spring Boot will create the bean itself and bind values from YAML by prefix name.
@ConfigurationProperties("app.catalog")
public class CatalogProperties {
// Human-readable catalog title (for example, for a header or logs)
private String title;
// If true, the service may "go quiet" or return empty selections
private boolean maintenanceMode;
// Limit on the number of featured courses in the response
private int maxFeaturedCount;
// Default policy: whether to hide unpublished courses
private boolean defaultPublishedOnly;
// Flag: whether to print a startup summary on launch
private boolean startupReportEnabled;
// Important: the default is an empty list, not null (so the code is predictable)
private List<CourseItemProperties> courses = new ArrayList<>();
// getters/setters
}
Yes, there are many fields here, but that is exactly the normal situation: this is the “passport” of our application block. The important thing is that this passport must not turn into a service: no loadCourses() or filterFeatured() inside CatalogProperties. That is all bootstrap/service-layer work, while the properties class stores data only.
For the binder to actually write values, we need getters and setters. Usually there are many of them and they are boring, but sometimes boredom is a sign of stability (like adult life). I will show one example; the rest follow the same pattern.
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
Now let us describe the course list item. This is where the fun begins: we bind string values from YAML to proper types. Track and level are enums from the domain model, the launch date is LocalDate, and the price is a nested MoneyProperties object. This is exactly the case where typed configuration really saves nerves: you do not write manual parsing, and the IDE does not let you confuse “level” with “track”.
package com.example.catalogservice.config;
import java.time.LocalDate;
import com.example.catalogservice.catalog.domain.CourseLevel;
import com.example.catalogservice.catalog.domain.CourseTrack;
public class CourseItemProperties {
// Technical identifier (internal)
private String id;
// Part of the URL / human-readable key
private String slug;
// Course title
private String title;
// Short description (for example, for cards)
private String shortDescription;
// Enum from the domain: the binder will map the string "SPRING" -> CourseTrack.SPRING
private CourseTrack track;
// Enum from the domain: "BASIC" -> CourseLevel.BASIC
private CourseLevel level;
// Duration in days (primitive - it is important to watch the default of 0)
private int durationDays;
// "Featured" flag for selections
private boolean featured;
// Whether the course is published (affects filters and visibility)
private boolean published;
// An ISO date like 2026-04-01 will be bound to LocalDate automatically
private LocalDate launchDate;
// Nested configuration object (amount/currency)
private MoneyProperties price;
// getters/setters
}
And here is the nested price structure. The object is as simple as a stool, and that is great: price is data, not behavior.
package com.example.catalogservice.config;
public class MoneyProperties {
// Amount in minor units (for example, cents) so we do not store double
private long amount;
// Currency as a string (for example, "GBP")
private String currency;
// getters/setters
}
A nice extra detail: we reused the domain enums (CourseTrack, CourseLevel) in the configuration. That is fine, because enums are a stable part of the domain and we do not need to create duplicates in the config package. But today we do not bind CourseCard directly as a domain model, because domain models are often stricter/more immutable, and such binding requires more discipline (and today we keep everything as transparent as possible).
To avoid getting lost in names, it is helpful to compare YAML keys and Java fields once by eye. Spring Boot does relaxed binding, so the key format can be convenient for YAML (kebab-case), while Java stays camelCase.
| YAML key | Java field |
|---|---|
| app.catalog.maintenance-mode | maintenanceMode |
| app.catalog.max-featured-count | maxFeaturedCount |
| app.catalog.default-published-only | defaultPublishedOnly |
| app.catalog.startup-report-enabled | startupReportEnabled |
| app.catalog.courses[0].short-description | shortDescription |
| app.catalog.courses[0].launch-date | launchDate |
3. Registration: @ConfigurationPropertiesScan
We do not need another round of theory about registration methods here. For catalog-service, we will make one working choice: @ConfigurationPropertiesScan on the application’s main class. That is enough for CatalogProperties to get into the ApplicationContext as an ordinary dependency and be injected into services, runners, and bootstrap components without trouble.
package com.example.catalogservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan // Enables searching for @ConfigurationProperties classes in the base package
public class CatalogServiceApplication {
public static void main(String[] args) {
SpringApplication.run(CatalogServiceApplication.class, args);
}
}
This closes the registration question consistently for the whole project. Technically, a properties class can also be made alive via @Component, but as the main style that only mixes component scanning with configuration binding and later easily leads to confusion.
4. YAML and importing catalog-data.yaml
When CatalogProperties appears, YAML stops being “just a set of strings” and becomes configuration with shape. And here it is important to keep the file structure in order: we do not need a giant application.yaml sheet where application settings and catalog data are mixed together. We already know spring.config.import, so we split things neatly: base settings go in application.yaml, and the list of courses goes in catalog-data.yaml.
The import does not do anything fundamentally new here: it simply gives the binder the same configuration layer, but split across two supported files.
Let us start with application.yaml. Here we have the application name, the catalog data import, and behavior flags/limits.
spring:
application:
name: catalog-service
config:
# Connect a separate file with catalog data (the courses list)
import: "classpath:catalog-data.yaml"
app:
catalog:
# Title that can later be used in logs or the startup summary
title: "Spring+ Catalog"
# Service behavior flags
maintenance-mode: false
default-published-only: true
startup-report-enabled: true
# Response limits
max-featured-count: 4
And now catalog-data.yaml, where the course list lives. Here it is important to keep the shape: a course is an element of the courses list, and it has a nested price object. Dates are best written in ISO format YYYY-MM-DD, so that the binder can safely connect it to LocalDate.
app:
catalog:
courses:
# Each list item is a separate course (CourseItemProperties)
- id: "spring-boot"
slug: "spring-boot"
title: "Spring Boot"
short-description: "Boot fundamentals"
track: "SPRING"
level: "BASIC"
duration-days: 28
featured: true
published: true
# ISO date format so it binds cleanly to java.time.LocalDate
launch-date: "2026-04-01"
# Nested "price" object -> MoneyProperties
price:
amount: 19900
currency: "GBP"
Here you can notice a nice effect: YAML becomes self-documenting. Its data structure is visible, and it matches the Java model. If you open CatalogProperties and the YAML side by side, you can literally see how one maps to the other.
One more practical tip: try to keep courses defined in one place. If it starts appearing both in application.yaml and in the imported file, you risk surprises with “who wins by precedence” and why the list is not the one you expected. That is not a Boot bug — that is our configuration design mistake.
5. CourseCatalogSeeder: config → domain
Now the main question: we have CatalogProperties, and inside it a list of CourseItemProperties. But the web layer and the service layer work with the domain model CourseCard, because that is what we expose outward and what we build the catalog logic around. So somewhere we need a “translator” from the config structure to the domain structure. The best place is the bootstrap layer, namely CourseCatalogSeeder.
The seeder does two things: it takes the course list from properties, turns it into a list of CourseCard, and stores it in the in-memory repository. We do not turn the repository into a “magic” component that reads YAML by itself — the repository remains a storage, and the seeder is the data loader at startup.
Let us start with the repository: it needs a method to replace the entire data list. Yes, that is mutation — but it is local, at startup. The main thing is that this method should not be called from controllers “on a whim”.
import java.util.List;
public void replaceAll(List<CourseCard> courses) {
// At startup we replace the data snapshot entirely (after that - read only)
this.courses = List.copyOf(courses);
}
Now the seeder. It will be an ApplicationRunner, because that is a natural “after the context has started, perform an application startup step” point. And we inject dependencies through the constructor: CatalogProperties and the repository.
package com.example.catalogservice.catalog.bootstrap;
import com.example.catalogservice.catalog.domain.CourseCard;
import com.example.catalogservice.catalog.domain.Money;
import com.example.catalogservice.catalog.repository.InMemoryCourseCatalogRepository;
import com.example.catalogservice.config.CatalogProperties;
import com.example.catalogservice.config.CourseItemProperties;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class CourseCatalogSeeder implements ApplicationRunner {
private final CatalogProperties properties;
private final InMemoryCourseCatalogRepository repository;
// Important: dependencies come through the constructor, not through "config lookup"
public CourseCatalogSeeder(CatalogProperties properties, InMemoryCourseCatalogRepository repository) {
this.properties = properties;
this.repository = repository;
}
@Override
public void run(ApplicationArguments args) {
// 1) Take the configuration model (properties)
// 2) Turn it into the domain model (CourseCard)
// 3) Put it into the repository with one atomic "replaceAll"
var courses = properties.getCourses().stream()
.map(this::toDomain) // mapping config -> domain
.toList();
repository.replaceAll(courses);
}
private CourseCard toDomain(CourseItemProperties p) {
// Move the nested price structure into the domain Money
Money price = new Money(p.getPrice().getAmount(), p.getPrice().getCurrency());
// Straightforward assembly of a domain object from configuration
return new CourseCard(
p.getId(),
p.getSlug(),
p.getTitle(),
p.getShortDescription(),
p.getTrack(),
p.getLevel(),
p.getDurationDays(),
p.isFeatured(),
p.isPublished(),
p.getLaunchDate(),
price
);
}
}
Pay attention to an important methodological idea: CatalogProperties and its nested classes are the configuration model, while CourseCard is the domain model. They are similar in fields, but their purpose is different. And as long as we do not introduce a separate DTO layer, the seeder is exactly the place where these two worlds shake hands and then go their separate ways.
6. Service behavior from CatalogProperties
Once the courses are loaded, we get the second benefit of typed configuration: not only the catalog data, but also the service behavior becomes controllable through CatalogProperties fields. This is the moment when the application starts to feel like a real service: you change YAML, the behavior changes, without recompilation, without editing Java code, without “let us just hardcode it quickly”.
Suppose we have a method that returns a featured selection. There are three places where configuration affects the result. If maintenanceMode is enabled, we return nothing at all. If defaultPublishedOnly is enabled, we hide unpublished courses. And we limit the result with maxFeaturedCount.
import java.util.List;
public List<CourseCard> featured() {
// Maintenance mode: the service "goes quiet" and returns no selections
if (properties.isMaintenanceMode()) {
return List.of();
}
return repository.findAll().stream()
.filter(CourseCard::isFeatured) // take featured only
.filter(this::passesPublishedDefault) // apply the default "published only"
.limit(properties.getMaxFeaturedCount()) // limit the output size by config
.toList();
}
And a small helper so the condition reads normally. Here we are not trying to be super clever, just making the code a bit easier to understand.
private boolean passesPublishedDefault(CourseCard course) {
// If defaultPublishedOnly is disabled, let all courses through
// If it is enabled, let only published courses through
return !properties.isDefaultPublishedOnly() || course.isPublished();
}
This gives us very “honest” logic: the service does not know a single string key. It only knows the properties fields, and their meaning is obvious from the names themselves. That is the main advantage of typed configuration: instead of “string magic” you get “readability magic”. Sometimes that is even better.
If you have a common course search method with filters (track/level/limit), you can use the same approach: when the query parameter publishedOnly is not passed, the service substitutes the default value from defaultPublishedOnly. The important thing is that the default lives in configuration, not in controller code, otherwise the controller will slowly start to “get smarter”, and we do not want that.
7. StartupSummaryRunner controlled by a flag
Before moving to typed configuration, startup code often looks like a mixed bag: some @Value here, some Environment there, a bit of hardcoding, and maybe even System.out.println thrown in for good measure. After CatalogProperties appears, the runner becomes clear: it depends on one configuration model and does its job only if the startupReportEnabled flag is enabled.
In a learning project, we can still print to the console so that we see the effect immediately. The main thing is to show that behavior is controlled through properties, not through “because I felt like it today”.
import com.example.catalogservice.config.CatalogProperties;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class StartupSummaryRunner implements ApplicationRunner {
private final CatalogProperties properties;
public StartupSummaryRunner(CatalogProperties properties) {
this.properties = properties;
}
@Override
public void run(ApplicationArguments args) {
// Use a configuration flag to enable/disable the startup report
if (!properties.isStartupReportEnabled()) {
return;
}
// Demo: read the config through a typed object, not through string keys
System.out.println("Catalog: " + properties.getTitle()); // Catalog: Spring+ Catalog
System.out.println("Courses in config: " + properties.getCourses().size()); // Courses in config: 1
}
}
Even in such a small example, you can see why @Value everywhere is irritating: instead of one object with clear fields, you would be holding two or three string keys, and the runner would start “knowing YAML” directly. Now it knows only CatalogProperties and does its job exactly to the extent needed.
One subtle point is worth noting separately: StartupSummaryRunner prints properties.getCourses().size(), but in reality you may want to print the number of courses in the repository (after the seeder runs). Then the order of runner execution matters. If you already know @Order, you can make the seeder run earlier and the summary later. But even without that, the principle remains the same: startup behavior is controlled by typed config.
8. Typical mistakes with @ConfigurationProperties
Mistake #1: add @ConfigurationProperties, but do not register the class as a bean.
The most common cause of null in a runner is not “Spring broke”, but that the class with @ConfigurationProperties("app.catalog") never got into the container at all. Usually people forget @ConfigurationPropertiesScan, @EnableConfigurationProperties, or place the class outside the base scanning area. As a result, the binder simply has nothing to bind values to, and you end up looking for the problem in the wrong place.
Mistake #2: use @Component on a properties class as a “default repair”.
Sometimes it really does create a bean, but the price of such a “repair” is mixing two different registration models. You get a class that is both a component and configuration properties at the same time. On a small project this may pass unnoticed, but later confusion starts: it becomes unclear how the bean is created and why it works at all. It is much calmer and cleaner to stick to the standard path through @ConfigurationPropertiesScan.
Mistake #3: forget that binding depends on ordinary Java accessors.
If a field has no setter, the setter is named incorrectly, or the signature does not match, the binder simply cannot write the value. The most unpleasant part is that some fields may bind while others remain at defaults, and the error gets disguised as “strange application behavior”. Primitives are especially dangerous: int maxFeaturedCount will quietly stay 0, and you will start investigating “why the filter returns nothing”, although the problem is not in the filter at all.
Mistake #4: rely on primitive defaults and fail to notice that configuration did not bind.
0, false, and other default values look “normal” and are therefore especially sneaky. The application does not crash immediately; it just starts behaving differently from what you expected. In a learning project it is better to set such values explicitly in YAML and not leave critical settings at the mercy of language defaults. Then the “did not bind” problem becomes more visible.
Mistake #5: keep List as null by default.
If you write private List<CourseItemProperties> courses; and do not initialize the list, then if the key is missing in YAML you will get null. And later some code will call properties.getCourses().size() and fail far away from the real cause. For configuration it is almost always better to assume the list is empty by default, not absent. An empty List makes behavior more predictable and removes unnecessary null checks.
GO TO FULL VERSION