namalzi

← All posts

Digging into Spring's Source 9: @Conditional, @Profile and @Import — Choosing Beans by Condition

Spring · Published

How @Conditional and @Profile, which register a bean only when a condition holds, and @Import, which pulls in other configuration, are evaluated inside the container — when and how — explained for someone new to Spring. Why a condition is evaluated three times, the three kinds of @Import, what DeferredImportSelector "defers" and why, the ImportStack that catches circular imports, and why every @EnableXxx is just @Import in disguise, all checked against the real source and small runnable examples.

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.

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

spring-context/src/main/java/org/springframework/context/annotation/ConditionEvaluator.java, lines 81–106

Reading from the top:

  1. If @Conditional is not present (not even as a meta-annotation), there is no reason to skip, so false. An ordinary bean with no condition stops here.
  2. If no phase was given, it is deduced from the metadata. A configuration-class candidate (a class carrying @Component, @ComponentScan, @Import or @ImportResource, or one with @Bean methods, ConfigurationClassUtils lines 174–189) gets PARSE_CONFIGURATION; anything else gets REGISTER_BEAN. Section 2 explains these two phases.
  3. All condition classes named in @Conditional are collected, instantiated and sorted by @Order (collectConditions, lines 114–128). They are created with a plain BeanUtils.instantiateClass (lines 137–140). A condition object is not a bean. Nothing is injected into it.
  4. The conditions are asked in turn, and as soon as any one returns false from matches, the answer is "skip" (true). Every condition has to match for the bean to be registered.

How shouldSkip(metadata, phase) decides. If @Conditional is not present (not even as a meta-annotation), it returns false: do not skip. If it is present but no phase is given, it calls itself again with phase = PARSE_CONFIGURATION for a configuration-class candidate, or phase = REGISTER_BEAN for anything else. With a phase, collectConditions() creates the condition classes with new and sorts them by @Order (condition objects are not beans). If any condition whose phase matches returns false from matches(), the answer is true: skip, and the definition is not registered. If all pass, the answer is false 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 three points where a condition is evaluated. ① At registration, register() or component scanning asks with the phase deduced; if it does not match, no blueprint is created. ② At parse time, ConfigurationClassParser.processConfigurationClass asks with PARSE_CONFIGURATION; if it does not match, the configuration class is not read. ③ When blueprints are written, the TrackedConditionEvaluator in ConfigurationClassBeanDefinitionReader asks with REGISTER_BEAN; if it does not match, the blueprint is not registered. If all three match, the BeanDefinition is registered. 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).

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

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassBeanDefinitionReader.java, lines 514–536

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

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

spring-context/src/main/java/org/springframework/context/annotation/ProfileCondition.java, lines 37–48

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.

The path along which @Profile is evaluated. @Profile itself carries @Conditional(ProfileCondition.class), so ProfileCondition.matches reads the value and calls Environment.matchesProfiles. That parses the expression with Profiles.of and asks AbstractEnvironment.isProfileActive about each name. If the name is among the active profiles it matches; otherwise it matches only if there are no active profiles and the name is among the default profiles; otherwise it is skipped. How one line of @Profile reaches the Environment. The grey box is the expression parser, which this post only names.

	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

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

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java, lines 581–652

The branches one class listed in @Import can take. If it is an ImportSelector, it is checked for DeferredImportSelector: if so it is put aside with DeferredImportSelectorHandler.handle, otherwise selectImports is called right away and processImports runs again on the names returned. If it is not an ImportSelector but an ImportBeanDefinitionRegistrar, the object is instantiated and collected on the configClass, and registerBeanDefinitions is called when blueprints are written. Otherwise importStack.registerImport records who imported it and processConfigurationClass treats it as a configuration class. 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);
			}
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java, lines 813–823

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]

When the three kinds in the example are called. Inside ConfigurationClassParser.parse, while AppConfig is parsed MailSelector.selectImports is called right away, LateSelector is put aside and SchedulerRegistrar is collected. While OtherConfig is parsed, OtherSelector.selectImports is called. After all candidates, deferredImportSelectorHandler.process finally calls LateSelector.selectImports. Then ConfigurationClassBeanDefinitionReader.loadBeanDefinitions calls SchedulerRegistrar.registerBeanDefinitions, and when the bean is created MailConfig.setImportMetadata is called. 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.

  1. In the third branch of the section-4 code, importStack.registerImport(importer's metadata, imported class name) records who imported whom. ImportStack is a stack and, at the same time, the ImportRegistry holding this record (lines 755–766).
  2. When parsing is complete, ConfigurationClassPostProcessor registers this ImportRegistry as a singleton bean (lines 493–496).
  3. When the bean is created, ImportAwareBeanPostProcessor looks 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;
		}

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassPostProcessor.java, lines 607–617

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

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

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java, lines 588–590

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

spring-context/src/main/java/org/springframework/context/annotation/ConfigurationClassParser.java, lines 654–666

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

How the circular import is caught. While processing ConfigA's @Import, ConfigA is pushed and "ConfigA imported ConfigB" is recorded. ConfigB is parsed as a configuration class, pushed, and "ConfigB imported ConfigA" is recorded. When ConfigA is about to be parsed again, isChainedImportOnStack checks that it is on the stack and that following the importers leads back to itself, and if so throws a BeanDefinitionParsingException through CircularImportProblem. 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

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)

Where the three @EnableXxx annotations lead through @Import. @EnableAspectJAutoProxy imports AspectJAutoProxyRegistrar, an ImportBeanDefinitionRegistrar, which registers the definition of AnnotationAwareAspectJAutoProxyCreator. @EnableAsync imports AsyncConfigurationSelector, an ImportSelector, which for mode PROXY picks the @Configuration ProxyAsyncConfiguration. @EnableTransactionManagement imports TransactionManagementConfigurationSelector, an ImportSelector, which for mode PROXY picks AutoProxyRegistrar, an ImportBeanDefinitionRegistrar, and the @Configuration ProxyTransactionManagementConfiguration. 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.

More posts