Mở một ứng dụng Spring bất kỳ, bạn sẽ thấy lớp OrderService nhận PaymentGateway và Notifier qua constructor, và không có dòng new nào ở đâu cả. Ai tạo các đối tượng đó, theo thứ tự nào, và vì sao đổi cài đặt khi chạy test lại không phải sửa code nghiệp vụ? Câu trả lời là Dependency Injection (DI) và một IoC container ở phía sau. Bài này giải thích Dependency Injection bằng một ví dụ đơn hàng, rồi tự viết một container 83 dòng bằng Java 21 và reflection, hỗ trợ constructor injection, singleton, bind interface với cài đặt, phát hiện vòng phụ thuộc. Cuối cùng chúng ta chạy cùng kịch bản trên Spring Framework 6.1 để so sánh và hiểu những gì ApplicationContext làm hộ bạn. Toàn bộ output là kết quả chạy thật trên Apple M4, 16 GB RAM, JDK 21.

Bài này nối tiếp Mediator pattern và Memento: Mediator giảm phụ thuộc chéo giữa các thành phần, còn Dependency Injection giải quyết một câu hỏi gốc hơn, đó là ai chịu trách nhiệm tạo và nối các đối tượng. Nếu bạn quen với nguyên lý SOLID, DI chính là cách hiện thực nguyên lý Dependency Inversion một cách có hệ thống.

Vấn đề: lớp tự tạo phụ thuộc của mình

Bắt đầu từ code mà ai cũng từng viết:

class OrderService {
    private final PaymentGateway gateway = new MomoGateway();
    private final Notifier notifier = new EmailNotifier("smtp.internal", 587);
    private final OrderRepository repo = new JdbcOrders(DriverManager.getConnection(URL));
    ...
}

Ba vấn đề xuất hiện ngay, đều là lý do cần Dependency Injection. Thứ nhất, OrderService gắn chặt với Momo, SMTP và JDBC: muốn thêm VNPay phải sửa lớp nghiệp vụ. Thứ hai, không thể test: mỗi lần chạy unit test là gọi cổng thanh toán thật và mở kết nối DB thật. Thứ ba, OrderService phải biết cách cấu hình các phụ thuộc (host SMTP, URL DB), nên lớp nghiệp vụ lẫn với chi tiết hạ tầng. Martin Fowler gọi giải pháp là tách "cấu hình" khỏi "sử dụng", và trong bài viết kinh điển về Inversion of Control và Dependency Injection ông viết: lựa chọn giữa các kiểu tiêm "ít quan trọng hơn nguyên tắc tách cấu hình khỏi sử dụng".

Dependency Injection là gì, và khác gì Inversion of Control

Inversion of Control (IoC) là nguyên tắc rộng: framework gọi code của bạn thay vì bạn gọi framework. Dependency Injection là một dạng cụ thể của IoC cho việc lấy phụ thuộc. Tài liệu Spring định nghĩa ngắn gọn: Dependency Injection là quá trình mà "đối tượng chỉ khai báo phụ thuộc của mình qua đối số constructor, đối số factory method hoặc thuộc tính được set sau khi tạo", và container sẽ tiêm chúng khi tạo bean (Spring Framework Reference, Introduction to the IoC Container). Thay vì lớp tự gọi new hoặc tự tra cứu qua Service Locator, quyền kiểm soát được đảo ngược cho container.

Fowler phân biệt ba kiểu tiêm và một phương án thay thế:

Cách lấy phụ thuộcCơ chếƯuNhược
Constructor injectionPhụ thuộc truyền qua constructorĐối tượng luôn đầy đủ, có thể bất biến (final), dễ testConstructor dài khi có nhiều phụ thuộc
Setter injectionContainer gọi setter sau khi tạoHợp với phụ thuộc tuỳ chọn, cấu hình lại đượcĐối tượng có thể ở trạng thái chưa đủ; cần kiểm tra null
Interface injectionLớp cài interface do container quy địnhTường minhXâm lấn nhất, ít dùng ngày nay
Service LocatorLớp tự hỏi một registry trung tâmĐơn giản để bắt đầuPhụ thuộc bị ẩn trong thân hàm, mọi lớp phụ thuộc vào locator

