What this post covers
This is the fourteenth post in the "Digging into Spring's Source" series. Part 13 looked at ResolvableType and MergedAnnotations, the tools Spring uses to read types and annotations. This time we follow the most common job those tools are used for: putting a string into an object's property. On the map in Part 1 this is U12 in Phase 3 (foundations), one of the units you "pull out when the main path gets stuck".
When you write port=8080 in a configuration file, the value is the string "8080". But the bean's field is int port. Somebody has to turn the string into a number and call setPort(8080). There are three questions to answer.
- Who does the type conversion? There are two systems,
PropertyEditorandConversionService. Why two, and which one is asked first? - How does
BeanWrapper, which sets a property by name, work? How does it walk a dotted path like"address.city"or an index like"tags[0]"? - Where does the container use these two? Where does it look for the bean named
conversionService, and which route turns@Value("8080")into anint?
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Modules read most | spring-core (core.convert), spring-beans (beans) |
| Classes read most | GenericConversionService, DefaultConversionService, TypeConverterDelegate, PropertyEditorRegistrySupport, AbstractNestablePropertyAccessor, BeanWrapperImpl |
Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.
My study notes go much deeper, line by line. This post keeps only the main thread that someone seeing Spring for the first time can follow. What was left out is named at the end, under "If you want to go deeper".
Words to know first
A property is a value a Java object exposes through a getXxx()/setXxx() pair. If an object has getCity() and setCity(String), we say it has a property called city. This convention is the JavaBeans convention, and finding getters and setters from a name alone is called introspection.
PropertyEditor is an interface from the JDK's java.beans package. It was originally made for GUI tools that turn what the user typed into a value, so you feed it a string with setAsText(String) and take the value out with getValue(). Spring has used it for configuration strings from the very beginning.
ConversionService is Spring's own conversion system, added in Spring 3.0. It converts from any type to any type, not just from strings, and each conversion rule is registered as a small interface called Converter.
A TypeDescriptor holds "exactly what the target is". List.class alone says nothing about the element type, but the TypeDescriptor of a List<Integer> field knows the elements are Integer and even carries the annotations on that field. Internally it uses ResolvableType from Part 13.
BeanWrapper is Spring's interface for wrapping one object and reading or writing its properties by name. Call setPropertyValue("port", "8080") and it does the conversion and the setPort() call for you. The implementation is BeanWrapperImpl.
PropertyValues is a list of "this value for this property" pairs. The BeanDefinition from Part 3 holds configuration values in this form, and populateBean() from Part 4 pushes them into the bean.
1. Why there are two conversion systems
Spring has two routes from a string to a value. One did not replace the other: both are alive, and one method asks them in order. Let us start with how that came to be.
Left: the old system. Right: the one added in 3.0. The blue box is where the two meet.
PropertyEditor — what the JDK gave us
PropertyEditor is not Spring's; it lives in the JDK's java.beans. You can see that in the import of the PropertyEditorRegistry interface (PropertyEditorRegistry line 19). Using it takes two steps: setAsText("8080") makes the editor parse the string and keep the number inside, and getValue() takes it out. So an editor holds state, and it can only convert starting from a string.
Spring wrote a lot of these editors itself. The beans.propertyeditors package has 28 classes such as CustomNumberEditor, CustomBooleanEditor, ClassEditor and URLEditor (29 files minus package-info.java). PropertyEditorRegistrySupport.createDefaultEditors() decides which of them is attached to which type.
private Map<Class<?>, PropertyEditor> createDefaultEditors() {
Map<Class<?>, PropertyEditor> defaultEditors = new HashMap<>(64);
// Simple editors, without parameterization capabilities.
// The JDK does not contain a default editor for any of these target types.
defaultEditors.put(Charset.class, new CharsetEditor());
defaultEditors.put(Class.class, new ClassEditor());
// ...
// The JDK does not contain default editors for number wrapper types!
// Override JDK primitive number editors with our own CustomNumberEditor.
defaultEditors.put(byte.class, new CustomNumberEditor(Byte.class, false));
defaultEditors.put(Byte.class, new CustomNumberEditor(Byte.class, true));
// ...
defaultEditors.put(int.class, new CustomNumberEditor(Integer.class, false));
defaultEditors.put(Integer.class, new CustomNumberEditor(Integer.class, true));
// ...
// Only register config value editors if explicitly requested.
if (this.configValueEditorsActive) {
StringArrayPropertyEditor sae = new StringArrayPropertyEditor();
defaultEditors.put(String[].class, sae);
// ...
}
return defaultEditors;
}
There are 47 put calls in this method, but the last 4 inside the if are only added to a registry on which useConfigValueEditors() was called (lines 161–163). So there are 43 default editors, 47 with the configuration-only ones switched on. The one that turns "8080" into an int is right there: CustomNumberEditor(Integer.class, false). The trailing false means an empty string is not allowed.
On top of the default editors you can add custom editors: registerCustomEditor(type, editor) for a type, or registerCustomEditor(type, "property path", editor) for one particular property (PropertyEditorRegistry lines 44 and 69). When looking one up, the path-specific editor is checked first, then the type-specific one (PropertyEditorRegistrySupport lines 338–362).
ConversionService — Spring's own system, since 3.0
To fix the two limitations of editors (they hold state and only start from strings), the core.convert package appeared in 3.0. The entry point is the ConversionService interface. It has just two canConvert methods and two convert methods (plus one default method), and its Javadoc says it is "thread-safe" (ConversionService lines 21–29). No state, so many threads can share it.
<T> @Nullable T convert(@Nullable Object source, Class<T> targetType);
// ...
@Nullable Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
spring-core/src/main/java/org/springframework/core/convert/ConversionService.java lines 75–106
A single conversion rule is written as one of four interfaces, all in core.convert.converter.
| Interface | Meaning | When to use it |
|---|---|---|
Converter<S, T> |
One S to one T. A single method, convert(S) (lines 37–46) |
Most of the time. Works as a lambda |
ConverterFactory<S, R> |
From S to every subtype of R. getConverter(Class<T>) builds a converter per target type (lines 33–43) |
String → any Number, String → any Enum |
GenericConverter |
One converter for several type pairs (ConvertiblePair), receiving TypeDescriptors so it can see element types and annotations (lines 49–66) |
Collection, array and map conversion |
ConditionalConverter |
When the type pair is not enough and matches(source, target) decides case by case (lines 43–52) |
Added on top of any of the three above |
ConvertiblePair is a small value object holding "from which type to which type" (lines 72–119). In section 2 it is the key of the map converters are looked up in.
So why do both remain?
Side by side they look like this.
PropertyEditor |
ConversionService |
|
|---|---|---|
| Whose is it | JDK java.beans |
Spring core.convert (since 3.0) |
| Source type | Strings only | Anything |
| State | Yes (setAsText → getValue) |
None (thread-safe) |
| Detail of the target type | One Class |
TypeDescriptor (generic elements, annotations) |
| Registered by default | 43 editors | 52 converters (section 2) |
ConversionService wins on every row, yet Spring did not remove the editors. Too much user code, and Spring's own code for configuration-only types such as Resource, Class and URL, is written as editors. Instead, as the Javadoc of ConfigurableBeanFactory.setConversionService() puts it, a ConversionService can be plugged in "as an alternative to JavaBeans PropertyEditors" (ConfigurableBeanFactory lines 165–168). Section 3 shows where the two meet.
Easy to get wrong: how many default converters are there?
"N default converters" depends on what you count. The core.convert.support package has 54 files, but once you drop the non-converters (GenericConversionService, DefaultConversionService, ConversionUtils, ConvertingPropertyEditorAdapter and so on) and package-info.java, 47 converter classes remain (including one abstract base class). On the other hand, the number of registrations DefaultConversionService.addDefaultConverters() actually makes is 52, plus 2 more only when Kotlin is on the classpath, for 54 (DefaultConversionService lines 89–185, Kotlin branch at lines 181–184). There are more registrations than classes because a single ObjectToStringConverter is registered again and again for pairs such as Number→String, Boolean→String and Locale→String (line 143, line 155, line 164). This post says "52 registrations".
2. How ConversionService finds a converter
The standard implementation of ConversionService is GenericConversionService (line 63), and DefaultConversionService is that class pre-filled with the default converters. Its constructor does one thing: addDefaultConverters(this) (lines 53–55).
public static void addDefaultConverters(ConverterRegistry converterRegistry) {
addScalarConverters(converterRegistry);
addCollectionConverters(converterRegistry);
converterRegistry.addConverter(new ByteBufferConverter((ConversionService) converterRegistry));
converterRegistry.addConverter(new DateToInstantConverter());
// ...
converterRegistry.addConverter(new ObjectToObjectConverter());
converterRegistry.addConverter(new IdToEntityConverter((ConversionService) converterRegistry));
converterRegistry.addConverter(new FallbackObjectToStringConverter());
converterRegistry.addConverter(new ObjectToOptionalConverter((ConversionService) converterRegistry));
converterRegistry.addConverter(new OptionalToObjectConverter((ConversionService) converterRegistry));
}
addScalarConverters registers single-value conversions for numbers, booleans, enums, Locale, UUID and the like (lines 139–185); addCollectionConverters registers conversions between arrays, collections and maps (lines 114–137). "8080" → Integer is handled by the StringToNumberConverterFactory on the first lines (line 142), which calls NumberUtils.parseNumber underneath (StringToNumberConverterFactory line 64).
One convert() call
What convert() does is "find the matching converter and call it".
@Override
public @Nullable Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType) {
Assert.notNull(targetType, "Target type to convert to cannot be null");
if (sourceType == null) {
Assert.isTrue(source == null, "Source must be [null] if source type == [null]");
return handleResult(null, targetType, convertNullSource(null, targetType));
}
if (source != null && !sourceType.getObjectType().isInstance(source)) {
throw new IllegalArgumentException("Source to convert from must be an instance of [" +
sourceType + "]; instead it was a [" + source.getClass().getName() + "]");
}
GenericConverter converter = getConverter(sourceType, targetType);
if (converter != null) {
Object result = ConversionUtils.invokeConverter(converter, source, sourceType, targetType);
return handleResult(sourceType, targetType, result);
}
return handleConverterNotFound(source, sourceType, targetType);
}
The heart is getConverter(), and that is where the cache lives.
Yellow is the cache, blue the search, red the "not found" outcome.
protected @Nullable GenericConverter getConverter(TypeDescriptor sourceType, TypeDescriptor targetType) {
ConverterCacheKey key = new ConverterCacheKey(sourceType, targetType);
GenericConverter converter = this.converterCache.get(key);
if (converter != null) {
return (converter != NO_MATCH ? converter : null);
}
converter = this.converters.find(sourceType, targetType);
if (converter == null) {
converter = getDefaultConverter(sourceType, targetType);
}
if (converter != null) {
this.converterCache.put(key, converter);
return converter;
}
this.converterCache.put(key, NO_MATCH);
return null;
}
- Look in the cache first.
converterCacheis a map keyed by the (source type, target type) pair (line 79). Even "there is none" is cached, as a marker object calledNO_MATCH(line 74). The second time the same pair is asked, the search below is skipped. - On a miss,
Converters.find()searches. It builds the list of the source type's supertypes (superclasses and interfaces) and the list of the target type's supertypes, and tries every pair in order (lines 500–514). Ask forString→Integerand it goes throughString→Integer,String→Number,String→Comparable, … all the way down toObject→Object. That is why oneString→Numberregistration (aConverterFactory) can serve every numeric type without a separateIntegerconverter. - For each pair,
ConvertersForPairis consulted. Converters registered for the same pair are pushed onto a deque withaddFirst(line 617), so the one registered last is checked first (lines 620–636). If nothing is registered for the pair, the conditional converters that said "I will consider any pair" are asked throughmatches(lines 527–532). - Still nothing: the default converter. If the source can simply be assigned to the target type, a do-nothing
NO_OP_CONVERTERis returned (lines 253–255); otherwisenull. Whenconvert()receivesnullit throwsConverterNotFoundException(lines 278–290).
If running the converter throws, ConversionUtils.invokeConverter wraps it in a ConversionFailedException (lines 36–48). Remember that "not found" and "found but failed" are different exceptions; it matters in section 3.
Example: what the default converters can and cannot do
I created a bare DefaultConversionService and tried a few conversions. This example was run with the released Spring Framework 7.0.9 (not with a build of the 7.1.0-SNAPSHOT this series reads).
// An example written for this post (not Spring's code)
DefaultConversionService cs = new DefaultConversionService();
// 1) String → Integer (StringToNumberConverterFactory)
Integer port = cs.convert("8080", Integer.class);
// 2) String → List (StringToCollectionConverter). Without an element type, the strings stay strings
List<?> raw = cs.convert("a, b,c", List.class);
// 3) Give it the element type and the elements are converted too — the TypeDescriptor carries that
TypeDescriptor listOfInteger = TypeDescriptor.collection(List.class, TypeDescriptor.valueOf(Integer.class));
Object numbers = cs.convert("1,2,3", TypeDescriptor.valueOf(String.class), listOfInteger);
// 4) String → ZoneId — no dedicated converter, but ObjectToObjectConverter finds and calls ZoneId.of(String)
ZoneId zone = cs.convert("Asia/Seoul", ZoneId.class);
// 5) String → LocalDate — no valueOf/of/from(String) and no String constructor, so no converter is found
cs.convert("2026-10-10", LocalDate.class); // ConverterNotFoundException
// 6) Register one converter and it works — a Converter<String, LocalDate> is just a lambda
cs.addConverter(String.class, LocalDate.class, LocalDate::parse);
LocalDate date = cs.convert("2026-10-10", LocalDate.class);
The examples print their headings and labels in Korean. In the output blocks of this English version those labels are translated; the values, the order, and anything printed by Spring are the actual output. The output.
① 8080 (Integer)
② [a, b, c] / first element type String
③ [1, 2, 3] / first element type Integer
④ Asia/Seoul (java.time.ZoneRegion)
⑤ ConverterNotFoundException: No converter found capable of converting from type [java.lang.String] to type [java.time.LocalDate]
⑥ 2026-10-10 (LocalDate)
canConvert(String → LocalDate) = true
The difference between ② and ③ is why TypeDescriptor exists. Given only List.class, StringToCollectionConverter splits on commas and adds the strings as they are; given a TypeDescriptor that carries an element type, it asks the ConversionService again for each element (StringToCollectionConverter lines 62–84). When the target is a bean's List<Integer> field, the container builds the TypeDescriptor from the field (TypeDescriptor line 101), so it behaves like ③.
Easy to get wrong
- Why
ZoneIdworks butLocalDatedoes not. Neither has a dedicated converter. The difference is thatObjectToObjectConverterlooks for a fixed set of method names on the target type:valueOf,of,from(plus atoXxx()method on the source, or a constructor taking the source type) (Javadoc lines 42–60, lines 182–197).ZoneId.of(String)exists;LocalDateonly hasparse(CharSequence), which is not on the list. This converter is registered for theObject→Objectpair (lines 82–84), and since the supertype search above ends atObject→Object, it is the last candidate for every pair. - Register a second converter for the same pair and the new one wins, because of
addFirst. If you dislike a default converter, you do not need to remove it; register your own for the same pair. - The cache is cleared on every registration.
addConvertercallsinvalidateCache()(lines 104–107, lines 274–276). That is why ⑥ succeeds even though ⑤ had just cached aNO_MATCH.
3. Which comes first — TypeConverterDelegate.convertIfNecessary
The two systems meet in a single package-private class, TypeConverterDelegate (line 57). BeanWrapperImpl and the SimpleTypeConverter that handles @Value both call this class's convertIfNecessary() underneath. The method is 170 lines long, but all you need is the order.
The numbers are the order in which it asks. Line numbers refer to TypeConverterDelegate.
public <T> @Nullable T convertIfNecessary(@Nullable String propertyName, @Nullable Object oldValue, @Nullable Object newValue,
@Nullable Class<T> requiredType, @Nullable TypeDescriptor typeDescriptor) throws IllegalArgumentException {
// Custom editor for this type?
PropertyEditor editor = this.propertyEditorRegistry.findCustomEditor(requiredType, propertyName);
ConversionFailedException conversionAttemptEx = null;
// No custom editor but custom ConversionService specified?
ConversionService conversionService = this.propertyEditorRegistry.getConversionService();
if (editor == null && conversionService != null && newValue != null && typeDescriptor != null) {
TypeDescriptor sourceTypeDesc = TypeDescriptor.forObject(newValue);
if (conversionService.canConvert(sourceTypeDesc, typeDescriptor)) {
try {
return (T) conversionService.convert(newValue, sourceTypeDesc, typeDescriptor);
}
catch (ConversionFailedException ex) {
// fallback to default conversion logic below
conversionAttemptEx = ex;
}
}
}
Object convertedValue = newValue;
// Value not of required type?
if (editor != null || (requiredType != null && !ClassUtils.isAssignableValue(requiredType, convertedValue))) {
// ...
if (editor == null) {
editor = findDefaultEditor(requiredType);
}
convertedValue = doConvertValue(oldValue, convertedValue, requiredType, editor);
}
// ...
}
spring-beans/src/main/java/org/springframework/beans/TypeConverterDelegate.java lines 114–284
| Step | What | Where |
|---|---|---|
| ① | Is there a custom editor (property path first, then type) | line 118 |
| ② | No custom editor and a ConversionService is set: canConvert → convert. On success, return right here |
lines 123–135 |
| ③ | If the type still does not fit, find a default editor and convert | lines 155–158, lines 341–352 |
| ④ | Still not: the standard rules — array, collection and map elements, a constructor taking String, enum names, Number to Number |
lines 163–239 |
| ⑤ | Nothing fits: throw | lines 241–272 |
In short: custom editor > ConversionService > default editor. The Javadoc of PropertyEditorRegistrySupport.overrideDefaultEditor() says it in one sentence: "A ConversionService will override such a default editor, whereas custom editors usually override the ConversionService" (lines 181–182).
findDefaultEditor in step ③ looks at the 43 editors from section 1 and, if none fits, asks the class loader whether a class named "type name + Editor" exists, as the JavaBeans convention says (BeanUtils.findEditorByConvention lines 552–590). Types that had no such class are remembered in a set so they are never asked again (line 588).
Easy to get wrong
- A failing
ConversionServicedoes not throw right away. When step ② throwsConversionFailedException, it is stored in a variable (conversionAttemptEx) and the editor route is still tried. If an editor succeeds, its result is used and the exception only goes to the debug log (lines 275–281); if the editors fail too, the originalConversionFailedExceptionis thrown (lines 242–244). By contrast, "no converter" (canConvertreturnsfalse) moves straight on to the editors with no exception at all. - A type with a
Stringconstructor gets in without any converter. It is one of the step ④ rules:requiredType.getConstructor(String.class)is looked up and called (lines 203–208).Tag("sale")in the example of section 6 takes this route. It is skipped, though, when the target is an interface or an enum, or when step ② already failed (conversionAttemptEx != null). - What leaves the method is rewrapped into two kinds of exception. The method throws
IllegalStateException("no matching editors or conversion strategy found", lines 269–270) orIllegalArgumentException, and the caller,TypeConverterSupport, turns "there is no way" (ConverterNotFoundException,IllegalStateException) intoConversionNotSupportedExceptionand "there is a way but the value is wrong" (ConversionException,IllegalArgumentException) intoTypeMismatchException(lines 70–84).BeanWrapperImplapplies the same rule (AbstractNestablePropertyAccessorlines 566–584). Telling the two apart in an error message gets you to the cause faster.
4. BeanWrapper — setting a property by name
Deciding where the converted value goes is BeanWrapper's job. The interfaces are layered, so here is a map first.
| Interface / class | What it does | Where |
|---|---|---|
PropertyAccessor |
Read and write by name: getPropertyValue, setPropertyValue, setPropertyValues, getPropertyType |
lines 37–188 |
ConfigurablePropertyAccessor |
The above plus PropertyEditorRegistry and TypeConverter. setConversionService, setAutoGrowNestedPaths |
line 37, line 61, line 89 |
BeanWrapper |
The above plus access to the wrapped object and its PropertyDescriptors (getter/setter info) |
lines 49–89 |
AbstractNestablePropertyAccessor |
Walking paths, indexes, calling conversion — the real work is here | line 71 |
BeanWrapperImpl |
The abstract class above plus "access through getters and setters" | line 62 |
The important row is ConfigurablePropertyAccessor extending PropertyEditorRegistry and TypeConverter. A BeanWrapper is a property accessor and at the same time an editor registry and a type converter. That is why one BeanWrapperImpl is all the container needs to convert and inject while building a bean. Its constructor switches on the default editors and creates a TypeConverterDelegate (AbstractNestablePropertyAccessor lines 103–108).
How getters and setters are found — CachedIntrospectionResults
Finding setPort from the name "port" is the job of CachedIntrospectionResults. It introspects each class once and keeps the result in a static map; the Javadoc says "Information is cached statically" (lines 51–54). There are two maps: classes whose class loader is safe to cache go into a strongly referenced map (lines 95–96), the rest into a softly referenced one (lines 102–103; the choice is made at lines 145–171).
Given a name, BeanWrapperImpl takes the PropertyDescriptor from that cache and wraps it in a BeanPropertyHandler.
@Override
protected @Nullable PropertyHandler getLocalPropertyHandler(String propertyName) {
PropertyDescriptor pd = getCachedIntrospectionResults().getPropertyDescriptor(propertyName);
return (pd != null ? new BeanPropertyHandler((GenericTypeAwarePropertyDescriptor) pd) : null);
}
spring-beans/src/main/java/org/springframework/beans/BeanWrapperImpl.java lines 192–196
BeanPropertyHandler.setValue is where the setter is finally invoked by reflection (lines 283–287). Its toTypeDescriptor() returns the TypeDescriptor built from the setter's parameter (lines 245–248), and that is what gets passed to the conversion of section 3.
The route for one plain property
A name with no dot and no bracket, such as "port", is handled by processLocalProperty.
private void processLocalProperty(PropertyPath.Segment segment, PropertyValue pv) {
// ...
String name = segment.name();
PropertyHandler ph = getLocalPropertyHandler(name);
if (ph == null || !ph.isWritable()) {
// ...
throw createNotWritablePropertyException(name);
}
Object oldValue = null;
try {
Object originalValue = pv.getValue();
Object valueToApply = originalValue;
if (!Boolean.FALSE.equals(pv.conversionNecessary)) {
if (pv.isConverted()) {
valueToApply = pv.getConvertedValue();
}
else {
// ...
valueToApply = convertForProperty(name, oldValue, originalValue, ph.toTypeDescriptor());
}
pv.getOriginalPropertyValue().conversionNecessary = (valueToApply != originalValue);
}
ph.setValue(valueToApply);
}
// ...
}
Three steps. Find the handler (getter/setter), or throw NotWritablePropertyException. Convert the value to the setter's type with convertForProperty, which calls section 3's convertIfNecessary underneath (lines 586–591). Finally call the setter through ph.setValue. If the PropertyValue already carries a converted value (isConverted), the conversion is skipped; that is an optimization the container uses, shown in section 6.
The three steps of the code above (find the handler → convert → call the setter). The yellow cylinder is the cache built only once per class.
5. "address.city" and "tags[0]" — nested paths
The real power of BeanWrapper is paths. setPropertyValue("address.city", "Seoul") means getAddress().setCity("Seoul"), and "tags[0]" means getTags().set(0, …). The grammar is written in the Javadoc of PropertyPath, a class new in this commit (@since 7.1, line 51).
* PropertyPath := Segment ('.' Segment)*
* Segment := Name Index* -- Name may be empty only if at least one Index follows
* Name := char* excluding '.', '[', ']'
* Index := '[' Key ']'
* Key := QuotedKey | RawKey
spring-beans/src/main/java/org/springframework/beans/PropertyPath.java lines 39–43
Each dot-separated piece is a segment, a record of a name and a list of keys (line 198). "address.city" is two segments, [address] and [city]; "tags[0]" is one segment with the name tags and the key 0.
The yellow box is the cache of child BeanWrapperImpls; the red box is the exception you get when autoGrowNestedPaths is off. Line numbers refer to AbstractNestablePropertyAccessor.
setPropertyValue(String, Object) first resolves the path into "the accessor that owns the last segment" and "the last segment" (lines 216–232, lines 799–805). It peels off one segment at a time, recursively, until only the last one is left (lines 831–839). Going down one level is getNestedPropertyAccessor.
private AbstractNestablePropertyAccessor getNestedPropertyAccessor(PropertyPath.Segment segment) {
// ...
// Get value of bean property.
Object value = getPropertyValue(segment);
if (value == null || (value instanceof Optional<?> optional && optional.isEmpty())) {
if (isAutoGrowNestedPaths()) {
value = setDefaultValue(segment);
}
else {
throw new NullValueInNestedPathException(getRootClass(), this.nestedPath + segment.toCanonicalName());
}
}
// Lookup cached sub-PropertyAccessor, create new one if not found.
AbstractNestablePropertyAccessor nestedPa = nestedAccessors.get(segment);
if (nestedPa == null || nestedPa.getWrappedInstance() != ObjectUtils.unwrapOptional(value)) {
String canonicalName = segment.toCanonicalName();
// ...
nestedPa = newNestedPropertyAccessor(value, this.nestedPath + canonicalName + NESTED_PROPERTY_SEPARATOR);
// Inherit all type-specific PropertyEditors.
copyDefaultEditorsTo(nestedPa);
copyCustomEditorsTo(nestedPa, canonicalName);
nestedAccessors.put(segment, nestedPa);
}
// ...
return nestedPa;
}
Reading it top to bottom:
- Call
getAddress()to read the value. If it isnullthere is nowhere to go. - Two ways out of
null. IfautoGrowNestedPathsis on,setDefaultValuecreates an object with that type's default constructor, pushes it in throughsetAddress, and carries on (lines 887–931; for arrays, collections and maps it creates an empty one). If it is off, you getNullValueInNestedPathException. The default is off (AbstractPropertyAccessorline 45). - Create a child
BeanWrapperImplaround the value and cache it. The child inherits the parent's editors,ConversionServiceandautoGrowsettings (lines 147–154). From here"city"is set inside that child by the plain-property route of section 4.
A segment with keys, such as "tags[0]", goes to processKeyedProperty. It first reads the value of tags without the key (with the same autoGrow rule if that is null, lines 368–393) and then does Array.set for an array, list.set for a List, or map.put for a Map (lines 271–366). Setting a List index beyond its size fills the gap with null and then adds (lines 317–331). The element type comes from the generics through ph.getCollectionType(…), so putting "3" into a List<Integer> converts it to an Integer (lines 309–316).
Example: using BeanWrapperImpl directly
Person has name, int port, Address address (initially null) and List<String> tags (initially null). This example was also run with Spring Framework 7.0.9.
// An example written for this post (not Spring's code)
Person person = new Person();
BeanWrapperImpl bw = new BeanWrapperImpl(person);
bw.setPropertyValue("name", "Kim"); // ① setName("Kim")
bw.setPropertyValue("port", "8080"); // ② String → int (CustomNumberEditor)
try {
bw.setPropertyValue("address.city", "Seoul"); // ③ address is null
}
catch (BeansException ex) { /* print */ }
bw.setAutoGrowNestedPaths(true);
bw.setPropertyValue("address.city", "Seoul"); // ④ creates an Address, sets it, then goes down
bw.setPropertyValue("tags[0]", "spring"); // ⑤ creates the List,
bw.setPropertyValue("tags[2]", "beans"); // fills the gap with null
try { bw.setPropertyValue("port", "eighty"); } // ⑥ a string that cannot be converted
catch (BeansException ex) { /* print */ }
try { bw.setPropertyValue("nmae", "Lee"); } // ⑦ a name that does not exist
catch (BeansException ex) { /* print */ }
The output.
① name = Kim
② port = 8080 (int)
③ NullValueInNestedPathException: Invalid property 'address' of bean class [ex.u12.BeanWrapperExample$Person]: Value of nested property 'address' is null
④ address.city = Seoul
⑤ tags = [spring, null, beans]
⑥ TypeMismatchException: Failed to convert property value of type 'java.lang.String' to required type 'int' for property 'port'; For input string: "eighty"
⑦ NotWritablePropertyException: Invalid property 'nmae' of bean class [ex.u12.BeanWrapperExample$Person]: Bean property 'nmae' is not writable or has an invalid setter method. Did you mean 'name'?
The message in ③, "Value of nested property 'address' is null", is built by the constructor of NullValueInNestedPathException (lines 37–38). The "Did you mean 'name'?" in ⑦ is BeanWrapperImpl looking for similar names while building the exception (lines 204–208). ⑥ is the "there is a way but the value is wrong" exception from section 3.
Easy to get wrong
- The default of
autoGrowNestedPathsdepends on where you are. ABeanWrapperImplyou create yourself has it off (③ above), butDataBinder, which binds web requests to objects, starts with its own field set totrue(DataBinderline 168). That is whyaddress.cityjust works in a form.DataBinderis covered in Part 27. - Growing by index has a limit too:
autoGrowCollectionLimit, whose default isInteger.MAX_VALUE(AbstractPropertyAccessorline 47). Where indexes come from the outside world it is wise to lower it, andDataBinderdoes. - This example ran on 7.0.9.
PropertyPathandInvalidPropertyPathExceptionare@since 7.1, so 7.0.9 does not have them and the path-splitting code inside is different. The exception classes and messages above are 7.0.9's; the line numbers in this post are 7.1.0-SNAPSHOT's. That 7.1 throws the new exception for a malformed path (something like"a[") is something I checked in the source only, not by running it.
6. Where the container uses these two
So far these were tools. The container picks them up in three places.
Top: when the container starts. Bottom left: when a bean is built. Bottom right: when @Value is injected.
① At start-up: find the conversionService bean and plug it in
finishBeanFactoryInitialization, the eleventh step of refresh() seen in Part 7, does this just before building all the singletons.
// Initialize conversion service for this context.
if (beanFactory.containsBean(CONVERSION_SERVICE_BEAN_NAME) &&
beanFactory.isTypeMatch(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class)) {
beanFactory.setConversionService(
beanFactory.getBean(CONVERSION_SERVICE_BEAN_NAME, ConversionService.class));
}
CONVERSION_SERVICE_BEAN_NAME is the string "conversionService" (ConfigurableApplicationContext line 73). It is looked up by name, not by type. Without a bean of that name, the container's conversionService field stays null (AbstractBeanFactory line 136), step ② of section 3 is skipped entirely, and only editors run. In other words, a container with nothing configured converts with the editor system alone.
The editor side is prepared much earlier, in prepareBeanFactory, the third step of refresh(). It registers a ResourceEditorRegistrar (line 734), which overrides the default editors for configuration types such as Resource, File, URL and Class (ResourceEditorRegistrar lines 102–123, lines 129–136). That is how the Resource of Part 12 can arrive through @Value("classpath:…"). Because it registers as a "default editor override" rather than a custom editor, it ranks below the ConversionService in section 3's order.
② When a bean is built: everything rides on one BeanWrapperImpl
What the instantiation step of Part 4 returns is actually not the bean but a BeanWrapper around the bean. The default-constructor case is the shortest.
protected BeanWrapper instantiateBean(String beanName, RootBeanDefinition mbd) {
try {
Object beanInstance = getInstantiationStrategy().instantiate(mbd, beanName, this);
BeanWrapper bw = new BeanWrapperImpl(beanInstance);
initBeanWrapper(bw);
return bw;
}
// ...
}
initBeanWrapper copies the container's settings onto that wrapper. Two lines.
protected void initBeanWrapper(BeanWrapper bw) {
bw.setConversionService(getConversionService());
registerCustomEditors(bw);
}
The ConversionService plugged in at ① goes in on the first line; the second line, registerCustomEditors, switches on the configuration-only editors (useConfigValueEditors, the point where section 1's 43 become 47) and applies registrars such as ResourceEditorRegistrar and the custom editors (lines 1322–1342). Constructor injection goes through the same initBeanWrapper in ConstructorResolver (line 139) and uses that wrapper as the TypeConverter for constructor arguments (line 670).
Next, populateBean calls applyPropertyValues (line 1458). This is where the PropertyValues held by the BeanDefinition of Part 3 go into the real object.
TypeConverter converter = getCustomTypeConverter();
if (converter == null) {
converter = bw;
}
BeanDefinitionValueResolver valueResolver = new BeanDefinitionValueResolver(this, beanName, mbd, converter);
// Create a deep copy, resolving any references for values.
List<PropertyValue> deepCopy = new ArrayList<>(original.size());
boolean resolveNecessary = false;
for (PropertyValue pv : original) {
if (pv.isConverted()) {
deepCopy.add(pv);
}
else {
String propertyName = pv.getName();
Object originalValue = pv.getValue();
// ...
Object resolvedValue = valueResolver.resolveValueIfNecessary(pv, originalValue);
Object convertedValue = resolvedValue;
boolean convertible = isConvertibleProperty(propertyName, bw);
if (convertible) {
convertedValue = convertForProperty(resolvedValue, propertyName, bw, converter);
}
// Possibly store converted value in merged bean definition,
// in order to avoid re-conversion for every created bean instance.
if (resolvedValue == originalValue) {
if (convertible) {
pv.setConvertedValue(convertedValue);
}
deepCopy.add(pv);
}
// ...
}
}
// ...
// Set our (possibly massaged) deep copy.
try {
bw.setPropertyValues(new MutablePropertyValues(deepCopy));
}
// ...
Three steps. converter = bw: unless a converter was set explicitly, the BeanWrapper itself is the converter. resolveValueIfNecessary turns "a reference to another bean" into that bean and a configuration string (TypedStringValue) into a string (BeanDefinitionValueResolver line 131, string branch at lines 220–226). convertForProperty converts that value to the setter's type, taking a shortcut for a BeanWrapperImpl that reads the TypeDescriptor straight from the cached PropertyDescriptor (lines 1777–1788, BeanWrapperImpl lines 181–190). The final bw.setPropertyValues calls the setPropertyValue of sections 4 and 5 once per value (AbstractPropertyAccessor lines 128–133).
③ When @Value is injected: SimpleTypeConverter
@Value("8080") is not handled by populateBean's PropertyValues but by doResolveDependency from Part 5. After resolving the string, it calls getTypeConverter().
TypeConverter converter = (typeConverter != null ? typeConverter : getTypeConverter());
try {
return converter.convertIfNecessary(value, type, descriptor.getTypeDescriptor());
}
@Override
public TypeConverter getTypeConverter() {
TypeConverter customConverter = getCustomTypeConverter();
if (customConverter != null) {
return customConverter;
}
else {
// Build default TypeConverter, registering custom editors.
SimpleTypeConverter typeConverter = new SimpleTypeConverter();
typeConverter.setConversionService(getConversionService());
registerCustomEditors(typeConverter);
return typeConverter;
}
}
SimpleTypeConverter is a BeanWrapperImpl minus the wrapped object. It extends the same TypeConverterSupport (line 33) and receives the ConversionService and custom editors exactly as initBeanWrapper does. So @Value and XML or PropertyValues configuration are converted by the same rules (the order in section 3). descriptor.getTypeDescriptor() passes a TypeDescriptor built from the field, so a List<Integer> field gets its elements converted too.
Example: your own Converter as the conversionService bean
I wrote a Money(long amount, String currency) record and a Converter<String, Money> that turns "1000 KRW" into Money, and registered a DefaultConversionService as a @Bean named conversionService. For comparison I added an int port and a Tag that has a String constructor. Run with Spring Framework 7.0.9.
// An example written for this post (not Spring's code)
@Component
static class Shop {
@Value("1000 KRW") Money price; // needs the Converter
@Value("8080") int port; // a default editor does it
@Value("sale") Tag tag; // the String constructor does it
}
@Configuration
static class ConversionConfig {
@Bean
DefaultConversionService conversionService() { // the method name is the bean name
DefaultConversionService cs = new DefaultConversionService();
cs.addConverter(new StringToMoneyConverter());
return cs;
}
}
Output of a container started with ConversionConfig, and of one started with Shop only.
① price = Money[amount=1000, currency=KRW], port = 8080, tag = Tag(sale)
② UnsatisfiedDependencyException: Error creating bean with name 'moneyConverterExample.Shop': Unsatisfied dependency expressed through field 'price': Failed to convert value of type 'java.lang.String' to required type 'ex.u12.MoneyConverterExample$Money'; Cannot convert value of type 'java.lang.String' to required type 'ex.u12.MoneyConverterExample$Money': no matching editors or conversion strategy found
root cause: IllegalStateException: Cannot convert value of type 'java.lang.String' to required type 'ex.u12.MoneyConverterExample$Money': no matching editors or conversion strategy found
The innermost message in ② is the sentence built at step ⑤ of section 3's table, TypeConverterDelegate lines 256–270. Money has no String constructor and there is no class called MoneyEditor, so all four steps are passed and it ends up here. port and tag would have worked without a converter, but price failed in the same bean, so the whole bean is not created.
The values and the exception message come from the example's actual output above. ①–⑤ inside the boxes are the steps of section 3's table.
Instead of a DefaultConversionService @Bean, you can use ConversionServiceFactoryBean, a FactoryBean that takes a set through setConverters and registers it on a fresh DefaultConversionService in afterPropertiesSet (line 52, lines 65–83). It checks whether each object is a Converter, ConverterFactory or GenericConverter and calls the right addConverter (ConversionServiceFactory lines 49–62). I did not run this one.
Easy to get wrong
- The bean name must be exactly
conversionService. Name it@Bean DefaultConversionService myConverters()and the container never plugs it in;@Valueruns on editors alone, because the code in ① looks it up by name. Spring Boot is known to wire aConversionServicein its own way, separately from this, but that is not something I checked in this repository's source. - Converted values are written back into the blueprint.
pv.setConvertedValue(convertedValue)inapplyPropertyValuesstores the result in thePropertyValueinside the mergedBeanDefinition(lines 1715–1728), so that when the same blueprint builds many objects, as with a prototype bean, the conversion is skipped from the second time on (lines 1669–1674). Thepv.isConverted()branch in section 4 is the reading side. BeanWrapperImplis "almost an internal class". Its Javadoc says so, "for almost all purposes - an internal class", and points outside code toPropertyAccessorFactory.forBeanPropertyAccess(lines 44–47). The examples in this post use it directly to show the behaviour.
If you want to go deeper
A lot was left out to keep the main thread. Names and locations only. The adapter that links the two systems the other way round, making a ConversionService look like a PropertyEditor: ConvertingPropertyEditorAdapter (lines 34–74). ConverterAdapter's matches, which checks generics as well, and matchesFallback, which restores the pre-6.2.3 behaviour (GenericConversionService lines 331–350). How getClassHierarchy treats arrays and enums (lines 541–560). The cache of 18 pre-built TypeDescriptors for common types (lines 56–71). Lazy overriding of default editors through setDefaultEditorRegistrar (since 6.2.3, lines 174–176) and its container-side partner BeanFactoryDefaultEditorRegistrar (AbstractBeanFactory lines 2135–2141). BeanInfoFactory, which swaps the actual introspection implementation, and the default SimpleBeanInfoFactory (CachedIntrospectionResults lines 218–226). Converting Map keys and handling Optional properties (AbstractNestablePropertyAccessor lines 343–366). DirectFieldAccessor, which writes fields directly instead of going through getters and setters. BeanUtils.copyProperties and instantiateClass (line 735, line 131). And DataBinder, which binds web requests to objects and validates them using exactly the converters of this post, is covered in Part 27.
Summary
| Concept | One line |
|---|---|
PropertyEditor |
The JDK's java.beans string-to-value converter. Stateful and strings only. Spring ships 43 default editors |
ConversionService |
Spring's conversion system since 3.0. Stateless, any type pair. DefaultConversionService registers 52 |
The four Converter interfaces |
Converter (1:1), ConverterFactory (1 to every subtype), GenericConverter (many pairs, TypeDescriptor), ConditionalConverter (decides case by case) |
GenericConversionService.getConverter |
Cache → every supertype pair → conditional converters → NO_OP if assignable. Even NO_MATCH is cached |
TypeDescriptor |
A description of the target including element types and annotations. What tells List and List<Integer> apart |
Order in convertIfNecessary |
Custom editor → ConversionService → default editor → standard rules (String constructor and so on) → exception |
BeanWrapper |
An accessor that reads and writes properties by name, and at the same time an editor registry and a type converter |
CachedIntrospectionResults |
Introspects each class once and keeps it in a static map |
| Nested paths | A child BeanWrapperImpl per segment, going down. A null in the middle is created or thrown on, depending on autoGrowNestedPaths |
| Container wiring | finishBeanFactoryInitialization plugs in the bean named conversionService; each bean's BeanWrapperImpl and each @Value's SimpleTypeConverter receive the same settings |
What to read next
In this post every configuration value was "the string as written". But a value wrapped in #{…}, such as @Value("#{systemProperties['user.home']}"), is not a string: it is an expression to be evaluated. In doResolveDependency from section 6 ③, right before convertIfNecessary, there was evaluateBeanDefinitionString (line 1682); that is where the expression is evaluated, and the tool that evaluates it is SpEL. The next post, Part 15: SpEL — How an Expression Is Parsed and Evaluated, follows how that expression becomes tokens, then a tree, then a value, and how that value passes through this post's TypeConverter once more.
- Part 1: Reading Source Code with AI, and the Analysis Map
- Part 2: The Big Map — How Modules and Packages Are Divided
- Part 3: The BeanFactory Hierarchy and BeanDefinition
- Part 4: How a Bean Gets Created — the Creation Flow and the Three-Level Cache
- Part 5: How @Autowired Picks a Bean — Dependency Injection Resolution
- Part 7: The Twelve Steps of refresh() — the Order the Container Starts In
- Part 13: ResolvableType and MergedAnnotations — How Types and Annotations Are Read