Trong quá trình phát triển phần mềm, một chương trình hiếm khi chỉ bao gồm những lớp hoặc hàm hoạt động độc lập. Các thành phần thường phải sử dụng dịch vụ, dữ liệu hoặc chức năng do thành phần khác cung cấp. Ví dụ, một lớp xử lý đơn hàng có thể cần một kho dữ liệu để lưu thông tin, một dịch vụ gửi email để thông báo cho khách hàng và một bộ ghi nhật ký để theo dõi hoạt động. Mối quan hệ sử dụng lẫn nhau này được gọi là dependency, hay sự phụ thuộc.

Nếu các phụ thuộc được tạo trực tiếp bên trong một lớp, mã nguồn sẽ nhanh chóng trở nên cứng nhắc, khó kiểm thử và khó thay đổi. Dependency Injection (DI), thường được dịch là tiêm phụ thuộc, là một kỹ thuật thiết kế giúp giải quyết vấn đề đó. Thay vì để một đối tượng tự tạo ra các thành phần mà nó cần, những thành phần này được cung cấp từ bên ngoài. Nhờ vậy, mã nguồn giảm sự liên kết chặt chẽ giữa các lớp và trở nên linh hoạt hơn.

Dependency Injection không chỉ là một tính năng của một framework cụ thể. Đây là một nguyên lý thiết kế có thể áp dụng trong nhiều ngôn ngữ và nền tảng như Java, C#, TypeScript, Python, Kotlin, PHP hay Go. DI thường được nhắc đến cùng với các nguyên tắc SOLID, đặc biệt là Dependency Inversion Principle – nguyên tắc đảo ngược sự phụ thuộc.

1. Dependency là gì?

Dependency là một đối tượng hoặc dịch vụ mà một thành phần khác cần đến để hoàn thành nhiệm vụ. Chẳng hạn, trong một ứng dụng bán hàng, lớp OrderService có thể phụ thuộc vào OrderRepository để đọc và ghi đơn hàng.

Một ví dụ đơn giản bằng Java:

public class OrderService {
    private MySqlOrderRepository repository = new MySqlOrderRepository();
    public void createOrder(Order order) {
        repository.save(order);
    }
}

Trong ví dụ này, OrderService tự tạo một đối tượng MySqlOrderRepository. Cách làm này có vẻ đơn giản, nhưng nó tạo ra sự phụ thuộc trực tiếp giữa OrderService và một triển khai cụ thể là MySqlOrderRepository.

Nếu sau này muốn chuyển sang dùng PostgreSQL, cơ sở dữ liệu trong bộ nhớ hoặc một repository giả phục vụ kiểm thử, lập trình viên phải sửa mã nguồn của OrderService. Lớp nghiệp vụ vì thế bị gắn chặt với chi tiết kỹ thuật.

2. Dependency Injection là gì?

Dependency Injection là kỹ thuật trong đó một đối tượng nhận các phụ thuộc từ bên ngoài thay vì tự khởi tạo chúng. Thành phần sử dụng phụ thuộc không cần biết phụ thuộc được tạo ra như thế nào; nó chỉ cần biết phụ thuộc cung cấp những khả năng nào.

Có thể viết lại ví dụ trên như sau:

public interface OrderRepository {
    void save(Order order);
}
public class OrderService {
    private final OrderRepository repository;
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
    public void createOrder(Order order) {
        repository.save(order);
    }
}

Bây giờ OrderService không còn tự tạo MySqlOrderRepository. Nó chỉ phụ thuộc vào giao diện OrderRepository. Đối tượng cụ thể được truyền vào từ bên ngoài:

OrderRepository repository = new MySqlOrderRepository();
OrderService service = new OrderService(repository);

Trong môi trường kiểm thử, có thể truyền vào một triển khai giả:

OrderRepository fakeRepository = new InMemoryOrderRepository();
OrderService service = new OrderService(fakeRepository);

Điểm quan trọng của DI là quyền khởi tạo và lựa chọn phụ thuộc được chuyển ra bên ngoài đối tượng đang sử dụng phụ thuộc. Đối tượng nghiệp vụ chỉ tập trung vào trách nhiệm chính của nó.

3. Mối liên hệ với Dependency Inversion Principle

Dependency Injection và Dependency Inversion Principle có liên quan chặt chẽ nhưng không hoàn toàn đồng nghĩa.