Hướng dẫn của Spring nghiêng về constructor injection cho phụ thuộc bắt buộc và setter cho phụ thuộc tuỳ chọn: constructor cho phép thành phần bất biến, đảm bảo phụ thuộc không null và trả về đối tượng đã khởi tạo đầy đủ. Tài liệu cũng nêu một dấu hiệu đáng nhớ: nếu constructor có quá nhiều đối số thì lớp đang gánh quá nhiều trách nhiệm và nên được tách ra (Dependency Injection, Spring Framework Reference).

Dependency Injection thủ công: chưa cần container

Điều quan trọng cần nhìn ra trước: Dependency Injection không đòi hỏi framework. Chỉ cần lớp nhận phụ thuộc qua constructor và có một chỗ duy nhất nối chúng lại (thường gọi là composition root, thường là main):

class OrderService {
    private final PaymentGateway gateway;
    private final Notifier notifier;
    private final OrderRepository repo;

    OrderService(PaymentGateway gateway, Notifier notifier, OrderRepository repo) {
        this.gateway = gateway; this.notifier = notifier; this.repo = repo;
    }
}

// composition root
var service = new OrderService(new MomoGateway(), new EmailNotifier(), new InMemoryOrders());

Với ứng dụng nhỏ, đoạn nối tay này đã là Dependency Injection đầy đủ. Container chỉ có giá trị khi đồ thị đối tượng đủ lớn để việc nối tay trở nên tẻ nhạt và dễ sai: vài chục lớp, nhiều scope, cấu hình theo môi trường. Đó là lúc ta muốn giao việc "đọc constructor, tìm phụ thuộc, tạo theo đúng thứ tự" cho một cỗ máy.

Dependency Injection qua IoC container tự viết: bốn bước

Ý tưởng của container Dependency Injection: nhận một kiểu cần tạo, dùng reflection đọc constructor, đệ quy tạo từng tham số, rồi gọi newInstance. Ta thêm cache để mỗi kiểu chỉ tạo một lần (singleton), bảng ánh xạ interface sang lớp cài đặt, và theo dõi đường đang tạo để bắt vòng phụ thuộc.

Bước 1: annotation đánh dấu constructor. Giống @Autowired hay @Inject (Jakarta Dependency Injection), annotation này giữ lại tới runtime để reflection đọc được:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.CONSTRUCTOR)
public @interface Inject {}

Bước 2: khung container với ba bảng trạng thái.

public class MiniContainer {
    private final Map<Class<?>, Class<?>> bindings = new HashMap<>();      // interface -> lớp cài đặt
    private final Map<Class<?>, Supplier<?>> factories = new HashMap<>();  // bean tạo bằng tay
    private final Map<Class<?>, Object> singletons = new HashMap<>();      // cache
    private final Set<Class<?>> creating = new LinkedHashSet<>();          // đường đang tạo

    public <T> MiniContainer bind(Class<T> type, Class<? extends T> impl) {
        bindings.put(type, impl); return this;
    }
    public <T> MiniContainer bean(Class<T> type, Supplier<? extends T> factory) {
        factories.put(type, factory); return this;
    }
    public <T> T get(Class<T> type) { return type.cast(resolve(type)); }
}

Bước 3: thuật toán resolve. Đây là trái tim của container. Lưu ý đoạn phát hiện vòng: nếu kiểu đang được tạo mà lại được yêu cầu lần nữa, ta đã đi vòng tròn:

private Object resolve(Class<?> type) {
    Object cached = singletons.get(type);
    if (cached != null) return cached;

    if (creating.contains(type)) {
        List<String> path = new ArrayList<>();
        for (Class<?> c : creating) path.add(c.getSimpleName());
        path.add(type.getSimpleName());
        throw new IllegalStateException("Phụ thuộc vòng: " + String.join(" -> ", path));
    }
    creating.add(type);
    try {
        Object bean = create(type);
        singletons.put(type, bean);
        return bean;
    } finally {
        creating.remove(type);
    }
}

