Spring Core & Boot/Dependency Injection — 3 hình thức, cơ chế resolve, và generic-aware injection
4/41
Bài 4 / 41~12 phútNhập môn & IoC/DIMiễn phí lượt xem

Dependency Injection — 3 hình thức, cơ chế resolve, và generic-aware injection

Bài atomic về DI: constructor vs setter vs field — tại sao constructor được khuyến nghị (final, immutable, test ngoài Spring, phát hiện circular sớm); cơ chế Spring resolve @Autowired (by type → by name); generic-aware injection và Collection injection.

TL;DR: Dependency Injection có 3 hình thức: constructor (KHUYẾN NGHỊ — final field, immutable, test ngoài Spring, phát hiện circular tại startup), setter (chỉ optional dependency), field (anti-pattern). Spring resolve @Autowired theo thứ tự: type → @Qualifier@Primary → name → fail. Từ Spring 4+, container đọc generic parameter qua bytecode signature, inject đúng Repository<User> hay Repository<Order> mà không cần thêm qualifier. Hiểu 3 hình thức + thuật toán resolve giúp debug NoUniqueBeanDefinitionException trong 30 giây thay vì mò mẫm.

Bài IoC & DI giới thiệu 3 hình thức DI và so sánh @Autowired vs @Inject. Bài này đào sâu hơn: vì sao constructor injection được khuyến nghị về mặt cơ chế, Spring thực sự làm gì khi gặp @Autowired để biến khai báo thành object wiring, và generic-aware injection hoạt động ra sao — các câu hỏi mà chỉ đọc docs bề mặt không trả lời được.

1. 3 hình thức Dependency Injection

Cùng ví dụ: OrderService cần PaymentGateway. Ba cách inject:

1.1 Constructor injection — hình thức khuyến nghị

@Service
public class OrderService {
    private final PaymentGateway payment;   // final: khong doi sau construct

    public OrderService(PaymentGateway payment) {
        this.payment = payment;
    }
}

Từ Spring 4.3, không cần @Autowired khi class chỉ có 1 constructor — Spring tự nhận diện. Khai báo trên là đủ.

1.2 Setter injection — chỉ cho dependency optional

@Service
public class ReportService {
    private NotificationService notifier;

    @Autowired(required = false)
    public void setNotifier(NotificationService notifier) {
        this.notifier = notifier;
    }

    public void generate() {
        if (notifier != null) notifier.notify("report done");
    }
}

Field không thể final, có thể set lại sau construct — mutable, khó reasoning. Hợp lý duy nhất khi dependency thực sự optional (có thì dùng, không có thì skip).

1.3 Field injection — đây mới là anti-pattern

@Service
public class BadService {
    @Autowired                    // field injection = anti-pattern
    private PaymentGateway payment;
}
@Autowired không phải anti-pattern — field injection mới là

Bản thân annotation @Autowired hoàn toàn hợp lệ: nó dùng cho setter injection (mục 1.2) và từng dùng cho constructor. Thứ bị xem là anti-pattern là đặt @Autowired trực tiếp lên một private field. Câu "đừng dùng @Autowired" mà nhiều người nói thực ra nên hiểu là "đừng inject thẳng vào field".

Vậy vì sao đặt @Autowired lên field lại tệ? Gốc rễ nằm ở cơ chế: để inject được, Spring phải dùng reflection ghi thẳng vào private field sau khi object đã tạo xong. Một thao tác tưởng vô hại đó kéo theo cả chuỗi hệ quả — và đây chính là lý do field injection bị xếp vào anti-pattern:

  • Không thể final — field buộc phải mutable, mất đảm bảo visibility/thread-safe mà JMM trao cho final field (cơ chế ở mục 2.1).
  • Object tự nó không khởi tạo được — không có constructor parameter, nên bạn không thể new BadService(...) rồi truyền dependency vào. Class business bị trói cứng vào một DI container biết reflection: tách khỏi Spring là vô dụng, unit test thuần Java cũng không dựng nổi object (mục 2.3). Đây là vi phạm encapsulation nặng nhất — class che giấu việc nó phụ thuộc gì.
  • Dependency bị giấu khỏi API công khai — nhìn constructor không biết class cần gì. Một class có thể âm thầm tích thêm @Autowired field tới khi thành God class mà bề ngoài vẫn "gọn" (mục 5, vấn đề 2).
  • Object tồn tại ở trạng thái nửa vời — Spring tạo object với field null trước rồi mới inject. Thiếu bean thì object vẫn ra đời, NPE nổ lúc runtime thay vì fail ngay tại startup (mục 2.2).
  • Che giấu circular dependency — Spring lặng lẽ giải bằng early reference thay vì báo lỗi sớm, để bug tinh tế lọt ra runtime (mục 5, vấn đề 3).

