A bean is an object that Spring built and Spring owns. That is the whole idea, and everything in this article follows from it: which of your classes should become beans, how the container finds them, what name it files them under, and where you can go and look at the result while the application is running.
Every number and every error message below came out of a real Spring Boot 4.1.1 application on Java 21, started with ./gradlew bootRun. The bean counts are printed by the running context, the failures were caused on purpose, and the claim that @Service does nothing is backed by a byte-level sweep of all twelve Spring Framework 7.0.9 jars rather than by reputation.
![]()
The project is the one Spring Initializr generates: Spring Boot 4.1.1 (Spring Framework 7.0.9, embedded Tomcat 11.0.24) on OpenJDK 21.0.6, built with the Gradle 9.7.1 wrapper and the spring-boot-starter-webmvc dependency. You already know that the class carrying @SpringBootApplication fixes the package that gets scanned; this article is about what happens inside that radius.
What a Spring bean actually is
A bean is an object whose construction and lifetime the container owns. Three things are true of it and of nothing else in your codebase:
- You never call its constructor. The container does, once, at the point it decides.
- It is filed in a registry under a name. You can ask for it by that name or by its type.
- Other beans receive it instead of building it. A class that needs a
ProductRepositorydeclares it as a constructor parameter and the container supplies the one it already has.
Here are the four classes used throughout this article. The annotation is the only thing that makes them beans — nothing else about them is special:
package com.example.demo.catalog;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.stereotype.Repository;
@Repository
public class ProductRepository {
public List<Product> findAll() {
return List.of(
new Product("SKU-1", "Keyboard", new BigDecimal("59.00")),
new Product("SKU-2", "Monitor", new BigDecimal("219.00")));
}
}package com.example.demo.catalog;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final ProductRepository repository;
public ProductService(ProductRepository repository) {
this.repository = repository;
}
public List<Product> catalog() {
return repository.findAll();
}
}package com.example.demo.web;
import com.example.demo.catalog.Product;
import com.example.demo.catalog.ProductService;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/products")
public List<Product> products() {
return service.catalog();
}
}ProductService receives its ProductRepository through the constructor. That is all you need for now; the alternatives and the rules that govern them are the next article's subject.
Which objects belong in the container
This is the distinction that trips beginners hardest, because tutorials annotate everything and never say why. The test is how many of this object exist, and who decides when one is made.
package com.example.demo.catalog;
import java.math.BigDecimal;
// NOT a bean: no annotation, created with new, one instance per row of data.
public record Product(String sku, String name, BigDecimal price) {}| Object | A bean? | Why |
|---|---|---|
| Service holding business logic | Yes | One per application, has collaborators, stateless. |
| Repository / DAO | Yes | One per application, wraps a shared resource. |
| Controller | Yes | One per application, the MVC layer has to find it. |
| A helper that reads configuration | Yes | It needs the Environment or a properties object, which only the container can hand it. |
A Clock, RestClient, ObjectMapper you configure | Yes | Configured once, shared everywhere. |
JPA entity (@Entity) | No | One instance per database row. The persistence provider creates them. |
| DTO / request body / response body | No | One per request. Jackson creates them. |
Value object, record, enum constant | No | Created wherever it is needed, with new. |
| Anything holding per-request or per-user state | No | A shared singleton holding one user's data is a bug, not a design. |
The rule of thumb: if the object carries data, it is not a bean; if it carries behaviour and collaborators, it is. new Product(...) inside a repository is correct and will always be correct. new ProductRepository() inside a service is the thing the container exists to remove.
A concrete consequence: annotating an @Entity with @Component compiles, starts, and gives you exactly one shared Product for the entire application — which is never what you wanted.
@Component and the three stereotypes
@Component is the base annotation. @Service, @Repository and @Controller are specialisations of it, and "specialisation" here has a precise, mechanical meaning: each one is itself annotated with @Component. That is called a meta-annotation, and it is the entire reason the scanner finds all four.
javap -v on Service.class from spring-context-7.0.9.jar prints the annotations attached to the annotation itself:
javap -v org/springframework/stereotype/Service.class{
public abstract java.lang.String value();
RuntimeVisibleAnnotations:
0: org.springframework.core.annotation.AliasFor(
annotation=class Lorg/springframework/stereotype/Component;
)
}
RuntimeVisibleAnnotations:
0: java.lang.annotation.Target(value=[Ljava/lang/annotation/ElementType;.TYPE])
1: java.lang.annotation.Retention(value=Ljava/lang/annotation/RetentionPolicy;.RUNTIME)
2: java.lang.annotation.Documented
3: org.springframework.stereotype.ComponentThe last line is the whole trick. @Repository and @Controller print identically. Plain reflection says the same thing:
for (Class<?> a : new Class<?>[] {Service.class, Repository.class, Controller.class, RestController.class}) {
System.out.println(a.getSimpleName()
+ ": directly @Component? " + a.isAnnotationPresent(Component.class)
+ " annotations = " + Arrays.toString(a.getAnnotations()));
}Service: directly @Component? true annotations = [@Target({TYPE}), @Retention(RUNTIME), @Documented(), @org.springframework.stereotype.Component("")]
Repository: directly @Component? true annotations = [@Target({TYPE}), @Retention(RUNTIME), @Documented(), @org.springframework.stereotype.Component("")]
Controller: directly @Component? true annotations = [@Target({TYPE}), @Retention(RUNTIME), @Documented(), @org.springframework.stereotype.Component("")]
RestController: directly @Component? false annotations = [@Target({TYPE}), @Retention(RUNTIME), @Documented(), @org.springframework.stereotype.Controller(""), @ResponseBody()]@RestController is the interesting one: it is not directly @Component. It is @Controller plus @ResponseBody, and @Controller is @Component — so the scanner still finds it, one level further down the chain. Spring's annotation machinery walks meta-annotations transitively, which is why a stereotype you define yourself works too.

