Spring Boot ConfigData Source Code Analysis: Configuration Resource Discovery, Activation Context, Profile Deduction, Recursive Import and ContributorIterator Post-order Traversal
Reading Instructions
This article uses Spring Boot 2.7.18 implementation as the baseline and follows the actual execution path of ConfigDataEnvironment.processAndApply().
The code in the article mainly retains method names, conditions, and data structures, omitting logs, exception wrapping, and details unrelated to the main line, making it easier to observe the control flow.
The article strictly distinguishes five actions:
- resolve: Parse
ConfigDataLocationintoConfigDataResource. - load:
Loaderreads Resource intoConfigDataandPropertySource. - bind: Bind
spring.config.importandspring.config.activatefrom a singlePropertySource. - active: Contributor satisfies activation conditions under current
ActivationContext. - apply: Finally add valid
PropertySourceto the actualEnvironment.
ConfigData is not a file loader that "finishes reading application.yml", but a process of solving the configuration dependency graph in stages. Each stage continuously expands the imports that can currently be determined until the Contributor Tree reaches a stable state. After the Profile is determined, profile-specific resources are added, and then uniformly applied to the Environment.
2. Entry Point and Initial Contributor Tree
The execution entry of ConfigData is in the Environment preparation phase.
Before creating and refreshing ApplicationContext, SpringApplication first prepares ConfigurableEnvironment. EnvironmentPostProcessorApplicationListener calls ConfigDataEnvironmentPostProcessor, which constructs ConfigDataEnvironment and executes processAndApply().
The following work has not yet started at this time:
- BeanDefinition scanning;
- Auto-configuration class parsing;
- Singleton Bean creation;
ApplicationContextrefresh.
The entry call chain is as follows:
SpringApplication.run()
└─ prepareEnvironment()
└─ EnvironmentPostProcessorApplicationListener
└─ ConfigDataEnvironmentPostProcessor.postProcessEnvironment()
└─ ConfigDataEnvironment.processAndApply()
2.1 Source of Initial Contributors
When ConfigDataEnvironment is constructed, it reads the existing PropertySource in the current Environment and wraps them as Contributors of Kind.EXISTING.
These sources typically include:
commandLineArgssystemPropertiessystemEnvironmentdefaultProperties
They participate in early Binder queries and placeholder resolution, but will not be imported again by the ConfigData mechanism.
Subsequently, the framework creates INITIAL_IMPORT nodes based on the following configurations:
spring.config.importspring.config.additional-locationspring.config.location
If spring.config.location is not explicitly configured, the default search locations are used, for example:
classpath:/
classpath:/config/
file:./
file:./config/
file:./config/*/
The initial locations use Contributor representation instead of immediately calling Resolver, so that both initial locations and subsequent spring.config.import locations go through the same processing flow:
Select node to process
↓
resolveAndLoad
↓
Generate child Contributor
↓
Replace immutable tree
The logical structure of the initial tree is as follows:
ROOT
├─ EXISTING: commandLineArgs
├─ EXISTING: systemProperties
├─ EXISTING: systemEnvironment
├─ INITIAL_IMPORT: spring.config.import
├─ INITIAL_IMPORT: spring.config.additional-location
├─ INITIAL_IMPORT: spring.config.location / default search locations
└─ EXISTING: defaultProperties
INITIAL_IMPORT does not correspond to a file that has already been loaded, nor does it contribute PropertySource. It only holds a set of ConfigDataLocation used to trigger the first resolveAndLoad() in withProcessedImports().
4. Configuration Example Used Throughout
The following configuration simultaneously includes:
- Unconditional import;
- CloudPlatform condition;
- Profile condition;
- Recursive import;
- Profile-specific files.
Assume the runtime environment is eventually recognized as Kubernetes, and the base document activates the dev Profile.
Document 0 of application.yml:
spring:
profiles:
active: dev
config:
import: classpath:common.yml
app:
source: base
Document 1 of application.yml:
spring:
config:
activate:
on-cloud-platform: kubernetes
import: classpath:k8s.yml
platform:
mode: kubernetes
Document 2 of application.yml:
spring:
config:
activate:
on-profile: dev
import: classpath:feature.yml
feature:
mode: dev-document
common.yml:
spring:
config:
import: classpath:db.yml
common:
enabled: true
db.yml:
db:
url: jdbc:mysql://localhost/base
k8s.yml:
platform:
service-discovery: dns
feature.yml:
feature:
enabled: true
application-dev.yml:
spring:
config:
import: classpath:dev-db.yml
app:
source: application-dev
dev-db.yml:
db:
pool-size: 20
It is necessary to distinguish between "file" and "document" here.
application.yml is read by the Loader at once, but its three YAML documents become three independent PropertySource, which then generate three independent Contributors.
Each Contributor has its own: imports, activate, active state, children.
Therefore, just because a file has been read by the Loader does not mean each document in the file is active at the current stage.
6. Three Import Processing in processAndApply
processAndApply() calls withProcessedImports() three times, but there are only two types of ImportPhase:
BEFORE_PROFILE_ACTIVATIONAFTER_PROFILE_ACTIVATION
The first two calls belong to BEFORE, with the difference being the completeness of ActivationContext. The third enters AFTER after Profiles are created.
The main flow is as follows:
processAndApply() {
importer = new ConfigDataImporter(...);
contributors = processInitial(contributors, importer);
activationContext =
createActivationContext(contributors.getBinder(...));
contributors =
processWithoutProfiles(contributors, importer, activationContext);
activationContext =
withProfiles(contributors, activationContext);
contributors =
processWithProfiles(contributors, importer, activationContext);
applyToEnvironment(contributors, activationContext, ...);
}
6.1 First: BEFORE with null context
processInitial() passes null ActivationContext.
ImportPhase.get(null) returns BEFORE_PROFILE_ACTIVATION.
Documents without spring.config.activate are still active in properties.isActive(null) because their activate field is null.
Documents declaring any activate condition enter Activate.isActive(null) and directly return false.
This stage does not just read one layer of application.yml.
The imports of unconditional documents continue to expand, for example:
application.yml
↓ import
common.yml
↓ import
db.yml
The outer while loop runs until the entire currently visible unconditional dependency chain completes binding and import processing.
6.2 Second: BEFORE with CloudPlatform known, Profiles still null
After the first stage reaches stability, createActivationContext() derives CloudPlatform using the current Environment and Contributor-backed Binder, and explicitly sets profiles to null.
The derivation order is: first check if CloudPlatform is forcibly specified in the configuration, then call CloudPlatform.getActive(environment).
processWithoutProfiles() calls withProcessedImports() again.
The current phase is still BEFORE, but documents that were inactive due to on-cloud-platform can now be re-evaluated.
When the platform matches, the imports of these documents are expanded.
Documents that only declare on-profile remain inactive.
Documents declaring both platform and Profile require both conditions to be met, so they wait for the third stage.
6.3 Profile Derivation and Third AFTER
withProfiles() constructs a Binder from the current Contributor Tree, filters PropertySource with IGNORE_PROFILES, and reads:
spring.profiles.activespring.profiles.defaultspring.profiles.includespring.profiles.groupadditionalProfiles
The Profiles constructor also expands Profile groups and uses LinkedHashSet to maintain order and terminate cycles.
Subsequently, activationContext.withProfiles(profiles) generates a new context containing both CloudPlatform and Profiles.
processWithProfiles() calls withProcessedImports() for the third time.
At this point activationContext.getProfiles() != null, so ImportPhase.get() returns AFTER_PROFILE_ACTIVATION.
This stage has two new capabilities:
on-profiledocuments can be judged as active;- In addition to normal
resolve(), the Resolver also executesresolveProfileSpecific().
The standard file Resolver can thus discover: application-dev.yml, common-dev.yml, application-prod.yml.
8. Complete Execution Derivation Using Real Data Structures
8.1 Initial Tree
After ignoring EXISTING nodes, the initial tree is as follows:
ROOT
└─ INITIAL_IMPORT
imports = [classpath:/]
children = {}
8.2 First BEFORE: Load base documents and expand unconditional imports
INITIAL_IMPORT is selected. It has no activation condition, imports is not empty, and there is no BEFORE key in children.
When profiles == null, the Resolver only executes normal resolve and finds application.yml.
Loader reads three YAML documents and generates one ConfigData.
asContributors() generates three UNBOUND_IMPORT in reverse order of PropertySource, and attaches them to the new tree via INITIAL_IMPORT.withChildren(BEFORE, children):
INITIAL_IMPORT
└─ BEFORE
├─ UNBOUND application.yml#2
├─ UNBOUND application.yml#1
└─ UNBOUND application.yml#0
At this point, all three documents have been physically read, but activate and import have not yet been bound.
Post-order priority-order traversal first finds application.yml#2. Since Kind is UNBOUND_IMPORT, active is not checked, and withBoundProperties() is executed, resulting in:
BOUND application.yml#2
activate.on-profile = dev
imports = [feature.yml]
Since the current context is null, isActive(null) == false, and feature.yml is not expanded yet.
Next, bind application.yml#1:
BOUND application.yml#1
activate.on-cloud-platform = kubernetes
imports = [k8s.yml]
Context is still null, so it is also inactive, and k8s.yml is not expanded yet.
Finally, bind application.yml#0:
BOUND application.yml#0
activate = null
imports = [common.yml]
spring.profiles.active = dev
It is active under null context, so it will be selected in the next round, load common.yml, and write to BEFORE children.
common.yml first becomes UNBOUND common.yml, then binds to:
BOUND common.yml
imports = [db.yml]
It has no activation condition, so it continues to load db.yml. After db.yml is bound and has no imports, it no longer becomes a node to be processed.
The tree after the first BEFORE reaches stability is as follows:
INITIAL_IMPORT
└─ BEFORE
├─ BOUND application.yml#2
│ activate.on-profile = dev
│ imports = [feature.yml]
│ currently inactive
│
├─ BOUND application.yml#1
│ activate.on-cloud-platform = kubernetes
│ imports = [k8s.yml]
│ currently inactive
│
└─ BOUND application.yml#0
imports = [common.yml]
└─ BEFORE
└─ BOUND common.yml
imports = [db.yml]
└─ BEFORE
└─ BOUND db.yml
At this point, application.yml#1 and application.yml#2 have completed load and bind, but have not expanded internal imports.
The condition for the first stage to end is not "all files are activated", but rather there is no in the current tree:
UNBOUND_IMPORT- And no "active Contributor that has not been processed in BEFORE"
8.3 Second BEFORE: Platform condition configuration joins
createActivationContext() derives CloudPlatform.KUBERNETES, profiles = null.
In the second post-order traversal, application.yml#1's platform condition matches:
activate.on-cloud-platform = kubernetes
current platform = Kubernetes
Therefore isActive(context) == true, and its BEFORE key does not exist, so k8s.yml is loaded.
application.yml#1
activate.on-cloud-platform = kubernetes
isActive(context) = true
children[BEFORE] = [k8s.yml]
After k8s.yml completes binding, it has no imports.
application.yml#2 still requires dev Profile, while profiles == null:
application.yml#2
activate.on-profile = dev
isActive(context) = false
So it remains inactive.
8.4 withProfiles: Derive dev from active configuration
The Binder created by withProfiles() filters IGNORE_PROFILES and enables FAIL_ON_BIND_TO_INACTIVE_SOURCE for key bindings.
The base document application.yml#0 is currently active, and its spring.profiles.active: dev will be read.
Profiles also processes:
spring.profiles.activespring.profiles.defaultspring.profiles.includespring.profiles.groupadditionalProfiles
After completing group expansion, the final context is:
CloudPlatform = KUBERNETES
Profiles.active = [dev]
8.5 Third AFTER: Profile condition and profile-specific files join
When application.yml#2 is re-evaluated:
on-profile = dev
Profiles accepts dev
Therefore it becomes active. Since its AFTER key does not exist, feature.yml is loaded and bound in the AFTER phase.
For nodes that have already processed imports in BEFORE, their AFTER key still does not exist. Therefore, the same imports will be given to the Resolver again with Profiles.
The Resolver executes:
resolve(location)
resolveProfileSpecific(location, profiles)
Normal resources already exist in ConfigDataImporter.loaded and will be deduplicated. Only newly appearing profile-specific Resources will enter the loading result.
INITIAL_IMPORT re-resolves classpath:/ in the AFTER phase. Normal application.yml has been loaded and deduplicated.
StandardConfigDataLocationResolver generates application-dev.yml for dev.
StandardConfigDataLoader marks that PropertySource with PROFILE_SPECIFIC.
application-dev.yml first joins AFTER children as UNBOUND_IMPORT, then binds to:
imports = [dev-db.yml]
The current Profile is known, and the document is active, so dev-db.yml continues to expand in the AFTER phase.
The approximate structure of the final tree is as follows:
INITIAL_IMPORT
├─ AFTER
│ └─ BOUND application-dev.yml
│ imports = [dev-db.yml]
│ └─ AFTER
│ └─ BOUND dev-db.yml
│
└─ BEFORE
├─ BOUND application.yml#2
│ activate.on-profile = dev
│ └─ AFTER
│ └─ BOUND feature.yml
│
├─ BOUND application.yml#1
│ activate.on-cloud-platform = kubernetes
│ └─ BEFORE
│ └─ BOUND k8s.yml
│
└─ BOUND application.yml#0
└─ BEFORE
└─ BOUND common.yml
└─ BEFORE
└─ BOUND db.yml
The conditional documents of application.yml are initially all in INITIAL_IMPORT's BEFORE children because they come from the first normal resolution. Their internal imports, when later becoming active, are written to the children of the corresponding phase.
application-dev.yml comes from resolveProfileSpecific(), belongs to profile-specific resources, and is located in the AFTER high-priority area after tree adjustment.
10. Real Difference Between BEFORE and AFTER
ImportPhase describes: whether the current node's imports are executed before Profile derivation or after Profile derivation.
It does not directly indicate node inactive or active.
The logic of ImportPhase.get() has only one judgment:
phase = activationContext != null
&& activationContext.getProfiles() != null
? AFTER_PROFILE_ACTIVATION
: BEFORE_PROFILE_ACTIVATION;
Therefore, BEFORE means Profiles have not been determined yet, and AFTER means Profiles have been determined.
10.1 Differences in Resolver Behavior
ConfigDataImporter obtains Profiles from ActivationContext and passes them to ConfigDataLocationResolvers.resolve(...).
Resolvers always first call normal resolution resolver.resolve(context, location).
If Profiles is null, directly return the normal resolution result.
If Profiles is not null, it also executes resolver.resolveProfileSpecific(context, location, profiles).
Therefore:
- Normal resolution:
resolve(location) - Resolution after Profile known:
resolve(location)+resolveProfileSpecific(location, profiles)
For standard file locations, resolveProfileSpecific() traverses accepted profiles and passes the Profile to StandardConfigDataReference.
For example:
- Directory
classpath:/producesapplication-dev.ymlcandidate - Explicit file
common.ymlproducescommon-dev.ymlcandidate
When StandardConfigDataLoader detects resource.getProfile() != null, it adds PROFILE_SPECIFIC to the corresponding PropertySource.
10.2 Same imports processed twice
After a BOUND Contributor's imports complete BEFORE, it only has children[BEFORE].
When entering AFTER, hasUnprocessedImports(AFTER) still returns true. Therefore, the same Location is resolved once in each of the two contexts.
First time: Profiles = null, only resolve normal resources.
Second time: Profiles = [dev], resolve normal resources + profile-specific resources.
ConfigDataImporter.loaded uses ConfigDataResource for deduplication, so normal resources do not repeatedly execute Loader.load().
The value of the second resolution is discovering profile-specific Resources that did not exist before.
"Processed twice" means: the same configuration location is resolved once in each of the two contexts, which does not mean the same file must be physically read twice.
10.3 PROFILE_SPECIFIC tree adjustment
withChildren(AFTER, children) calls moveProfileSpecific().
This method looks for child nodes with PROFILE_SPECIFIC Option in the BEFORE subtree, removes them from their original position, removes the temporary Option, and adds them to the current node's AFTER children.
This adjustment allows profile-specific sources to enter the high-priority area after Profile activation, while preserving the original parent-child relationship of non-profile-specific imports.
The following two states are related but have different meanings:
fromProfileSpecificImport: comes fromConfigDataResolutionResult.isProfileSpecific(), indicating the Resource was produced byresolveProfileSpecific().PROFILE_SPECIFICOption: attached toPropertySource, used for subsequent tree structure and priority adjustment.
12. Options, Deduplication, Exceptions, and Final Priority
12.1 Source and Role of ConfigData.Option
Options are provided by ConfigDataLoader when constructing ConfigData and passed to ofUnboundImport() following PropertySource.
Spring Boot does not uniformly infer these markers based on types like "local configuration, remote configuration, configuration center". Custom Loaders decide which Options to return.
| Option | Set Location | Consumed Location | Function |
|---|---|---|---|
IGNORE_IMPORTS | Loader or EMPTY_LOCATION fixed option | withBoundProperties() | Remove spring.config.import after binding, normal properties can still contribute |
IGNORE_PROFILES | Determined by Loader | withProfiles(), include scanning, illegal property validation | Do not let this PropertySource participate in active, default, include, group derivation |
PROFILE_SPECIFIC | Set by StandardConfigDataLoader when resource.profile != null | moveProfileSpecific() | Move profile-specific sources to AFTER high-priority area |
EMPTY_LOCATION uses IGNORE_IMPORTS, indicating the Location is legal but has no actual PropertySource, and imports should not be read from the empty node again.
The precise definition of IGNORE_PROFILES is: ignore properties in this PropertySource that affect Profile derivation. It is not equivalent to "remote configuration must ignore Profile".
12.2 Resource Deduplication and Circular Import
ConfigDataImporter maintains Set<ConfigDataResource> loaded.
When a candidate Resource already exists, Loader is not called again.
The corresponding Location is recorded to loadedLocations.
Assume A import B, B import A:
load A
loaded = [A]
A import B
load B
loaded = [A, B]
When B resolves to A again
loaded.contains(A) == true
→ No new Contributor generated, loop naturally terminates
Deduplication is based on ConfigDataResource.equals() and ConfigDataResource.hashCode(), not string Location.
When different Locations resolve to the same Resource, it is also loaded only once.
When the same Location resolves to a new profile-specific Resource in the AFTER phase, it can still be loaded because the Resource is different.
12.3 optional, Empty Location and Mandatory Location Check
The optional: prefix and the Resource's own optional status are recorded by ConfigDataImporter.
ConfigDataNotFoundException uses ignore strategy for optional locations. Non-optional locations are handled according to spring.config.on-not-found strategy.
Resolver may return a legitimate empty directory Resource. Loader returns ConfigData.EMPTY, then creates EMPTY_LOCATION Contributor.
Before final apply, checkMandatoryLocations() collects non-optional imports from active Contributors, then subtracts:
- Locations already in the tree
loadedLocationsoptionalLocations
Locations that still exist are considered mandatory imports that failed to resolve successfully.
12.4 Final Apply and Property Priority
applyToEnvironment() first executes:
- Illegal Profile property validation;
- Mandatory location check.
Then uses ContributorIterator to traverse the entire tree.
Only nodes satisfying the following conditions are added to Environment:
Kind == BOUND_IMPORT
PropertySource != null
Active under final ActivationContext
The structured logic is as follows:
for (contributor : contributors) {
if (kind == BOUND_IMPORT
&& propertySource != null) {
if (contributor.isActive(finalContext)) {
environment
.getPropertySources()
.addLast(propertySource);
}
}
}
Inactive nodes remain in the Contributor Tree but do not enter Environment.
Command-line arguments, systemProperties and other EXISTING PropertySource are already in Environment and are before ConfigData results, so they maintain higher priority.
ConfigData nodes internally depend on the traversal order of AFTER → BEFORE → parent.
Finally, DefaultPropertiesPropertySource is moved to the end, maintaining the lowest priority for default properties.
14. Complete Execution Model
The complete execution of ConfigData can be compressed into the following continuous process.
Step one, wrap existing Environment PropertySource, create INITIAL_IMPORT, form ROOT.
ROOT
├─ EXISTING
├─ INITIAL_IMPORT
└─ EXISTING
Step two, execute first BEFORE.
context = null
Profiles = null
CloudPlatform = null
Continuously load and bind unconditional documents, recursively expand unconditional imports.
Step three, create ActivationContext. Derive CloudPlatform from Environment and Contributor Binder, Profiles still null.
Step four, execute second BEFORE. Re-evaluate platform conditions, continuously expand on-cloud-platform document imports.
Step five, calculate Profiles:
- Filter
IGNORE_PROFILES - Read active
- Read default
- Read include
- Read additionalProfiles
- Expand group
Step six, execute third AFTER.
- Re-evaluate on-profile conditions.
- Resolve each imports with Profiles again.
- Discover
application-dev.yml,common-dev.yml, other profile-specific Resources. - Use loaded Resource set for deduplication, and continue recursively expanding new nodes.
Step seven, each stage internally executes the same fixed-point algorithm:
Post-order priority-order traversal
↓
Select a node to process
↓
Bind or load
↓
withReplacement generates new tree
↓
Traverse from root again
↓
Until current stage stabilizes
Step eight, execute final apply.
- Validate mandatory locations
- Validate illegal Profile properties
- Only add active BOUND PropertySource
- Sort by
AFTER → BEFORE → parent - Set Environment's defaultProfiles and activeProfiles
The final mental model is as follows:
- Contributor Tree is the intermediate representation of ConfigData
- ActivationContext determines which nodes can continue to expand
- ImportPhase determines whether the location resolution has Profiles
- ContributorIterator gives priority post-order traversal
- withProcessedImports' while continuously expands currently solvable configuration dependencies to stability
- applyToEnvironment delivers the final result to the real Environment