Tóm lại, field injection đánh đổi vài dòng "trông gọn" bằng việc phá encapsulation và trói class vào container — lợi ích là ảo, chi phí là thật. Mục 2 mổ từng lý do theo cơ chế; mục 5 đưa code pitfall cụ thể.

1.4 Bảng so sánh tổng

Tiêu chíConstructorSetterField
final fieldKhôngKhông
Bắt buộc dependency tại initKhôngKhông
Test không cần SpringCó (new Service(mock))KhóKhông (cần reflection)
Phát hiện circular tại startupKhông (defer)Không (defer)
Force nhận biết class quá lớnCó (constructor 10 param trông xấu ngay)KhôngKhông
Tương thích Lombok @RequiredArgsConstructorKhôngKhông

2. Tại sao constructor injection được khuyến nghị — 4 lý do cơ chế

Đây là phần cốt lõi. Không phải "convention", mà là 4 lý do có nền tảng cơ chế:

2.1 final field → immutable → thread-safe

Constructor injection là cách DUY NHẤT để field có thể là final. Field final trong Java nghĩa là JMM (Java Memory Model) đảm bảo giá trị visible với mọi thread sau khi constructor hoàn thành — không cần thêm volatile hay synchronized.

Setter injection cho phép gọi service.setPayment(other) bất kỳ lúc nào sau khi đã inject. Nếu 2 thread đọc/ghi field cùng lúc mà không có synchronization → race condition. Constructor + final loại bỏ rủi ro này tại mức language guarantee.

2.2 Dependency bắt buộc — không tạo được object thiếu dependency

Với constructor injection, Spring không thể tạo OrderService nếu thiếu PaymentGateway — throw UnsatisfiedDependencyException tại startup. Không có null service nào chạy trong prod.

Với field injection, Spring tạo object trước rồi inject sau bằng reflection. Nếu quên khai báo bean (vd test setup thiếu @MockBean), object được tạo thành công với field null → NPE tại runtime khi method đầu tiên gọi field đó.

2.3 Test không cần Spring context

Constructor injection là pure Java — test chỉ cần new:

// Test voi constructor injection: khong can Spring, chay trong <1ms
@Test
void placeOrder_chargesPayment() {
    var mockPayment = mock(PaymentGateway.class);
    var service = new OrderService(mockPayment);   // no Spring context

    service.placeOrder(new Order(100));

    verify(mockPayment).charge(Money.of(100));
}

Field injection buộc phải dùng @SpringBootTest (khởi động toàn bộ context, chậm hàng giây) hoặc ReflectionTestUtils.setField(service, "payment", mockPayment) — brittle vì dùng string field name, break khi rename.

2.4 Phát hiện circular dependency tại startup

Đây là lý do quan trọng nhất về mặt design fail-fast. Khi A cần B và B cần A qua constructor:

@Service public class A { A(B b) { ... } }
@Service public class B { B(A a) { ... } }

Spring không thể tạo A (cần B trước) và không thể tạo B (cần A trước). Container throw BeanCurrentlyInCreationException ngay khi startup — lỗi lộ ra trước khi deploy xong, không phải lúc user gọi request đầu tiên.

Với field injection, Spring dùng cơ chế "early reference" (three-level cache — chi tiết ở bài Circular dependency): tạo A chưa init xong, expose reference, tạo B với early A, populate field. App start được — nhưng các bug tinh tế có thể xuất hiện sau. Spring Boot 2.6+ tắt feature này mặc định (spring.main.allow-circular-references=false) vì circular dependency là design smell.

Rule ngắn gọn: constructor injection biến design bug (circular, missing dependency) thành startup error thay vì runtime error. Fail fast = dễ debug hơn 10x.

