Spring IoC & Dependency Injection
Tung Bui Thanh (TungDaDev) (updated 6 months ago)11 mins read

Trong thế giới Java, Spring Framework gần như độc chiếm. Nếu coi cả hệ sinh thái Spring là một chiếc xe to, thì IoC Container (Inversion of Control) và Dependency Injection (DI) là cái động cơ kéo cả chiếc xe đó chạy.
Dev backend ai cũng gõ @Component, @Service, @Autowired hằng ngày, nhưng khi đụng tới bài toán kiến trúc lớn thì vẫn còn vài câu hỏi gốc rễ mà nhiều người chưa rõ:
- DIP, IoC và DI khác nhau thế nào về lý thuyết và kiến trúc?
- Một Spring bean thật sự đi qua những giai đoạn nào trong vòng đời, từ lúc nạp class tới lúc bị huỷ? Vì sao proxy cho
@Transactionalhay Spring Security chỉ được tạo ở giai đoạnBeanPostProcessor? - Vì sao field injection (
@Autowiredtrên private field) bị coi là anti-pattern nguy hiểm và bị loại khỏi các chuẩn code hiện đại? - Spring gỡ circular dependency bên trong
DefaultSingletonBeanRegistrybằng cache 3 tầng (three-level cache) như thế nào?
Phần dưới lần lượt trả lời từng câu, đi xuống tới mã nguồn của Spring khi cần.
# phân biệt dip vs ioc vs di
Nhiều người dùng ba khái niệm này lẫn lộn như một. Thật ra chúng nằm ở 3 tầng trừu tượng khác hẳn nhau:
- DIP: nguyên tắc thiết kế ở mức trừu tượng.
- IoC: pattern đảo ngược luồng điều khiển. Thay vì code của bạn tự
new OrderRepository(), bạn nhường việc đó cho Spring Container. - DI: việc cụ thể Spring làm: tạo instance
OrderRepositoryrồi "bơm" (inject) vàoOrderService.
# vòng đời của spring bean
Từ lúc được khai báo tới lúc sẵn sàng phục vụ HTTP request, một Spring bean phải qua một loạt bước kiểm tra và biến đổi bên trong ApplicationContext:
Sơ đồ trên là bản rút gọn để dễ nhớ; bên trong thì ranh giới các bước không rạch ròi vậy. ApplicationContextAware thật ra được gọi bởi một BeanPostProcessor (ApplicationContextAwareProcessor) ở bước 4, và @PostConstruct cũng do CommonAnnotationBeanPostProcessor gọi trong postProcessBeforeInitialization. Thứ tự init đầy đủ là @PostConstruct → afterPropertiesSet() → init-method. Javadoc của BeanFactory liệt kê đủ các bước nếu bạn muốn xem danh sách dài.
# vì sao postProcessAfterInitialization lại quan trọng
Khi bạn gắn @Transactional hay @Secured lên một method, object mà Spring inject vào các Controller/Service khác không phải class gốc của bạn!
- Ở bước này, Spring AOP chen vào và bọc object bằng một CGLIB dynamic proxy class (ví dụ:
OrderService$$SpringCGLIB$$0từ Spring 6). Spring Boot mặc địnhproxyTargetClass=truenên dùng CGLIB; nếu tắt đi và bean có interface thì Spring dùng JDK dynamic proxy, khi đó chỉ inject được theo interface. - Một ngoại lệ đáng nhớ là khi bean dính circular dependency: proxy được tạo sớm hơn, qua
getEarlyBeanReference()(xem phần cache 3 tầng bên dưới). - Nếu bạn gọi nội bộ
this.methodWithTransactional(), lời gọi chạy thẳng trong instance gốc, không đi qua proxy, thế là transaction bị bỏ qua hoàn toàn!
TIP
Để tránh bẫy self-invocation của Spring AOP, tách các method cần @Transactional hoặc @Async sang một Service riêng, hoặc inject chính Service đó qua lazy lookup để lời gọi đi qua CGLIB proxy.
# vì sao field injection bị loại
Tutorial cũ thì lúc nào cũng viết kiểu này:
@Service
public class OrderService {
@Autowired // FIELD INJECTION: ANTI-PATTERN NGUY HIỂM!
private PaymentClient paymentClient;
@Autowired
private OrderRepository orderRepository;
}Ngắn thì ngắn thật, nhưng chính tài liệu Spring khuyên dùng constructor injection, IntelliJ thì cảnh báo "Field injection is not recommended", và nhiều team cấm hẳn field injection trong code review vì 4 lý do khá nặng:
# constructor injection đúng chuẩn với requiredArgsConstructor
@Service
@RequiredArgsConstructor // Tự động sinh Constructor cho toàn bộ các trường 'final'
public class OrderService {
// Tham chiếu không đổi được sau khi tạo; 'final' còn đảm bảo publish an toàn giữa các thread
// (bản thân object được tham chiếu tới có bất biến hay không là chuyện khác)
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
// Viết Unit Test bằng 'new' thẳng, không cần Spring context hay reflection:
// OrderService service = new OrderService(mockPayment, mockRepo);
}Class chỉ có một constructor thì không cần gắn @Autowired (từ Spring 4.3), Spring tự dùng constructor đó. @RequiredArgsConstructor là của Lombok; không dùng Lombok thì tự viết constructor, kết quả y hệt.
# cache 3 tầng xử lý circular dependency
Bài toán kinh điển: class A cần inject B, còn class B lại cần inject A.
Khởi tạo kiểu thông thường thì: muốn tạo A phải có B, muốn tạo B lại phải có A -> vòng lặp vô tận, ném ra BeanCurrentlyInCreationException!
Để gỡ chuyện này cho singleton bean, DefaultSingletonBeanRegistry của Spring giữ một cache 3 tầng (three-level cache):
Vì sao cần tới tầng 3 mà không bỏ thẳng A thô vào tầng 2? Vì lúc tạo A, Spring chưa biết có ai cần tham chiếu sớm tới A hay không. Tầng 3 chỉ giữ một "lời hứa" (() -> getEarlyBeanReference(beanName, mbd, bean)). Chỉ khi thật sự có vòng, lời hứa này mới được gọi, và nếu A cần AOP thì proxy của A được tạo sớm ngay lúc đó, để B nhận đúng proxy chứ không phải object thô. Không có vòng thì proxy vẫn được tạo đúng chỗ ở postProcessAfterInitialization. Cũng vì cần gọi được constructor trước rồi mới inject, cơ chế này chỉ gỡ được vòng qua setter/field; vòng qua constructor thì luôn ném BeanCurrentlyInCreationException.
WARNING
Thay đổi từ Spring Boot 2.6+: Cache 3 tầng gỡ được phụ thuộc vòng cho setter/field injection, nhưng có circular dependency là dấu hiệu chia trách nhiệm sai. Từ Spring Boot 2.6, Spring Boot mặc định cấm hẳn circular dependency (spring.main.allow-circular-references: false; Spring Framework dùng riêng thì vẫn cho phép). Ứng dụng sẽ fail-fast ngay lúc khởi động!
# scope mismatch: inject prototype vào singleton
Mặc định, bean nào trong Spring cũng là singleton (chỉ có một instance trong ApplicationContext).
Giả sử bạn có ReportGenerator (scope prototype, mỗi lần lấy là một object mới) được inject vào BillingService (scope singleton):
@Component
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ReportGenerator {
private final String reportId = UUID.randomUUID().toString();
public String getReportId() {
return reportId;
}
}
@Service // Singleton (Mặc định)
@RequiredArgsConstructor
public class BillingService {
private final ReportGenerator reportGenerator; // Bẫy Scope Mismatch!
public void generate() {
System.out.println(reportGenerator.getReportId());
}
}# code này sai logic ở đâu?
BillingService là singleton nên chỉ được tạo đúng 1 lần lúc Spring khởi động. Đúng lúc đó, một instance ReportGenerator được inject vào.
- Suốt vòng đời ứng dụng, mọi lần gọi
billingService.generate()đều dùng lại mãi đúng cái instanceReportGeneratorban đầu. - Scope
prototypecoi như vô dụng!
# cách xử lý
# cách 1: dùng ObjectProvider
@Service
@RequiredArgsConstructor
public class BillingService {
private final ObjectProvider<ReportGenerator> reportGeneratorProvider;
public void generate() {
// Mỗi lần gọi getObject() sẽ kích hoạt container sinh ra một prototype mới
ReportGenerator generator = reportGeneratorProvider.getObject();
System.out.println(generator.getReportId());
}
}# cách 2: dùng scoped proxy
Spring inject một CGLIB proxy thay cho object thật (@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE, proxyMode = ScopedProxyMode.TARGET_CLASS)). Mỗi lần method của proxy được gọi, nó xin container một instance để chuyển tiếp lời gọi. Với prototype, "xin" nghĩa là tạo mới mỗi lần gọi method: gọi setX() rồi getX() trên cùng biến là chạm vào hai object khác nhau, rất dễ dính bug. Scoped proxy hợp với scope request/session (trong một request luôn trả cùng object) hơn là prototype.
Ngoài hai cách trên còn có @Lookup trên một method abstract/rỗng, Spring override method đó để trả prototype mới mỗi lần gọi.
TIP
Nên chọn cái nào: Ưu tiên ObjectProvider<T> vì code rõ ràng, dễ mock khi viết unit test và không tốn thêm overhead của dynamic proxy CGLIB lúc runtime.
# so sánh các kiểu dependency injection
| Tiêu chí | Constructor Injection (nên dùng) | Setter Injection | Field Injection (anti-pattern) |
|---|---|---|---|
Bất biến (final) | 🟢 Hỗ trợ 100% | 🔴 Không được | 🔴 Không được |
| Viết unit test | 🟢 Rất dễ (không cần Spring) | 🟡 Trung bình (gọi setter) | 🔴 Khó (cần Reflection / Mockito Runner) |
| Chặn coupling chặt | 🟢 Constructor dài là thấy ngay | 🔴 Dễ bị lạm dụng | 🔴 Giấu hẳn code smell |
| Null safety | 🟢 Tránh được NPE | 🔴 Dễ dính NPE nếu quên set | 🔴 Dễ dính NPE ngoài Spring context |
| Phát hiện circular dep | 🟢 Luôn fail-fast lúc startup | 🟡 Tự gỡ ngầm nếu được phép | 🟡 Tự gỡ ngầm nếu được phép |
# tổng kết và lời khuyên
Hiểu kỹ Spring IoC & Dependency Injection là chỗ phân biệt người dùng framework theo thói quen với người nắm được hệ thống đang chạy thế nào:
- Bỏ field injection: luôn dùng constructor injection với field
finalđể giữ bất biến và viết unit test không cần container. - Nắm vòng đời của bean: để biết chính xác lúc nào proxy AOP được tạo và tránh bẫy tự gọi method nội bộ (
this). - Cẩn thận với scope mismatch: dùng
ObjectProviderkhi inject bean có vòng đời ngắn hơn (prototype, request scope) vào singleton bean.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.