Dependency Inversion Principle là nguyên tắc thứ năm trong SOLID, gồm hai ý chính:

  • Các module cấp cao không nên phụ thuộc trực tiếp vào các module cấp thấp. Cả hai nên phụ thuộc vào abstraction, thường là interface.
  • Abstraction không nên phụ thuộc vào chi tiết; chi tiết nên phụ thuộc vào abstraction.

Trong ví dụ về OrderService, nghiệp vụ tạo đơn hàng là logic cấp cao, còn việc lưu vào MySQL là chi tiết cấp thấp. Nếu OrderService trực tiếp khởi tạo MySqlOrderRepository, module cấp cao đã phụ thuộc vào chi tiết cấp thấp. Khi sử dụng interface OrderRepository, cả hai phía cùng phụ thuộc vào abstraction.

DI là một trong những cách phổ biến để thực hiện nguyên tắc này. Tuy nhiên, chỉ truyền một đối tượng vào constructor chưa chắc đã tạo ra thiết kế tốt nếu abstraction được lựa chọn không hợp lý hoặc nếu lớp vẫn có quá nhiều trách nhiệm.

4. Ba hình thức Dependency Injection phổ biến

Constructor Injection

Constructor Injection là cách đưa phụ thuộc vào thông qua hàm khởi tạo.

public class ReportService {
    private final ReportRepository repository;
    public ReportService(ReportRepository repository) {
        this.repository = repository;
    }
}

Đây thường là hình thức được khuyến nghị vì các phụ thuộc bắt buộc được thể hiện rõ ràng ngay khi đối tượng được tạo. Đối tượng không thể tồn tại ở trạng thái thiếu phụ thuộc cần thiết. Việc kiểm thử cũng đơn giản vì chỉ cần truyền mock hoặc fake vào constructor.

Ưu điểm của Constructor Injection là tính minh bạch và khả năng bảo đảm tính bất biến. Nhược điểm là constructor có thể trở nên quá dài nếu một lớp phụ thuộc vào quá nhiều dịch vụ. Đây thường là dấu hiệu cho thấy lớp đang có quá nhiều trách nhiệm và nên được tách nhỏ.

Setter Injection

Setter Injection cung cấp phụ thuộc thông qua một phương thức setter:

public class NotificationService {
    private EmailSender emailSender;
    public void setEmailSender(EmailSender emailSender) {
        this.emailSender = emailSender;
    }
}

Cách này phù hợp với những phụ thuộc không bắt buộc hoặc có thể thay đổi trong vòng đời của đối tượng. Tuy nhiên, nếu phụ thuộc là bắt buộc, Setter Injection có thể khiến đối tượng bị sử dụng trước khi setter được gọi, từ đó dẫn đến lỗi trong thời gian chạy.

Method Injection

Method Injection truyền phụ thuộc trực tiếp vào phương thức cần sử dụng nó:

public class PricingService {
    public Price calculate(Product product, TaxPolicy taxPolicy) {
        return taxPolicy.apply(product.getBasePrice());
    }
}

Hình thức này hữu ích khi phụ thuộc chỉ cần thiết cho một thao tác cụ thể hoặc có thể thay đổi theo từng lần gọi. Nó giúp giảm trạng thái lưu trữ bên trong đối tượng, nhưng chữ ký phương thức có thể trở nên dài nếu quá nhiều phụ thuộc được truyền vào.

5. DI thủ công và DI Container

Dependency Injection có thể được thực hiện hoàn toàn thủ công, không cần framework. Đây là cách phù hợp với các ứng dụng nhỏ hoặc những trường hợp muốn giữ cho hệ thống đơn giản.

Ví dụ:

public class Application {
    public static void main(String[] args) {
        OrderRepository repository = new MySqlOrderRepository();
        PaymentGateway paymentGateway = new StripePaymentGateway();
        OrderService orderService =
            new OrderService(repository, paymentGateway);
        orderService.createOrder(order);
    }
}

Phần Application đóng vai trò composition root: nơi các đối tượng được tạo ra và kết nối với nhau. Cách này làm cho luồng khởi tạo rất rõ ràng.