Quote chính chủ — Spring Team

"The Spring team generally advocates constructor injection, as it lets you implement application components as immutable objects and ensures that required dependencies are not null."

Nguồn: Spring Framework Reference — Constructor-based or setter-based DI

3. Cơ chế bên dưới — Spring resolve @Autowired ra sao

Khi container gặp OrderService cần PaymentGateway, nó không đoán — nó chạy một thuật toán xác định. Hiểu thuật toán này là debug được NoUniqueBeanDefinitionExceptionNoSuchBeanDefinitionException trong 30 giây.

3.1 Thuật toán resolve dependency

flowchart TD
    Start["Resolve param: PaymentGateway"]
    Q1{"Co bean nao type PaymentGateway?"}
    Err1["NoSuchBeanDefinitionException<br/>-- khong co bean nao match"]
    Q2{"Co bao nhieu bean match type?"}
    Use["Use bean -- DONE"]
    Q3{"Co @Qualifier tren param?"}
    Match["Match by qualifier name"]
    Q4{"Co bean nao @Primary?"}
    UsePrimary["Use @Primary bean"]
    Q5{"Co bean ten = param name?"}
    UseByName["Use bean by name<br/>(implicit qualifier)"]
    Err2["NoUniqueBeanDefinitionException<br/>-- co nhieu bean, khong phan biet duoc"]

    Start --> Q1
    Q1 -->|"Khong co"| Err1
    Q1 -->|"Co"| Q2
    Q2 -->|"Dung 1"| Use
    Q2 -->|"Tren 1"| Q3
    Q3 -->|"Co"| Match
    Q3 -->|"Khong"| Q4
    Q4 -->|"Co"| UsePrimary
    Q4 -->|"Khong"| Q5
    Q5 -->|"Co"| UseByName
    Q5 -->|"Khong"| Err2

    style Use fill:#d1fae5
    style UsePrimary fill:#d1fae5
    style UseByName fill:#d1fae5
    style Err1 fill:#fee2e2
    style Err2 fill:#fee2e2

Ví dụ thực tế: có 2 bean StripePaymentMoMoPayment đều implement PaymentGateway. Constructor nhận PaymentGateway payment:

  • Không có @Qualifier + không có @Primary + tên param là payment (không khớp stripePayment hay moMoPayment) → NoUniqueBeanDefinitionException.
  • Fix bằng @Qualifier("stripePayment") trên param, HOẶC @Primary trên StripePayment, HOẶC đổi tên param thành stripePayment (implicit name match).

3.2 Implement bên dưới — AutowiredAnnotationBeanPostProcessor

Spring xử lý @Autowired qua class AutowiredAnnotationBeanPostProcessor — một BeanPostProcessor chạy sau khi bean được instantiate. Nó dùng reflection để scan field và constructor, gọi xuống DefaultListableBeanFactory.doResolveDependency() — đây là method thực thi thuật toán flowchart trên.

Việc inject diễn ra sau khi constructor chạy xong đối với field injection (vì cần object đã có trước khi set field). Ngược lại, constructor injection diễn ra trong lúc new — Spring truyền resolved dependency vào constructor argument. Đây là lý do constructor injection không cần BeanPostProcessor xử lý field.

Chi tiết về BeanDefinition, BeanFactoryPostProcessor và pipeline tạo bean: xem BeanDefinition & BeanFactoryPostProcessor.

4. Generic-aware injection — Spring 4+

Java xoá generic type tại runtime (type erasure) — List<String>List<Integer> đều thành List ở bytecode. Nhưng method signature và field signature trong bytecode vẫn giữ generic type parameter. Spring đọc signature này qua ResolvableType API để match đúng bean.

4.1 Inject đúng implementation theo generic

public interface Repository<T> { T findById(long id); }

@Component
public class UserRepository implements Repository<User> { ... }

@Component
public class OrderRepository implements Repository<Order> { ... }

@Service
public class UserService {
    private final Repository<User> repo;   // Spring chon UserRepository

    public UserService(Repository<User> repo) {
        this.repo = repo;
    }
}

