Bean là một object do Spring tạo ra và do Spring sở hữu. Toàn bộ ý tưởng chỉ có vậy, và mọi thứ trong bài này đều suy ra từ đó: class nào của bạn nên trở thành bean, container tìm chúng bằng cách nào, nó lưu dưới name gì, và bạn mở chỗ nào để xem kết quả khi application đang chạy.
Mọi con số và mọi thông báo lỗi bên dưới đều lấy từ một application Spring Boot 4.1.1 thật trên Java 21, chạy bằng ./gradlew bootRun. Số bean do chính context đang chạy in ra, các lỗi được dựng lại có chủ đích, và khẳng định "@Service không làm gì cả" được chứng minh bằng cách quét byte toàn bộ mười hai file jar của Spring Framework 7.0.9 chứ không phải theo trí nhớ.
![]()
Project vẫn là bản Spring Initializr sinh ra: Spring Boot 4.1.1 (Spring Framework 7.0.9, embedded Tomcat 11.0.24) trên OpenJDK 21.0.6, build bằng Gradle wrapper 9.7.1 với dependency spring-boot-starter-webmvc. Bạn đã biết class mang @SpringBootApplication quyết định package nào được scan; bài này nói về chuyện xảy ra bên trong bán kính đó.
Bean trong Spring thực sự là gì
Bean là object mà container sở hữu việc tạo ra và vòng đời của nó. Có ba điều đúng với bean và không đúng với bất cứ thứ gì khác trong codebase:
- Bạn không bao giờ gọi constructor của nó. Container gọi, một lần, vào thời điểm nó quyết định.
- Nó được lưu trong một registry dưới một name. Bạn hỏi bằng name đó hoặc bằng type.
- Các bean khác nhận nó thay vì tự dựng. Class nào cần
ProductRepositorythì khai nó làm constructor parameter, container đưa đúng cái đang có.
Đây là bốn class dùng xuyên suốt bài. Annotation là thứ duy nhất biến chúng thành bean — ngoài ra không có gì đặc biệt:
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 nhận ProductRepository qua constructor. Bây giờ chỉ cần vậy; các cách khác và quy tắc đi kèm là nội dung của bài tiếp theo.
Object nào thuộc về container
Đây là ranh giới làm người mới vấp nặng nhất, vì các tutorial thường annotate mọi thứ mà không nói tại sao. Câu hỏi kiểm tra là: object này tồn tại bao nhiêu bản, và ai quyết định khi nào tạo ra một bản?
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 | Là bean? | Vì sao |
|---|---|---|
| Service chứa business logic | Có | Một bản cho cả application, có collaborator, không giữ state. |
| Repository / DAO | Có | Một bản cho cả application, bọc một tài nguyên dùng chung. |
| Controller | Có | Một bản cho cả application, MVC layer phải tìm được nó. |
| Helper đọc config | Có | Nó cần Environment hoặc một properties object, chỉ container mới đưa được. |
Clock, RestClient, ObjectMapper bạn cấu hình | Có | Cấu hình một lần, dùng chung khắp nơi. |
JPA entity (@Entity) | Không | Mỗi dòng trong database là một instance. Persistence provider tạo chúng. |
| DTO / request body / response body | Không | Mỗi request một bản. Jackson tạo chúng. |
Value object, record, enum constant | Không | Tạo tại chỗ cần, bằng new. |
| Bất cứ thứ gì giữ state theo request hoặc theo user | Không | Một singleton dùng chung mà giữ dữ liệu của một user là bug, không phải thiết kế. |
Quy tắc nhanh: object mang dữ liệu thì không phải bean; object mang hành vi và collaborator thì là bean. new Product(...) bên trong repository là đúng và sẽ luôn đúng. new ProductRepository() bên trong service mới là thứ container sinh ra để dẹp đi.
Một hệ quả rất cụ thể: gắn @Component lên một @Entity vẫn compile, vẫn khởi động, và cho bạn đúng một Product dùng chung cho toàn bộ application — điều chưa bao giờ là thứ bạn muốn.
@Component và ba stereotype
@Component là annotation gốc. @Service, @Repository và @Controller là bản chuyên biệt hoá của nó, và "chuyên biệt hoá" ở đây có nghĩa rất máy móc: mỗi cái tự nó được annotate bằng @Component. Đó gọi là meta-annotation, và đó là toàn bộ lý do scanner tìm ra cả bốn.
javap -v trên Service.class lấy từ spring-context-7.0.9.jar in ra chính các annotation gắn lên annotation đó:
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.ComponentDòng cuối là toàn bộ mấu chốt. @Repository và @Controller in ra y hệt. Dùng reflection thuần cũng cho kết quả tương tự:
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 là trường hợp đáng chú ý: nó không trực tiếp là @Component. Nó là @Controller cộng @ResponseBody, còn @Controller mới là @Component — nên scanner vẫn tìm ra, chỉ là qua thêm một tầng. Cơ chế annotation của Spring duyệt meta-annotation bắc cầu, nên một stereotype bạn tự định nghĩa cũng chạy y như vậy.