@Component itself carries one meta-annotation of its own — @Indexed:
RuntimeVisibleAnnotations:
0: java.lang.annotation.Target(value=[Ljava/lang/annotation/ElementType;.TYPE])
1: java.lang.annotation.Retention(value=Ljava/lang/annotation/RetentionPolicy;.RUNTIME)
2: java.lang.annotation.Documented
3: org.springframework.stereotype.Indexed@Indexed is an opt-in for the optional spring-context-indexer annotation processor, which writes a META-INF/spring.components file at compile time so the container can skip the classpath walk. If you are not using that processor it does nothing at all.
So if all four are the same annotation underneath, does the choice matter? For two of them, yes — and for one of them, no.
What @Repository actually adds
@Repository is the only stereotype with real behaviour attached, and the behaviour is persistence exception translation. PersistenceExceptionTranslationPostProcessor looks for beans annotated @Repository, wraps them in a proxy, and converts provider-specific exceptions into Spring's DataAccessException hierarchy.
Proving it needs a project with a persistence layer, so this section uses a second generated project with data-jpa and h2. Two identical classes, differing only in the annotation:
@Repository
public class ItemRepository {
@PersistenceContext
private EntityManager em;
public Object broken() {
return em.createNativeQuery("select * from no_such_table").getResultList();
}
}@Component
public class ItemDao {
@PersistenceContext
private EntityManager em;
public Object broken() {
return em.createNativeQuery("select * from no_such_table").getResultList();
}
}=== PETPP bean names = [persistenceExceptionTranslationPostProcessor]
=== @Repository bean is an AOP proxy? true (com.example.jpademo.ItemRepository$$SpringCGLIB$$0)
=== @Component bean is an AOP proxy? false (com.example.jpademo.ItemDao)
=== calling the @Repository
threw org.springframework.dao.InvalidDataAccessResourceUsageException
is a Spring DataAccessException? true
cause org.hibernate.exception.SQLGrammarException
=== calling the @Component
threw org.hibernate.exception.SQLGrammarException
is a Spring DataAccessException? falseSame query, same failure, two different exception types. The @Repository bean is a CGLIB proxy and throws InvalidDataAccessResourceUsageException — a Spring exception, with the Hibernate one as its cause. The @Component bean is the plain object and throws Hibernate's exception straight through. That difference is what lets a service layer catch DataAccessException without importing Hibernate.
One honest caveat: the post-processor is not always there. In the webmvc-only project used for the rest of this article there is no persistenceExceptionTranslationPostProcessor bean at all, because spring-tx is not on the classpath. @Repository on a class that talks to nothing persistent is then exactly as inert as @Service.
What @Controller actually adds
RequestMappingHandlerMapping decides which beans are request handlers, and it decides by looking for @Controller (or a type-level @RequestMapping). A bean without either is invisible to MVC no matter how many @GetMapping methods it has.
Two classes with the same method, differing only in the stereotype:
@Service
public class ReportEndpoint {
@GetMapping("/report")
@ResponseBody
public String report() {
return "report from a @Service";
}
}@Controller
public class ReportController {
@GetMapping("/report2")
@ResponseBody
public String report() {
return "report from a @Controller";
}
}Printing the mappings the application actually registered:
RequestMappingHandlerMapping mapping = context.getBean(RequestMappingHandlerMapping.class);
mapping.getHandlerMethods().forEach((info, method) ->
System.out.println(" " + info + " -> " + method.getBeanType().getSimpleName()));
System.out.println("reportEndpoint is a bean? " + context.containsBean("reportEndpoint")); { [/error]} -> BasicErrorController
{ [/error], produces [text/html]} -> BasicErrorController
{GET [/report2]} -> ReportController
{GET [/products]} -> ProductController
reportEndpoint is a bean? true/report is missing. The @Service is a bean — containsBean says so — it is simply not a handler, and curl confirms it:
curl -s -i http://localhost:8086/report
curl -s -i http://localhost:8086/report2HTTP/1.1 404
Content-Type: application/json
{"timestamp":"2026-09-11T04:00:28.295Z","status":404,"error":"Not Found","path":"/report"}
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
report from a @ControllerThis is a second, quieter cause of "my endpoint returns 404" beyond the scan-radius one: the class was scanned, the bean exists, and the wrong stereotype is on it.
What @Service adds
Nothing. Not "almost nothing" — nothing.
The way to be sure is to unpack every Spring Framework 7.0.9 jar in the local Gradle cache and grep the class files for references to each stereotype's descriptor. Twelve jars, 6,193 class files:
grep -rla "org/springframework/stereotype/Service" .
grep -rla "org/springframework/stereotype/Repository" .
grep -rla "org/springframework/stereotype/Controller" .=== @Service referenced by:
=== @Repository referenced by:
org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor
=== @Controller referenced by:
org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping
org.springframework.web.bind.annotation.RestControllerRepeating the sweep over the Spring Boot 4.1.1 jars adds three more consumers of @Controller — BasicErrorController, ManagementErrorEndpoint and WebMvcTypeExcludeFilter, the last of which is how @WebMvcTest decides which beans belong in a web slice — and still zero for @Service.
So @Service is a semantic marker. It tells a reader "business logic lives here" and it tells static-analysis and architecture-test tools which layer a class belongs to. It changes nothing the container does. That is a perfectly good reason to keep using it — consistency in a codebase is worth something — but you should use it knowing that @Component would behave identically.
| Annotation | What it marks | What it actually adds | Where you use it |
|---|---|---|---|
@Component | Any container-managed object | Nothing beyond being scanned. It is the base every other row is built on. | Anything that does not fit the three layers: a mapper, a validator, a scheduled job, a client wrapper. |
@Service | Business logic | Nothing at run time. No class in Spring Framework 7.0.9 reads it. | The service layer, for readability and for architecture tests. |
@Repository | Data access | PersistenceExceptionTranslationPostProcessor proxies the bean and translates JPA/JDBC exceptions into DataAccessException — when spring-tx is present. | Hand-written DAOs. Spring Data interfaces already get it. |
@Controller | A web request handler | RequestMappingHandlerMapping treats the bean as a handler. @WebMvcTest includes it in the slice. | MVC controllers that return view names. |
@RestController | A web request handler whose returns are the response body | @Controller + @ResponseBody on every method. Not directly @Component — it inherits that through @Controller. | JSON APIs. The default for a REST service. |
How component scanning finds your classes
One recap sentence, because the previous article proved it with a 404: @ComponentScan with no arguments scans the package of the annotated class and every package below it, never sideways and never up.
Inside that radius, the term that matters is candidate component. A candidate is a class whose metadata satisfies at least one include filter and no exclude filter. The default include filter is @Component with meta-annotations considered, which is why all five annotations in the table above qualify. On top of that, a candidate must be concrete and independently instantiable: interfaces, abstract classes and non-static inner classes are read and discarded.