Spring đọc constructor signature: param là Repository<User>. Nó scan tất cả bean implement Repository, kiểm tra generic argument — UserRepository<User>, OrderRepository<Order>. Match đúng UserRepository mà không cần @Qualifier. Nếu không có generic-aware matching, cả 2 đều match RepositoryNoUniqueBeanDefinitionException.

4.2 Collection injection — gom tất cả implementation

Spring có thể inject tất cả bean cùng type vào List hoặc Map:

public interface PaymentGateway { String code(); void charge(long amount); }

@Component class StripePayment implements PaymentGateway {
    public String code() { return "stripe"; }
    // ...
}
@Component class MoMoPayment implements PaymentGateway {
    public String code() { return "momo"; }
    // ...
}
@Component class VnPayPayment implements PaymentGateway {
    public String code() { return "vnpay"; }
    // ...
}

@Service
public class PaymentRouter {
    private final Map<String, PaymentGateway> byCode;

    public PaymentRouter(List<PaymentGateway> all) {
        this.byCode = all.stream()
            .collect(java.util.stream.Collectors.toMap(PaymentGateway::code, g -> g));
    }

    public void charge(String code, long amount) {
        var gateway = byCode.get(code);
        if (gateway == null) throw new IllegalArgumentException("Unknown gateway: " + code);
        gateway.charge(amount);
    }
}

Spring inject tất cả StripePayment, MoMoPayment, VnPayPayment vào List<PaymentGateway>. PaymentRouter tự build map theo code(). Thêm payment gateway mới chỉ cần thêm class @Component — không sửa PaymentRouter. Đây là strategy pattern qua DI, không cần switch-case.

Nếu cần thứ tự xác định trong List, dùng @Order:

@Component @Order(1) class StripePayment implements PaymentGateway { ... }
@Component @Order(2) class MoMoPayment implements PaymentGateway { ... }

Spring sort theo @Order ascending trước khi inject vào List. Hữu ích cho chain-of-responsibility hoặc fallback handler.

Inject thẳng Map<String, PaymentGateway> (không tự build map):

public PaymentRouter(Map<String, PaymentGateway> gateways) {
    // key = bean name: "stripePayment", "moMoPayment", "vnPayPayment"
    // Spring inject map bean-name -> instance
}

Key trong map là bean name (tên class với chữ đầu lowercase), không phải code() — cần chú ý khi route theo business code.

5. Pitfall field injection — 3 vấn đề cụ thể

Vấn đề 1 — Test cần reflection hoặc Spring context:

// Field injection: phai dung @SpringBootTest hoac ReflectionTestUtils
@Service
public class OrderService {
    @Autowired private PaymentGateway payment;   // field injection = anti-pattern
}

// Test buoc phai:
@SpringBootTest  // boot ca context, cham hang giay
class OrderServiceTest { ... }
// Hoac:
ReflectionTestUtils.setField(service, "payment", mockPayment);  // brittle

✅ Constructor injection: new OrderService(mockPayment) — không cần Spring, chạy trong milliseconds.

Vấn đề 2 — Che giấu class quá nhiều dependency:

@Service
public class GodService {
    @Autowired private ServiceA a;
    @Autowired private ServiceB b;
    @Autowired private ServiceC c;
    @Autowired private ServiceD d;
    @Autowired private ServiceE e;
    @Autowired private ServiceF f;
    @Autowired private ServiceG g;
    // ... 5 field nua -- trong van "gon"
}

Class với 12 @Autowired field trông vẫn "gọn". Constructor 12 param lộ ngay — developer thấy mùi vấn đề, tách class. Field injection loại bỏ tín hiệu cảnh báo quan trọng này.

Vấn đề 3 — NPE khi circular dependency giải bằng early reference:

Với field injection, Spring dùng early reference để giải circular dependency. Trong constructor của A, this.b còn null — bean B chưa được inject vào A lúc này. Nếu constructor A spawn thread truy cập this.b, thread nhận null:

@Service
public class A {
    @Autowired private B b;

    @PostConstruct
    public void init() {
        // O day b da duoc inject -- OK
        b.setup();
    }

    // Nhung neu constructor goi method dung this.b --> null!
}

Constructor injection loại bỏ tình huống này vì Spring không thể tạo A nếu B chưa tồn tại đầy đủ.