Bản thân @Component cũng mang một meta-annotation riêng — @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 là chỗ bật cho annotation processor tuỳ chọn spring-context-indexer, thứ ghi ra file META-INF/spring.components lúc compile để container khỏi phải duyệt classpath. Nếu bạn không dùng processor đó thì nó không làm gì cả.
Vậy nếu cả bốn bên dưới đều là một annotation, việc chọn cái nào có quan trọng không? Với hai trong số đó thì có — và với một cái thì không.
@Repository thực sự thêm gì
@Repository là stereotype duy nhất gắn với hành vi thật, và hành vi đó là dịch exception của tầng persistence. PersistenceExceptionTranslationPostProcessor tìm các bean mang @Repository, bọc chúng trong một proxy, và đổi exception riêng của provider thành cây DataAccessException của Spring.
Muốn chứng minh thì cần một project có persistence layer, nên phần này dùng một project sinh thêm với data-jpa và h2. Hai class giống hệt nhau, chỉ khác 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? falseCùng một query, cùng một lỗi, hai loại exception khác nhau. Bean @Repository là một CGLIB proxy và ném InvalidDataAccessResourceUsageException — exception của Spring, với exception của Hibernate làm cause. Bean @Component là object trần và ném thẳng exception của Hibernate ra ngoài. Chính khác biệt đó cho phép service layer bắt DataAccessException mà không cần import Hibernate.
Một lưu ý cho sòng phẳng: post-processor này không phải lúc nào cũng có. Trong project chỉ có webmvc dùng cho phần còn lại của bài, không hề tồn tại bean persistenceExceptionTranslationPostProcessor, vì spring-tx không nằm trên classpath. Khi đó @Repository đặt lên một class không đụng gì tới persistence cũng vô dụng đúng bằng @Service.
@Controller thực sự thêm gì
RequestMappingHandlerMapping quyết định bean nào là request handler, và nó quyết định bằng cách tìm @Controller (hoặc một @RequestMapping ở mức type). Bean không có cái nào trong hai thứ đó thì MVC không nhìn thấy, dù có bao nhiêu method @GetMapping đi nữa.
Hai class có cùng một method, chỉ khác 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";
}
}In ra đúng những mapping mà application đã đăng ký:
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 không có mặt. Class @Service kia vẫn là bean — containsBean xác nhận — nó chỉ đơn giản không phải handler, và curl nói rõ:
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 @ControllerĐây là nguyên nhân thứ hai, âm thầm hơn, của "endpoint của tôi trả về 404", ngoài chuyện sai bán kính scan: class đã được scan, bean đã tồn tại, chỉ là gắn nhầm stereotype.
@Service thêm gì
Không gì cả. Không phải "gần như không gì" — là không gì.
Cách chắc chắn nhất là giải nén toàn bộ jar Spring Framework 7.0.9 trong Gradle cache và grep các file class tìm reference tới descriptor của từng stereotype. Mười hai jar, 6.193 file class:
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.RestControllerQuét lại trên các jar Spring Boot 4.1.1 thì có thêm ba chỗ dùng @Controller — BasicErrorController, ManagementErrorEndpoint và WebMvcTypeExcludeFilter, cái cuối chính là cách @WebMvcTest chọn bean nào thuộc về một web slice — và vẫn là con số không cho @Service.
Nên @Service là một marker mang tính ngữ nghĩa. Nó nói với người đọc "business logic nằm ở đây" và nói với các công cụ static analysis hay architecture test rằng class này thuộc layer nào. Nó không thay đổi bất cứ điều gì container làm. Đó vẫn là lý do hoàn toàn chính đáng để tiếp tục dùng — sự nhất quán trong một codebase là thứ có giá trị — nhưng hãy dùng với hiểu biết rằng @Component sẽ cho hành vi y hệt.
| Annotation | Đánh dấu cái gì | Thực sự thêm gì | Dùng ở đâu |
|---|---|---|---|
@Component | Mọi object do container quản lý | Không gì ngoài việc được scan. Đây là gốc mà mọi dòng còn lại dựng lên từ đó. | Những thứ không thuộc ba layer kia: mapper, validator, scheduled job, wrapper của một client. |
@Service | Business logic | Không gì lúc runtime. Không class nào trong Spring Framework 7.0.9 đọc nó. | Service layer, vì dễ đọc và vì architecture test. |
@Repository | Truy cập dữ liệu | PersistenceExceptionTranslationPostProcessor proxy bean và dịch exception JPA/JDBC thành DataAccessException — khi có spring-tx. | DAO viết tay. Interface của Spring Data đã có sẵn. |
@Controller | Một handler cho web request | RequestMappingHandlerMapping coi bean là handler. @WebMvcTest đưa nó vào slice. | Controller MVC trả về tên view. |
@RestController | Handler mà giá trị trả về chính là response body | @Controller + @ResponseBody cho mọi method. Không trực tiếp là @Component — nó thừa hưởng qua @Controller. | API JSON. Lựa chọn mặc định cho một REST service. |
Component scanning tìm class của bạn như thế nào
Một câu nhắc lại, vì bài trước đã chứng minh bằng lỗi 404: @ComponentScan không có argument sẽ scan package của class được annotate và mọi package bên dưới, không bao giờ đi ngang và không bao giờ đi lên.
Bên trong bán kính đó, khái niệm quan trọng là candidate component. Candidate là class có metadata thoả ít nhất một include filter và không dính exclude filter nào. Include filter mặc định là @Component có xét meta-annotation, nên cả năm annotation trong bảng trên đều đạt. Ngoài ra candidate phải là class cụ thể và tự tạo được: interface, abstract class và inner class không static đều được đọc rồi bỏ.