Scanning reads class files, it does not load classes
The scanner does not call Class.forName on every class in your package tree. It reads each .class file as bytes, through an ASM-based metadata reader, and decides from the constant pool whether the annotations are there. Only a class that passes becomes a BeanDefinition, and only a bean that gets instantiated is ever handed to the class loader.
This is not a detail you have to take on trust. Put a class with an exploding static initialiser inside the scanned package and leave it unannotated:
package com.example.demo.util;
// No annotation. The scanner reads this class FILE; it never loads the class.
public class Explosive {
static {
if (true) {
throw new IllegalStateException("Explosive was class-loaded!");
}
}
}Tomcat started on port 8086 (http) with context path '/'
Started DemoApplication in 0.446 seconds
=== started fine; Explosive was never loaded
beans = 152The application starts. The scanner walked right past Explosive.class, read its metadata, found no @Component and moved on. Now add one word:
@Component
public class Explosive { /* same static block */ }org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'explosive'
defined in file [.../com/example/demo/util/Explosive.class]: null
Caused by: java.lang.ExceptionInInitializerError
Caused by: java.lang.IllegalStateException: Explosive was class-loaded!Same class, same static block; the only difference is that this time the class was loaded, because it had to be instantiated. Notice also that the BeanCreationException quotes the .class file path rather than the class name — a direct consequence of the container having met this bean as a file first.
The practical payoff: a big package tree costs you a file read per class at startup, not a class load per class, which is why a Spring Boot application with thousands of classes still starts in under a second.
Filtering what gets scanned
@ComponentScan takes includeFilters and excludeFilters, each a list of @ComponentScan.Filter. An include filter widens the definition of "candidate"; an exclude filter removes classes that would otherwise qualify.
This is also the answer to "can I scan a package that is not under my main class?" — put @ComponentScan on an ordinary @Configuration class. That class gets picked up by the normal scan, and its own @ComponentScan is processed in turn:
package com.example.demo.config;
import com.example.plugins.Job;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
@Configuration
@ComponentScan(
basePackages = "com.example.plugins",
includeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = Job.class),
excludeFilters = @ComponentScan.Filter(type = FilterType.REGEX, pattern = ".*Experimental.*"))
public class PluginScanConfig {}com.example.plugins is a sibling of com.example.demo, so the default scan never reaches it. It contains three classes and none of them carries any annotation:
public interface Job { String name(); }
public class ReindexJob implements Job { public String name() { return "reindex"; } }
public class ExperimentalJob implements Job { public String name() { return "experimental"; } }
public class NotAJob {}=== Job beans = [reindexJob]
=== notAJob = false
=== experimentalJob = falseReindexJob became a bean because the include filter says "any class assignable to Job is a candidate". ExperimentalJob also implements Job but the regex exclude filter removed it — exclude filters win over include filters. NotAJob matched neither filter nor the default @Component rule, so it was ignored.
FilterType | Matches on | Typical use |
|---|---|---|
ANNOTATION | An annotation on the class, meta-annotations included | The default. Also used to exclude a whole stereotype from a scan. |
ASSIGNABLE_TYPE | A supertype or implemented interface | Registering a plugin family with no annotation on it. |
REGEX | The fully-qualified class name | Excluding *Test, *Legacy, a package prefix. |
ASPECTJ | An AspectJ type pattern | Rare; powerful when you already speak AspectJ. |
CUSTOM | Your own TypeFilter implementation | Anything the other four cannot express. |
Two flags worth knowing. useDefaultFilters = false switches off the built-in @Component rule entirely, so only your include filters apply — that is how you scan a package for only the classes you named. And @SpringBootApplication exposes scanBasePackages, scanBasePackageClasses and nameGenerator, but not includeFilters/excludeFilters; javap on the annotation lists exactly six attributes:
public interface org.springframework.boot.autoconfigure.SpringBootApplication {
public abstract java.lang.Class<?>[] exclude();
public abstract java.lang.String[] excludeName();
public abstract java.lang.String[] scanBasePackages();
public abstract java.lang.Class<?>[] scanBasePackageClasses();
public abstract java.lang.Class<? extends BeanNameGenerator> nameGenerator();
public abstract boolean proxyBeanMethods();
}So filtering the main scan means writing a separate @Configuration class, as above. There is also a third way to create a bean that has nothing to do with scanning — a @Bean factory method on a configuration class — and it gets an article of its own later in this series.
How a bean gets its name
Every bean has a name, and by default the name is derived from the class name. AnnotationBeanNameGenerator does it in two steps, which javap -c shows plainly:
protected java.lang.String buildDefaultBeanName(BeanDefinition);
22: invokestatic // Method org/springframework/util/ClassUtils.getShortName
27: invokestatic // Method org/springframework/util/StringUtils.uncapitalizeAsPropertyTake the simple class name, then uncapitalise it the way JavaBeans does. Printing the real registry contents for the seven components in the demo project:
demoApplication -> com.example.demo.DemoApplication
priceFormatter -> com.example.demo.catalog.PriceFormatter
productRepository -> com.example.demo.catalog.ProductRepository
productService -> com.example.demo.catalog.ProductService
mailer -> com.example.demo.notify.EmailNotifier
JSONExporter -> com.example.demo.util.JSONExporter
URLShortener -> com.example.demo.util.URLShortener
productController -> com.example.demo.web.ProductControllerNote that the package plays no part: ProductService in com.example.demo.catalog is called productService, full stop. Two classes with the same simple name in different packages therefore want the same bean name, which is a failure mode later in this article.
The two-capitals rule in bean naming
Two of those names look wrong and are not: URLShortener and JSONExporter kept their leading capital. That is the JavaBeans uncapitalisation rule, and javap -c on StringUtils.uncapitalizeAsProperty shows it exactly:
7: aload_0
8: invokevirtual // Method java/lang/String.length
11: iconst_1
12: if_icmple 39
15: aload_0 / charAt(0) / Character.isUpperCase -> ifeq 39
26: aload_0 / charAt(1) / Character.isUpperCase -> ifeq 39
37: aload_0
38: areturn // unchanged
39: changeFirstCharacterCase(s, false)In words: if the first two characters are both upper case, the string is returned unchanged; otherwise the first character is lower-cased. It is the same rule java.beans.Introspector.decapitalize applies, and the two agree on every case:
for (String s : new String[] {"ProductService", "URLShortener", "JSONExporter", "EmailNotifier", "AClass", "X"}) {
System.out.println(" Introspector.decapitalize(\"" + s + "\") = \"" + Introspector.decapitalize(s) + "\"");
} Introspector.decapitalize("ProductService") = "productService"
Introspector.decapitalize("URLShortener") = "URLShortener"
Introspector.decapitalize("JSONExporter") = "JSONExporter"
Introspector.decapitalize("EmailNotifier") = "emailNotifier"
Introspector.decapitalize("AClass") = "AClass"
Introspector.decapitalize("X") = "x"AClass is the case that catches people out — one letter then a capital is still "two upper case characters", so it stays AClass. This matters the moment you refer to a bean by name in a string: @Qualifier, @DependsOn, getBean("..."), spring.main.allow-bean-definition-overriding messages, @ConditionalOnBean(name = ...). Guessing uRLShortener gives you a NoSuchBeanDefinitionException with a name in it that you will stare at for a while.
Naming a bean explicitly
Every stereotype takes a value — the @AliasFor(annotation = Component.class) in the javap output above is what makes @Service("x"), @Repository("x") and @Controller("x") all set the same underlying attribute:
package com.example.demo.notify;
import org.springframework.stereotype.Component;
@Component("mailer")
public class EmailNotifier {}containsBean("emailNotifier") = false
containsBean("mailer") = trueThe explicit name replaces the default; it is not an additional alias. Give a bean an explicit name when the name is part of a contract — something referenced by a string in configuration, a @Qualifier, or a property — or when two classes with the same simple name would otherwise collide. Do not give one to every bean out of habit: a name that duplicates the derived name is noise that can drift out of sync with the class it names.
If you want to change the rule rather than individual names, @SpringBootApplication(nameGenerator = ...) takes a BeanNameGenerator implementation — the one place where fully-qualified bean names are a reasonable choice, for instance in a large multi-module codebase where simple names genuinely repeat.
The ApplicationContext
The container has two interfaces. BeanFactory is the minimum: getBean, containsBean, isSingleton, getType, getAliases — eighteen members in total, and nothing else. ApplicationContext is the one you actually use, and javap shows what it is:
public interface org.springframework.context.ApplicationContext extends
org.springframework.core.env.EnvironmentCapable,
org.springframework.beans.factory.ListableBeanFactory,
org.springframework.beans.factory.HierarchicalBeanFactory,
org.springframework.context.MessageSource,
org.springframework.context.ApplicationEventPublisher,
org.springframework.core.io.support.ResourcePatternResolverSix interfaces, of which two are BeanFactory refinements and four are extra jobs.