Trong các ứng dụng lớn, số lượng phụ thuộc và mối quan hệ giữa chúng có thể tăng nhanh. Khi đó, lập trình viên thường sử dụng DI container hoặc IoC container. Container chịu trách nhiệm đăng ký các kiểu đối tượng, tạo instance, giải quyết phụ thuộc và quản lý vòng đời của chúng.

Một số framework phổ biến có hỗ trợ DI là Spring trong Java, ASP.NET Core trong .NET, Angular trong TypeScript và NestJS trong Node.js. Tuy nhiên, framework chỉ là công cụ. Nếu cấu hình container quá phức tạp, hệ thống có thể trở nên khó hiểu và khó gỡ lỗi.

6. Dependency Injection và khả năng kiểm thử

Một trong những lợi ích lớn nhất của DI là hỗ trợ kiểm thử đơn vị. Khi lớp tự tạo phụ thuộc cụ thể, bài kiểm thử thường phải sử dụng tài nguyên thật hoặc phải áp dụng những kỹ thuật phức tạp để thay thế đối tượng.

Ví dụ, một dịch vụ gửi email có thể phụ thuộc vào EmailSender:

public class RegistrationService {
    private final UserRepository userRepository;
    private final EmailSender emailSender;
    public RegistrationService(
        UserRepository userRepository,
        EmailSender emailSender
    ) {
        this.userRepository = userRepository;
        this.emailSender = emailSender;
    }
    public void register(User user) {
        userRepository.save(user);
        emailSender.sendWelcomeEmail(user.getEmail());
    }
}

Trong bài kiểm thử, có thể dùng FakeUserRepository và FakeEmailSender. Nhờ vậy, bài kiểm thử không cần truy cập cơ sở dữ liệu thật hay gửi email thật. Nó chỉ tập trung xác minh rằng RegistrationService gọi đúng hành vi cần thiết.

DI cũng thúc đẩy việc thiết kế các interface nhỏ, có trách nhiệm rõ ràng. Đây là điều phù hợp với nguyên tắc Interface Segregation trong SOLID.

7. Những lợi ích chính của Dependency Injection

Giảm liên kết chặt chẽ

Các lớp không cần biết chi tiết về cách phụ thuộc được tạo ra. Có thể thay thế một triển khai bằng triển khai khác mà không sửa nhiều mã nguồn nghiệp vụ.

Tăng khả năng kiểm thử

Các phụ thuộc thật có thể được thay bằng mock, stub hoặc fake. Kiểm thử trở nên nhanh, cô lập và ổn định hơn.

Tăng khả năng tái sử dụng

Một lớp được thiết kế độc lập với quá trình khởi tạo có thể được dùng trong nhiều bối cảnh khác nhau, chẳng hạn ứng dụng web, chương trình dòng lệnh hoặc bài kiểm thử.

Tách biệt trách nhiệm

Lớp nghiệp vụ tập trung vào logic chính, trong khi lớp cấu hình hoặc composition root đảm nhiệm việc nối các thành phần với nhau. Điều này hỗ trợ nguyên tắc Single Responsibility.

Dễ thay đổi công nghệ

Có thể chuyển từ MySQL sang PostgreSQL, từ dịch vụ email này sang dịch vụ email khác hoặc từ một thư viện thanh toán sang thư viện mới mà không cần thay đổi toàn bộ logic ứng dụng.

8. Hạn chế và những hiểu lầm thường gặp

DI không phải là giải pháp cho mọi vấn đề. Nếu sử dụng thiếu cân nhắc, DI có thể làm hệ thống phức tạp hơn.

Thứ nhất, quá nhiều abstraction có thể khiến mã nguồn bị trừu tượng hóa quá mức. Không phải mọi lớp đều cần một interface riêng. Đối với một lớp đơn giản, ổn định và dễ kiểm thử, việc tạo thêm interface có thể chỉ làm tăng số lượng tệp mà không mang lại lợi ích thực tế.

Thứ hai, lạm dụng DI container có thể che giấu cách các đối tượng được tạo ra. Khi một phụ thuộc không được đăng ký đúng, lỗi chỉ xuất hiện lúc chạy chương trình. Vì vậy, cần hiểu rõ cấu hình, vòng đời và phạm vi của từng dependency.

Thứ ba, DI không tự động làm cho kiến trúc trở nên tốt. Một lớp vẫn có thể nhận mười phụ thuộc qua constructor nhưng vẫn vi phạm nguyên tắc trách nhiệm đơn nhất. Constructor quá dài nên được xem là tín hiệu cần xem xét lại thiết kế.