Scanning đọc file .class chứ không load class
Scanner không gọi Class.forName trên mọi class trong package tree của bạn. Nó đọc từng file .class dưới dạng bytes, qua một metadata reader dựa trên ASM, rồi dựa vào constant pool để biết annotation có ở đó không. Chỉ class nào qua được mới thành BeanDefinition, và chỉ bean nào được tạo ra mới thực sự đi qua class loader.
Đây không phải chi tiết phải tin suông. Đặt một class có static initializer nổ tung vào trong package được scan và không annotate gì:
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 = 152Application khởi động bình thường. Scanner đi ngang qua Explosive.class, đọc metadata, không thấy @Component và đi tiếp. Giờ thêm đúng một từ:
@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!Cùng một class, cùng một static block; khác biệt duy nhất là lần này class bị load, vì nó phải được tạo instance. Để ý thêm rằng BeanCreationException trích dẫn đường dẫn file .class chứ không phải tên class — hệ quả trực tiếp của việc container gặp bean này lần đầu dưới dạng một file.
Lợi ích thực tế: một package tree lớn khiến bạn tốn một lần đọc file cho mỗi class lúc khởi động, chứ không phải một lần load class cho mỗi class — đó là lý do một application Spring Boot hàng nghìn class vẫn khởi động dưới một giây.
Lọc những gì được scan
@ComponentScan nhận includeFilters và excludeFilters, mỗi cái là một danh sách @ComponentScan.Filter. Include filter nới rộng định nghĩa "candidate"; exclude filter loại bỏ những class lẽ ra đã đạt.
Đây cũng là câu trả lời cho "tôi scan được package nằm ngoài class main không?" — đặt @ComponentScan lên một class @Configuration bình thường. Class đó được scan mặc định tìm thấy, rồi @ComponentScan của chính nó được xử lý tiếp:
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 là package anh em của com.example.demo, nên scan mặc định không bao giờ với tới. Nó chứa ba class và không class nào mang 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 thành bean vì include filter nói "class nào gán được về Job đều là candidate". ExperimentalJob cũng implement Job nhưng regex exclude filter đã loại nó — exclude filter thắng include filter. NotAJob không khớp filter nào lẫn quy tắc @Component mặc định nên bị bỏ qua.
FilterType | Khớp theo | Dùng khi nào |
|---|---|---|
ANNOTATION | Một annotation trên class, tính cả meta-annotation | Mặc định. Cũng dùng để loại cả một stereotype khỏi một lần scan. |
ASSIGNABLE_TYPE | Một supertype hoặc interface được implement | Đăng ký một họ plugin không hề có annotation. |
REGEX | Tên class đầy đủ | Loại *Test, *Legacy, hoặc cả một package prefix. |
ASPECTJ | Một type pattern của AspectJ | Hiếm; rất mạnh nếu bạn đã quen AspectJ. |
CUSTOM | TypeFilter bạn tự viết | Mọi thứ bốn loại kia diễn đạt không nổi. |
Hai tuỳ chọn đáng biết. useDefaultFilters = false tắt hẳn quy tắc @Component có sẵn, chỉ còn include filter của bạn — đó là cách scan một package mà chỉ lấy đúng những class bạn chỉ định. Và @SpringBootApplication có scanBasePackages, scanBasePackageClasses, nameGenerator, nhưng không có includeFilters/excludeFilters; javap trên annotation này liệt kê đúng sáu attribute:
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();
}Nên muốn lọc lần scan chính thì phải viết một class @Configuration riêng như trên. Ngoài ra còn một cách thứ ba để tạo bean chẳng liên quan gì tới scanning — một @Bean factory method trên configuration class — và nó có hẳn một bài riêng ở phần sau của series.
Bean được đặt tên như thế nào
Mọi bean đều có name, và mặc định name suy ra từ tên class. AnnotationBeanNameGenerator làm việc đó qua hai bước, javap -c cho thấy rõ:
protected java.lang.String buildDefaultBeanName(BeanDefinition);
22: invokestatic // Method org/springframework/util/ClassUtils.getShortName
27: invokestatic // Method org/springframework/util/StringUtils.uncapitalizeAsPropertyLấy tên class ngắn, rồi viết thường chữ đầu theo kiểu JavaBeans. In ra nội dung registry thật cho bảy component trong project demo:
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Để ý package không đóng vai trò gì: ProductService nằm trong com.example.demo.catalog vẫn tên là productService, hết. Hai class trùng tên ngắn ở hai package khác nhau vì thế sẽ đòi cùng một bean name — một kiểu lỗi sẽ gặp ở cuối bài.
Quy tắc đặt tên cho class bắt đầu bằng hai chữ hoa
Hai cái tên ở trên trông như sai mà không hề sai: URLShortener và JSONExporter giữ nguyên chữ hoa đầu. Đó là quy tắc viết thường của JavaBeans, và javap -c trên StringUtils.uncapitalizeAsProperty cho thấy chính xác:
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)Nói bằng lời: nếu hai ký tự đầu đều viết hoa thì chuỗi được trả về nguyên vẹn; ngược lại ký tự đầu bị viết thường. Đó đúng là quy tắc mà java.beans.Introspector.decapitalize áp dụng, và hai bên khớp nhau ở mọi trường hợp:
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 là trường hợp hay làm người ta hụt: một chữ cái rồi tới một chữ hoa vẫn tính là "hai ký tự viết hoa", nên nó giữ nguyên AClass. Điều này quan trọng ngay khi bạn nhắc tới bean bằng chuỗi: @Qualifier, @DependsOn, getBean("..."), các thông báo của spring.main.allow-bean-definition-overriding, @ConditionalOnBean(name = ...). Đoán là uRLShortener thì bạn nhận một NoSuchBeanDefinitionException với cái tên bạn sẽ nhìn chằm chằm một lúc lâu.
Đặt tên bean tường minh
Mọi stereotype đều nhận một value — chính @AliasFor(annotation = Component.class) trong output javap phía trên làm cho @Service("x"), @Repository("x") và @Controller("x") cùng set một attribute gốc:
package com.example.demo.notify;
import org.springframework.stereotype.Component;
@Component("mailer")
public class EmailNotifier {}containsBean("emailNotifier") = false
containsBean("mailer") = trueName tường minh thay thế name mặc định, nó không phải alias thêm vào. Hãy đặt name tường minh khi cái name đó là một phần của hợp đồng — thứ được nhắc bằng chuỗi trong config, trong @Qualifier, hay trong một property — hoặc khi hai class trùng tên ngắn sẽ đụng nhau. Đừng đặt cho mọi bean theo thói quen: một name trùng đúng với name suy ra là thứ thừa và có thể lệch khỏi tên class lúc nào không hay.
Nếu bạn muốn đổi hẳn quy tắc thay vì đổi từng cái tên, @SpringBootApplication(nameGenerator = ...) nhận một implementation của BeanNameGenerator — đây là chỗ duy nhất mà bean name dạng đầy đủ là lựa chọn hợp lý, ví dụ trong một codebase nhiều module mà tên ngắn thật sự lặp lại.
ApplicationContext
Container có hai interface. BeanFactory là mức tối thiểu: getBean, containsBean, isSingleton, getType, getAliases — tổng cộng mười tám thành viên, không hơn. ApplicationContext mới là thứ bạn thực sự dùng, và javap cho thấy nó là gì:
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.ResourcePatternResolverSáu interface, trong đó hai cái mở rộng BeanFactory và bốn cái là việc khác hẳn.