private Object create(Class<?> type) {
    Supplier<?> f = factories.get(type);
    if (f != null) return f.get();

    Class<?> impl = bindings.getOrDefault(type, type);
    if (impl.isInterface() || Modifier.isAbstract(impl.getModifiers()))
        throw new IllegalStateException("Không có cài đặt cho " + type.getSimpleName() + " (thiếu bind)");

    Constructor<?> ctor = pickConstructor(impl);
    Class<?>[] paramTypes = ctor.getParameterTypes();
    Object[] args = new Object[paramTypes.length];
    for (int i = 0; i < args.length; i++) args[i] = resolve(paramTypes[i]);
    ctor.setAccessible(true);
    return ctor.newInstance(args);   // (đã bọc try/catch ReflectiveOperationException)
}

Bước 4: chọn constructor. Ưu tiên constructor có @Inject; nếu lớp chỉ có một constructor thì dùng luôn, đúng như Spring từ phiên bản 4.3 không còn bắt buộc @Autowired khi chỉ có một constructor:

private Constructor<?> pickConstructor(Class<?> impl) {
    Constructor<?>[] all = impl.getDeclaredConstructors();
    for (Constructor<?> c : all) if (c.isAnnotationPresent(Inject.class)) return c;
    if (all.length == 1) return all[0];
    throw new IllegalStateException(impl.getSimpleName() + " có nhiều constructor, cần @Inject");
}

Container này chưa có thread-safety, scope khác singleton, tiêm theo qualifier và vòng đời @PostConstruct; nhưng phần lõi ở trên đã đủ để minh hoạ cơ chế.

Chạy thử: wiring, thay cài đặt khi test, vòng phụ thuộc

