1. Limits of built-in conversion
ConversionService has already given us a general mechanism, but the built-in String -> enum works only as long as the value from the config matches the enum constant name. As soon as someone writes sms instead of SMS or leaves extra spaces, the strict rule stops helping. This is where our own Converter comes in: one place where we normalize the input and make the error loud right at startup.
Let us imagine that in contextflow.properties we have a setting:
# The config value comes in as a string and can easily be written in any case
contextflow.notifications.default-channel=sms
And our domain model is honest and strict:
import com.example.contextflow.domain.model.NotificationChannel;
// In code, we want to work with the domain type, not with a string
NotificationChannel channel = NotificationChannel.SMS;
The problem is that Java enums are case-sensitive by default. Code like this looks harmless, but fails because of one wrong case:
// The config contains "sms", but the enum expects exactly "SMS"
String raw = "sms";
NotificationChannel channel = NotificationChannel.valueOf(raw); // IllegalArgumentException
If at this point we start scattering trim().toUpperCase() across services, we are simply bringing string parsing back into the business layer. We need a different approach: services keep working with NotificationChannel, while the normalization and validation rule lives in a separate Converter<String, NotificationChannel> at the configuration boundary.
2. The Converter<S, T> interface
At this stage it is very important not to mix things up: Converter is not “some magical Spring class”, but a pretty down-to-earth idea. It is a small translator: it takes a value of one type and returns a value of another. Nothing more. If your Converter has started writing files, accessing the network, or choosing beans by profile, congratulations, you have accidentally built a mini-monster.
In Spring the interface looks like this, conceptually simplified:
// S is the input type, T is the result type
public interface Converter<S, T> {
// The only responsibility: take S and turn it into T
T convert(S source);
}
The letters S and T are generics, meaning “source type” and “result type”. For our task:
- S will be String because the text comes from properties,
- T will be NotificationChannel because the services need the domain type.
And here is a useful practical thought: a good Converter is usually a pure function. That means that if you convert "sms" twice, you get the same result twice. It should not depend on time, randomness, external services, or the cat's mood.
Another important rule for a beginner: Converter is not the place for “smart guesses”. If the config is wrong, it is better to fail at startup with a clear error than to “quietly choose something by default”. Because “quietly” almost always means “someone will later cry silently in the logs”.
3. StringToNotificationChannelConverter
Now we are going to do exactly what we came for: write a converter that accepts strings like " sms " and "Sms", and returns the honest NotificationChannel.SMS. Important: we are doing this not for beauty, but so that the config is more tolerant of human input while still being strictly validated.
First, let us remind ourselves what our enum looks like. You probably already have it in ContextFlow, but it is useful to see what we are converting:
// Domain type: a list of allowed notification channels
public enum NotificationChannel {
EMAIL, SMS, CONSOLE
}
Now we write the converter. I will show a version that does three things: checks for emptiness, normalizes case, and provides a clear error.
import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.core.convert.converter.Converter;
public class StringToNotificationChannelConverter
implements Converter<String, NotificationChannel> {
@Override
public NotificationChannel convert(String source) {
// An empty/blank value is a configuration error
if (source == null || source.isBlank()) {
throw new IllegalArgumentException("Notification channel is blank");
}
// Normalize human input: trim spaces and convert to uppercase
return NotificationChannel.valueOf(source.trim().toUpperCase());
}
}
Pay attention to two details that affect application quality in a very “grown-up” way.
The first detail is isBlank(). Sometimes an empty value is left in the config by accident: the key exists, but the value was not filled in. For us this is a configuration error, not “well, let it be null”.
The second detail is that we do not create a fallback option. That is, we do not say “if anything goes wrong, use CONSOLE”. This approach hides the problem: the config is wrong, but the application kept running in an unpredictable mode. If this is a learning project, you simply will not notice that something went wrong. If this is production, you will notice, but too late and in a bad mood.
If you want to support aliases like stdout → CONSOLE, that can also be done here, but carefully. For example, like this:
// Example of alias support: this is still about input format, not business logic
String v = source.trim().toUpperCase();
if (v.equals("STDOUT")) v = "CONSOLE";
return NotificationChannel.valueOf(v);
The main thing is not to turn the converter into “business rules”. Aliases are about input format, not about choosing a notification strategy.
4. Registering in ConversionService
The most common beginner scenario is this: “I wrote a converter... and nothing changed.” And that makes sense: Spring is not telepathic. You need to tell it explicitly: “Here is the general conversion mechanism, and here is one more rule for it.”
We will use DefaultConversionService (as in the previous lecture) and add our converter to it. Important: the bean must be named conversionService so that the container picks it up as global infrastructure.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
@Configuration
public class ConversionConfig {
@Bean
public ConversionService conversionService() {
// Global type conversion service for the entire ApplicationContext
DefaultConversionService service = new DefaultConversionService();
// Register the rule: String -> NotificationChannel
service.addConverter(new StringToNotificationChannelConverter());
return service;
}
}
This is where important “container magic” happens, but it is explainable magic. At startup the ApplicationContext will see the bean named conversionService and understand: “Oh, this is the main type translator.” After that, when Spring tries to inject a value from @Value into a constructor parameter of type NotificationChannel, it will use this ConversionService.
If we draw it very schematically, the chain looks like this:
flowchart TD
A["contextflow.properties: 'sms'"] --> B["@Value('${...}') gives String"]
B --> C["ConversionService"]
C --> D["NotificationChannel.SMS"]
D --> E["constructor injection in bean"]
And this is exactly the moment when a “string from a file” becomes a “type in code” before entering the business logic.
5. Using with @Value and NotificationChannel
When the converter is registered, the nicest part is seeing the result in code. We will create a small settings bean (it makes sense to keep such things in the infrastructure layer, for example infrastructure.configsupport) that stores the typed configuration.
import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class NotificationSettings {
private final NotificationChannel defaultChannel;
public NotificationSettings(
// Spring reads the string from properties and converts it to an enum through ConversionService
@Value("${contextflow.notifications.default-channel}") NotificationChannel defaultChannel
) {
this.defaultChannel = defaultChannel;
}
public NotificationChannel getDefaultChannel() {
return defaultChannel;
}
}
Notice that here there is neither trim(), toUpperCase(), nor valueOf(). And that is not because we are “lazy”. It is because we deliberately moved the conversion to where it belongs.
Now you can add a small diagnostic check at startup. For example, in your ScenarioRunner or in a separate runner that you already start from the context:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Check that the typed setting really came up from the config
NotificationSettings s = ctx.getBean(NotificationSettings.class);
// We expect to see SMS, even if the config says "sms"
System.out.println("Default channel = " + s.getDefaultChannel()); // Default channel = SMS
}
If contextflow.properties contains sms, you will see SMS. That is a good sign: it means the converter is really participating in the process.
And now for an important architectural bonus: your service that sends notifications can depend not on a string, but on NotificationChannel. Even if inside you use routing via switch (which is fine in a learning project), it will become clean and type-safe.
6. Placing the converter in the project
It is very tempting to put StringToNotificationChannelConverter next to NotificationChannel “because they are related”. But that is like putting a screwdriver in a bag of pasta: there is a connection (both are in the kitchen), but it is inconvenient to use and the meaning of the layers gets blurred.
NotificationChannel is part of the domain. It is the semantic model: which notification channels exist in the system.
StringToNotificationChannelConverter is part of the Spring infrastructure, because it solves a technical task: how to turn a configuration string into a domain type.
Therefore, in ContextFlow it makes sense to keep the converter in a package like:
- com.example.contextflow.support.conversion (as suggested in the project spec)
And the configuration that assembles the conversion layer in:
- com.example.contextflow.config.core (or close to core wiring)
That gives a readable picture: the domain stays clean, Spring mechanics are isolated, and conversion settings do not “leak” into services.
And one more important point: a converter is part of a global mechanism. If you start creating ten different ConversionService instances all over the application, you will lose predictability. Conversion works one way in one place and differently in another. Usually this ends with the question “why does it work in one bean but fail in another?!” The answer will be simple: “because you built two different worlds for yourself.”
7. Invalid value: fail-fast at startup
Let us deliberately “break” the config. Set, for example:
# Deliberately invalid channel — to see fail-fast when the context starts
contextflow.notifications.default-channel=telepathy
If we had manual parsing in a business method, the application could start normally and fail only when someone first tries to send a notification. That is unpleasant: the error appears late, non-obviously, and sometimes even in a different execution thread (if we ever added asynchronous behavior).
With a converter something different happens: Spring tries to create NotificationSettings, sees @Value(...) NotificationChannel, starts conversion, and our converter throws IllegalArgumentException. As a result, the context will not start.
Yes, it sounds harsh. But in fact this is the behavior an engineer likes: fail-fast. We found the error at the configuration boundary, at startup, before a single business action happened.
It is important to understand that in the stack trace you will see not only IllegalArgumentException, but also Spring “wrappers” — for example, something like “failed to convert value” or “unsatisfied dependency”. Do not panic. This is normal error “packaging”: Spring shows which bean could not be created and why.
The practical advice here is simple: when you see such an error, first look at the bottom for the cause (Caused by), then move upward and see which bean and which field/parameter failed. After a couple of days of practice, you will read such errors almost as quickly as messenger messages (just less pleasantly, but more usefully).
8. Common mistakes when working with converters
Mistake #1: the converter starts making business decisions.
If inside convert() you suddenly choose “which NotificationSender to use” or “which discount strategy to enable”, you have mixed layers. A converter should translate types, not control application behavior. Otherwise you get a strange design where config and business logic are tangled in one method that is called by the container somewhere out there.
Mistake #2: an unknown value becomes a quiet default.
The phrase “well, if it is unclear, let us use CONSOLE” sounds caring, but it is care with a catch. You hide a configuration error, and then wonder why notifications “suddenly stopped arriving on SMS”. Better to fail immediately. A configuration error should be loud, otherwise it will be expensive.
Mistake #3: forgot about spaces and case, and the converter became useless.
If your converter does exactly NotificationChannel.valueOf(source), you gain almost nothing compared with the built-in behavior. The minimum value of a custom converter is usually normalization (trim(), toUpperCase()) or a clear error. Otherwise you just wrote code for the sake of code.
Mistake #4: convert() returns null as a “normal result”.
Technically, a Converter can return null, and sometimes this is used (for example, empty string → null). But for a required setting like default-channel, this is almost always a bad idea: you will get null in the settings bean and then an NPE in an unexpected place. If the setting is required, it is better to throw an exception.
Mistake #5: the converter was registered not in the shared conversionService, but “somewhere locally”.
People often do this: they create new DefaultConversionService() inside one service and add the converter there. It works in that place... but not everywhere else. In the end, you get “magic” of your own making. Keep one shared ConversionService bean and add rules there.
GO TO FULL VERSION