Liên hệ các bài khác

Bài này là một mảnh trong chuỗi. Ghép với:

  • IoC & DI: nền tảng — phân biệt IoC (principle) và DI (implementation), so sánh @Autowired vs @Inject, giải thích service locator anti-pattern là gì và vì sao tránh.
  • Circular dependency: cơ chế three-level cache Spring dùng để giải circular dependency qua field/setter injection — và vì sao bật lại allow-circular-references=true là band-aid, không phải fix gốc.
  • BeanDefinition & BeanFactoryPostProcessor: pipeline đầy đủ từ @ComponentBeanDefinitionBeanFactoryPostProcessor → instantiation — nơi AutowiredAnnotationBeanPostProcessor nằm trong chuỗi.

Tóm tắt

  • DI có 3 hình thức: constructor (khuyến nghị), setter (optional dependency), field (anti-pattern).
  • Constructor injection khuyến nghị vì 4 lý do cơ chế: final field (immutable, JMM safe), dependency bắt buộc tại init (không NPE), test thuần Java (không cần Spring context), phát hiện circular tại startup (fail fast).
  • Spring resolve @Autowired theo thuật toán: type@Qualifier@Primaryname → fail. Biết flowchart này là debug được NoUniqueBeanDefinitionException ngay.
  • Implement bên dưới: AutowiredAnnotationBeanPostProcessor scan @Autowired, gọi DefaultListableBeanFactory.doResolveDependency() — không magic, chỉ là reflection + type matching.
  • Generic-aware injection (Spring 4+): đọc bytecode signature, match Repository<User> với UserRepository mà không cần @Qualifier.
  • Collection injection: inject tất cả implementation vào List<T> hoặc Map<String, T> — nền tảng của strategy/plugin pattern không cần factory.
  • Field injection ẩn 3 vấn đề: test cần reflection, che giấu class quá lớn, NPE khi circular dependency giải bằng early reference.

Tự kiểm tra

Tự kiểm tra
Q1
Constructor injection khuyến nghị vì 4 lý do. Hãy giải thích lý do "phát hiện circular dependency tại startup" theo cơ chế — điều gì xảy ra khi A và B inject nhau qua constructor so với qua field?

Với constructor injection: Spring cần tạo A trước (cần B) và tạo B trước (cần A) — không thể bắt đầu từ đâu. Container phát hiện vòng tại bước topological sort và throw BeanCurrentlyInCreationException ngay khi startup. Lỗi lộ ra trước khi app deployed.

Với field injection: Spring dùng cơ chế early reference (three-level cache). Tạo A (chưa inject field), expose early reference của A, tạo B với early A, populate field b của A = B đã tạo xong. App start được — nhưng trong constructor của A, this.b vẫn còn null ở thời điểm constructor chạy. Bug chỉ lộ khi code trong constructor truy cập this.b.

Spring Boot 2.6+ tắt feature này mặc định (spring.main.allow-circular-references=false) vì circular dependency là design smell. Fix đúng là refactor — tách logic chung sang class C, hoặc dùng @Lazy. Chi tiết: bài Circular dependency.

Q2
Bạn có 2 bean: StripePaymentMoMoPayment đều implement PaymentGateway, không có @Primary. Constructor của OrderService nhận PaymentGateway payment. Spring sẽ làm gì? Kể 3 cách fix và khi nào dùng cách nào.

Spring throw NoUniqueBeanDefinitionException tại startup — có 2 bean cùng type PaymentGateway, không có qualifier hoặc primary để phân biệt, tên param payment không khớp tên bean nào (stripePayment hay moMoPayment).

Fix 1 — @Qualifier: OrderService(@Qualifier("stripePayment") PaymentGateway p). Dùng khi cần chỉ rõ từng vị trí inject một implementation cụ thể. Tên mặc định bean = tên class chữ đầu lowercase.

Fix 2 — @Primary: đặt @Primary trên StripePayment. Spring chọn Primary khi có ambiguity. Dùng khi có 1 default rõ ràng (90% case dùng Stripe, MoMo chỉ cho user VN).