ApplicationContext thêm gì so với BeanFactory
| Interface | Cho bạn cái gì |
|---|---|
ListableBeanFactory | Liệt kê registry: getBeanDefinitionNames, getBeanNamesForType, getBeansOfType, getBeanNamesForAnnotation. BeanFactory trần chỉ trả lời được câu hỏi về một bean bạn đã gọi đúng tên. |
HierarchicalBeanFactory | Có context cha, nên một context có thể hỏi ngược lên context khác. |
EnvironmentCapable | getEnvironment() — property, profile, toàn bộ mô hình cấu hình. |
MessageSource | getMessage(...) — phân giải chuỗi đa ngôn ngữ. |
ApplicationEventPublisher | publishEvent(...) và @EventListener. |
ResourcePatternResolver | getResource("classpath:...") và getResources("classpath*:..."). |
Bốn trong số đó chạy thật trên context đang sống:
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 = 171Còn một interface thứ ba bạn sẽ gặp: ConfigurableApplicationContext, chính là thứ SpringApplication.run trả về. Nó bổ sung các thao tác vòng đời — refresh(), close(), registerShutdownHook(), getBeanFactory() — thứ mà application cần còn code thường ngày thì không nên gọi.
Đọc registry lúc runtime
Bắt đầu bằng con số gây bất ngờ. Một project Spring Boot 4.1.1 với đúng một dependency là spring-boot-starter-webmvc và một class @SpringBootApplication rỗng:
ConfigurableApplicationContext context = SpringApplication.run(DemoApplication.class, args);
System.out.println("=== bean definition count = " + context.getBeanDefinitionCount());=== bean definition count = 145145 bean trước khi bạn viết một dòng code nào. Một phần danh sách name, đã sắp xếp, cho thấy trong đó có gì:
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 welcomePageHandlerMappingCó ba dạng name ở đó: name camel-case ngắn cho bean do @Bean method khai báo, tên class đầy đủ cho chính các auto-configuration class, và prefix-TênClassProperties cho các binding @ConfigurationProperties. demoApplication — class main của bạn — nằm chung trong đó, vì nó là một bean bình thường như mọi bean khác.
Thêm bảy component của bài này vào:
definitions = 152
singletons = 169
getBeansOfType(Object.class) = 168152 definition, trong đó 8 là của bạn. Số singleton lớn hơn số definition, nghe vô lý cho tới khi liệt kê phần chênh — 19 object được đăng ký thẳng vào dạng singleton, phía sau không có definition nào:
applicationEventMulticaster
applicationStartup
autoConfigurationReport
contextAttributes
contextParameters
environment
messageSource
servletContext
springApplicationArguments
springBootBanner
springBootLoggerGroups
springBootLoggingLifecycle
springBootLoggingSystem
systemEnvironment
systemProperties
webServerGracefulShutdown
webServerStartStopĐó là các object hạ tầng mà Spring Boot đã cầm sẵn trong tay trước khi context khởi động, nên nó nhét thẳng vào singleton map thay vì mô tả trước. getBeanDefinitionCount() không thấy chúng; getSingletonCount() thì có.
Giờ tới chính API đó, chạy trên context vừa rồi:
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? trueBa điều rút ra. Hai lời gọi getBean trả về cùng một identity hash — hỏi theo type và hỏi theo name chạm tới đúng một object. containsBean phân biệt hoa thường, vì name chỉ là một key trong map. Và getBeansOfType trả về Map<String, T> đánh khoá theo bean name, đó là cách chuẩn để hỏi "đưa tôi mọi implementation của interface này".
| Lời gọi | Trả về | Dùng để |
|---|---|---|
getBean(Type.class) | Bean duy nhất của type đó | Lấy một collaborator đã biết từ bên ngoài container. |
getBean("name") | Object — cast hoặc dùng bản có hai parameter | Lấy bean khi biết name mà có thể chưa biết type. |
containsBean("name") | boolean | Câu trả lời nhanh nhất cho "class của tôi có thật sự được scan không?". |
getBeanNamesForType(Type.class) | String[] | Xem có bao nhiêu ứng viên, mà không tạo instance nào. |
getBeansOfType(Type.class) | Map<String, T> | Gom mọi implementation của một interface. Có tạo instance. |
getBeanNamesForAnnotation(A.class) | String[] | Kiểm kê: bean nào mang @Service, bean nào mang marker của bạn. |
getBeanDefinitionCount() / getBeanDefinitionNames() | int / String[] | Chẩn đoán, và cách nhanh nhất để thấy auto-configuration đã cho bạn những gì. |
Inject ApplicationContext vào một bean cũng giống inject bất kỳ collaborator nào khác:
@Service
public class DiagnosticsService {
private final ApplicationContext context;
public DiagnosticsService(ApplicationContext context) {
this.context = context;
}
}⚠️ Inject context vào để gọi
getBeantrong business logic là một anti-pattern có tên hẳn hoi — service locator. Nó giấu dependency khỏi constructor, vô hiệu hoá compiler, và làm class không test được nếu thiếu container. Hãy inject collaborator, đừng inject container. Những chỗ dùng chính đáng là code chẩn đoán, code mức framework, và các tra cứu thật sự động khi type chỉ biết được lúc runtime.
BeanDefinition là gì
Container không tạo object của bạn ngay lúc tìm thấy. Trước hết nó lưu một công thức — một BeanDefinition — rồi mới tạo instance sau, từ công thức đó. Thiết kế hai pha này là lý do post-processor có thể sửa bean trước khi bean tồn tại, proxy có thể được chèn vào, và container phát hiện được trùng name mà chưa hề chạy constructor của bạn.
Definition đọc được lúc runtime:
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]Mỗi dòng ở đó đều đáng dừng lại: definition giữ tên class dạng chuỗi, không phải một Class object, vì lúc đăng ký chưa có gì được load. resource trỏ tới đúng file .class mà scanner đã đọc. scope đọc ra "singleton" — giá trị này, và scope nghĩa là gì, là nội dung của một bài sau; giờ cứ coi đó là mặc định.
Một bean đến từ nơi khác chứ không từ scan thì trông khác hẳn:
requestMappingHandlerMapping
definition class = ConfigurationClassBeanDefinition
getBeanClassName = null
getScope = ""
resource = class path resource [.../WebMvcAutoConfiguration$EnableWebMvcConfiguration.class]ScannedGenericBeanDefinition so với ConfigurationClassBeanDefinition, và bean class name là null — vì bean đó do một factory method sinh ra chứ không phải constructor, nên chưa có class nào để gọi tên cho tới khi method chạy. Hai cơ chế đăng ký khác nhau, một registry, một cách hỏi thống nhất.
Ba kiểu đăng ký bean thất bại
Bean không bao giờ được đăng ký
Class nằm ngoài bán kính scan — trong một package không nằm dưới class @SpringBootApplication. Không có lỗi, không có warning, log không có gì; class đó chỉ là một class bình thường không ai hỏi tới, và mọi request tới endpoint của nó trả 404. Bài trước đã dựng lại đầy đủ tình huống này; công cụ chẩn đoán chỉ là một dòng:
System.out.println(context.containsBean("helloController"));false nghĩa là nó chưa bao giờ được scan, và cách sửa là chuyển class xuống dưới package chính hoặc mở rộng scanBasePackages.
Hai bean muốn cùng một name
Vì name mặc định lấy từ tên class ngắn và bỏ qua package, hai class cùng tên ProductRepository ở hai package khác nhau sẽ đụng nhau. Cả hai đều annotate, cả hai đều trong bán kính:
package com.example.demo.legacy;
import org.springframework.stereotype.Repository;
@Repository
public class ProductRepository {}Application không khởi động:
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)Thông báo nêu tên cả hai class, nên đây là một trong những lỗi khởi động dễ chịu hơn cả. Nó cũng bị bắt ngay lúc scan, trước khi bất kỳ dòng code nào của bạn chạy — thiết kế hai pha trả công ngay tại đây.
Cách sửa là đặt name tường minh cho một trong hai:
@Repository
@Repository("legacyProductRepository")
public class ProductRepository {}catalog : [productRepository]
legacy : [legacyProductRepository]Cả hai đều được đăng ký, và getBeanNamesForType tìm thấy từng cái dưới name riêng. Đổi tên một trong hai class thường là cách sửa tốt hơn, vì hai type trùng tên ngắn cũng gây rối ngay ở phần import — nhưng khi bản trùng nằm trong một library bạn không kiểm soát, name tường minh là thứ bạn có.
NoSuchBeanDefinitionException
Hỏi một thứ không phải bean thì hỏng ngay tại chỗ hỏi. Có hai dạng, và thông báo cho biết bạn sai kiểu nào:
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" nghĩa là không có gì trong registry gán được về type đó — class chưa được annotate, hoặc chưa được scan, hoặc nó là một data class mà lẽ ra không nên hỏi tới. "No bean named" nghĩa là type không liên quan, đơn giản là key chuỗi đó không có trong map — gõ sai, hoặc bean có name thật là URLShortener chứ không phải cái bạn đoán.
Khi exception này đến từ một điểm inject thay vì một lời gọi getBean trực tiếp, thông báo sẽ dài hơn và kèm theo vị trí inject — nhưng nửa đầu vẫn y hệt, và thứ cần kiểm tra đầu tiên luôn là containsBean và getBeanNamesForType.
FAQ
@Service có khác @Component ở điểm nào đáng kể không?
Không. Quét byte toàn bộ mười hai jar Spring Framework 7.0.9 không tìm ra class nào đọc @Service, và quét tương tự trên các jar Spring Boot 4.1.1 cũng ra con số không. Nó là tài liệu, và nó được scanner nhận ra chỉ vì được meta-annotate bằng @Component. Dùng nó cho dễ đọc và cho architecture test; đừng chờ đợi hành vi từ nó.
Có phải mọi class trong service layer đều nên đánh @Service?
Chỉ những class là bean — những class có collaborator và giữ một bản cho cả application. Một helper không state, không dependency thì tốt hơn khi là class với static method hoặc một object bình thường tạo tại chỗ dùng. Annotate mọi thứ làm registry nhiễu và che mất object nào thực sự tham gia vào container.
Vì sao bean của tôi tên là URLShortener chứ không phải uRLShortener?
Vì Spring viết thường tên class ngắn theo quy tắc JavaBeans: nếu hai ký tự đầu đều viết hoa thì tên được giữ nguyên. URLShortener, JSONExporter và cả AClass đều giữ chữ hoa đầu; ProductService thành productService. Điều này chỉ ảnh hưởng khi bạn nhắc tới bean bằng chuỗi.
Làm sao liệt kê toàn bộ bean trong một application đang chạy?
context.getBeanDefinitionNames() sắp xếp rồi in ra, như trong bài này — không cần dependency nào và chạy được ở mọi application. Nếu có spring-boot-starter-actuator, endpoint /actuator/beans cho cùng danh sách dưới dạng JSON kèm type, scope và dependency của từng bean, tiện hơn nhiều khi danh sách đã dài tới 150 dòng.
Có thể đặt @ComponentScan lên một class không phải class main không?
Có, và đó là cách đúng mỗi khi bạn cần scan một package ngoài bán kính chính hoặc cần áp filter. Đặt nó lên một class @Configuration bình thường nằm trong package được scan; class đó được scan mặc định tìm thấy rồi @ComponentScan của nó được xử lý tiếp. Lưu ý @ComponentScan đặt trên class main sẽ thay thế bán kính mặc định chứ không cộng thêm, nên tách ra một configuration class riêng thường gọn gàng hơn.
Entity hay record có bao giờ nên là bean không?
Về kỹ thuật thì được — container sẵn sàng quản lý mọi class cụ thể có constructor dùng được. Về thực tế thì không, và lý do là phép đếm: bean được tạo một lần rồi dùng chung, còn entity tồn tại mỗi dòng database một bản và record mỗi giá trị một bản. Một Product dùng chung cho cả application không phải thiết kế, đó là bug và nó sẽ trông giống như rò rỉ dữ liệu giữa các user.
Kết luận
Bean là object mà container dựng lên, đặt tên và phát ra; thứ gì có collaborator thì đủ tiêu chuẩn, thứ gì mang dữ liệu thì không. @Service, @Repository và @Controller bên dưới đều là @Component — chính meta-annotation đó là lý do scanner tìm ra chúng — và trong ba cái chỉ @Repository với @Controller thay đổi hành vi của framework, điều mà một lần quét toàn bộ 6.193 file class của Spring Framework 7.0.9 xác nhận. Scanning đọc file .class dưới dạng bytes và không load gì cho tới khi một bean phải được dựng, nên một class không annotate với static initializer nổ tung vẫn nằm yên vô hại trong package được scan. Name là tên class ngắn viết thường chữ đầu, với URLShortener giữ nguyên là URLShortener, và nó là key vào một registry vốn đã chứa 145 bean trước khi bạn thêm bean đầu tiên. Mỗi mục trong registry đó khởi đầu là một BeanDefinition — một công thức giữ tên class, một scope và file mà nó được đọc ra — và đó là ý tưởng mà vài bài kế tiếp đều dựng lên từ đó.
Bài tiếp theo nói về chính phần đấu dây: các cách inject dependency — constructor, setter, field injection, @Autowired thực sự làm gì, và @Qualifier cùng @Primary phân xử thế nào khi có nhiều hơn một ứng viên khớp.