Spring có thể đưa một dependency vào object của bạn ở ba chỗ: parameter của constructor, một setter method, hoặc ghi thẳng vào private field. Cả ba đều chạy được. Chúng khác nhau đúng một điểm — thời điểm giá trị tới, tính theo mốc object được tạo ra — và mọi khác biệt còn lại, kể cả chuyện cách nào cho bạn viết unit test thuần và cách nào để lọt vòng lặp dependency, đều sinh ra từ đúng một điểm đó.
Toàn bộ nội dung bên dưới chạy trên Spring Boot 4.1.1 (Spring Framework 7.0.9) với Java 21.0.6 và Gradle 9.7.1. Mọi dòng console, exception và banner lỗi đều copy từ terminal, kể cả những lỗi được dựng lại có chủ đích.
![]()
Ví dụ cố tình giữ nhỏ: một component AuditLog và ba service đều cần nó, viết theo ba cách khác nhau.
Constructor, setter và field: ba injection point
Ba cách đặt cạnh nhau. Dependency giống hệt nhau, chỉ đổi lối vào.
@Service
public class ByConstructor {
private final AuditLog auditLog;
public ByConstructor(AuditLog auditLog) {
this.auditLog = auditLog;
}
public void use() { auditLog.record("constructor injection"); }
}@Service
public class BySetter {
private AuditLog auditLog;
@Autowired
public void setAuditLog(AuditLog auditLog) {
this.auditLog = auditLog;
}
public void use() { auditLog.record("setter injection"); }
}@Service
public class ByField {
@Autowired
private AuditLog auditLog;
public void use() { auditLog.record("field injection"); }
}Chưa cần chạy đã thấy ba điều. Chỉ cách thứ nhất khai báo được auditLog là final. Chỉ cách thứ nhất không có @Autowired ở đâu cả. Và chỉ cách thứ nhất vẫn compile được nếu bạn gỡ Spring khỏi classpath mà giữ nguyên class.
Trace: mỗi cách nhận giá trị lúc nào
Thêm một dòng print vào mỗi constructor và vào setter, container sẽ tự kể phần còn lại. Đây là output thật:
[constructor] inside the constructor, auditLog = injected
[field] inside the constructor, auditLog = null
[setter] inside the constructor, auditLog = null
[setter] inside setAuditLog(), auditLog = injected
---- context is up, every bean is complete ----
AUDIT: constructor injection
AUDIT: setter injection
AUDIT: field injectionĐọc kỹ bốn dòng đầu, vì đó là cả bài này thu nhỏ:
- Constructor injection. Container resolve
AuditLogtrước, rồi mới gọinew ByConstructor(auditLog). Giá trị đã có sẵn từ statement đầu tiên trong constructor. Không tồn tại khoảnh khắc nào mà mộtByConstructorcó mặt nhưng thiếu dependency. - Setter injection. Container gọi constructor không có parameter, nhận về một object có field là
null, sau đó mới gọisetAuditLog(...). Giữa hai lời gọi đó có mộtBySetterđầy đủ type nhưng field rỗng. - Field injection. Thời điểm giống hệt setter injection, chỉ khác là container ghi bằng reflection thẳng vào private field thay vì qua method. Chính cái ghi bằng reflection này là lý do không cần setter, và cũng là lý do field không thể
final.

