namalzi

← All posts

Digging into Spring's Source 14: Type Conversion and BeanWrapper — From a String to an Object's Property

Spring · Published

How the string "8080" from a configuration file ends up in a bean's int field, followed through the source. Why Spring has both PropertyEditor and ConversionService and which one it asks first, how BeanWrapper walks a path like "address.city", and where the container plugs these two in — explained for someone seeing Spring for the first time.

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.

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.

The string from configuration enters TypeConverterDelegate.convertIfNecessary, which asks two systems in order. On the left, the PropertyEditor system from java.beans handles strings only through setAsText and getValue, holds state, and is managed by PropertyEditorRegistrySupport with 43 default editors plus custom editors. On the right, the ConversionService system handles any type pair statelessly through convert, and GenericConversionService manages 52 default converters plus the Converters you register. Either way the result is int port = 8080. 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;
	}

spring-beans/src/main/java/org/springframework/beans/PropertyEditorRegistrySupport.java lines 223–294

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));
	}

spring-core/src/main/java/org/springframework/core/convert/support/DefaultConversionService.java lines 89–105

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);
	}

spring-core/src/main/java/org/springframework/core/convert/support/GenericConversionService.java lines 168–185

The heart is getConverter(), and that is where the cache lives.

GenericConversionService.convert calls getConverter, which first looks in converterCache. On a miss, Converters.find tries every pair of the source type's supertypes and the target type's supertypes in order; for each pair ConvertersForPair.getConverter checks the converters registered for that pair, latest first, and conditional converters decide with matches. A hit is cached and returned; no hit but an assignable source yields NO_OP_CONVERTER; otherwise NO_MATCH is cached and a ConverterNotFoundException is thrown. The converter found is then run. 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;
	}

spring-core/src/main/java/org/springframework/core/convert/support/GenericConversionService.java lines 224–243

  1. Look in the cache first. converterCache is a map keyed by the (source type, target type) pair (line 79). Even "there is none" is cached, as a marker object called NO_MATCH (line 74). The second time the same pair is asked, the search below is skipped.
  2. 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 for String → Integer and it goes through String→Integer, String→Number, String→Comparable, … all the way down to Object→Object. That is why one String→Number registration (a ConverterFactory) can serve every numeric type without a separate Integer converter.
  3. For each pair, ConvertersForPair is consulted. Converters registered for the same pair are pushed onto a deque with addFirst (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 through matches (lines 527–532).
  4. Still nothing: the default converter. If the source can simply be assigned to the target type, a do-nothing NO_OP_CONVERTER is returned (lines 253–255); otherwise null. When convert() receives null it throws ConverterNotFoundException (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

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 order in convertIfNecessary. First it checks for a custom editor and converts with it if there is one. Otherwise, if a ConversionService is set and canConvert is true, it calls ConversionService.convert and is done if that succeeds. On ConversionFailedException it remembers the exception and goes on to find a default editor and convert with that. Then the standard rules apply: a String constructor, an enum name, Number-to-Number conversion. If the result fits the target type it is returned; otherwise an IllegalStateException saying no matching editors or conversion strategy found is thrown. 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

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);
		}
		// ...
	}

spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java lines 395–470

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.

A name with no dot or bracket, as in setPropertyValue("port", value), is handled by processLocalProperty. getLocalPropertyHandler("port") takes the PropertyDescriptor from CachedIntrospectionResults (which introspects each class once and keeps it in a static map) and wraps it in a BeanPropertyHandler. If there is no handler or it is not writable, NotWritablePropertyException is thrown. Otherwise, if pv is already converted (isConverted, section 6's shortcut) the value goes straight through; if not, convertForProperty(…, ph.toTypeDescriptor()) converts it to the setter's parameter type via section 3's convertIfNecessary. Finally ph.setValue(value) calls setPort by reflection 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.

Setting address.city through bw.setPropertyValue: PropertyPath.parse splits it into the segments address and city. getNestedPropertyAccessor calls getAddress to read the value; if it is not null a child BeanWrapperImpl for address is created and cached. If it is null and autoGrowNestedPaths is true, a new Address is created and set through setAddress before the child is made; if false, NullValueInNestedPathException is thrown. In the child, processLocalProperty finds the setter in CachedIntrospectionResults, convertForProperty converts the value, and BeanPropertyHandler.setValue calls setCity. 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;
	}

spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java lines 849–885

Reading it top to bottom:

  1. Call getAddress() to read the value. If it is null there is nowhere to go.
  2. Two ways out of null. If autoGrowNestedPaths is on, setDefaultValue creates an object with that type's default constructor, pushes it in through setAddress, and carries on (lines 887–931; for arrays, collections and maps it creates an empty one). If it is off, you get NullValueInNestedPathException. The default is off (AbstractPropertyAccessor line 45).
  3. Create a child BeanWrapperImpl around the value and cache it. The child inherits the parent's editors, ConversionService and autoGrow settings (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

6. Where the container uses these two

So far these were tools. The container picks them up in three places.

Inside refresh, prepareBeanFactory registers ResourceEditorRegistrar and finishBeanFactoryInitialization looks for the bean named conversionService and plugs it in with beanFactory.setConversionService. When one bean is created, instantiateBean creates a BeanWrapperImpl, initBeanWrapper calls setConversionService and registerCustomEditors, and applyPropertyValues converts the PropertyValues and calls the setters. When injecting @Value, doResolveDependency builds a SimpleTypeConverter through getTypeConverter, with the same ConversionService and editors. 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));
		}

spring-context/src/main/java/org/springframework/context/support/AbstractApplicationContext.java lines 954–959

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;
		}
		// ...
	}

spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractAutowireCapableBeanFactory.java lines 1333–1343

initBeanWrapper copies the container's settings onto that wrapper. Two lines.

	protected void initBeanWrapper(BeanWrapper bw) {
		bw.setConversionService(getConversionService());
		registerCustomEditors(bw);
	}

spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java lines 1309–1312

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));
		}
		// ...

spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractAutowireCapableBeanFactory.java lines 1686–1745

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());
				}

spring-beans/src/main/java/org/springframework/beans/factory/support/DefaultListableBeanFactory.java lines 1684–1687

	@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;
		}
	}

spring-beans/src/main/java/org/springframework/beans/factory/support/AbstractBeanFactory.java lines 939–952

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 example's two runs. ① Started with ConversionConfig, so a conversionService bean exists: all three fields are injected — price = Money[amount=1000, currency=KRW], port = 8080, tag = Tag(sale). ② Started with Shop only, so there is no conversionService bean: converting price's "1000 KRW" to Money follows section 3's order — ① no custom editor, ② no ConversionService, ③ no MoneyEditor, ④ no String constructor — and ends at ⑤ IllegalStateException (no matching editors or conversion strategy found), so the whole Shop bean fails with UnsatisfiedDependencyException. port (③ default editor) and tag (④ String constructor) would have worked without a converter, but they are in the same bean, so they fail with it 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

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.

More posts