@Component and its specialisations have one hard requirement that nobody mentions: you must own the source file. The annotation has to be written into the class, which means the class has to be one you can edit. The moment you need a bean of a type that arrived on the classpath inside somebody else's jar, that route is closed and no amount of component scanning will open it.
@Bean is the answer to exactly that problem, and the rest of this article is what follows from it: where the boundary between the two styles actually sits, and the proxyBeanMethods flag that decides whether a call from one @Bean method to another returns the existing singleton or quietly builds a second object. Every number and every error message below is copied from a terminal running Spring Boot 4.1.1.
![]()
The toolchain is Spring Boot 4.1.1 (Spring Framework 7.0.9) on OpenJDK 21.0.6, built with Gradle 9.7.1 through the wrapper, and the third-party library used as the running example is Caffeine 3.2.4.
@Component only works on classes you own
Here is the wall, in its most ordinary form. The application needs an in-memory cache, so Caffeine goes into build.gradle:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'com.github.ben-manes.caffeine:caffeine'
}No version — Caffeine is in the Spring Boot BOM, so the build resolves one for you:
./gradlew dependencies --configuration compileClasspath\--- com.github.ben-manes.caffeine:caffeine -> 3.2.4Now try to make a Cache bean the way you make every other bean. The type you want is this:
unzip -l ~/.gradle/caches/modules-2/files-2.1/com.github.ben-manes.caffeine/caffeine/3.2.4/*/caffeine-3.2.4.jar | grep "cache/Cache" 1965 02-01-1980 00:00 com/github/benmanes/caffeine/cache/Cache.classA .class file. Not a .java file — a compiled class inside a read-only archive in the Gradle cache. There is nowhere to type @Component, and the workarounds are all worse than the problem:
- Fork the library and add the annotation. You now maintain a fork of Caffeine forever, and every upgrade is a merge.
- Subclass it.
Cacheis an interface whose implementations are package-private and produced by a builder. Even where subclassing is possible, it makes your application's bean a type the library never intended to be extended. - Hold it in a
staticfield. That is the global variable the container exists to replace, and it is untestable for the same reason. - Wrap it in your own
@Component. Legitimate sometimes, but it means every caller talks to your wrapper instead of the library's API, and you have written a class whose only content is a constructor call.
The framework's actual answer is a factory method: a method you write, on a class the container reads, whose return value becomes the bean. You own the method even though you do not own the class it returns.

@Bean factory methods
A @Bean method is a method on a @Configuration class. The container calls it once, takes whatever it returns, and registers that object as a bean. What happens inside the method is ordinary Java — a builder, an if, a loop, a call to a factory the library ships.
package com.example.demo.cache;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Product> productCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.recordStats()
.build();
}
}Three details in those eight lines:
The method name becomes the bean name. productCache() registers a bean called productCache. There is no separate naming step and no string to keep in sync — rename the method and the bean is renamed with it.
@Bean(name = "...") overrides that, and it replaces the method name rather than adding to it. A method userCache() annotated @Bean(name = "userSessions") produces a bean named userSessions and no bean named userCache.
The declared return type is what the container indexes. Cache is an interface; Caffeine's builder returns some package-private implementation class. Declaring the method as returning Cache means the container matches injection points against the interface, and the implementation class stays an implementation detail — the same discipline as programming to an interface anywhere else.
Injecting it is completely ordinary. The consuming class is one you do own, so it gets a stereotype and constructor injection:
package com.example.demo.cache;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.stereotype.Service;
@Service
public class CatalogService {
private final Cache<String, Product> productCache;
public CatalogService(Cache<String, Product> productCache) {
this.productCache = productCache;
}
public Product find(String sku) {
return productCache.get(sku, key -> new Product(key, "loaded from the database"));
}
public String stats() {
return productCache.stats().toString();
}
}Calling find twice and printing the cache statistics shows the bean is a real, shared, configured Caffeine cache and not a fresh one per call:
find(A-1) = Product[sku=A-1, name=loaded from the database]
find(A-1) = Product[sku=A-1, name=loaded from the database]
stats = CacheStats{hitCount=1, missCount=1, loadSuccessCount=1, loadFailureCount=0, totalLoadTime=12667, evictionCount=0, evictionWeight=0}
productCache = true
userCache = false
userSessions = trueOne miss, one hit. The last three lines are containsBean checks and they confirm the naming rules: productCache exists, and userCache — the method name of a @Bean(name = "userSessions") method — does not.
Registering one class several times
A cache is not one thing. Product data can sit for half an hour; a session cache must not. Two @Bean methods, same class, different configuration:
@Bean
public Cache<String, Product> productCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.recordStats()
.build();
}
@Bean(name = "userSessions")
public Cache<String, User> userCache() {
return Caffeine.newBuilder()
.maximumSize(500)
.expireAfterWrite(Duration.ofMinutes(5))
.build();
}This is the case a stereotype cannot express at all. @Component says "this class is one bean"; there is no way to say "this class is three beans with different constructor arguments". A factory method has no such limit, because a method can be written as many times as you like.
Note that the two methods here return different generic types, Cache<String, Product> and Cache<String, User>, and Spring resolves generics when it matches injection points — so CatalogService asking for Cache<String, Product> gets the right one with nothing extra. When two beans really do share a type, telling the container which to inject is the job of @Qualifier and @Primary, covered in the previous article on dependency injection.
Stereotype or @Bean: which one?
The boundary is not a matter of taste. It follows from what each mechanism can physically do:
| Situation | Use | Why |
|---|---|---|
You own the class and new is all that is needed | @Component / @Service / @Repository | The scan finds it, the constructor is the only construction logic, and there is nothing for a factory method to add. |
| The class is compiled into a third-party jar | @Bean | There is no source file to annotate. This is the case with no alternative. |
The class comes from the JDK — Clock, HttpClient, ObjectMapper | @Bean | Same reason. You will never annotate java.time.Clock. |
| Construction needs real logic — a builder, a timeout, a branch on a property | @Bean | A method body can contain anything. An annotation cannot. |
| One class must become several beans with different settings | @Bean | A stereotype gives exactly one bean per class. |
You are adapting something into the container — a FactoryBean, a library's own factory, an object built by a static method | @Bean | Factory methods are the adapter layer between a library's construction API and the container. |
The bean is a BeanFactoryPostProcessor or similar container infrastructure | static @Bean | It has to exist before ordinary beans do. See below. |
| It is your own class but needs a value that only exists at runtime | Either | A stereotype with @Value on the constructor is usually cleaner; a @Bean method is better when the value drives a branch. |
Two realistic examples, one of each.
Stereotype. CatalogService above. You wrote it, its constructor takes its collaborators, and there is no construction logic beyond assigning fields. Writing a @Bean method for it would mean maintaining a second place that has to change every time the constructor does — pure cost, no benefit.
@Bean. The Caffeine cache. You did not write Cache, it is built by a fluent builder rather than a constructor, its settings are a business decision that belongs in your codebase, and you need two of them. Every one of those points is a reason the stereotype route cannot work.
The rule of thumb that falls out: a stereotype is a declaration that a class is a bean; a @Bean method is a recipe for producing one. If there is no recipe worth writing, do not write one.
How one @Bean method depends on another
Beans depend on beans, so factory methods need a way to reach each other. There are two forms, and they are not equivalent.
Declare a parameter. The container resolves it from the context exactly as it resolves a constructor argument:
@Bean
public Reporter reporter(Meter meter) {
return new Reporter(meter);
}Call the other method directly. It reads like an ordinary Java call, because syntactically that is what it is:
@Bean
public Reporter reporter() {
return new Reporter(meter());
}The second form is the one that surprises people, because whether it is an ordinary Java call depends on a flag you have probably never set.
proxyBeanMethods and the second object you did not ask for
Here is the experiment. Three plain classes, one of which prints its own identity hash code when constructed:
public class Meter {
public Meter() {
System.out.println(" Meter constructed -> " + System.identityHashCode(this));
}
}
public class Reporter {
public final Meter meter;
public Reporter(Meter meter) { this.meter = meter; }
}
public class Auditor {
public final Meter meter;
public Auditor(Meter meter) { this.meter = meter; }
}A configuration class in which two @Bean methods call a third directly:
@Configuration
public class MetricsConfig {
@Bean
public Meter meter() {
return new Meter();
}
@Bean
public Reporter reporter() {
return new Reporter(meter());
}
@Bean
public Auditor auditor() {
return new Auditor(meter());
}
}And a runner that prints what the container ended up with:
@Bean
public CommandLineRunner report(MetricsConfig config, Meter meter, Reporter reporter, Auditor auditor) {
return args -> {
System.out.println("config class = " + config.getClass().getName());
System.out.println("meter bean = " + System.identityHashCode(meter));
System.out.println("reporter.meter = " + System.identityHashCode(reporter.meter));
System.out.println("auditor.meter = " + System.identityHashCode(auditor.meter));
};
}Read the Java and you would expect three new Meter() calls: one for the meter bean, one inside reporter(), one inside auditor(). What actually happens:
Meter constructed -> 1467977993
config class = com.example.demo.full.MetricsConfig$$SpringCGLIB$$0
meter bean = 1467977993
reporter.meter = 1467977993
auditor.meter = 1467977993One construction. One object. Three references to it.
The second line is the mechanism. config.getClass() is not MetricsConfig — it is MetricsConfig$$SpringCGLIB$$0, a subclass generated at runtime. That is what @Configuration means by default: full mode, in which the class is subclassed by CGLIB and every @Bean method is overridden with an interceptor. When reporter() calls meter(), it is calling the override, and the override asks the container for the meter bean instead of running the method body. The body ran exactly once, when the container created the bean.
Adding two more lines to the runner shows the subclass is genuinely a subclass:
config class = com.example.demo.full.MetricsConfig$$SpringCGLIB$$0
extends = com.example.demo.full.MetricsConfig
implements = [interface org.springframework.context.annotation.ConfigurationClassEnhancer$EnhancedConfiguration]That EnhancedConfiguration marker interface is how Spring recognises a configuration class it has already enhanced. The $$SpringCGLIB$$ in the name is worth memorising — it turns up in stack traces, in toString() output and in logs, and it means "you are looking at a proxied configuration class", not at a bug.

proxyBeanMethods = false
@Configuration has a flag that turns the subclassing off. The class is otherwise unchanged — same three methods, same direct calls:
@Configuration
@Configuration(proxyBeanMethods = false)
public class MetricsConfig {
// identical body
}Same runner, same application, one flag different:
Meter constructed -> 1857007886
Meter constructed -> 1179272258
Meter constructed -> 1002911155
config class = com.example.demo.lite.MetricsConfig
meter bean = 1857007886
reporter.meter = 1179272258
auditor.meter = 1002911155Three constructions. Three different objects. The Meter inside Reporter is not the Meter bean. This is lite mode: no subclass — config.getClass() is now plainly MetricsConfig — so meter() is an ordinary method call that runs its body every time. The container still has exactly one meter bean; the other two objects simply are not beans at all. Nothing fails, nothing is logged, and every piece of state you thought was shared is now duplicated.
(The two runs above live in packages com.example.demo.full and com.example.demo.lite so both configurations can sit in one project and be started as two separate applications. Nothing else differs between them.)
Lite mode is not a trap for its own sake. Full mode costs something real: a CGLIB subclass has to be generated, loaded and instantiated for every @Configuration class at startup, and every inter-bean call goes through an interceptor rather than the JVM's own call. It also means the configuration class cannot be final and neither can its @Bean methods, which is friction in Kotlin and an obstacle for ahead-of-time and native-image builds that prefer no runtime class generation.
Spring Boot itself chooses lite mode everywhere it can. @AutoConfiguration, the annotation on every auto-configuration class in the framework, carries it — javap -v on the annotation prints:
RuntimeVisibleAnnotations:
3: org.springframework.context.annotation.Configuration(
proxyBeanMethods=false
)So the hundreds of @Bean methods Boot runs on your behalf are all in lite mode. (That auto-configuration is nothing more than @Bean methods guarded by conditions is the subject of the last article in this series.)
The rule for using lite mode safely is one line: in a lite-mode configuration class, never call another @Bean method directly — declare it as a parameter instead. The parameter form goes through the container in both modes, so it is correct regardless of the flag. Same lite-mode class, rewritten:
@Configuration(proxyBeanMethods = false)
public class MetricsConfig {
@Bean
public Meter meter() {
return new Meter();
}
@Bean
public Reporter reporter(Meter meter) {
return new Reporter(meter);
}
@Bean
public Auditor auditor(Meter meter) {
return new Auditor(meter);
}
} Meter constructed -> 1857007886
config class = com.example.demo.params.MetricsConfig
meter bean = 1857007886
reporter.meter = 1857007886
auditor.meter = 1857007886One construction again, with no proxy involved. That is why the parameter form is the better default even in full mode: it says what it means, it works in both modes, and it survives somebody adding proxyBeanMethods = false to the class a year from now.
@Bean methods on a plain @Component class
@Bean is not restricted to @Configuration classes. Put the same methods on a @Component and the application starts perfectly:
@Component
public class MetricsBeans {
@Bean
public Meter meter() { return new Meter(); }
@Bean
public Reporter reporter() { return new Reporter(meter()); }
@Bean
public Auditor auditor() { return new Auditor(meter()); }
} Meter constructed -> 645643802
Meter constructed -> 949314262
Meter constructed -> 559998250
config class = com.example.demo.compcfg.MetricsBeans
meter bean = 645643802
reporter.meter = 949314262
auditor.meter = 559998250Three objects. A @Bean method on any class that is not a full-mode @Configuration class behaves as lite mode — the same duplication, with nothing in the source to warn you. That is the practical reason to keep bean definitions on classes annotated @Configuration: the annotation is the signal to every reader, and to Spring, that inter-bean calls are meant to be honoured.
static @Bean methods are never proxied
One more case where full mode does not save you. A static method cannot be overridden, so CGLIB cannot intercept it — even inside a default @Configuration class:
@Configuration
public class StaticConfig {
@Bean
public static Meter meter() { return new Meter(); }
@Bean
public Reporter reporter() { return new Reporter(meter()); }
} Meter constructed -> 1835316563
Meter constructed -> 490630452
meter bean = 1835316563
reporter.meter = 490630452Two objects, in full mode, from a class with no flags set.
static is nevertheless required in one situation: a @Bean method returning a BeanFactoryPostProcessor — PropertySourcesPlaceholderConfigurer being the one people meet — because that bean has to be created before the container can process annotations on the configuration class that declares it. Leave the static off and Spring tells you so at startup:
INFO o.s.c.a.ConfigurationClassEnhancer : @Bean method PlaceholderConfig.placeholders is non-static
and returns an object assignable to Spring's BeanFactoryPostProcessor interface. This will result in
a failure to process annotations such as @Autowired, @Resource, and @PostConstruct within the
method's declaring @Configuration class. Add the 'static' modifier to this method to avoid these
container lifecycle issues; see @Bean javadoc for complete details.It is logged at INFO, so it scrolls past unnoticed. Outside that narrow case, do not make @Bean methods static — you lose interception and gain nothing.
@Import, and configuration classes as beans
Configuration does not have to live in one class, and it does not have to be found by the scan. @Import names another configuration class explicitly:
@Configuration(proxyBeanMethods = false)
@Import(ClockConfig.class)
public class CacheConfig {
// ...
}@Configuration(proxyBeanMethods = false)
public class ClockConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneOffset.UTC);
}
}clock = java.time.Clock$SystemClockThe Clock bean is registered even though ClockConfig was never scanned for. That matters in two places: a configuration class that lives outside your base package (a shared library, typically) and a configuration class you deliberately keep out of the scan so it can be pulled in only where it is wanted. It is also, incidentally, a second illustration of the article's opening point — java.time.Clock is an abstract class in the JDK, and Clock.system(...) is a static factory. There is no annotation you could ever put on it.
Worth stating plainly, because it explains several behaviours at once: a @Configuration class is itself a bean. That is why the runner above could take MetricsConfig config as a parameter and print its class. It can have a constructor with dependencies, it can be injected elsewhere, and in full mode the bean in the container is the CGLIB subclass rather than the class you wrote.
Three failures worth recognising
A @Bean method on a class nobody registered
@Bean does nothing on its own. The class carrying the method has to reach the container — by being scanned, by being imported, or by being the application class. Forget that and the method is just a method:
package com.example.demo.forgot;
// no annotation here at all
public class RegistryConfig {
@Bean
public Clock clock() {
return Clock.system(ZoneOffset.UTC);
}
}The failure surfaces somewhere else entirely — at whatever tried to inject the bean:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.forgot.TimeService required a bean of type
'java.time.Clock' that could not be found.
Action:
Consider defining a bean of type 'java.time.Clock' in your configuration."Consider defining a bean" is misleading when you have already defined one. The check is always the same: the class holding the @Bean method is missing @Configuration, or it is in a package the scan never reaches.
A final @Configuration class
Full mode subclasses the class, and a final class cannot be subclassed. Spring detects this before it tries:
@Configuration
public final class FinalConfig {
@Bean
public Clock clock() { return Clock.system(ZoneOffset.UTC); }
}org.springframework.beans.factory.parsing.BeanDefinitionParsingException: Configuration problem:
@Configuration class 'FinalConfig' may not be final. Remove the final modifier to continue.
Offending resource: class path resource [com/example/demo/finalcfg/FinalConfig.class]
at org.springframework.context.annotation.ConfigurationClass.validate(ConfigurationClass.java:251)
at org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitionsIndividual methods have the same constraint, with its own message:
Configuration problem: @Bean method 'clock' must not be private or final; change the method's
modifiers to continue.Removing final is the usual fix. Adding proxyBeanMethods = false also works — a lite-mode configuration class is never subclassed, so final is allowed and the application starts — but only take that route if the class has no inter-bean calls to break.
Two @Bean methods with the same bean name
Two configuration classes, each declaring a bean named clock, is an easy accident: one is yours, one came from a library, or two people added the same helper a week apart.
@Configuration
public class AuditConfig {
@Bean
public Clock clock() { return Clock.system(ZoneOffset.UTC); }
}
@Configuration
public class BillingConfig {
@Bean
public Clock clock() { return Clock.system(ZoneId.of("Asia/Ho_Chi_Minh")); }
}Spring Boot's default is to refuse:
***************************
APPLICATION FAILED TO START
***************************
Description:
The bean 'clock', defined in class path resource [com/example/demo/dup/BillingConfig.class], could
not be registered. A bean with that name has already been defined in class path resource
[com/example/demo/dup/AuditConfig.class] and overriding is disabled.
Action:
Consider renaming one of the beans or enabling overriding by setting
spring.main.allow-bean-definition-overriding=trueThis is a good default, and the "Action" is a trap. Take it and the application starts, one definition silently replaces the other, and all you get is a line at INFO:
INFO o.s.b.f.s.DefaultListableBeanFactory : Overriding bean definition for bean 'clock' with a
different definition: replacing [... factoryBeanName=auditConfig; factoryMethodName=clock ...]
with [... factoryBeanName=billingConfig; factoryMethodName=clock ...]clock zone = Asia/Ho_Chi_MinhThe application is now running on whichever definition happened to be registered last — an ordering that depends on class scanning and can change when you rename a file. Rename one of the beans instead.
⚠️
spring.main.allow-bean-definition-overriding=truemakes a startup failure disappear by converting it into a behaviour that depends on scan order. Treat aBeanDefinitionOverrideExceptionas a naming bug to fix, not a setting to change.
FAQ
Can I put @Bean methods on my @SpringBootApplication class?
Yes. @SpringBootApplication includes @SpringBootConfiguration, which is @Configuration, so the application class is a full-mode configuration class and @Bean methods on it work exactly as described here. It is fine for one or two beans in a small service; past that, move them into named configuration classes so the application class stays a launcher.
Why does my @Bean method run twice?
Because something is calling it directly in lite mode. Either the class is @Configuration(proxyBeanMethods = false), or the methods are on a @Component rather than a @Configuration class, or the called method is static. Print System.identityHashCode on both sides to confirm, then convert the direct call into a method parameter, which is correct in every mode.
Should I set proxyBeanMethods = false on all my configuration classes?
Only if you also follow the rule that goes with it: no direct calls between @Bean methods. In a library or starter — where startup cost is multiplied across every application that depends on it, and native-image support matters — it is the right default, and it is what Spring Boot's own @AutoConfiguration does. In ordinary application code the saving is negligible and the risk of a future direct call is not, so leaving the default alone is defensible.
Can a @Bean method be private, final or static?
Not private or final in a full-mode class — Spring fails at startup with "must not be private or final; change the method's modifiers to continue", because it cannot override them. static is allowed but never intercepted, so direct calls to a static @Bean method build a new object every time even in full mode. Use static only for BeanFactoryPostProcessor beans, which must exist before the container processes the declaring class.
Does @Bean work on a class that is not annotated at all?
No. The method is invisible until the class itself is a bean definition — reached by component scanning, by @Import, or by being the application class. The symptom is not an error on the configuration class but a required a bean of type ... that could not be found at the injection point, which sends people looking in the wrong file.
How do I register two beans of the same type from one configuration class?
Write two methods. Give them distinct names — the method name is the bean name — or set @Bean(name = "...") explicitly. If the two beans have different generic parameters, such as Cache<String, Product> and Cache<String, User>, injection by type already tells them apart; if they are the same type, mark one @Primary or select with @Qualifier.
Conclusion
@Component registers a class you own; @Bean registers an object you produce. That is the whole boundary, and everything else follows from it — third-party types, JDK types, builders, branching construction logic and one class registered several times are all cases where there is no source file to annotate or no single answer to annotate it with. Bean names come from method names unless @Bean(name = ...) says otherwise, @Import pulls in configuration the scan never sees, and a @Configuration class is itself a bean.
The part that costs people real time is proxyBeanMethods. By default @Configuration is full mode: the class is subclassed by CGLIB, a direct call from reporter() to meter() is intercepted, and all three references point at the same object — 1467977993, three times. Set proxyBeanMethods = false, or move the methods to a @Component, or make one of them static, and the same source produces 1857007886, 1179272258 and 1002911155: three objects, no error, no warning. The defence is to stop relying on interception at all and declare inter-bean dependencies as method parameters, which behaves identically in both modes.
The next article stays inside the container and asks how long a bean lives: bean scope and lifecycle — singleton, prototype, request and session, and the @PostConstruct and @PreDestroy callbacks that run at each end of it.