Tới lúc context chạy xong — dòng ---- context is up ---- — cả ba đều đầy đủ và cả ba đều hoạt động. Khác biệt chỉ tồn tại trong lúc khởi động. Có điều phần lớn thứ hỏng liên quan tới dependency injection đều hỏng trong lúc khởi động.
Vì sao @Autowired không bắt buộc trên constructor?
ByConstructor ở trên không có annotation nào trên constructor mà vẫn được inject. Từ Spring 4.3, một class có đúng một constructor thì không cần @Autowired — container không phải chọn gì cả, nên nó dùng luôn constructor đó và resolve từng parameter như một dependency. Đây là lý do code Spring hiện đại gần như không còn @Autowired nào.
"Đúng một" là toàn bộ quy tắc. Thêm constructor thứ hai là bỗng dưng container bị bắt phải chọn, và nó từ chối đoán.
Hai constructor, và sự im lặng theo sau
@Service
public class NotificationService {
private final AuditLog auditLog;
private final Mailer mailer;
public NotificationService(AuditLog auditLog) {
this(auditLog, null);
}
public NotificationService(AuditLog auditLog, Mailer mailer) {
this.auditLog = auditLog;
this.mailer = mailer;
}
public void notifyUser(String to) { /* ... */ }
}Hai constructor, không cái nào được đánh dấu. Application không khởi động được:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name
'notificationService' defined in file [.../com/example/demo/NotificationService.class]:
Failed to instantiate [com.example.demo.NotificationService]: No default constructor found
Caused by: org.springframework.beans.BeanInstantiationException: Failed to instantiate
[com.example.demo.NotificationService]: No default constructor found
Caused by: java.lang.NoSuchMethodException: com.example.demo.NotificationService.<init>()Thông báo này đánh lừa gần như tất cả những ai gặp nó lần đầu. Nó không bảo bạn "thêm constructor không có parameter vào". Nó nói: tôi thấy nhiều hơn một constructor ứng viên, không cái nào được đánh dấu, nên tôi quay về tìm default constructor và cũng không có nốt. Cách sửa là đánh dấu cái bạn muốn:
@Autowired
public NotificationService(AuditLog auditLog, Mailer mailer) {
this.auditLog = auditLog;
this.mailer = mailer;
}AUDIT: notify ada@example.com
MAIL -> ada@example.comCó một phiên bản khó chịu hơn của cùng tình huống. Nếu một trong hai constructor chính là constructor không có parameter thì bước quay về kia thành công — và không có gì phàn nàn cả:
public NotificationService() {
System.out.println("chosen constructor: NotificationService()");
}
public NotificationService(AuditLog auditLog, Mailer mailer) {
System.out.println("chosen constructor: NotificationService(AuditLog, Mailer)");
this.auditLog = auditLog;
this.mailer = mailer;
}chosen constructor: NotificationService()
Exception in thread "main" java.lang.NullPointerException: Cannot invoke
"com.example.demo.AuditLog.record(String)" because "this.auditLog" is null
at com.example.demo.NotificationService.notifyUser(NotificationService.java:22)Context khởi động sạch sẽ. Bean có mặt. Mọi field trong nó đều null, và bạn phát hiện ra ở request đầu tiên. Một constructor thì không cần annotation; hai constructor thì cần đúng một @Autowired.
Vì sao nên dùng constructor injection
Tài liệu chính thức của Spring khuyến nghị constructor injection, và lời khuyến nghị đó rất dễ nhắc lại nhưng cũng rất hay bị giải thích qua loa. Dưới đây là bốn lý do, mỗi lý do đều có code chứng minh chứ không chỉ nói suông.
Field final và object hoàn chỉnh ngay khoảnh khắc nó tồn tại
Constructor injection cho phép field là final. Đó không phải trang trí: nó nghĩa là field được gán đúng một lần, trong lúc construct, và compiler đảm bảo điều đó. Không ai gán null đè lên sau này được, không có @PostConstruct nào bị quên, và bất kỳ thread nào cầm được reference tới object cũng chắc chắn nhìn thấy field đã khởi tạo xong.
Field injection không làm được, và compiler nói thẳng trước cả khi Spring nhúng tay vào:
@Service
public class FinalFieldService {
@Autowired
private final AuditLog auditLog;
public void use() { auditLog.record("final field"); }
}FinalFieldService.java:10: error: variable auditLog not initialized in the default constructorKhông có mẹo nào cứu được chỗ này. Một field final bắt buộc phải được gán trước khi mọi constructor kết thúc, mà bản chất của field injection là ghi sau khi constructor đã trả về. final và field injection loại trừ nhau.
Khoảng trống null mà field injection để lại
"Field là null trong một khoảnh khắc" nghe rất lý thuyết, cho tới khi bạn viết một dòng gì đó trong constructor. Class dưới đây không hề gượng ép — tính sẵn một giá trị trong constructor là chuyện Java bình thường:
@Service
public class ReportService {
@Autowired
private AuditLog auditLog;
private final String banner;
public ReportService() {
auditLog.record("ReportService starting up");
this.banner = "reports ready";
}
}org.springframework.beans.factory.BeanCreationException: Error creating bean with name
'reportService' defined in file [.../com/example/demo/ReportService.class]:
Failed to instantiate [com.example.demo.ReportService]: Constructor threw exception
Caused by: java.lang.NullPointerException: Cannot invoke
"com.example.demo.AuditLog.record(String)" because "this.auditLog" is null
at com.example.demo.ReportService.<init>(ReportService.java:15) ~[main/:na]Dependency đã khai báo, bean nó cần có sẵn trong context, vậy mà vẫn null — vì container chưa kịp tới bước ghi bằng reflection. Nhìn code chẳng thấy chỗ nào sai; thứ tự thực thi là thứ vô hình.
Giờ thử phạm đúng lỗi đó với constructor injection. Không có lỗi nào để phạm: parameter nằm trong scope, khác null, trước khi statement đầu tiên của constructor chạy. Và khi dependency thật sự thiếu thì lỗi không phải một NPE nằm đâu đó trong code của bạn — nó là một chẩn đoán, in ra trước khi application khởi động:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.CartService required a bean of type
'com.example.demo.PricingClient' that could not be found.
Action:
Consider defining a bean of type 'com.example.demo.PricingClient' in your configuration.Công bằng với field injection: khi bean thiếu hẳn thì nó cũng chết lúc khởi động, và thông báo cũng tốt không kém, có nêu cả tên field lẫn annotation trên đó.
Description:
Field pricing in com.example.demo.CartService required a bean of type
'com.example.demo.PricingClient' that could not be found.
The injection point has the following annotations:
- @org.springframework.beans.factory.annotation.Autowired(required=true)Khác biệt vì vậy không nằm ở "khởi động hay runtime" khi bean thiếu. Nó nằm ở chỗ constructor injection làm cho object nửa vời không biểu diễn được, nên khoảng trống NPE phía trên đơn giản là không tồn tại.
Dependency đếm được ngay trên signature
Một class có tám dependency là một class đang làm tám việc. Với constructor injection, nó hiện nguyên hình:
public OrderService(OrderRepository orders, PaymentGateway payments, InventoryClient inventory,
PricingService pricing, EmailSender email, AuditLog audit,
MetricsRecorder metrics, FeatureFlags flags) {Không ai nhìn đoạn đó rồi nghĩ là ổn cả. Với field injection, đúng tám dependency ấy biến thành tám dòng annotation rải rác trong thân class, và từng dòng một trông đều vô hại — đó mới chính là vấn đề. Cảm giác khó chịu khi nhìn một danh sách parameter dài là cảm giác có ích: nó là phản hồi thiết kế, báo rằng class này cần được tách. Field injection gây tê chỗ đó.
Một unit test không có Spring nào bên trong
Đây là lập luận quyết định trong thực tế. Một class dùng constructor injection là một class Java bình thường, nên test có thể tự dựng nó bằng stub và không nhắc tới Spring lấy một chữ:
class CheckoutServiceTest {
private final PaymentGateway stub = amountCents -> "stub:" + amountCents;
@Test
void constructor_injected_service_needs_no_container() {
CheckoutService service = new CheckoutService(stub);
assertThat(service.checkout(2500)).isEqualTo("stub:2500");
}
@Test
void field_injected_service_cannot_be_built_the_same_way() {
FieldCheckoutService service = new FieldCheckoutService();
assertThatThrownBy(() -> service.checkout(2500))
.isInstanceOf(NullPointerException.class)
.hasMessage("Cannot invoke \"com.example.demo.PaymentGateway.charge(long)\" "
+ "because \"this.gateway\" is null");
}
@Test
void field_injected_service_needs_reflection_to_test() {
FieldCheckoutService service = new FieldCheckoutService();
ReflectionTestUtils.setField(service, "gateway", stub);
assertThat(service.checkout(2500)).isEqualTo("stub:2500");
}
}Cả ba đều pass:
CheckoutServiceTest > field_injected_service_needs_reflection_to_test() PASSED
CheckoutServiceTest > field_injected_service_cannot_be_built_the_same_way() PASSED
CheckoutServiceTest > constructor_injected_service_needs_no_container() PASSED
BUILD SUCCESSFUL in 703ms
4 actionable tasks: 4 executedCái đáng nhìn là test ở giữa. new FieldCheckoutService() compile được, chạy được, và trả về một object ném exception ngay khi bạn dùng tới. Test thứ ba cho thấy lối thoát duy nhất nếu không dựng container: ReflectionTestUtils.setField(service, "gateway", stub) — nghĩa là test của bạn giờ phụ thuộc vào tên của một private field, và một lần rename mà bình thường compiler bắt được sẽ lặng lẽ làm hỏng test.
Phương án thay cho reflection là khởi động context, và việc đó không miễn phí. Vẫn một assertion đó, đo từ file XML report của JUnit qua ba lần chạy trên máy này:
| Kiểu test | Thời gian suite |
|---|---|
| Gọi constructor thuần, không Spring | 0.021 s, 0.021 s, 0.027 s |
@SpringBootTest có khởi động context | 0.787 s, 0.849 s, 0.913 s |
Chênh khoảng bốn mươi lần, trên một application chỉ có đúng một bean đáng quan tâm. Hãy xem các con số này là chỉ dấu về độ lớn của khác biệt chứ không phải benchmark — nhưng độ lớn đó không giảm đi khi application to ra, nó chỉ tệ thêm. Constructor injection là thứ giữ cho đường nhanh luôn còn mở.
Khi nào setter injection là lựa chọn đúng?
Setter injection không phải sai lầm; nó chỉ là công cụ hẹp hơn. Nó xứng đáng có chỗ trong hai tình huống.
Thứ nhất là dependency thật sự tuỳ chọn: thứ mà class vẫn chạy được khi thiếu, và việc thiếu là một cấu hình hợp lệ chứ không phải lỗi. Thứ hai là collaborator có thể thay sau khi object đã tạo xong — một strategy đổi lúc runtime, một component được cấu hình lại qua endpoint quản trị. Cả hai đều cần field mutable theo đúng định nghĩa, nên final vốn đã không nằm trong bàn cân.
Thứ mà setter injection không dùng để giải quyết là "class này có quá nhiều constructor parameter". Đó là phản hồi thiết kế ở phần trước; bịt miệng nó bằng setter không làm class nhỏ đi.
Dependency tuỳ chọn: required = false, Optional và ObjectProvider
Có bốn cách nói "inject cái này nếu nó tồn tại". Đặt hết vào một class, và không đăng ký bean FraudCheck nào cả:
@Service
public class OptionalDemo {
private final Optional<FraudCheck> viaOptional;
private final ObjectProvider<FraudCheck> viaProvider;
private final @Nullable FraudCheck viaNullable;
@Autowired(required = false)
private FraudCheck viaField;
private FraudCheck viaSetter;
public OptionalDemo(Optional<FraudCheck> viaOptional,
ObjectProvider<FraudCheck> viaProvider,
@Nullable FraudCheck viaNullable) {
this.viaOptional = viaOptional;
this.viaProvider = viaProvider;
this.viaNullable = viaNullable;
}
@Autowired(required = false)
public void setViaSetter(FraudCheck viaSetter) {
System.out.println("setViaSetter() was called");
this.viaSetter = viaSetter;
}
}Optional<FraudCheck> : isPresent=false
ObjectProvider<FraudCheck> : getIfAvailable=null
@Nullable FraudCheck : null
@Autowired(required=false) field : null
@Autowired(required=false) setter : nullHãy để ý thứ vắng mặt trong output đó: dòng setViaSetter() was called không hề in ra. Với required = false trên một setter, container không gọi method đó lấy một lần khi không có ứng viên nào. Đây là khác biệt hành vi thật so với dạng field, và là lý do một setter required = false không bao giờ được là nơi duy nhất bạn đặt giá trị mặc định — method đó có thể không bao giờ chạy.
Đăng ký một implementation thì mọi thứ được điền đủ, kể cả setter:
setViaSetter() was called
Optional<FraudCheck> : isPresent=true
ObjectProvider<FraudCheck> : getIfAvailable=VelocityFraudCheck
@Nullable FraudCheck : VelocityFraudCheck
@Autowired(required=false) field : VelocityFraudCheck
@Autowired(required=false) setter : VelocityFraudCheckNên chọn cái nào:
| Cách diễn đạt "tuỳ chọn" | Dùng khi |
|---|---|
Optional<T> làm constructor parameter | Lựa chọn mặc định. Bản thân type đã nói lên tính tuỳ chọn, field vẫn giữ được final, và compiler ép mọi nơi gọi tới phải xử lý trường hợp rỗng. |
@Nullable T làm constructor parameter | Bạn muốn field có type T thuần thay vì Optional. Spring Framework 7 dùng org.jspecify.annotations.Nullable của JSpecify, vốn đã có sẵn trên classpath. |
ObjectProvider<T> | Bạn cần nhiều hơn là "có hay không": getIfAvailable(), getIfUnique(), stream() và orderedStream() đều nằm ở đây, và việc resolve được hoãn tới lúc bạn hỏi. |
@Autowired(required = false) | Code cũ, và không gì khác. Trên field nó để lại một null trơ trọi; trên setter thì method có thể không bao giờ được gọi. |
Hai bean cùng type: NoUniqueBeanDefinitionException
Mọi thứ tới đây đều giả định mỗi type chỉ có một ứng viên. Nửa thú vị của dependency injection bắt đầu khi có hai. Một interface, hai implementation, cả hai đều là component:
public interface PaymentGateway {
String charge(long amountCents);
}
@Component
public class StripeGateway implements PaymentGateway {
@Override
public String charge(long amountCents) { return "stripe:" + amountCents; }
}
@Component
public class PaypalGateway implements PaymentGateway {
@Override
public String charge(long amountCents) { return "paypal:" + amountCents; }
}
@Service
public class CheckoutService {
private final PaymentGateway gateway;
public CheckoutService(PaymentGateway gateway) {
this.gateway = gateway;
}
public String checkout(long amountCents) { return gateway.charge(amountCents); }
}Application không khởi động. Exception gốc chính là cái mà ai rồi cũng thuộc tên:
org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with
name 'checkoutService' defined in file [.../com/example/demo/CheckoutService.class]:
Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type
'com.example.demo.PaymentGateway' available: expected single matching bean but found 2:
paypalGateway,stripeGatewayCòn failure analyser của Spring Boot biến nó thành đoạn dưới đây, và đây mới là thứ bạn thật sự thấy trên console:
***************************
APPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.CheckoutService required a single bean, but 2 were found:
- paypalGateway: defined in file [.../com/example/demo/PaypalGateway.class]
- stripeGateway: defined in file [.../com/example/demo/StripeGateway.class]
This may be due to missing parameter name information
Action:
Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed
Ensure that your compiler is configured to use the '-parameters' flag.
You may need to update both your build tool settings as well as your IDE.
(See https://github.com/spring-projects/spring-framework/wiki/Spring-Framework-6.1-Release-Notes#parameter-name-retention)Gợi ý -parameters ở cuối được analyser in ra vô điều kiện và thường là mồi nhử trên một build Spring Boot bằng Gradle hay Maven, nơi plugin đã bật sẵn flag đó. Nó chỉ liên quan tới đúng một trong bốn cách sửa bên dưới, và phần nói về cách sửa đó sẽ chứng minh cả hai chiều.
Thuật toán resolve rất ngắn và đáng thuộc: khớp theo type, nếu còn nhiều hơn một ứng viên thì phân định bằng @Primary, rồi jakarta.annotation.Priority, rồi so tên bean với tên của injection point. Không gì phân định được thì từ chối khởi động.

Cách 1: đánh dấu một bean là @Primary
@Component
@Primary
public class StripeGateway implements PaymentGateway { /* ... */ }checkout -> stripe:2500@Primary nằm trên bean, không phải trên injection point, và trả lời một câu hỏi ở tầm toàn application: "khi không ai nói gì khác thì lấy cái nào?" Điều đó khiến nó hợp cho một mặc định thật sự — DataSource chính, ObjectMapper thông thường, gateway mà chín mươi phần trăm code nên dùng. Nó sai khi bị dùng để dập một thông báo lỗi, vì nó lặng lẽ trả lời thay cho mọi injection point khác, kể cả những chỗ sẽ được viết vào năm sau.
Hai bean cùng mang @Primary là quay về vạch xuất phát, với thông báo cụ thể hơn:
NoUniqueBeanDefinitionException: No qualifying bean of type 'com.example.demo.PaymentGateway'
available: more than one 'primary' bean found among candidates: [paypalGateway, stripeGateway]Cách 2: @Qualifier ngay tại injection point
public CheckoutService(@Qualifier("paypalGateway") PaymentGateway gateway) {
this.gateway = gateway;
}checkout -> paypal:2500@Qualifier là mặt đối lập của @Primary: nó sống tại injection point và chỉ quyết định đúng một chỗ nối. Chuỗi bên trong khớp với giá trị qualifier khai trên bean và — như ở đây — quay về khớp với tên bean. Cả hai đều đáng biết, vì gọi tên bean là thứ đa số ví dụ hay làm, còn khai một qualifier riêng mới là thứ sống sót qua một lần đổi tên class:
@Component
@Qualifier("cards")
public class StripeGateway implements PaymentGateway { /* ... */ } public CheckoutService(@Qualifier("cards") PaymentGateway gateway) { /* ... */ }checkout -> stripe:2500Dạng này tốt hơn. @Qualifier("stripeGateway") trói nơi gọi vào tên class của implementation; @Qualifier("cards") trói nó vào một vai trò, mà vai trò mới là thứ nơi gọi thật sự quan tâm.
@Qualifier dùng được ở mọi injection point chứ không riêng constructor — trên field, và trên parameter của setter:
@Autowired
@Qualifier("stripeGateway")
private PaymentGateway gateway;
@Autowired
public void setOther(@Qualifier("paypalGateway") PaymentGateway other) {
this.other = other;
}checkout -> stripe:2500 / paypal:2500Cách 3: đặt tên parameter trùng tên bean
Bước cuối của thuật toán resolve là so tên, và bạn có thể nhắm vào đó một cách chủ ý. Không đổi gì ngoài tên parameter:
public CheckoutService(PaymentGateway stripeGateway) {
this.gateway = stripeGateway;
}checkout -> stripe:2500Không annotation nào cả, mà vẫn resolve được. Đây cũng là lý do @Autowired private PaymentGateway stripeGateway; "tự nhiên chạy" trong rất nhiều bài hướng dẫn — tên field đang làm nhiệm vụ qualify.
Giờ tới phần cảnh báo, tức là thứ mà gợi ý -parameters trong banner lỗi muốn nói. Tên parameter chỉ vào được file class khi javac nhận flag -parameters; thiếu nó thì lúc runtime parameter của constructor tên là arg0 và chẳng còn gì để khớp. Plugin Gradle và Maven của Spring Boot thêm flag đó giúp bạn — javap -v trên class vừa rồi cho thấy attribute đó:
MethodParameters:
Name Flags
stripeGatewayGỡ flag ra, không đổi gì khác, attribute MethodParameters biến mất kéo theo cả chỗ nối:
tasks.withType(JavaCompile).configureEach {
options.compilerArgs.remove('-parameters')
}Description:
Parameter 0 of constructor in com.example.demo.CheckoutService required a single bean, but 2 were found:
- paypalGateway: ...
- stripeGateway: ...Nên cơ chế này có thật nhưng mong manh: nó phụ thuộc một flag của compiler, nó vỡ dưới mọi obfuscator, và một lần rename mà IDE coi là an toàn — đổi tên parameter — sẽ lặng lẽ đổi luôn bean nào được nối vào. Dùng khi bạn biết rõ mình đang làm gì, hoặc đừng dùng.
Cách 4: custom qualifier annotation
@Qualifier là một meta-annotation, nên bạn có thể dựng một qualifier có type của riêng mình từ nó:
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER })
public @interface CardPayments {
}@Component
@CardPayments
public class StripeGateway implements PaymentGateway { /* ... */ }
@Component
@WalletPayments
public class PaypalGateway implements PaymentGateway { /* ... */ }@Service
public class CheckoutService {
private final PaymentGateway cards;
private final PaymentGateway wallets;
public CheckoutService(@CardPayments PaymentGateway cards,
@WalletPayments PaymentGateway wallets) {
this.cards = cards;
this.wallets = wallets;
}
}checkout -> stripe:2500 / paypal:2500Đây chính là @Qualifier("cards") với chuỗi ma thuật được thay bằng một type. Gõ sai bây giờ là lỗi compile chứ không phải lỗi lúc khởi động, IDE tìm được mọi nơi dùng tới, và annotation có một trang Javadoc để viết rõ "card payments" nghĩa là gì. Trên bất cứ dự án nào lớn hơn đồ chơi, đây là phiên bản nên chọn.
@Primary và @Qualifier đi cùng nhau
Chúng sống chung được, và thứ tự ưu tiên đáng xem tận mắt thay vì đoán. StripeGateway mang @Primary; một trong hai parameter dưới đây xin thứ khác:
public CheckoutService(PaymentGateway byDefault,
@Qualifier("paypalGateway") PaymentGateway byQualifier) {
this.byDefault = byDefault;
this.byQualifier = byQualifier;
}no qualifier -> stripe:2500
@Qualifier -> paypal:2500@Qualifier thắng. @Primary chỉ được hỏi tới khi injection point không bày tỏ mong muốn gì, và đó đúng là hành vi bạn cần: một mặc định hợp lý cho tất cả, có thể ghi đè tại đúng chỗ cần thứ khác.
Inject tất cả implementation cùng lúc
Đôi khi câu trả lời cho "implementation nào?" là "tất cả" — một tập validator, một chuỗi handler, một registry tra theo tên. Khai báo một collection của type interface và container sẽ điền vào:
@Service
public class GatewayRegistry {
private final List<PaymentGateway> all;
private final Map<String, PaymentGateway> byName;
public GatewayRegistry(List<PaymentGateway> all, Map<String, PaymentGateway> byName) {
this.all = all;
this.byName = byName;
}
}Với ba implementation trên classpath:
List<PaymentGateway> size = 3
BankTransferGateway -> bank:100
PaypalGateway -> paypal:100
StripeGateway -> stripe:100
Map<String, PaymentGateway> keys = [bankTransferGateway, paypalGateway, stripeGateway]
bankTransferGateway -> bank:100
paypalGateway -> paypal:100
stripeGateway -> stripe:100List<T> nhận mọi bean gán được về T. Map<String, T> nhận đúng các bean đó với key là tên bean — chú ý key bắt buộc phải là String, vì tên bean là chuỗi. Đây là cách chuẩn để một tính năng mở rộng được bằng cách thêm class: registry không đổi, @Component mới tự động gia nhập collection.
Sắp thứ tự List bằng @Order
Thứ tự trong list đó không do bạn chọn. Ở đây nó tình cờ là thứ tự scan, mà thứ tự scan không phải cam kết — nó đổi khi bạn rename class, tách package, hay đóng gói lại vào jar. Nếu thứ tự có ý nghĩa thì phải nói ra:
@Component
@Order(1)
public class StripeGateway implements PaymentGateway { /* ... */ }
@Component
@Order(2)
public class PaypalGateway implements PaymentGateway { /* ... */ }
@Component
@Order(3)
public class BankTransferGateway implements PaymentGateway { /* ... */ }List<PaymentGateway> size = 3
StripeGateway -> stripe:100
PaypalGateway -> paypal:100
BankTransferGateway -> bank:100
Map<String, PaymentGateway> keys = [bankTransferGateway, paypalGateway, stripeGateway]Hai chi tiết trong output đó. List giờ đúng thứ tự đã khai — số nhỏ đứng trước — và một class có thể implement org.springframework.core.Ordered thay cho annotation nếu giá trị cần tính toán. Còn Map thì không đổi: việc sắp xếp chỉ áp dụng cho array và collection, không áp dụng cho map, nên Map<String, T> cho bạn thứ tự đăng ký và bạn nên coi nó là không có thứ tự.
ObjectProvider phơi bày đúng lựa chọn đó một cách tường minh, và đây là cách rõ nhất để thấy chuyện gì đang xảy ra:
public GatewayRegistry(ObjectProvider<PaymentGateway> gateways) {
this.gateways = gateways;
}stream() : [BankTransferGateway, PaypalGateway, StripeGateway]
orderedStream() : [StripeGateway, PaypalGateway, BankTransferGateway]
getIfUnique() : nullstream() cho thứ tự đăng ký, orderedStream() áp dụng @Order, còn getIfUnique() trả về null thay vì ném exception khi type bị nhập nhằng — một cách thứ năm, bằng code, để xử lý nhiều ứng viên.
Vòng lặp dependency
Hai bean cần lẫn nhau là kiểu hỏng duy nhất mà lựa chọn injection point không chỉ đổi thông báo lỗi, mà đổi luôn chuyện application có khởi động được hay không.
@Service
public class OrderService {
private final InvoiceService invoiceService;
public OrderService(InvoiceService invoiceService) {
this.invoiceService = invoiceService;
}
}@Service
public class InvoiceService {
private final OrderService orderService;
public InvoiceService(OrderService orderService) {
this.orderService = orderService;
}
}Spring Boot vẽ luôn vòng lặp ra cho bạn:
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| invoiceService defined in file [.../com/example/demo/InvoiceService.class]
↑ ↓
| orderService defined in file [.../com/example/demo/OrderService.class]
└─────┘
Action:
Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.Đây không phải một quyết định chính sách của Spring, đây là số học. Để construct orderService, container cần một invoiceService hoàn chỉnh; để construct invoiceService, nó cần một orderService hoàn chỉnh. Không cái nào đi trước được. Không có property nào làm gì được chuyện đó, và chính property được nêu trong khối Action: kia chứng minh điều đó — bật nó lên thì đúng vòng lặp ấy vẫn chết, chỉ khác dòng cuối:
spring.main.allow-circular-references=trueDescription:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
| invoiceService defined in file [.../com/example/demo/InvoiceService.class]
↑ ↓
| orderService defined in file [.../com/example/demo/OrderService.class]
└─────┘
Action:
Despite circular references being allowed, the dependency cycle between beans could not be broken. Update your application to remove the dependency cycle.Giờ viết lại đúng hai class đó bằng field injection:
@Service
public class OrderService {
@Autowired
private InvoiceService invoiceService;
}@Service
public class InvoiceService {
@Autowired
private OrderService orderService;
}Vòng lặp bây giờ giải được về mặt vật lý, vì cả hai object đều tạo rỗng được rồi mới ghi field sau — một reference sớm tới orderService còn dở dang có thể đưa cho invoiceService rồi vá lại sau. Spring Boot vẫn từ chối theo mặc định (mặc định này có từ Boot 2.6), với cùng banner nhưng nêu tên hai field thay vì hai class:
┌─────┐
| invoiceService (field private com.example.demo.OrderService com.example.demo.InvoiceService.orderService)
↑ ↓
| orderService (field private com.example.demo.InvoiceService com.example.demo.OrderService.invoiceService)
└─────┘Nhưng ở đây thì property thật sự có tác dụng:
spring.main.allow-circular-references=trueorder:sku-1 invoice:sku-1
⚠️ Property đó là công cụ hỗ trợ migration, không phải một lựa chọn thiết kế. Nó có hiệu lực toàn cục, nó làm mọi vòng lặp sau này trong application trở nên im lặng, và nó để lại những bean giữ reference tới các object chưa khởi tạo xong vào lúc reference được lấy. Nếu buộc phải thêm, hãy ghi kèm một comment có ngày tháng nói rõ nó dành cho vòng lặp nào.
Cách sửa thật sự cho một vòng lặp
Một vòng lặp là thông tin: nó nói rằng hai class cùng biết thứ mà bên kia cần, tức là ranh giới giữa chúng đang đặt sai chỗ. Các cách sửa thật sự, xếp đại khái theo mức độ thường đúng:
- Đưa phần logic chung sang một class thứ ba mà cả hai cùng phụ thuộc vào. Vòng lặp thường tồn tại vì một mảnh hành vi đã bị gán cho một trong hai bên một cách tuỳ tiện.
- Đảo một chiều bằng event. Nếu
OrderServicegọiInvoiceServicechỉ để báo rằng có chuyện vừa xảy ra, hãy publish một application event thay vì giữ reference. - Tách một interface mà một bên phụ thuộc vào và bên kia implement, để dependency lúc compile chỉ đi một chiều dù lời gọi đi chiều ngược lại.
- Gộp chúng lại. Hai class không construct độc lập được thường là một class bị tách quá sớm.
@Lazy trên một trong hai injection point cũng phá được vòng lặp qua constructor, bằng cách inject một proxy chỉ resolve bean thật khi dùng lần đầu — và proxy đó không hề trừu tượng, nó có hẳn tên class:
public OrderService(@Lazy InvoiceService invoiceService) {OrderService ctor, invoiceService is a InvoiceService$$SpringCGLIB$$0
order:sku-1 invoice:sku-1Nó chạy được, và nó vẫn là đúng vòng lặp ấy, chỉ thêm một CGLIB proxy đứng chắn phía trước. Chỉ dùng khi hai class đó không phải của bạn để sửa.
Và hãy để ý constructor injection vừa làm gì cho bạn ở đây: nó biến một vấn đề thiết kế thành lỗi khởi động có in ra tên cả hai class, ngay lần chạy đầu tiên, trên máy của bạn. Field injection sẽ nhận cùng thiết kế đó và khởi động bình thường.
So sánh constructor, setter và field
| Constructor | Setter | Field | |
|---|---|---|---|
Có cần @Autowired | Không, nếu chỉ có đúng một constructor | Có, trên method | Có, trên field |
| Giá trị tới lúc nào | Trước khi thân constructor chạy | Sau khi tạo object, qua method | Sau khi tạo object, qua reflection |
Field để final được | Được | Không | Không |
| Có lúc nào object nửa vời | Không bao giờ | Có, giữa lúc tạo và lúc gọi setter | Có, giữa lúc tạo và lúc ghi field |
| Hiện ra trên API công khai | Có — signature của constructor | Có — setter | Không |
| Quá nhiều dependency | Lộ rõ, một danh sách parameter dài | Bị giấu | Bị giấu |
| Test JUnit thuần không Spring | Được, new với stub | Được, new rồi gọi setter | Chỉ với ReflectionTestUtils hoặc container |
| Dependency tuỳ chọn | Optional<T>, @Nullable, ObjectProvider<T> | @Autowired(required = false) — method có thể không chạy | @Autowired(required = false) — để lại null |
| Vòng lặp dependency | Luôn chết lúc khởi động, bật property cũng vậy | Cho phép nếu bật property | Cho phép nếu bật property |
| Dùng cho | Mọi dependency bắt buộc. Mặc định. | Collaborator thật sự tuỳ chọn hoặc thay được | Không gì trong code production |
FAQ
Có nên dùng @RequiredArgsConstructor của Lombok không?
Có, và đó đúng là constructor injection: Lombok sinh ra một constructor nhận mọi field final, và với một constructor duy nhất thì Spring không cần @Autowired. javap trên class được sinh ra xác nhận chẳng có gì đặc biệt:
public class com.example.demo.CheckoutService {
private final com.example.demo.PaymentGateway gateway;
public com.example.demo.CheckoutService(com.example.demo.PaymentGateway);
}Có một cái bẫy đáng biết. Annotation trên field không được copy sang parameter của constructor sinh ra, theo mặc định, nên một @Qualifier đặt trên field final sẽ bị bỏ qua và lỗi nhập nhằng quay lại:
Parameter 0 of constructor in com.example.demo.CheckoutService required a single bean, but 2 were found:Cách sửa là một file lombok.config ở thư mục gốc của project, sau đó qualifier được copy và chỗ nối resolve được:
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier@Qualifier đặt được trên setter hay field không?
Được. Trên field thì đặt @Autowired và @Qualifier cùng chỗ, ngay trên field. Trên setter thì @Autowired đặt trên method còn @Qualifier đặt trên parameter — đó là dạng duy nhất chạy đúng khi setter nhận nhiều hơn một argument, và nên tập dùng dạng đó cho quen.
Nếu hai bean cùng được đánh dấu @Primary thì sao?
Nhập nhằng quay lại nguyên vẹn, với thông báo nêu đúng vấn đề — và chú ý là Spring Boot không in banner lỗi định dạng cho trường hợp này, chỉ có exception:
NoUniqueBeanDefinitionException: No qualifying bean of type 'com.example.demo.PaymentGateway'
available: more than one 'primary' bean found among candidates: [paypalGateway, stripeGateway]@Primary nghĩa là "mặc định", mà mặc định thì chỉ có một. Nếu hai library mỗi bên đánh dấu bean của mình là primary, cách sửa là @Qualifier tại các injection point của bạn, chứ không phải thêm một @Primary thứ ba.
@Autowired trên static field có tác dụng gì không?
Không. Field vẫn null và container ghi đúng một dòng ở mức INFO mà rất dễ lướt qua:
INFO f.a.AutowiredAnnotationBeanPostProcessor : Autowired annotation is not supported on
static fields: private static com.example.demo.AuditLog com.example.demo.ByConstructor.auditLogMột static field thuộc về class chứ không thuộc về bean, nên container không có instance nào để điền vào. Nếu bạn định dùng static field để với tới một bean từ chỗ không được quản lý, cách sửa thật sự là biến đoạn code đó thành bean luôn.
Field injection có bao giờ chấp nhận được không?
Trong class test thì có — @Autowired trên field trong một @SpringBootTest là cách viết thông thường, object dù sao cũng do framework dựng, và không lập luận nào ở trên áp dụng được. Trong class @Configuration thì ít ra cũng bào chữa được. Trong các class service, repository và controller của production, không có lý do nào mà constructor injection không phục vụ tốt hơn, và riêng việc field được final đã đáng giá hai dòng code thêm vào.
Vì sao class của tôi có tám constructor parameter?
Vì nó có tám dependency, và constructor là chỗ duy nhất phơi ra điều đó. Danh sách parameter là triệu chứng chứ không phải bệnh — chuyển sang field injection chỉ giấu triệu chứng đi và để class phức tạp y nguyên. Hãy tìm một cụm parameter luôn được dùng cùng nhau rồi tách chúng thành một collaborator, hoặc chia class theo đường nứt nơi các method khác nhau dùng các parameter khác nhau.
Kết luận
Có ba injection point và một thứ phân biệt chúng: constructor injection đưa giá trị vào trước khi object tồn tại, còn setter và field injection đưa vào sau đó. Từ đúng một sự thật ấy sinh ra field final, một object không bao giờ bị bắt gặp ở trạng thái nửa vời, số lượng dependency không giấu đi được, một test JUnit chạy trong 21 mili giây vì không hề dựng context, và một vòng lặp dependency chết to tiếng ngay lần chạy đầu thay vì nằm im. @Autowired không cần thiết khi chỉ có một constructor và bắt buộc ngay khi có hai. Khi một type có nhiều bean, thứ tự resolve là type, rồi @Primary, rồi @Priority, rồi tên — và @Qualifier tại injection point ghi đè tất cả, trong đó custom qualifier annotation là phiên bản sống sót qua refactor. Muốn lấy hết? Xin một List hoặc một Map, và thêm @Order nếu thứ tự là một phần của cam kết.
Bài kế tiếp chuyển từ injection point sang chỗ khai báo: @Configuration và @Bean — những factory method đăng ký bean mà container không bao giờ scan ra được, và khi nào nên dùng chúng thay cho annotation stereotype.