Spring Map Binding: Something you need to know

2025-03-01

Recently, I came across an interesting behavior in Spring - one that perfectly captures its ✨magic✨ in action. So, I thought it's worth sharing as a fun fact and something to know about.

Real-Life Scenario

As experienced developers, we follow the principles of The Twelve-Factor App and know that configuration should be kept separate from the application code.

Now, let’s say we’re building a multi-tenant application. At some point, we might need a Map to associate certain settings — like time zones — with each tenant. A simplified version of this might look something like this:

package com.tulski.relaxedproperties;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

import java.util.Map;

@SpringBootApplication
@EnableConfigurationProperties(RelaxedPropertiesApplication.AppConfig.class)
public class RelaxedPropertiesApplication {

    private static final Logger log = LoggerFactory.getLogger(RelaxedPropertiesApplication.class);

    public static void main(String[] args) {
        final var context = SpringApplication.run(RelaxedPropertiesApplication.class, args);
        final var appConfig = context.getBean(AppConfig.class);
        log.info("Output config:\n{}", appConfig);
    }

    @ConfigurationProperties(prefix = "app-config")
    record AppConfig(Map<String, String> tenantTimezone) {
    }
}

Config Data Files

The most common way to set values is to use config data files (such as application.properties or application.yaml).

# YAML
app-config:
  tenant-timezone:
    PL: Europe/Warsaw
    DE: Europe/Berlin
# Properties
app-config.tenant-timezone.PL=Europe/Warsaw
app-config.tenant-timezone.DE=Europe/Berlin

Both solutions are very intuitive, so the outcome of running the RelaxedPropertiesApplication should not surprise anyone.

# output
AppConfig[
    tenantTimezone={
        PL=Europe/Warsaw
        DE=Europe/Berlin
    }
]

Environment Variables

Another available solution is to use environment variables. If we use envs, we need to be aware that map keys will be in lower case. Why? When Spring Boot processes system environment properties, it converts them from SCREAMING_SNAKE_CASE into properties notation (lowercase and dotted).

Spring mentions this in its documentation.

Only the environment variable name is lower-cased, not the value. When setting MY_PROPS_VALUES_KEY=VALUE, the values Map contains a {"key"="VALUE"} entry.

source: Binding Maps From Environment Variables

# envs
APPCONFIG_TENANTTIMEZONE_PL=Europe/Warsaw
APPCONFIG_TENANTTIMEZONE_DE=Europe/Berlin
# properties notation
appconfig.tenanttimezone.pl=Europe/Warsaw
appconfig.tenanttimezone.de=Europe/Berlin
# output
AppConfig[
    tenantTimezone={
        pl=Europe/Warsaw,
        de=Europe/Berlin
    }
]

Binding Maps From YAML File and Environment Variables

Ok, what if I configure the application both in a configuration file and via environment variables at the same time? Environment variables will be converted to properties notation and, using relaxed binding, will override the config data file. OS environment variables take precedence over configuration data files.

# output
AppConfig[
    tenantTimezone={
        pl=Europe/Warsaw,
        de=Europe/Berlin,
        PL=Europe/Warsaw,
        DE=Europe/Berlin
    }
]

Notice the pairs of pl and PL and DE and de keys - they are sort of "duplicated". But what does it really mean for variables to be applied using relaxed binding? Let’s take a look at this example.

# application.yaml
app-config:
  tenant-timezone:
    EN: America/New_York
    En: America/New_York
    eN: America/New_York
    E-N: America/New_York
    e-n: America/New_York
# envs
APPCONFIG_TENANTTIMEZONE_EN=Europe/London
# outcome
AppConfig[
    tenantTimezone={
        en=Europe/London,
        EN=Europe/London,
        En=Europe/London,
        eN=Europe/London,
        E-N=Europe/London,
        e-n=Europe/London
    }
]

APPCONFIG_TENANTTIMEZONE_EN was matched to each key in the config data file and overwrote its value.

Summary

  1. Keys from environment variable are always lowercase: Spring Boot automatically converts environment variable keys to lowercase when applying them.

  2. Be careful when binding values from environment variables with default values set in YAML - it can end up with ‘duplicate’ keys.

  3. Consider normalizing keys in your code: To avoid potential mismatches or confusion, it's a good practice to standardize key formats in your application code.

Key normalization

The code with key normalization might look like the one below:

@@ -8,6 +8,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
 import org.springframework.boot.context.properties.EnableConfigurationProperties;

 import java.util.Map;
+import java.util.stream.Collectors;

 @SpringBootApplication
 @EnableConfigurationProperties(RelaxedPropertiesApplication.AppConfig.class)
@@ -23,6 +24,14 @@ public class RelaxedPropertiesApplication {

     @ConfigurationProperties(prefix = "app-config")
     record AppConfig(Map<String, String> tenantTimezone) {
+
+        AppConfig(Map<String, String> tenantTimezone) {
+            this.tenantTimezone = tenantTimezone.entrySet().stream()
+                    .collect(Collectors.toMap(
+                            entry -> entry.getKey().toUpperCase(),
+                            Map.Entry::getValue
+                    ));
+        }
     }
 }

The source code can be found on my Github.