Kịch bản demo gồm OrderService nhận ba interface. Ta nối cài đặt thật, sau đó thay Notifier bằng bản giả cho test, rồi cố ý tạo vòng phụ thuộc A → B → C → A và thiếu binding. Lệnh: cd code/s6-20/mini && javac -d out src/*.java && java -cp out Demo.

== 1. Wiring bình thường ==
  OrderService được tạo
  [db] lưu DH-1001 = 1250000
  [mail -> khach@example.com] Đơn DH-1001 đã thanh toán: MOMO-OK-1250000
  singleton: true
== 2. Thay cài đặt khi test, không sửa OrderService ==
  OrderService được tạo
  [db] lưu DH-2002 = 99000
  Notifier giả đã nhận: khach@example.com|Đơn DH-2002 đã thanh toán: MOMO-OK-99000
== 3. Phụ thuộc vòng ==
  Phụ thuộc vòng: A -> B -> C -> A
== 4. Thiếu binding ==
  Không có cài đặt cho Notifier (thiếu bind)

Kết quả cho thấy ba lợi ích cụ thể của Dependency Injection. Một, OrderService không có new nào và vẫn chạy, vì container đọc constructor và dựng cả ba phụ thuộc. Hai, kịch bản 2 thay Notifier bằng một lambda ghi vào StringBuilder mà không đụng một dòng nào của OrderService: đây chính là cách unit test hoạt động với DI. Ba, lỗi cấu hình được báo bằng thông điệp rõ ràng, kèm đường đi của vòng phụ thuộc, thay vì StackOverflowError khó hiểu.

Một chi tiết thiết kế của Dependency Injection kiểu này: phương thức refresh(Class...) trong code cho phép khởi tạo sớm các bean gốc. Spring làm điều tương tự: tài liệu cho biết container kiểm tra cấu hình, gồm cả bean thiếu và vòng phụ thuộc, ngay khi nạp, và singleton được khởi tạo sớm theo mặc định để lỗi cấu hình nổ lúc khởi động chứ không phải lúc có người dùng đầu tiên gọi vào.

Cùng kịch bản trên Spring Framework 6.1

Bây giờ so sánh Dependency Injection của ta với container thật. Dự án Maven chỉ cần spring-context 6.1.x (bản dùng trong demo là 6.1.12, phát hành tháng 8/2024; không dùng Spring Boot) và JDK 21. Cấu hình bằng @Configuration với các phương thức @Bean, trong đó tham số của phương thức chính là phụ thuộc được tiêm:

@Configuration
static class AppConfig {
    @Bean PaymentGateway gateway() { return new MomoGateway(); }
    @Bean Notifier notifier() { return new EmailNotifier(); }
    @Bean OrderService orderService(PaymentGateway g, Notifier n) { return new OrderService(g, n); }
}

try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
    ctx.getBean(OrderService.class).checkout("DH-1001", 1_250_000);
}

Kịch bản vòng phụ thuộc: bean a cần b qua tham số, bean b cần a. Output thật (đã rút gọn dòng cảnh báo log dài của Spring):

== 1. ApplicationContext với @Configuration + @Bean ==
  OrderService được tạo
  [mail -> khach@example.com] Đơn DH-1001: MOMO-OK-1250000
  singleton: true
  số bean trong context: 8
== 2. Phụ thuộc vòng qua constructor ==
  UnsatisfiedDependencyException
  gốc: BeanCurrentlyInCreationException
  Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?

Hai điều đáng chú ý. Thứ nhất, getBeanDefinitionCount() trả về 8 dù ta chỉ khai báo 3 bean: phần còn lại là các bean hạ tầng nội bộ như bộ xử lý annotation, minh chứng rằng container tự mang theo nhiều cơ chế mà bản mini của ta không có. Thứ hai, cơ chế phát hiện vòng giống hệt bản tự viết: Spring cũng theo dõi bean "đang được tạo" và ném BeanCurrentlyInCreationException khi gặp lại chính nó. Tài liệu Spring nêu rằng với constructor injection, vòng phụ thuộc không thể giải quyết và container sẽ báo lỗi lúc chạy; cách xử lý là tái cấu trúc, hoặc chuyển một phía sang setter injection, dù tài liệu không khuyến nghị cách sau.

Container Dependency Injection thật làm thêm những gì

So với 83 dòng của ta, Spring (và các container khác như Guice, Dagger, Jakarta CDI) bổ sung nhiều thứ, và hiểu chúng giúp bạn đoán đúng hành vi khi gặp lỗi lạ:

  • Component scanning: quét classpath tìm lớp có @Component, @Service, @Repository thay vì đăng ký bằng tay.
  • Scope: singleton là mặc định, ngoài ra có prototype (mỗi lần lấy tạo mới), request, session trong web.
  • Qualifier: khi có hai bean cùng kiểu (hai cổng thanh toán), dùng @Qualifier, @Primary hoặc tên bean để chọn. Bản mini của ta sẽ bị lỗi mơ hồ, còn Spring ném NoUniqueBeanDefinitionException.
  • Vòng đời: gọi @PostConstruct, @PreDestroy, InitializingBean, và đóng bean khi context đóng (ta đã thấy try-with-resources trên context).
  • BeanPostProcessor và AOP: container có thể thay bean bằng một proxy trước khi tiêm, nền tảng cho @Transactional, @Async. Chi tiết xem Proxy pattern: JDK dynamic proxy, CGLIB và Spring AOP.
  • Đồ thị phụ thuộc và thứ tự huỷ: bean tạo sau thì huỷ trước.
  • Tối ưu thời gian chạy: reflection có chi phí khi khởi động, nên các công cụ như Spring AOT và GraalVM native image sinh code nối bean ở thời điểm build.

Một chuẩn đáng nhớ là Jakarta Dependency Injection (trước đây là JSR-330), quy định tập annotation nhỏ như @Inject, @Named, @Singleton mà nhiều container cùng hiểu. Mã dùng chuẩn này dễ chuyển đổi giữa Spring, Guice và Quarkus hơn mã dùng annotation riêng của một framework.

Khi nào không cần container Dependency Injection

Dependency Injection là kỹ thuật thiết kế, còn container chỉ là công cụ. Một số đánh đổi cần cân nhắc:

  • Ứng dụng nhỏ, công cụ dòng lệnh, hàm Lambda khởi động nhanh: nối tay trong main thường đủ, nhanh hơn và dễ đọc. Chi phí quét classpath và reflection của container có thể thành điểm nghẽn thời gian khởi động.
  • Phép màu khó debug: khi cấu hình Dependency Injection sai ở thời gian chạy, bạn phải đọc stack trace của container. Fowler nhắc đây là điểm yếu: trong bài về Inversion of Control, ở phần so sánh với Service Locator, ông viết rằng inversion of control "tends to be hard to understand and leads to problems when you are trying to debug" (tạm dịch: thường khó hiểu và gây rắc rối khi gỡ lỗi), nên ông chỉ dùng khi thật sự cần.
  • Lạm dụng Service Locator qua ApplicationContext.getBean() trong code nghiệp vụ: quay lại đúng vấn đề che giấu phụ thuộc mà DI sinh ra để loại bỏ.
  • Constructor quá dài: không phải lỗi của DI mà là tín hiệu lớp làm quá nhiều việc. Tách lớp, đừng chuyển sang field injection để che dấu hiệu.

Lỗi thường gặp với Dependency Injection

1. Field injection bằng @Autowired trên trường private. Code gọn nhưng không tạo được đối tượng trong unit test mà không có container hay reflection, không thể khai báo final, và che giấu số lượng phụ thuộc. Nên ưu tiên constructor injection.

2. Vòng phụ thuộc bị "chữa" bằng setter. Đây là lỗi hay gặp nhất khi mới dùng Dependency Injection. Chuyển một phía sang setter làm lỗi biến mất nhưng giữ nguyên thiết kế xấu: hai lớp biết nhau quá nhiều. Hãy tách phần chung thành lớp thứ ba, hoặc dùng sự kiện (xem Mediator pattern) để một phía không cần biết phía kia.

3. Tiêm bean prototype vào singleton. Dependency Injection chỉ chạy một lần, lúc tạo singleton, nên bean "prototype" được tiêm vào thực chất thành singleton. Dùng ObjectProvider hoặc Provider để lấy mới mỗi lần.

4. Trạng thái chia sẻ trong singleton. Singleton do nhiều luồng dùng chung, nên một trường List hay SimpleDateFormat thay đổi được sẽ gây lỗi đua dữ liệu. Giữ bean không trạng thái, hoặc làm cho trạng thái an toàn luồng.

5. Hai bean cùng kiểu. Dependency Injection theo kiểu cần đúng một ứng viên. Thêm một cài đặt thứ hai của PaymentGateway làm toàn bộ ứng dụng không khởi động được vì không biết tiêm cái nào. Dùng @Primary hoặc @Qualifier, và có test khởi động context.

6. Dùng new trong một bean được quản lý. Đối tượng tạo bằng new nằm ngoài container, không được tiêm, không có proxy giao dịch. Nếu cần nhiều thể hiện, hãy tiêm một factory.

Checklist và tóm tắt

  • Áp dụng Dependency Injection: lớp nghiệp vụ nhận phụ thuộc qua constructor, phụ thuộc vào interface chứ không vào lớp cài đặt cụ thể.
  • Một "composition root" duy nhất chịu trách nhiệm nối đối tượng, dù bằng tay hay bằng container.
  • Dùng constructor injection cho phụ thuộc bắt buộc, setter cho phụ thuộc tuỳ chọn, tránh field injection.
  • Constructor dài là dấu hiệu lớp có quá nhiều trách nhiệm.
  • Vòng phụ thuộc là lỗi thiết kế: tái cấu trúc thay vì né bằng setter.
  • Test khởi động toàn bộ context trong CI để lỗi wiring nổ sớm.
  • Hiểu container Dependency Injection làm gì (reflection, cache singleton, phát hiện vòng, proxy) để đọc được stack trace khi lỗi.

Mã nguồn đầy đủ nằm trong code/s6-20/: thư mục mini/ (container tự viết, run_output.txt) và spring-demo/ (Spring Framework 6.1, run_output_spring.txt). Muốn đọc thêm về nguồn gốc khái niệm, hãy xem bài của Fowler và chương IoC container trong Spring Reference đã dẫn ở trên.

Bài tiếp theo

Có container nối đối tượng, bước tiếp theo là cho nó một lớp truy cập dữ liệu sạch. Bài tới: Repository pattern và Unit of Work với JPA, nơi chúng ta tách logic truy vấn khỏi nghiệp vụ và xem EntityManager đóng vai Unit of Work ra sao.