What this post covers
This is the ninth post in the "Digging into Spring's Source" series. Part 8 followed a @Configuration class and @ComponentScan through ConfigurationClassParser until they became bean blueprints (BeanDefinitions). Two things were tucked inside that process: "use this configuration only under some condition" and "this configuration pulls in other configuration". This post opens those two.
There are four questions to answer.
- For a bean marked
@Conditionalor@Profile, when is it decided whether to register it — when the bean is created, or earlier? - Why can
@Importtake three kinds of thing (@Configurationclasses,ImportSelector,ImportBeanDefinitionRegistrar), and how is each handled differently? - What does
DeferredImportSelector"defer", and why must it? What happens when two configurations@Importeach other? - Why is every
@EnableXxxannotation, such as@EnableAsyncor@EnableTransactionManagement, an application of@Import?
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Modules read most | spring-context (the context.annotation package), a little of spring-core and spring-tx |
| Classes read most | ConditionEvaluator, ConfigurationClassParser, ConfigurationClassBeanDefinitionReader, ProfileCondition |
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 "Left out of this post".
Words to know first
A configuration class is a class such as one marked @Configuration that says "what to make into beans". The container parses it (reads and interprets it) to build bean blueprints (BeanDefinitions), and later builds objects from those blueprints. Blueprints are in Part 3, parsing in Part 8.
Metadata (AnnotationMetadata, MethodMetadata) is an object holding "which annotations, with which values, are on this class or method". The container reads annotations through this metadata without loading the class. Every condition in this post is evaluated against metadata.
A condition (Condition) is a small object that answers "may this bean be registered?" with true or false. You attach it to a class or a @Bean method with @Conditional(SomeCondition.class).
A profile is a named group of runtime settings, such as "dev" or "prod". When you want different beans depending on which profile is switched on, you write @Profile("dev").
Environment is the "current surroundings" object the container holds. The active profiles and configuration values (system properties, environment variables and so on) are read from it. Part 12 will cover it properly; this post looks only at the methods that answer questions about profiles.
A meta-annotation is an annotation placed on another annotation. The declaration of @Profile carries @Conditional(...), and the declaration of @EnableAsync carries @Import(...). Spring follows "annotations on annotations" when it reads them, which is why the two stars of this post, @Profile and @EnableXxx, turn out to be applications of @Conditional and @Import.
1. A condition is evaluated when the blueprint is made, not when the bean is
The declaration of @Conditional is short: its value is an array of Condition classes and it may go on a class or a method (Conditional lines 59–68). Condition is an interface with a single method, boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) (Condition lines 48–61). Those two arguments are all a condition can see. metadata is the annotation information of the class or method being checked; context is a window onto the Environment, the BeanDefinitionRegistry (the blueprints registered so far), the ResourceLoader and the ClassLoader (ConditionContext lines 41–65). The Javadoc of Condition adds a warning: conditions are checked immediately before the blueprint is registered, must follow the same restrictions as a BeanFactoryPostProcessor, and must never touch bean instances (lines 26–33). When a condition runs, no bean has been created yet.
The class that actually evaluates conditions is ConditionEvaluator. It is package-private, and its heart is one method, shouldSkip.
public boolean shouldSkip(@Nullable AnnotatedTypeMetadata metadata, @Nullable ConfigurationPhase phase) {
if (metadata == null || !metadata.isAnnotated(Conditional.class.getName())) {
return false;
}
if (phase == null) {
if (metadata instanceof AnnotationMetadata annotationMetadata &&
ConfigurationClassUtils.isConfigurationCandidate(annotationMetadata)) {
return shouldSkip(metadata, ConfigurationPhase.PARSE_CONFIGURATION);
}
return shouldSkip(metadata, ConfigurationPhase.REGISTER_BEAN);
}
List<Condition> conditions = collectConditions(metadata);
for (Condition condition : conditions) {
ConfigurationPhase requiredPhase = null;
if (condition instanceof ConfigurationCondition configurationCondition) {
requiredPhase = configurationCondition.getConfigurationPhase();
}
if ((requiredPhase == null || requiredPhase == phase) && !condition.matches(this.context, metadata)) {
return true;
}
}
return false;
}
Reading from the top:
- If
@Conditionalis not present (not even as a meta-annotation), there is no reason to skip, sofalse. An ordinary bean with no condition stops here. - If no
phasewas given, it is deduced from the metadata. A configuration-class candidate (a class carrying@Component,@ComponentScan,@Importor@ImportResource, or one with@Beanmethods,ConfigurationClassUtilslines 174–189) getsPARSE_CONFIGURATION; anything else getsREGISTER_BEAN. Section 2 explains these two phases. - All condition classes named in
@Conditionalare collected, instantiated and sorted by@Order(collectConditions, lines 114–128). They are created with a plainBeanUtils.instantiateClass(lines 137–140). A condition object is not a bean. Nothing is injected into it. - The conditions are asked in turn, and as soon as any one returns
falsefrommatches, the answer is "skip" (true). Every condition has to match for the bean to be registered.
The code above and points 1–4 on one page. Green means "register", red means "skip".
It is worth noticing that the method is called shouldSkip, not matches. The callers ask "should I skip this?", not "should I register this?", and on true they return on the spot.
2. The same condition is asked three times — at registration, at parse time, and when blueprints are written
shouldSkip is not called from one place. It stands at three points along the road from configuration to blueprint.
The blue boxes are the three places shouldSkip is called; the red boxes are what happens on a skip.
| Where | Which phase | On a skip | |
|---|---|---|---|
| ① At registration | AnnotatedBeanDefinitionReader.doRegisterBean (line 257), and isConditionMatch in component scanning (lines 554–560) |
Deduced (PARSE_CONFIGURATION for a configuration class) |
No blueprint is created at all |
| ② At parse time | ConfigurationClassParser.processConfigurationClass (lines 249–251) |
PARSE_CONFIGURATION |
The class's @Bean, @Import and @ComponentScan are never read |
| ③ When blueprints are written | ConfigurationClassBeanDefinitionReader — TrackedConditionEvaluator for classes (line 135), line 196 for @Bean methods |
REGISTER_BEAN |
The blueprint is not registered (and an existing one is removed) |
① is what register does inside new AnnotationConfigApplicationContext(AppConfig.class). ② and ③ happen in the invokeBeanFactoryPostProcessors step of refresh() (AbstractApplicationContext line 604, Part 7), where ConfigurationClassPostProcessor runs the parser and then the reader (ConfigurationClassPostProcessor line 452, line 464).
The two phases: PARSE_CONFIGURATION and REGISTER_BEAN
Why are there "phases" at all? The answer is ConfigurationCondition, an extension of Condition. It adds one method, getConfigurationPhase(), whose return type is the enum ConfigurationPhase with two values (ConfigurationCondition lines 30–61). The Javadoc defines them like this (lines 43–58).
PARSE_CONFIGURATION— evaluated while a configuration class is being parsed. If it does not match, the configuration class is not added at all.REGISTER_BEAN— evaluated when a regular bean is added. It does not prevent configuration classes from being added. By this time, every configuration class has been parsed.
Look back at shouldSkip in section 1: a plain Condition that is not a ConfigurationCondition has requiredPhase == null, so it answers in whichever phase it is asked. A ConfigurationCondition, on the other hand, answers only in the phase it chose and is ignored in the other. A condition that checks "is such-and-such bean already registered?" must choose REGISTER_BEAN, because during parsing the beans of configuration classes not yet read are invisible. That is why phases exist.
The TrackedConditionEvaluator used for classes in ③ checks one more thing.
public boolean shouldSkip(ConfigurationClass configClass) {
Boolean skip = this.skipped.get(configClass);
if (skip == null) {
if (configClass.isImported()) {
boolean allSkipped = true;
for (ConfigurationClass importedBy : configClass.getImportedBy()) {
if (!shouldSkip(importedBy)) {
allSkipped = false;
break;
}
}
if (allSkipped) {
// ...
skip = true;
}
}
if (skip == null) {
skip = conditionEvaluator.shouldSkip(configClass.getMetadata(), ConfigurationPhase.REGISTER_BEAN);
}
this.skipped.put(configClass, skip);
}
return skip;
}
If this configuration class was pulled in by @Import from somewhere else, it is skipped whenever every class that imported it was skipped. If a @Conditional configuration A imports configuration B and A is dropped by its condition, it is only natural for B to go with it. The result is remembered in the skipped map so that no class is asked twice.
Example: how many times is a condition called?
I wrote a condition that matches only when the system property ex.feature exists, and put it on one @Bean method and on one configuration class. Each time matches is called it prints what it is checking.
// An example written for this post (not Spring's code)
static class OnFeatureProperty implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
boolean present = context.getEnvironment().containsProperty("ex.feature");
System.out.println(" OnFeatureProperty.matches -> " + present + " (checked on " + describe(metadata) + ")");
return present; // describe() returns "method <name>" for a method, "class <name>" for a class
}
}
@Configuration
static class FeatureConfig {
@Bean @Conditional(OnFeatureProperty.class) Feature feature() { return new Feature(); } // condition on a method
@Bean String always() { return "always"; }
}
@Configuration
@Conditional(OnFeatureProperty.class) // condition on the whole configuration class
static class GatedConfig {
@Bean String gated() { return "gated"; }
}
// main: new AnnotationConfigApplicationContext(FeatureConfig.class, GatedConfig.class),
// once without the property and once after System.setProperty("ex.feature", "on")
The output:
1) without ex.feature
OnFeatureProperty.matches -> false (checked on class ex.u07.ConditionalExample$GatedConfig)
OnFeatureProperty.matches -> false (checked on method feature)
Feature beans=[]
String beans=[always]
2) with ex.feature=on
OnFeatureProperty.matches -> true (checked on class ex.u07.ConditionalExample$GatedConfig)
OnFeatureProperty.matches -> true (checked on class ex.u07.ConditionalExample$GatedConfig)
OnFeatureProperty.matches -> true (checked on method feature)
OnFeatureProperty.matches -> true (checked on class ex.u07.ConditionalExample$GatedConfig)
Feature beans=[feature]
String beans=[always, gated]
This matches the table exactly. Without the property, GatedConfig is asked once at ① and dropped straight away, never reaching ② or ③. With the property it is asked three times: ① (registration), ② (parsing) and ③ (writing blueprints). The condition on the @Bean method is asked only at ③, once. The feature method appears between the second and third class checks because the reader processes FeatureConfig first.
This example was run with the released Spring Framework 7.0.9 and produced the output above (the 7.1.0-SNAPSHOT this series reads was not built and run directly).
Easy to get wrong
- A condition on a class is called more than once. Up to three times, as shown above. Anything expensive or with side effects inside a condition is repeated that many times. Only the
TrackedConditionEvaluator, inside ③, remembers results. - A condition on a configuration class blocks everything inside it. The Javadoc of
@Conditionalsays the@Beanmethods,@Importand@ComponentScanof that class are all subject to the condition (lines 41–44). After thereturnat ②, the parser never opens the class. - Conditions are not inherited.
@Conditionaldeliberately lacks@Inherited, and conditions on superclasses or overridden methods are not considered (lines 46–52).
3. @Profile is an application of @Conditional
Open the declaration of @Profile and you find @Conditional at the end.
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Conditional(ProfileCondition.class)
public @interface Profile {
// ...
String[] value();
}
spring-context/src/main/java/org/springframework/context/annotation/Profile.java, lines 107–118
So writing @Profile("dev") is the same as writing @Conditional(ProfileCondition.class), and it is evaluated at the same three points as in section 2. There is no new machinery, only one more condition called ProfileCondition.
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
MultiValueMap<String, @Nullable Object> attrs = metadata.getAllAnnotationAttributes(Profile.class.getName());
if (attrs != null) {
for (Object value : attrs.get("value")) {
if (context.getEnvironment().matchesProfiles((String[]) value)) {
return true;
}
}
return false;
}
return true;
}
It takes the value of @Profile out of the metadata and hands it to Environment.matchesProfiles. That is all. The rest belongs to spring-core.
How one line of @Profile reaches the Environment. The grey box is the expression parser, which this post only names.
matchesProfiles(String...)is shorthand foracceptsProfiles(Profiles.of(expressions))(Environmentlines 113–115).Profiles.ofunderstands not only a name such as"dev"but expressions such as"!prod","a & b"or"(a & b) | c"(Profileslines 71–73). The actual parsing is done byProfilesParser, which splits on(,),&,|and!(lines 55–59). The rule that&and|may not be mixed without parentheses is in the Javadoc too (lines 61–64).- Whether a leaf of the expression (a single name) is switched on is answered by
AbstractEnvironment.isProfileActive.
protected boolean isProfileActive(String profile) {
validateProfile(profile);
Set<String> currentActiveProfiles = doGetActiveProfiles();
return (currentActiveProfiles.contains(profile) ||
(currentActiveProfiles.isEmpty() && doGetDefaultProfiles().contains(profile)));
}
spring-core/src/main/java/org/springframework/core/env/AbstractEnvironment.java, lines 407–412
The rule is two lines long. A profile among the active profiles is on, and only when there are no active profiles at all are the default profiles consulted instead. Unless changed, the default profiles are just "default" (line 100, lines 250–252). Active profiles are set directly with setActiveProfiles, or, if that set is empty, read from the spring.profiles.active property (lines 272–283).
Example: dev and prod
// An example written for this post (not Spring's code)
@Configuration
static class StorageConfig {
@Bean @Profile("dev") Storage devStorage() { return new DevStorage(); } // only under dev
@Bean @Profile("prod") Storage prodStorage() { return new ProdStorage(); } // only under prod
@Bean @Profile("!prod") String notProdMarker() { return "not-prod"; } // whenever prod is off
}
static void run(String... activeProfiles) { // main: run("dev"); run("prod"); run();
var context = new AnnotationConfigApplicationContext();
context.getEnvironment().setActiveProfiles(activeProfiles); // must happen before refresh()
context.register(StorageConfig.class);
context.refresh();
// prints the active profiles and the names of the Storage-typed and String-typed beans
context.close();
}
active=[dev] -> Storage beans=[devStorage], String beans=[notProdMarker]
active=[prod] -> Storage beans=[prodStorage], String beans=[]
active=[] -> Storage beans=[], String beans=[notProdMarker]
The third line is the second half of isProfileActive. With nothing switched on, the active set is empty and the default profile "default" is consulted; it is neither "dev" nor "prod", so both are dropped and only "!prod" matches. This example was also run with Spring Framework 7.0.9.
Easy to get wrong
setActiveProfilesmust be called beforerefresh(). As section 2 showed, conditions are evaluated when blueprints are made, and that is finished insiderefresh(). That is why the example callsregisterandrefreshseparately instead of passing the class to the constructor: theAnnotationConfigApplicationContext(Class...)constructor from Part 1 callsrefresh()right away (lines 89–93).@Profile({"a", "b"})means "a or b". The loop inProfileConditionreturnstrueas soon as one matches, and the Javadoc ofProfiles.ofsays the same: any one matching expression is enough (lines 49–50). "a and b" is@Profile("a & b").- A bean without
@Profileis registered regardless of profiles. That is the finalreturn trueinProfileCondition, although with no@Profilepresent the first line ofshouldSkiphas already finished, so that line is rarely reached.
4. The three kinds of @Import
@Import means "treat these classes as configuration too". Its value is Class<?>[], and the Javadoc says it accepts four kinds — @Configuration classes, ImportSelector, ImportBeanDefinitionRegistrar and BeanRegistrar — plus regular component classes (Import lines 33–36, lines 72–77). This post covers the three that have been there for a long time; BeanRegistrar, new in 7.0, is only named under "Left out".
Inside doProcessConfigurationClass, which reads one configuration class, the parser handles @Import right after @ComponentScan (ConfigurationClassParser lines 363–364; the full order is in Part 8). getImports collects the @Imports declared directly on the class, those present as meta-annotations and, since 7.0, those on implemented interfaces (lines 541–579), and processImports sorts them one by one into branches. (The circular-import check, the exception handling and the BeanRegistrar branch added in 7.0 are cut with // ....)
private void processImports(ConfigurationClass configClass, SourceClass currentSourceClass,
Collection<SourceClass> importCandidates, Predicate<String> filter, boolean checkForCircularImports) {
// ...
this.importStack.push(configClass);
try {
for (SourceClass candidate : importCandidates) {
if (candidate.isAssignable(ImportSelector.class)) {
// ...
if (selector instanceof DeferredImportSelector deferredImportSelector) {
this.deferredImportSelectorHandler.handle(configClass, deferredImportSelector);
}
else {
String[] importClassNames = selector.selectImports(currentSourceClass.getMetadata());
Collection<SourceClass> importSourceClasses = asSourceClasses(importClassNames, filter);
processImports(configClass, currentSourceClass, importSourceClasses, filter, false);
}
}
// ...
else if (candidate.isAssignable(ImportBeanDefinitionRegistrar.class)) {
// ...
configClass.addImportBeanDefinitionRegistrar(registrar, currentSourceClass.getMetadata());
}
else {
// ...
this.importStack.registerImport(
currentSourceClass.getMetadata(), candidate.getMetadata().getClassName());
processConfigurationClass(candidate.asConfigClass(configClass), filter);
}
}
}
// ...
finally {
this.importStack.pop();
}
// ...
}
From the left: the ImportSelector, ImportBeanDefinitionRegistrar and configuration-class branches. Yellow boxes are "not now — collected for later".
| Branch | What it does | When it runs | Result |
|---|---|---|---|
ImportSelector |
selectImports(metadata of the importing class) returns an array of class names |
During parsing, on the spot (unless it is a DeferredImportSelector, section 5) |
processImports runs again on the names returned, so a selector may return selectors or registrars too |
ImportBeanDefinitionRegistrar |
registerBeanDefinitions(metadata, registry) registers blueprints directly |
During parsing only the object is created and collected (ConfigurationClass line 236); it is called when blueprints are written (ConfigurationClassBeanDefinitionReader line 152, lines 423–428) |
Things @Bean cannot do: blueprints with hand-picked names, scopes and attributes, registering a proxy creator, and so on |
| Any other class | Parsed like a configuration class through processConfigurationClass |
During parsing, on the spot (recursively) | The imported class itself is registered as a bean, and its @Beans become blueprints |
A class imported through the third branch carries "who imported me". The ConfigurationClass built by candidate.asConfigClass(configClass) stores the importer in importedBy (ConfigurationClass lines 99–103), and the reader registers every class for which isImported() is true as a bean of its own (lines 144–146, lines 159–184). The bean name comes from importBeanNameGenerator, which by default is the fully qualified class name (ConfigurationClassPostProcessor lines 165–166; the example output later shows it). The getImportedBy() that the TrackedConditionEvaluator of section 2 consulted is this same value.
ImportSelector and ImportBeanDefinitionRegistrar objects are not beans either, but the ParserStrategyUtils.instantiateClass in the cut lines (lines 598–599, lines 626–628) lets them receive the Environment, BeanFactory, ClassLoader and ResourceLoader through constructor arguments or Aware interfaces (ParserStrategyUtils lines 56–69, ImportSelector lines 30–47). That is one difference from the Condition of section 1, which is simply new-ed.
5. The deferred selector, and "who imported me?"
DeferredImportSelector: after every configuration has been parsed
DeferredImportSelector is an interface extending ImportSelector that adds no required method (lines 39–49). The only difference is when the parser deals with it. In the code of section 4, a DeferredImportSelector is handed to deferredImportSelectorHandler.handle instead of having selectImports called.
void handle(ConfigurationClass configClass, DeferredImportSelector importSelector) {
DeferredImportSelectorHolder holder = new DeferredImportSelectorHolder(configClass, importSelector);
if (this.deferredImportSelectors == null) {
DeferredImportSelectorGroupingHandler handler = new DeferredImportSelectorGroupingHandler();
handler.register(holder);
handler.processGroupImports();
}
else {
this.deferredImportSelectors.add(holder);
}
}
Normally (when the list is not null) handle does nothing but add the selector to a list. The method that empties the list, process() (lines 825–839), is called on the last line of parse(candidates), after every candidate configuration class has been processed (line 200). It sets the list to null, sorts the collected selectors by @Order, groups them, calls each one's selectImports and runs processImports on the names returned (lines 859–880). Any DeferredImportSelector met during that work finds the list null and is processed immediately by the first branch of handle.
Why defer? The Javadoc of DeferredImportSelector says it is "particularly useful when the selected imports are @Conditional" (lines 24–26). A condition such as "import the default DataSource configuration only if the user has not defined a DataSource bean" gives the right answer only after all of the user's configuration has been parsed. Asked in the middle of parsing, it cannot see the beans of configuration classes not yet read and decides wrongly. So the decision is pushed back until the user's configuration has all been read. (Spring Boot's auto-configuration is known to be built on this device, but Spring Boot is a separate project and this post did not verify that in its source.)
Example: the order in which the three kinds are called
Two configuration classes are registered. AppConfig imports a DeferredImportSelector (LateSelector), a plain ImportSelector (MailSelector) and an ImportBeanDefinitionRegistrar (SchedulerRegistrar); OtherConfig imports OtherSelector, which exists only to show when OtherConfig is parsed. MailConfig, returned by MailSelector, implements ImportAware and prints the name of whoever imported it.
// An example written for this post (not Spring's code). Each method prints its own name and the importing class when called
@Configuration
static class MailConfig implements ImportAware { // kind ①: a configuration class
@Override public void setImportMetadata(AnnotationMetadata importMetadata) { /* print */ }
@Bean Mailer mailer() { return new Mailer(); }
}
static class MailSelector implements ImportSelector { // kind ②: returns names
@Override public String[] selectImports(AnnotationMetadata importingClassMetadata) {
return new String[] {MailConfig.class.getName()};
}
}
static class LateSelector implements DeferredImportSelector { // kind ②, the deferred variant
@Override public String[] selectImports(AnnotationMetadata importingClassMetadata) { return new String[0]; }
}
static class SchedulerRegistrar implements ImportBeanDefinitionRegistrar { // kind ③: registers a blueprint directly
@Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
registry.registerBeanDefinition("scheduler", new RootBeanDefinition(Scheduler.class));
}
}
@Configuration
@Import({LateSelector.class, MailSelector.class, SchedulerRegistrar.class})
static class AppConfig { }
@Configuration
@Import(OtherSelector.class) // OtherSelector is like LateSelector but a plain ImportSelector
static class OtherConfig { }
// main: new AnnotationConfigApplicationContext(AppConfig.class, OtherConfig.class)
creating context with AppConfig, OtherConfig
MailSelector.selectImports (importing ex.u07.ImportExample$AppConfig)
OtherSelector.selectImports (importing ex.u07.ImportExample$OtherConfig)
LateSelector.selectImports (importing ex.u07.ImportExample$AppConfig)
SchedulerRegistrar.registerBeanDefinitions (importing ex.u07.ImportExample$AppConfig)
MailConfig.setImportMetadata <- imported by ex.u07.ImportExample$AppConfig
Mailer beans=[mailer]
Scheduler beans=[scheduler]
MailConfig beans=[ex.u07.ImportExample$MailConfig]
The five output lines placed where they happen in the source. The yellow box is where the deferred selector finally runs.
LateSelector is listed first in @Import, yet it was called after the selector of OtherConfig, because it runs only once every candidate has been parsed. And the registrar runs later still, when the reader writes blueprints. This example was also run with Spring Framework 7.0.9.
ImportAware: receiving the importer's metadata
The last output line, MailConfig.setImportMetadata, comes last because it happens when the bean is created. The path has three parts.
- In the third branch of the section-4 code,
importStack.registerImport(importer's metadata, imported class name)records who imported whom.ImportStackis a stack and, at the same time, theImportRegistryholding this record (lines 755–766). - When parsing is complete,
ConfigurationClassPostProcessorregisters thisImportRegistryas a singleton bean (lines 493–496). - When the bean is created,
ImportAwareBeanPostProcessorlooks the record up in the before-initialisation hook (Part 6) and hands it over.
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
if (bean instanceof ImportAware importAware) {
ImportRegistry ir = this.beanFactory.getBean(IMPORT_REGISTRY_BEAN_NAME, ImportRegistry.class);
AnnotationMetadata importingClass = ir.getImportingClassFor(ClassUtils.getUserClass(bean).getName());
if (importingClass != null) {
importAware.setImportMetadata(importingClass);
}
}
return bean;
}
This post-processor is registered by ConfigurationClassPostProcessor at the end of postProcessBeanFactory (line 338). ImportAware earns its keep when the imported configuration needs to read attributes of the annotation, as the @EnableXxx annotations of section 7 do.
Easy to get wrong
- An
ImportBeanDefinitionRegistrarruns even later than aDeferredImportSelector, because it belongs to the blueprint-writing stage, not parsing. The reader handles each configuration class in the order@Bean→@ImportResource→ registrars (lines 147–153), so a registrar callingregistry.containsBeanDefinition(...)can see the@Beans of its own class and of earlier classes, but not those of configuration classes that come later. - A class imported with
@Importbecomes a bean even without@Configuration. The third branch treats "neither a selector nor a registrar" as a configuration class, and the reader registers everyisImported()class as a bean. This is the "regular component classes" of the Javadoc. - A class imported by two configurations is parsed once. When
processConfigurationClassmeets a class it has already handled, it only mergesimportedByand returns (lines 253–261). If a directly registered (non-imported) version shows up later, however, the imported one is dropped in favour of it (lines 272–277).
6. When they import each other — ImportStack
In the section-4 code, processImports does importStack.push on the way in and pop on the way out. In between it parses imported configuration classes recursively, so the stack holds "the configuration classes currently being imported". The part cut from the top of that method uses this stack to catch cycles.
if (checkForCircularImports && isChainedImportOnStack(configClass)) {
this.problemReporter.error(new CircularImportProblem(configClass, this.importStack));
}
private boolean isChainedImportOnStack(ConfigurationClass configClass) {
if (this.importStack.contains(configClass)) {
String configClassName = configClass.getMetadata().getClassName();
AnnotationMetadata importingClass = this.importStack.getImportingClassFor(configClassName);
while (importingClass != null) {
if (configClassName.equals(importingClass.getClassName())) {
return true;
}
importingClass = this.importStack.getImportingClassFor(importingClass.getClassName());
}
}
return false;
}
If the class is already on the stack, the "who imported whom" record from section 5 is followed backwards to see whether the class itself turns up. If it does, problemReporter.error is called, and the default reporter, FailFastProblemReporter, throws a BeanDefinitionParsingException on the spot (lines 70–72). That exception is a subclass of BeanDefinitionStoreException (line 30). The calls that pass checkForCircularImports = false are the recursive one for names returned by a selector (line 610) and the processing of deferred selectors (lines 866–868); in those cases the same configuration class being on the stack already is normal.
Example: A imports B, B imports A
// An example written for this post (not Spring's code)
@Configuration
@Import(ConfigB.class) // A imports B, and
static class ConfigA { }
@Configuration
@Import(ConfigA.class) // B imports A again
static class ConfigB { }
// main: new AnnotationConfigApplicationContext(ConfigA.class) inside a try, printing the exception's class and message
org.springframework.beans.factory.parsing.BeanDefinitionParsingException
Configuration problem: A circular @Import has been detected: Illegal attempt by @Configuration class 'CircularImportExample.ConfigB' to import class 'CircularImportExample.ConfigA' as 'CircularImportExample.ConfigA' is already present in the current import stack [CircularImportExample.ConfigB->CircularImportExample.ConfigA]
Offending resource: ex.u07.CircularImportExample$ConfigB
The [ConfigB->ConfigA] in the message is the stack at that moment. The most recent entry comes first, which is why ConfigB is written first.
Lining the message up with the source (CircularImportProblem lines 1165–1174): parsing ConfigA imports ConfigB (stack [A]); parsing ConfigB tries to import ConfigA and enters processConfigurationClass(A) (stack [B, A]). A's parsing has not finished, so A is not yet in the "already processed" map, doProcessConfigurationClass(A) runs again, and inside it processImports(A, …, true) finds isChainedImportOnStack(A) true. The message reports "B attempting to import A" because its subject is the top of the stack (importStack.element(), which is B). This example was also run with Spring Framework 7.0.9.
Easy to get wrong
- Importing the same class twice without a cycle is fine. The diamond where A imports B and C, and B also imports C, never has C on the stack twice, so it passes and only
importedByis merged, as section 5 showed. - This is not the circular reference of Part 4. The cycle in Part 4 is between bean objects that need each other, and the three-level cache resolves it. The cycle here is between configuration classes importing each other, and it ends in an exception at parse time. A cycle between blueprints has no way out.
7. Every @EnableXxx is @Import
Spring has a number of "switch this feature on" annotations: @EnableAsync, @EnableTransactionManagement, @EnableAspectJAutoProxy and more. Open their declarations and all three look alike. One @Import sits on them as a meta-annotation, and that is all.
| Annotation | What its @Import names |
Kind | What it does |
|---|---|---|---|
@EnableAspectJAutoProxy (line 122) |
AspectJAutoProxyRegistrar |
ImportBeanDefinitionRegistrar (line 34) |
Registers the blueprint of the auto-proxy creator and configures it from the proxyTargetClass and exposeProxy attributes (lines 42–57) |
@EnableAsync (line 167) |
AsyncConfigurationSelector |
ImportSelector (extends AdviceModeImportSelector, line 33) |
Returns ProxyAsyncConfiguration when mode is PROXY, or the name of the AspectJ configuration when it is ASPECTJ (lines 45–50) |
@EnableTransactionManagement (spring-tx line 162) |
TransactionManagementConfigurationSelector |
ImportSelector (extends AdviceModeImportSelector, line 38) |
For PROXY, returns two names: AutoProxyRegistrar and ProxyTransactionManagementConfiguration (lines 47–53) |
Grey is the annotation, blue the two kinds from section 4 (selectors and registrars). The transaction side shows that a name returned by a selector can itself be a registrar.
Of the three, one takes the registrar branch and two take the selector branch; a name a selector returns, such as ProxyAsyncConfiguration, then goes through the configuration-class branch, so all three branches of section 4 are in play. The user writes one line, @EnableAsync, on a @Configuration class; the parser's collectImports follows meta-annotations and finds @Import(AsyncConfigurationSelector.class) (lines 571–577), and from there everything is the same as if the user had written @Import directly.
The selectImports(AnnotationMetadata) of AdviceModeImportSelector, which both selectors extend, shows what the "metadata of the importing class" argument is for (lines 66–84). It works out which annotation to look at from the generic argument (AdviceModeImportSelector<EnableAsync>) (line 68), reads the mode attribute of that annotation on the importing class (line 71, line 78) and passes it to the subclass's selectImports(AdviceMode) (line 79). Write @EnableAsync(mode = ASPECTJ) and a different configuration class is selected. For the same reason AbstractAsyncConfiguration, the parent of the selected ProxyAsyncConfiguration, implements ImportAware and receives the remaining attributes of @EnableAsync along the path of section 5 (line 47, line 57). AbstractTransactionManagementConfiguration on the transaction side does the same (line 49).
In short, @EnableXxx is not a new mechanism. It is an @Import hidden behind a meta-annotation, combined with a selector that reads attribute values to choose which configuration to import, or a registrar that registers blueprints directly. Knowing the three kinds from this post, you can open the declaration of any @EnableXxx and simply follow its @Import. Part 27 does exactly that for the caching, async and retry switches.
Left out of this post
To keep the main thread visible, the following are only named with their locations. The fourth kind of @Import, BeanRegistrar, new in 7.0 (ConfigurationClassParser lines 613–621). @ImportResource, which imports XML configuration (lines 367–375). processMemberClasses, which uses the same ImportStack when handling nested classes of a configuration class (lines 410–438). DeferredImportSelector.Group and getImportGroup, which sort and filter deferred selectors together (DeferredImportSelector lines 47–123), and getExclusionFilter, which filters a selector's candidates (ImportSelector lines 81–83). The check that a configuration class with REGISTER_BEAN-phase conditions may not use @ComponentScan (lines 340–345). skippedBeanMethods, which remembers the names of @Bean methods dropped by a condition so that overloads of the same name are dropped too (ConfigurationClassBeanDefinitionReader lines 196–202). And ImportAwareAotContribution, which supports ImportAware in AOT builds (from ConfigurationClassPostProcessor line 621).
Recap
| Concept | In one line |
|---|---|
@Conditional / Condition |
An object answering "may this blueprint be registered?" with true/false. Not a bean; created with new |
ConditionEvaluator.shouldSkip |
Collects the conditions and says "skip" if any one fails. Returns false straight away when there is no @Conditional |
| The three points | At registration (deduced) → at parse time (PARSE_CONFIGURATION) → when blueprints are written (REGISTER_BEAN). A class condition is called up to three times |
ConfigurationCondition |
A condition that chooses which of the two phases it is evaluated in. Use REGISTER_BEAN to look for other beans |
TrackedConditionEvaluator |
Drops an imported configuration when every importer was dropped. Remembers results |
@Profile |
An application of @Conditional(ProfileCondition.class). Asks Environment.matchesProfiles about an expression |
| Profile rule | On if among the active profiles. Only when no profile is active are the default profiles ("default") consulted |
The three kinds of @Import |
A configuration class (parsed at once), ImportSelector (returns names → processed again), ImportBeanDefinitionRegistrar (collected, then registers blueprints directly) |
DeferredImportSelector |
Runs at the end of parse(), after every candidate configuration has been parsed. For importing conditional configuration |
ImportAware / ImportRegistry |
The parser records "who imported whom", and a post-processor hands it over when the bean is created |
ImportStack |
The stack of configuration classes being imported. If following the chain leads back to itself: BeanDefinitionParsingException |
@EnableXxx |
An @Import hidden behind a meta-annotation: a selector that picks configuration by attribute values, or a registrar that registers blueprints |
What to look at next
This is the last unit on the main path within Phase 1 (the IoC container) of the reading map. After what a blueprint looks like (Part 3), how an object is built from it (Part 4) and how annotations become blueprints (Part 8), we have now seen the machinery for choosing and importing blueprints.
From here the main path moves on to AOP. What @EnableAspectJAutoProxy and @EnableTransactionManagement in section 7 both register is an auto-proxy creator — the post-processor that Part 4 described only as "returning a proxy instead of the original in the after-initialisation hook". The next post reads spring-aop to see how that proxy is actually built, and what separates the JDK approach, which works through interfaces, from the CGLIB approach, which subclasses the class.
- Next: Digging into Spring's Source 10: How AOP Proxies Are Made — the JDK Way and the CGLIB Way
- Part 1: How I Read It with AI, and the Map
- Part 3: The BeanFactory Hierarchy and BeanDefinition
- Part 4: How a Bean Gets Created — the Creation Flow and the Three-Level Cache
- Part 6: Lifecycle Callbacks and PostProcessors — Where to Hook into the Container
- Part 7: The Twelve Steps of refresh() — the Order in Which the Container Starts
- Part 8: How @Configuration and @ComponentScan Become Blueprints