Command Palette

Search for a command to run...

[Spring Boot Basics] Spring Beans and the ApplicationContext: @Component, Stereotypes and Component Scanning

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.

Annotated classes on the left becoming named beans inside an ApplicationContext

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:

  1. You never call its constructor. The container does, once, at the point it decides.
  2. It is filed in a registry under a name. You can ask for it by that name or by its type.
  3. Other beans receive it instead of building it. A class that needs a ProductRepository declares 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:

src/main/java/com/example/demo/catalog/ProductRepository.java
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")));
    }
}
src/main/java/com/example/demo/catalog/ProductService.java
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();
    }
}
src/main/java/com/example/demo/web/ProductController.java
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.

src/main/java/com/example/demo/catalog/Product.java
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) {}
ObjectA bean?Why
Service holding business logicYesOne per application, has collaborators, stateless.
Repository / DAOYesOne per application, wraps a shared resource.
ControllerYesOne per application, the MVC layer has to find it.
A helper that reads configurationYesIt needs the Environment or a properties object, which only the container can hand it.
A Clock, RestClient, ObjectMapper you configureYesConfigured once, shared everywhere.
JPA entity (@Entity)NoOne instance per database row. The persistence provider creates them.
DTO / request body / response bodyNoOne per request. Jackson creates them.
Value object, record, enum constantNoCreated wherever it is needed, with new.
Anything holding per-request or per-user stateNoA 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:

Bash
javap -v org/springframework/stereotype/Service.class
Text
{
  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.Component

The last line is the whole trick. @Repository and @Controller print identically. Plain reflection says the same thing:

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

@Service, @Repository and @Controller each meta-annotated with @Component, and what each one adds

@Component itself carries one meta-annotation of its own — @Indexed:

Text
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:

ItemRepository.java
@Repository
public class ItemRepository {
 
    @PersistenceContext
    private EntityManager em;
 
    public Object broken() {
        return em.createNativeQuery("select * from no_such_table").getResultList();
    }
}
ItemDao.java
@Component
public class ItemDao {
 
    @PersistenceContext
    private EntityManager em;
 
    public Object broken() {
        return em.createNativeQuery("select * from no_such_table").getResultList();
    }
}
Text
=== 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? false

Same 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:

ReportEndpoint.java
@Service
public class ReportEndpoint {
 
    @GetMapping("/report")
    @ResponseBody
    public String report() {
        return "report from a @Service";
    }
}
ReportController.java
@Controller
public class ReportController {
 
    @GetMapping("/report2")
    @ResponseBody
    public String report() {
        return "report from a @Controller";
    }
}

Printing the mappings the application actually registered:

Java
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"));
Text
  { [/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:

Bash
curl -s -i http://localhost:8086/report
curl -s -i http://localhost:8086/report2
Text
HTTP/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 @Controller

This 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:

Bash
grep -rla "org/springframework/stereotype/Service" .
grep -rla "org/springframework/stereotype/Repository" .
grep -rla "org/springframework/stereotype/Controller" .
Text
=== @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.RestController

Repeating the sweep over the Spring Boot 4.1.1 jars adds three more consumers of @ControllerBasicErrorController, 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.

AnnotationWhat it marksWhat it actually addsWhere you use it
@ComponentAny container-managed objectNothing 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.
@ServiceBusiness logicNothing at run time. No class in Spring Framework 7.0.9 reads it.The service layer, for readability and for architecture tests.
@RepositoryData accessPersistenceExceptionTranslationPostProcessor proxies the bean and translates JPA/JDBC exceptions into DataAccessException — when spring-tx is present.Hand-written DAOs. Spring Data interfaces already get it.
@ControllerA web request handlerRequestMappingHandlerMapping treats the bean as a handler. @WebMvcTest includes it in the slice.MVC controllers that return view names.
@RestControllerA 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.

The ordered pipeline from a package tree to a bean sitting in the registry

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:

src/main/java/com/example/demo/util/Explosive.java
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!");
        }
    }
}
Text
Tomcat started on port 8086 (http) with context path '/'
Started DemoApplication in 0.446 seconds
=== started fine; Explosive was never loaded
beans = 152

The application starts. The scanner walked right past Explosive.class, read its metadata, found no @Component and moved on. Now add one word:

src/main/java/com/example/demo/util/Explosive.java
@Component
public class Explosive { /* same static block */ }
Text
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:

src/main/java/com/example/demo/config/PluginScanConfig.java
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:

Java
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 {}
Text
=== Job beans = [reindexJob]
=== notAJob   = false
=== experimentalJob = false

ReindexJob 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.

FilterTypeMatches onTypical use
ANNOTATIONAn annotation on the class, meta-annotations includedThe default. Also used to exclude a whole stereotype from a scan.
ASSIGNABLE_TYPEA supertype or implemented interfaceRegistering a plugin family with no annotation on it.
REGEXThe fully-qualified class nameExcluding *Test, *Legacy, a package prefix.
ASPECTJAn AspectJ type patternRare; powerful when you already speak AspectJ.
CUSTOMYour own TypeFilter implementationAnything 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:

Text
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:

Text
protected java.lang.String buildDefaultBeanName(BeanDefinition);
    22: invokestatic  // Method org/springframework/util/ClassUtils.getShortName
    27: invokestatic  // Method org/springframework/util/StringUtils.uncapitalizeAsProperty

Take the simple class name, then uncapitalise it the way JavaBeans does. Printing the real registry contents for the seven components in the demo project:

Text
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.ProductController

Note 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:

Text
 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:

Java
for (String s : new String[] {"ProductService", "URLShortener", "JSONExporter", "EmailNotifier", "AClass", "X"}) {
    System.out.println("  Introspector.decapitalize(\"" + s + "\") = \"" + Introspector.decapitalize(s) + "\"");
}
Text
  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:

src/main/java/com/example/demo/notify/EmailNotifier.java
package com.example.demo.notify;
 
import org.springframework.stereotype.Component;
 
@Component("mailer")
public class EmailNotifier {}
Text
containsBean("emailNotifier")      = false
containsBean("mailer")             = true

The 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:

Text
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.ResourcePatternResolver

Six interfaces, of which two are BeanFactory refinements and four are extra jobs.

The ApplicationContext as one name-to-instance registry holding your beans alongside Spring Boot's

What ApplicationContext adds over BeanFactory

InterfaceWhat it gives you
ListableBeanFactoryEnumerate the registry: getBeanDefinitionNames, getBeanNamesForType, getBeansOfType, getBeanNamesForAnnotation. A bare BeanFactory can only answer questions about a bean you already name.
HierarchicalBeanFactoryA parent context, so one context can fall back to another.
EnvironmentCapablegetEnvironment() — properties, profiles, the whole configuration model.
MessageSourcegetMessage(...) — internationalised text resolution.
ApplicationEventPublisherpublishEvent(...) and @EventListener.
ResourcePatternResolvergetResource("classpath:...") and getResources("classpath*:...").

Four of those in one run, against the live context:

Java
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);
Text
getId()                      = demo
getEnvironment().getProperty = 8086
getResource(...).exists()    = true
getBeanNamesForType count    = 171

There 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:

Java
ConfigurableApplicationContext context = SpringApplication.run(DemoApplication.class, args);
System.out.println("=== bean definition count = " + context.getBeanDefinitionCount());
Text
=== bean definition count = 145

145 beans before you write a line of application code. A sample of the names, sorted, gives you the flavour of what is in there:

Text
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 welcomePageHandlerMapping

Three 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:

Text
definitions = 152
singletons  = 169
getBeansOfType(Object.class) = 168

152 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:

Text
applicationEventMulticaster
applicationStartup
autoConfigurationReport
contextAttributes
contextParameters
environment
messageSource
servletContext
springApplicationArguments
springBootBanner
springBootLoggerGroups
springBootLoggingLifecycle
springBootLoggingSystem
systemEnvironment
systemProperties
webServerGracefulShutdown
webServerStartStop

Those 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:

Java
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));
Text
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? true

Three 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".

CallReturnsUse it for
getBean(Type.class)The single bean of that typeFetching a known collaborator from outside the container.
getBean("name")Object — cast or use the two-arg formFetching a bean whose name you know and whose type you may not.
containsBean("name")booleanThe 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:

DiagnosticsService.java
@Service
public class DiagnosticsService {
 
    private final ApplicationContext context;
 
    public DiagnosticsService(ApplicationContext context) {
        this.context = context;
    }
}

⚠️ Injecting the context so you can call getBean in 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:

Java
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());
Text
  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:

Text
  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:

Java
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:

src/main/java/com/example/demo/legacy/ProductRepository.java
package com.example.demo.legacy;
 
import org.springframework.stereotype.Repository;
 
@Repository
public class ProductRepository {}

The application does not start:

Text
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:

src/main/java/com/example/demo/legacy/ProductRepository.java
@Repository
@Repository("legacyProductRepository") 
public class ProductRepository {}
Text
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:

Java
ctx.getBean(Product.class);     // Product is a record, not a bean
ctx.getBean("productservice");  // lower-case s
Text
org.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.

Related Posts

[Spring Boot Basics] Spring Boot Profiles and Configuration Precedence: Environment Variables and Command-Line Arguments

Spring Boot 4.1.1 profiles and configuration precedence, measured on the packaged jar: application-dev.yml merged over application.yml key by key, every way to set spring.profiles.active and which of two active profiles wins, spring.profiles.default, multi-document files, profile groups and @Profile expressions, the real property source order from command-line arguments down to @PropertySource, where config files are searched, environment variables, spring.config.import and the startup error each mistake produces.

[Spring Boot Basics] Spring Bean Scopes and the Bean Lifecycle: singleton, prototype, @PostConstruct and @PreDestroy

Spring bean scopes and the bean lifecycle proved by running them on Spring Boot 4.1.1: why singleton means one per container and not one per JVM, singleton vs prototype vs request vs session vs application with real instance counts from curl, why @PreDestroy never fires on a prototype, the singleton-holds-a-prototype trap and the ObjectProvider, @Lookup and scoped-proxy fixes, the full fourteen-step lifecycle order traced from a real run, and what @Lazy actually costs.

[Spring Boot Basics] @RestController and Request Mapping in Spring Boot: @GetMapping, @PostMapping, @PutMapping and @DeleteMapping

How @RestController and request mapping work in Spring Boot 4.1.1, verified on a real project: @Controller versus @ResponseBody, the path of one request through DispatcherServlet, @GetMapping, @PostMapping, @PutMapping, @PatchMapping and @DeleteMapping on a CRUD controller, path patterns and which mapping wins, trailing slashes, consumes, produces, params and headers with the status each mismatch returns, implicit HEAD and OPTIONS, and what each return type becomes.

[Spring Boot Basics] Spring Framework vs Spring Boot: What Auto-Configuration, Starters and the Embedded Server Actually Do

Spring Framework 7.0.9 versus Spring Boot 4.1.1 on Java 21: what a plain Spring web application made you write, what starters and the spring-boot-dependencies BOM replace it with, why Boot 4 renamed the web starter to spring-boot-starter-webmvc, how auto-configuration backs off, embedded Tomcat against a WAR, and a map of the Spring ecosystem.