What ApplicationContext adds over BeanFactory
| Interface | What it gives you |
|---|---|
ListableBeanFactory | Enumerate the registry: getBeanDefinitionNames, getBeanNamesForType, getBeansOfType, getBeanNamesForAnnotation. A bare BeanFactory can only answer questions about a bean you already name. |
HierarchicalBeanFactory | A parent context, so one context can fall back to another. |
EnvironmentCapable | getEnvironment() — properties, profiles, the whole configuration model. |
MessageSource | getMessage(...) — internationalised text resolution. |
ApplicationEventPublisher | publishEvent(...) and @EventListener. |
ResourcePatternResolver | getResource("classpath:...") and getResources("classpath*:..."). |
Four of those in one run, against the live context:
ApplicationContext ctx = SpringApplication.run(DemoApplication.class, args);
System.out.println("getId() = " + ctx.getId());
System.out.println("getEnvironment().getProperty = " + ctx.getEnvironment().getProperty("server.port"));
System.out.println("getResource(...).exists() = "
+ ctx.getResource("classpath:application.properties").exists());
System.out.println("getBeanNamesForType count = " + ctx.getBeanNamesForType(Object.class).length);getId() = demo
getEnvironment().getProperty = 8086
getResource(...).exists() = true
getBeanNamesForType count = 171There is also a third interface you will meet: ConfigurableApplicationContext, which is what SpringApplication.run actually returns. It adds the lifecycle operations — refresh(), close(), registerShutdownHook(), getBeanFactory() — that an application needs and ordinary code should not call.
Reading the registry at run time
Start with the surprise. A Spring Boot 4.1.1 project with one dependency, spring-boot-starter-webmvc, and a single empty @SpringBootApplication class:
ConfigurableApplicationContext context = SpringApplication.run(DemoApplication.class, args);
System.out.println("=== bean definition count = " + context.getBeanDefinitionCount());=== bean definition count = 145145 beans before you write a line of application code. A sample of the names, sorted, gives you the flavour of what is in there:
NAME basicErrorController
NAME characterEncodingFilter
NAME demoApplication
NAME dispatcherServlet
NAME dispatcherServletRegistration
NAME errorAttributes
NAME jacksonJsonMapper
NAME localeResolver
NAME multipartResolver
NAME mvcContentNegotiationManager
NAME mvcConversionService
NAME org.springframework.boot.autoconfigure.aop.AopAutoConfiguration
NAME org.springframework.context.annotation.internalAutowiredAnnotationProcessor
NAME requestMappingHandlerAdapter
NAME requestMappingHandlerMapping
NAME server-org.springframework.boot.web.server.autoconfigure.ServerProperties
NAME tomcatServletWebServerFactory
NAME viewResolver
NAME welcomePageHandlerMappingThree shapes of name are visible there: short camel-case names for beans declared by @Bean methods, fully-qualified class names for the auto-configuration classes themselves, and prefix-FullyQualifiedProperties for @ConfigurationProperties bindings. demoApplication — your own main class — sits among them, because it is an ordinary bean like the rest.
Adding the seven components from this article:
definitions = 152
singletons = 169
getBeansOfType(Object.class) = 168152 definitions, of which 8 are yours. The singleton count is higher than the definition count, which looks wrong until you list the difference — 19 objects were registered directly as singletons, with no definition behind them:
applicationEventMulticaster
applicationStartup
autoConfigurationReport
contextAttributes
contextParameters
environment
messageSource
servletContext
springApplicationArguments
springBootBanner
springBootLoggerGroups
springBootLoggingLifecycle
springBootLoggingSystem
systemEnvironment
systemProperties
webServerGracefulShutdown
webServerStartStopThose are infrastructure objects that Spring Boot already had in hand before the context started, so it put them straight into the singleton map rather than describing them first. getBeanDefinitionCount() does not see them; getSingletonCount() does.
Now the API itself, run against that context:
ProductService svc = ctx.getBean(ProductService.class);
System.out.println("getBean(ProductService.class) = " + svc);
System.out.println("getBean(\"productService\") = " + ctx.getBean("productService"));
System.out.println("getBeanNamesForType(ProductRepository.class) = "
+ Arrays.toString(ctx.getBeanNamesForType(ProductRepository.class)));
System.out.println("containsBean(\"productService\") = " + ctx.containsBean("productService"));
System.out.println("containsBean(\"ProductService\") = " + ctx.containsBean("ProductService"));
System.out.println("getBeansOfType(ProductRepository.class) = "
+ ctx.getBeansOfType(ProductRepository.class));
System.out.println("getBeanNamesForAnnotation(Service.class) = "
+ Arrays.toString(ctx.getBeanNamesForAnnotation(Service.class)));
System.out.println("same instance twice? " + (ctx.getBean(ProductService.class) == svc));getBean(ProductService.class) = com.example.demo.catalog.ProductService@6fc6deb7
getBean("productService") = com.example.demo.catalog.ProductService@6fc6deb7
getBeanNamesForType(ProductRepository.class) = [productRepository]
containsBean("productService") = true
containsBean("ProductService") = false
getBeansOfType(ProductRepository.class) = {productRepository=com.example.demo.catalog.ProductRepository@78a0ff63}
getBeanNamesForAnnotation(Service.class) = [productService]
same instance twice? trueThree things to read out of that. The two getBean calls return the same identity hash — by type and by name reach the same object. containsBean is case-sensitive, because the name is a plain map key. And getBeansOfType returns a Map<String, T> keyed by bean name, which is the idiomatic way to ask "give me every implementation of this interface".
| Call | Returns | Use it for |
|---|---|---|
getBean(Type.class) | The single bean of that type | Fetching a known collaborator from outside the container. |
getBean("name") | Object — cast or use the two-arg form | Fetching a bean whose name you know and whose type you may not. |
containsBean("name") | boolean | The fastest possible answer to "was my class actually scanned?". |
getBeanNamesForType(Type.class) | String[] | Discovering how many candidates exist, without instantiating any of them. |
getBeansOfType(Type.class) | Map<String, T> | Collecting every implementation of an interface. Instantiates them. |
getBeanNamesForAnnotation(A.class) | String[] | Auditing: which beans carry @Service, which carry your own marker. |
getBeanDefinitionCount() / getBeanDefinitionNames() | int / String[] | Diagnostics, and the fastest way to see what auto-configuration gave you. |
Injecting the ApplicationContext into a bean works the same way any collaborator does:
@Service
public class DiagnosticsService {
private final ApplicationContext context;
public DiagnosticsService(ApplicationContext context) {
this.context = context;
}
}⚠️ Injecting the context so you can call
getBeanin business logic is an anti-pattern with a name — service locator. It hides dependencies from the constructor, defeats the compiler, and makes the class untestable without a container. Inject the collaborator, not the container. The legitimate uses are diagnostics, framework-level code and genuinely dynamic lookup where the type is not known until run time.
What a BeanDefinition is
The container does not create your objects when it finds them. It first stores a recipe — a BeanDefinition — and instantiates later, from the recipe. That two-phase design is why post-processors can modify beans before they exist, why proxies can be inserted, and why the container can detect a name clash without ever running your constructor.
A definition is readable at run time:
ConfigurableListableBeanFactory bf = context.getBeanFactory();
BeanDefinition bd = bf.getBeanDefinition("productService");
System.out.println("definition class = " + bd.getClass().getSimpleName());
System.out.println("getBeanClassName = " + bd.getBeanClassName());
System.out.println("getScope = \"" + bd.getScope() + "\"");
System.out.println("isLazyInit = " + bd.isLazyInit());
System.out.println("resource = " + bd.getResourceDescription()); productService
definition class = ScannedGenericBeanDefinition
getBeanClassName = com.example.demo.catalog.ProductService
getScope = "singleton"
isSingleton = true
isLazyInit = false
resource = file [.../build/classes/java/main/com/example/demo/catalog/ProductService.class]Every field there is worth a second: the definition holds the class name as a string, not a Class object, because at registration time nothing was loaded. resource points at the .class file the scanner read. scope reads "singleton" — the value, and what scopes mean, is the subject of a later article; take it as the default for now.
A bean that came from somewhere other than a scan looks different:
requestMappingHandlerMapping
definition class = ConfigurationClassBeanDefinition
getBeanClassName = null
getScope = ""
resource = class path resource [.../WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]ScannedGenericBeanDefinition versus ConfigurationClassBeanDefinition, and a null bean class name — because that bean is produced by a factory method, not by a constructor, so there is no class to name until the method runs. Two different registration mechanisms, one registry, one uniform way to ask what is in it.
Three ways bean registration goes wrong
The bean that is never registered
The class sits outside the scan radius — in a package that is not under the @SpringBootApplication class. There is no error, no warning, nothing in the log; the class is simply an ordinary class nobody asked for, and every request to its endpoint returns 404. The previous article reproduced this in full; the diagnostic is one line:
System.out.println(context.containsBean("helloController"));false means it was never scanned, and the fix is to move the class under the main package or to widen scanBasePackages.
Two beans that want the same name
Because the default name comes from the simple class name and ignores the package, two classes called ProductRepository in different packages collide. Both annotated, both inside the radius:
package com.example.demo.legacy;
import org.springframework.stereotype.Repository;
@Repository
public class ProductRepository {}The application does not start:
org.springframework.beans.factory.BeanDefinitionStoreException: Failed to parse configuration class
[com.example.demo.DemoApplication]
...
Caused by: org.springframework.context.annotation.ConflictingBeanDefinitionException:
Annotation-specified bean name 'productRepository' for bean class [com.example.demo.legacy.ProductRepository]
conflicts with existing, non-compatible bean definition of same name and
class [com.example.demo.catalog.ProductRepository]
at ClassPathBeanDefinitionScanner.checkCandidate(ClassPathBeanDefinitionScanner.java:363)
at ClassPathBeanDefinitionScanner.doScan(ClassPathBeanDefinitionScanner.java:290)The message names both classes, which makes this one of the friendlier startup failures. It is also caught during scanning, before any of your code runs — the two-phase design paying for itself.
The fix is an explicit name on one of them:
@Repository
@Repository("legacyProductRepository")
public class ProductRepository {}catalog : [productRepository]
legacy : [legacyProductRepository]Both are registered, and getBeanNamesForType finds each under its own name. Renaming one of the classes is usually the better fix, because two types with the same simple name are confusing in imports too — but when the duplicate lives in a library you do not control, the explicit name is what you have.
NoSuchBeanDefinitionException
Asking for something that is not a bean fails at the point of asking. Two flavours, and the message tells you which mistake you made:
ctx.getBean(Product.class); // Product is a record, not a bean
ctx.getBean("productservice"); // lower-case sorg.springframework.beans.factory.NoSuchBeanDefinitionException
No qualifying bean of type 'com.example.demo.catalog.Product' available
org.springframework.beans.factory.NoSuchBeanDefinitionException
No bean named 'productservice' available"No qualifying bean of type" means nothing in the registry is assignable to that type — the class was never annotated, or never scanned, or it is a data class that should never have been asked for. "No bean named" means the type is irrelevant and the string key is simply not in the map — a typo, or a bean whose real name is URLShortener rather than the one you guessed.
When this exception arrives from an injection point rather than a direct getBean, the message is longer and carries the injection site with it — but the first half is identical, and the first thing to check is always containsBean and getBeanNamesForType.
FAQ
Is @Service different from @Component in any way that matters?
No. A byte-level sweep of all twelve Spring Framework 7.0.9 jars finds zero classes that read @Service, and the same sweep over the Spring Boot 4.1.1 jars finds zero as well. It is documentation, and it is picked up by the scanner only because it is meta-annotated with @Component. Use it for readability and for architecture tests; do not expect behaviour from it.
Should every class in my service layer be annotated @Service?
Only the ones that are beans — the ones with collaborators, held one per application. A helper with no state and no dependencies is better as a class with static methods or as a plain object constructed where it is used. Annotating everything makes the registry noisy and hides which objects actually participate in the container.
Why is my bean called URLShortener and not uRLShortener?
Because Spring uncapitalises the simple class name with the JavaBeans rule: if the first two characters are both upper case the name is returned unchanged. URLShortener, JSONExporter and even AClass keep their leading capital; ProductService becomes productService. This only matters when you refer to the bean by name in a string.
How do I list every bean in a running application?
context.getBeanDefinitionNames() sorted and printed, as in this article — it needs no dependency and works in any application. If you have spring-boot-starter-actuator, the /actuator/beans endpoint gives you the same list as JSON with each bean's type, scope and dependencies, which is more useful once the list is 150 entries long.
Can I put @ComponentScan on a class that is not the main class?
Yes, and it is the right answer whenever you need to scan a package outside the main radius or apply filters. Put it on an ordinary @Configuration class inside the scanned package; that class is found by the default scan and its own @ComponentScan is then processed. Be aware that @ComponentScan on the main class replaces the default radius rather than adding to it, which is why a separate configuration class is usually cleaner.
Can an entity or a record ever be a bean?
Technically yes — the container will happily manage any concrete class with a usable constructor. Practically no, and the reason is arithmetic: a bean is created once and shared, while an entity exists once per database row and a record once per value. A single shared Product for an entire application is not a design, it is a bug that will look like a data leak between users.
Conclusion
A bean is an object the container constructs, names and hands out; everything with collaborators qualifies, and everything that carries data does not. @Service, @Repository and @Controller are all @Component underneath — that meta-annotation is exactly why the scanner finds them — and of the three only @Repository and @Controller change what the framework does, which a sweep of all 6,193 Spring Framework 7.0.9 class files confirms. Scanning reads .class files as bytes and loads nothing until a bean has to be built, which is why an unannotated class with an exploding static initialiser sits harmlessly in a scanned package. The name is the decapitalised simple class name, with URLShortener staying URLShortener, and it is the key into a registry that already holds 145 beans before you add one of your own. Each entry in that registry started life as a BeanDefinition — a recipe holding a class name, a scope and the file it was read from — which is the idea the next few articles all build on.
The next article is about the wiring itself: the ways to inject a dependency — constructor, setter and field injection, what @Autowired really does, and how @Qualifier and @Primary settle things when more than one candidate matches.