Summary:
After upgrading to Spring Boot 3.4.0, @ConfigurationProperties classes no longer handle empty objects (something: { }) in YAML correctly. In version 3.2.4, this worked as expected, but now the affected field remains null.
Details:
After upgrading to Spring Boot 3.4.0, the following YAML configuration no longer behaves as expected:
props:
directories:
public:
images: {}
The corresponding @ConfigurationProperties class is defined as follows:
@Getter
@Setter
@Configuration
@ConfigurationProperties("props")
public class ServerProperties {
private Directory directories;
// other vars
public void setDirectories(Map<String, Object> structure) {
directories = Directory.buildDirectoryStructure(null, structure, ".");
}
// inner classes and other stuff
}
And here is the Directory class:
@Getter
public class Directory {
private final Directory parent;
private final String path;
private final Map<String, Directory> directories;
public Directory(Directory parent, String path) {
this.parent = parent;
this.path = path;
this.directories = new HashMap<>();
}
public Directory(Directory parent, String path, Map<String, Directory> directories) {
this.parent = parent;
this.path = path;
this.directories = directories;
}
public void put(String key, Directory value) {
directories.put(key, value);
}
public Directory get(String name) {
return directories.get(name);
}
public Directory withoutDotSegment() {
String pathWithoutDotSegment = path.startsWith("./") ? path.substring(2) : path;
return new Directory(parent, pathWithoutDotSegment, directories);
}
@SuppressWarnings({"unchecked"})
public static Directory buildDirectoryStructure(Directory parent, Map<String, Object> structure, String childPath) {
Directory directory = new Directory(parent, childPath);
structure.forEach((name, value) -> {
if (value instanceof Map) {
String path = "%s/%s".formatted(childPath, name);
Directory subDirectory = buildDirectoryStructure(directory, (Map<String, Object>) value, path);
directory.put(name, subDirectory);
}
});
return directory;
}
}
In Spring Boot 3.2.4, the directories field in the ServerProperties class is correctly bound to the structure defined in the application.yaml. However, in version 3.4.0, this field remains null. It seems the Binder no longer interprets empty objects or treats them differently than before. Because of this, it does not even call the setter method.
The previous behavior can be achieved with the following modification:
props:
directories:
public:
images:
eof: # or any key you want
(But this change gives me the following warning: "Cannot resolve configuration property 'props. directories. public. images'", and this makes it "dirty" IMO)
Steps to reproduce:
- Create a Spring Boot 3.4.0 project with the YAML and
@ConfigurationPropertiesclass shown above. - Run the application and inspect the
directoriesfield in theServerPropertiesinstance. - The
directoriesfield will benull. - Repeat the same steps with Spring Boot 3.2.4, where the field is correctly populated as an empty Map.
Environment:
Spring Boot version: 3.4.0 Java version: 17 Operating System: Windows 10
Notes:
This behavior change is not documented in the known changes or is unclear if this is an intentional modification. If this is a planned change, it would be helpful to update the documentation with guidance or workarounds.
Related resources:
I found a commit that I think may have caused this behavior change: https://github.com/spring-projects/spring-boot/commit/be5039041cb776d5ea303e0a08047ea3dedc01f1
Comment From: philwebb
I think this is most likely due to #35403 which is an intentional change. The problem is quite similar to #24133 where we want to support null values, but in this case we want a way to define an empty collection.
@DatApplePy Are you able to work around the problem by adding a default empty Directory property, or checking that it's not null before using it? Another option would be to switch to constructor binding where the constructor would be called with a null map.
Comment From: DatApplePy
@philwebb Could you explain me please what you mean by "adding a default empty Directory property"? In the application.yaml or in the code?
Comment From: philwebb
Sure. I mean perhaps you could have a constructor that sets the private field to a default value. Then if the setter isn't called it wouldn't matter.
Comment From: DatApplePy
@philwebb Unfortunately default value is not an option here. The purpose of this directories property is to use it to create all the necessary folders (if they don't exist) and other components of the system can also use it (and thus know what folders are available).
ServerProperties and Directory classes are in the details of this ticket (updated).
@Component
@RequiredArgsConstructor
public class FileSystemManager {
private final ServerProperties serverProperties;
@EventListener(ContextRefreshedEvent.class)
public void createRequiredDirectoriesOnStart() {
processDirectories(serverProperties.getDirectories());
}
private void processDirectories(Directory directory) {
Map<String, Directory> subDirectories = directory.getDirectories();
if (subDirectories.isEmpty()) {
String pathString = directory.getPath();
try {
createDirectory(Path.of(pathString));
} catch (Exception ex) {
throw new ApplicationContextException(
"Startup error: Directory '%s' could not be created".formatted(pathString), ex);
}
} else {
subDirectories.forEach((key, value) -> {
processDirectories(value);
});
}
}
public void createDirectory(Path directoryPath) throws IOException {
if (Files.notExists(directoryPath)) {
Files.createDirectories(directoryPath);
}
}
// other methods
}
BUT during this conversation the question arose in my mind: Do I really need to solve this problem this way? No. Can it be done easier? Yes. All I wanted to say is that, in my opinion, the issue can be closed. This change helped me realize that I need to change the desing. If you have any tips/ideas, I'm all ears.
Comment From: rsteppac
@philwebb I ran into the same issue, using Kotlin data classes with @ConfigurationProperties; with the old behavior we were able to define all fields as non-null. Now we need to either deal with dummy values in maps or make the fields nullable which requires null handling throughout the code that uses the configuration.
Comment From: philwebb
@rsteppac Can you provide a sample to show what you mean?
Comment From: rsteppac
@philwebb before upgrading to SpringBoot 3.4 this configuration and matching configuration Kotlin data class worked: the map in MyConfig was set to an empty map during context start.
issue-demonstrator:
map-that-used-to-be-empty-but-now-is-null: {}
@ConfigurationProperties("issue-demonstrator")
data class MyConfig(
val mapThatUsedToBeEmptyButNowIsNull: Map<String,String>,
)
After the upgrade the above yields a runtime error at context start:
Error creating bean with name 'issue-demonstrator-com.xxx.MyConfig': Could not bind properties to 'MyConfig' : prefix=issue-demonstrator, ignoreInvalidFields=false, ignoreUnknownFields=true
org.springframework.boot.context.properties.ConfigurationPropertiesBindException: Error creating bean with name 'issue-demonstrator-com.xxx.MyConfig': Could not bind properties to 'MyConfig' : prefix=issue-demonstrator, ignoreInvalidFields=false, ignoreUnknownFields=true
at app//org.springframework.boot.context.properties.ConstructorBound.from(ConstructorBound.java:47)
[..]
Caused by: java.lang.NullPointerException: Parameter specified as non-null is null: method com.xxx.MyConfig.<init>, parameter mapThatUsedToBeEmptyButNowIsNull
at com.xxx.MyConfig.<init>(MyConfig.kt)
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499)
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
at kotlin.reflect.jvm.internal.calls.CallerImpl$Constructor.call(CallerImpl.kt:41)
at kotlin.reflect.jvm.internal.KCallableImpl.callDefaultMethod$kotlin_reflection(KCallableImpl.kt:207)
at kotlin.reflect.jvm.internal.KCallableImpl.callBy(KCallableImpl.kt:112)
at org.springframework.beans.BeanUtils$KotlinDelegate.instantiateClass(BeanUtils.java:940)
at org.springframework.beans.BeanUtils.instantiateClass(BeanUtils.java:190)
... 68 more
To work around this we now have to declare mapThatUsedToBeEmptyButNowIsNull as nullable: Map<String,String>?, which requires null checks in the code that uses the field.
Comment From: wilkinsona
@rsteppac you can use @DefaultValue to indicate that the Map should be initialized in the absence of a property value that would have caused it to be initialized.
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.DefaultValue
@ConfigurationProperties("issue-demonstrator")
data class MyConfig(
@DefaultValue
val mapThatUsedToBeEmptyButNowIsNull: Map<String,String>,
)
Comment From: rsteppac
@wilkinsona , thank you for pointing out the @DefaultValue annotation. I will use that.
I have one itch with using default values though: Unintentionally omitting a config property and explicitly setting it to an empty collection behave the same.
Comment From: gnu9
I ran into the same problem as @rsteppac, and I don't understand why this issue was closed.
It's clearly wrong to interpret an empty dictionary as a null value, so this issue rightfully describes a bug.
I can use @DefaultValue as a workaround, but it's really not assuring to see that Spring Boot fails at correctly parsing empty dictionaries, but this issue just got closed.