Fix 3 — Collection injection: đổi param thành List<PaymentGateway> all — Spring inject tất cả implementation. Dùng khi cần route động theo runtime data (code thanh toán user chọn) — strategy/plugin pattern, không cần sửa router khi thêm gateway mới.

Q3
Giải thích vì sao field injection khiến test khó hơn constructor injection. Cho ví dụ code cụ thể cho cả 2 trường hợp.

Field injection đặt field private, không có constructor parameter để truyền mock vào. Test không thể new OrderService(mockPayment) vì constructor không nhận dependency.

Để test field injection, phải chọn 1 trong 2 cách đắt:

Cách 1 — Boot Spring context:

@SpringBootTest
class OrderServiceTest {
  @Autowired OrderService service;
  @MockBean PaymentGateway payment;  // replace bean trong context
}

Khởi động toàn bộ application context — tốn 3-10 giây mỗi lần test suite chạy.

Cách 2 — Reflection:

ReflectionTestUtils.setField(service, "payment", mockPayment);

Brittle: dùng string "payment" — rename field là refactor break test mà compiler không bắt được.

Constructor injection:

@Test
void test() {
  var mock = mock(PaymentGateway.class);
  var service = new OrderService(mock);  // no Spring, <1ms
  service.placeOrder(new Order(100));
  verify(mock).charge(100);
}

Chạy trong unit test thuần, không cần Spring context, chậm nhất vài milliseconds.

Q4
Spring 4+ có thể inject đúng Repository<User> khi có cả UserRepositoryOrderRepository mà không cần @Qualifier. Cơ chế bên dưới là gì — Java type erasure không xoá hết thông tin generic sao?

Java type erasure xoá generic type tại runtime heapList<String>List<Integer> đều là List ở runtime. Nhưng bytecode (.class file) vẫn giữ generic signature trong attribute Signature của method/field descriptor.

Spring dùng class ResolvableType để đọc attribute Signature qua reflection API (java.lang.reflect.ParameterizedType). Khi thấy constructor param có type Repository<User>, Spring biết cần generic argument là User, không chỉ raw type Repository.

Sau đó Spring scan tất cả bean implement Repository, kiểm tra generic argument của từng implementation. UserRepository implements Repository<User> có argument User — match. OrderRepository implements Repository<Order> có argument Order — không match. Inject đúng UserRepository.

Nếu không có generic-aware matching, cả 2 đều là RepositoryNoUniqueBeanDefinitionException buộc phải thêm @Qualifier. Generic-aware giảm boilerplate và làm code readable hơn.

Q5
Đoạn sau có vấn đề gì? Sửa lại theo best practice:
@Service
public class NotificationHub {
  @Autowired private EmailService email;
  @Autowired private SmsService sms;
  @Autowired private PushService push;
  @Autowired private SlackService slack;
  @Autowired private TelegramService telegram;
  @Autowired private WebhookService webhook;
}

Vấn đề chính: field injection với 6 dependency. Hai anti-pattern kết hợp:

1 — Anti-pattern field injection: không có field nào final, không test được ngoài Spring, NPE tiềm ẩn nếu bean nào đó không có trong context.

2 — God class dấu hiệu: constructor injection 6 param sẽ trông xấu ngay — developer thấy mùi vấn đề và tách class. Field injection che giấu tín hiệu này. NotificationHub biết về 6 channel — vi phạm Single Responsibility. Nên tách: 1 NotificationDispatcher inject List<NotificationChannel>.

Sửa 1 — Constructor injection (immediate fix):

@Service
@RequiredArgsConstructor  // Lombok sinh constructor
public class NotificationHub {
  private final EmailService email;
  private final SmsService sms;
  // ... constructor 6 param goi y "class nay co van de"
}

Sửa 2 — Strategy pattern (proper fix):

public interface NotificationChannel { void send(String msg); }

@Service
public class NotificationHub {
  private final List<NotificationChannel> channels;

  public NotificationHub(List<NotificationChannel> channels) {
      this.channels = channels;
  }
  // them channel moi -> them @Component, khong sua hub
}

Strategy pattern qua collection injection: NotificationHub không biết về từng channel cụ thể, chỉ biết interface. Thêm TelegramChannel mới = thêm class @Component, không sửa NotificationHub.

Bài tiếp theo: Circular dependency — three-level cache

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên