Stereotype semantics, @Bean methods for code you don't own, why @Configuration classes get CGLIB-proxied, circular dependency resolution via the three-level cache, and @Qualifier vs @Primary.
Published September 23, 2026
@Component, @Service, @Repository, @Controller all register a class as a Spring bean identically at the mechanical level — the differences are about intent and, for @Repository specifically, actual added behavior:
@Component // generic — "this is a Spring-managed bean," no more specific meaning
@Service // service-layer business logic — same mechanics as @Component, communicates architectural intent
@Repository // data-access layer — ADDITIONALLY enables exception translation: a driver-specific exception
// (e.g. a MongoDB-specific exception) gets translated into a Spring DataAccessException,
// so calling code can catch a consistent exception type regardless of which database is behind it
@Controller // web layer — handles HTTP requests, works with Spring MVC's view resolution
@Repository's exception translation is the one case where the stereotype isn't purely cosmetic — it's implemented via the same BeanPostProcessor mechanism from Bean Lifecycle In Detail, wrapping repository beans with translation logic.
@SpringBootApplication // implicitly includes @ComponentScan of the current package and sub-packages
public class MyApplication { ... }
@ComponentScan(basePackages = "com.example.orders", excludeFilters = @Filter(type = FilterType.REGEX, pattern = ".*LegacyService"))
By default, Spring Boot's @SpringBootApplication scans the package it's declared in and everything below it — classes in sibling or parent packages are invisible to component scanning unless explicitly included via basePackages. This is a common source of "why isn't my bean being picked up" bugs: a class in a package outside the scan root simply never gets registered.
Stereotype annotations only work on classes you can annotate directly — a third-party SDK client class can't be retrofitted with @Component. @Bean methods inside an @Configuration class solve this:
@Configuration
class ThirdPartyConfig {
@Bean
StripeClient stripeClient(@Value("\${stripe.api-key}") String apiKey) {
return new StripeClient(apiKey); // you control construction explicitly, since you can't annotate StripeClient itself
}
}
@Configuration
class AppConfig {
@Bean Engine engine() { return new Engine(); }
@Bean Car car() { return new Car(engine()); } // calls engine() — but does this create a SECOND Engine?
}
If car() naively called the plain Java method engine(), it would invoke the raw method body and construct a second Engine — violating singleton scope, since engine() was also registered as its own bean. Spring prevents this by CGLIB-subclassing the @Configuration class at startup: the actual class Spring instantiates is a runtime-generated subclass of AppConfig that intercepts every inter-@Bean-method call, checking the container first — if engine() was already created as a singleton, the intercepted call returns the existing bean instead of running the method body again. This is why @Configuration classes can't be final (CGLIB needs to subclass them) and why calling a @Bean method from within another @Bean method in the same config class correctly returns the shared singleton instead of a fresh object.
@Service class ServiceA { @Autowired ServiceB b; } // setter/field injection
@Service class ServiceB { @Autowired ServiceA a; }
For setter/field injection, Spring can resolve this circular reference using a three-level cache: as ServiceA is being constructed, Spring exposes an early, not-fully-initialized reference to it in a cache before ServiceA's fields are populated. When ServiceB needs a ServiceA to satisfy its own field injection, it retrieves that early reference from the cache rather than triggering another full construction — both objects end up correctly wired to each other once both finish initializing.
Why constructor injection can't be resolved this way: a constructor needs its argument fully available before the object can be constructed at all — there's no "early, not-yet-initialized reference" to hand out, because the object doesn't exist yet until the constructor returns. Two beans circularly requiring each other via constructor injection is a genuine, unresolvable circular dependency, and Spring throws BeanCurrentlyInCreationException at startup — this is, not coincidentally, one of the strongest practical arguments for constructor injection despite the general preference for it elsewhere: it converts a circular-dependency design smell into a startup failure, instead of silently working around it.
@Component @Primary
class EmailNotifier implements Notifier { ... } // the DEFAULT choice when multiple candidates exist
@Component
class SmsNotifier implements Notifier { ... }
class AlertService {
AlertService(@Qualifier("smsNotifier") Notifier notifier) { ... } // explicitly overrides @Primary for this specific injection site
}
@Primary sets a default candidate applied whenever multiple beans of a type exist and no more specific instruction is given. @Qualifier names a specific bean at the exact injection point, and takes precedence over @Primary when both are present — @Primary is a fallback rule, @Qualifier is an explicit override.
Q: What actually goes wrong if a class marked @Configuration is declared final?
A: CGLIB proxying (needed for the inter-@Bean-method interception described above) requires subclassing the configuration class at runtime — a final class can't be subclassed, so Spring can't create the proxy, and inter-bean-method calls would bypass singleton enforcement entirely (silently creating duplicate instances) rather than failing loudly, making this a subtle bug rather than a startup error.
Q: Is @ComponentScan's default (scan-from-current-package-down) ever a problem in a multi-module project?
A: Yes — if application code lives in a different package tree than the @SpringBootApplication class's package, those classes won't be auto-detected unless basePackages is explicitly widened, which is a common real onboarding confusion in projects with an unconventional package layout.
Q: Can @Repository's exception translation be disabled or bypassed?
A: It's tied to the PersistenceExceptionTranslationPostProcessor bean being present (which Spring Boot auto-configures by default) — removing or not registering that post-processor would leave @Repository-annotated classes as functionally identical to @Component, with no translation behavior.
Q: If setter injection resolves circular dependencies via the three-level cache, is that actually a good thing to rely on? A: Generally no — most Spring guidance today treats a circular dependency, even a setter-injection-resolvable one, as a design smell worth fixing (usually by extracting shared behavior into a third bean both depend on) rather than a feature to lean on; the cache exists as a pragmatic escape hatch for legacy code, not as an endorsed pattern for new code.