Thứ tư, mock quá nhiều có thể khiến bài kiểm thử phụ thuộc vào chi tiết triển khai thay vì hành vi thực tế. DI nên hỗ trợ thiết kế tốt, không nên trở thành lý do để tạo ra một mạng lưới mock khó bảo trì.

Kết luận

Dependency Injection là một kỹ thuật thiết kế quan trọng giúp xây dựng phần mềm linh hoạt, dễ kiểm thử và dễ bảo trì. Bản chất của DI là tách việc sử dụng một phụ thuộc khỏi việc khởi tạo phụ thuộc đó. Nhờ vậy, các thành phần có thể phụ thuộc vào abstraction thay vì các chi tiết cụ thể.

DI phát huy hiệu quả khi được kết hợp với những nguyên tắc như SOLID, phân chia trách nhiệm rõ ràng và thiết kế interface hợp lý. Constructor Injection thường là lựa chọn an toàn và minh bạch nhất, trong khi DI container phù hợp với những hệ thống có cấu trúc lớn nhưng cần được sử dụng có kiểm soát.

Dù mang lại nhiều lợi ích, Dependency Injection không nên bị xem như một mục tiêu tự thân. Một kiến trúc tốt không phải là kiến trúc có nhiều interface hoặc nhiều cấu hình nhất, mà là kiến trúc làm cho sự thay đổi trở nên dễ dàng với mức độ phức tạp hợp lý. Khi được áp dụng đúng lúc và đúng mức, Dependency Injection sẽ giúp mã nguồn rõ ràng hơn, các bài kiểm thử đáng tin cậy hơn và vòng đời phát triển phần mềm hiệu quả hơn.

Bài viết khác

Decision Table Testing(Kiểm thử bảng quyết định)

1.Decision Table Testing là gì? Decision Table Testing (hay còn gọi là Kiểm thử bảng quyết định) là một kỹ thuật thiết kế kiểm thử hộp đen được sử dụng để kiểm tra các tính năng có nhiều điều kiện kết hợp với nhau để đưa ra các hành động hoặc kết quả khác nhau. […]

Dio & HTTP Client

     Trong hệ sinh thái Flutter/Dart, lập trình viên có thể sử dụng nhiều thư viện khác nhau để thực hiện các yêu cầu HTTP. Trong đó, HTTP Client là khái niệm và công cụ nền tảng giúp ứng dụng gửi, nhận dữ liệu qua HTTP; còn Dio là một thư viện HTTP Client […]

Flutter Flavors & Multiple Environments

Flutter Flavors & Multiple Environments 1. Tổng quan về Flutter Flavors & Multiple Environments Trong quá trình phát triển ứng dụng Flutter, một dự án thường cần nhiều môi trường hoạt động khác nhau để phục vụ các giai đoạn phát triển, kiểm thử và triển khai thực tế. Ví dụ, ứng dụng có thể […]

Firebase Analytics – Phân tích dữ liệu và hành vi người dùng trong ứng dụng

Firebase Analytics – Phân tích dữ liệu và hành vi người dùng trong ứng dụng 1. Tổng quan về Firebase Analytics Firebase Analytics là một dịch vụ phân tích dữ liệu được tích hợp trong nền tảng Firebase của Google, giúp nhà phát triển thu thập, theo dõi và phân tích hành vi của người […]

Firebase Cloud Messaging (FCM)

Firebase Cloud Messaging (FCM) 1. Tổng quan về Firebase Cloud Messaging Firebase Cloud Messaging (FCM) là dịch vụ nhắn tin đám mây do Google cung cấp, cho phép ứng dụng gửi và nhận thông báo hoặc dữ liệu từ máy chủ đến các thiết bị như điện thoại Android, iPhone và trình duyệt web. FCM […]

API Error Handling

API Error Handling 1. Tổng quan về API Error Handling API Error Handling là kỹ thuật xử lý lỗi trong quá trình giao tiếp giữa các thành phần của hệ thống thông qua API (Application Programming Interface). Kỹ thuật này bao gồm việc phát hiện lỗi, phân loại nguyên nhân, trả về thông tin phù […]

Leave a Reply

Your email address will not be published. Required fields are marked *