Dependency injection: viết code dễ test và dễ thay đổi bằng cách "tiêm" phụ thuộc

  • author-image

    Nguyễn Đình Cường

  • blog-tag
  • blog-comment 0 comment
  • created-date 09 Nov, 2026
blog-thumbnail

Bạn viết một class gửi email chào mừng, bên trong tự tạo kết nối SMTP. Khi viết test, mỗi lần chạy lại gửi email thật, hoặc test thất bại vì không có mạng. Dependency injection (DI) là cách đơn giản để thoát khỏi tình trạng đó, và nó dễ hơn nhiều so với tên gọi nghe có vẻ học thuật.

Dependency là gì?

Khi class A cần dùng class B để làm việc thì B là dependency (phụ thuộc) của A. Câu hỏi là: A có tự tạo ra B hay được cung cấp B từ bên ngoài?

Cách làm có vấn đề: tự tạo phụ thuộc bên trong

class SmtpMailer {send(to, subject, body) { /* gửi email thật qua SMTP */ }}class WelcomeService {constructor() {this.mailer = new SmtpMailer();   // gắn cứng}welcome(user) {this.mailer.send(user.email, 'Chào mừng', 'Xin chào ' + user.name);}}

Hạn chế:

  • Muốn test WelcomeService mà không gửi email thật là rất khó.
  • Muốn đổi sang dịch vụ email khác phải sửa bên trong WelcomeService.
  • Class bị gắn chặt (tight coupling) vào một cài đặt cụ thể.

Giải pháp: nhận phụ thuộc từ bên ngoài

class WelcomeService {constructor(mailer) {this.mailer = mailer;             // được "tiêm" vào}welcome(user) {this.mailer.send(user.email, 'Chào mừng', 'Xin chào ' + user.name);}}// Dùng thậtconst service = new WelcomeService(new SmtpMailer());// Dùng trong test: bản giả ghi lại những gì được gửiclass FakeMailer {constructor() { this.sent = []; }send(to, subject, body) { this.sent.push({ to, subject, body }); }}const fake = new FakeMailer();new WelcomeService(fake).welcome({ name: 'An', email: 'an@example.com' });console.log(fake.sent.length);  // 1console.log(fake.sent[0].to);   // an@example.com

Chỉ thay đổi một chút: truyền mailer qua constructor thay vì tự tạo. Nhờ vậy WelcomeService không còn biết (và không quan tâm) mailer cụ thể là gì, miễn là nó có phương thức send. Đó chính là dependency injection.

Ba cách tiêm phổ biến

  1. Qua constructor (khuyến nghị): phụ thuộc bắt buộc được cung cấp ngay khi tạo đối tượng, đối tượng luôn ở trạng thái hợp lệ.
  2. Qua setter hoặc thuộc tính: gán sau khi tạo. Linh hoạt nhưng dễ quên gán.
  3. Qua tham số hàm: truyền phụ thuộc vào trực tiếp hàm cần dùng. Đơn giản và hiệu quả với hàm thuần.
// Python: tiêm qua tham số hàmdef create_report(orders, clock):return {"generated_at": clock(), "count": len(orders)}# Dùng thậtfrom datetime import datetimecreate_report(orders, datetime.now)# Trong test: thời gian cố địnhdef test_create_report():report = create_report([1, 2], lambda: "2025-01-01")assert report["generated_at"] == "2025-01-01"

Ví dụ này còn giải quyết bài toán quen thuộc: tách thời gian hiện tại ra thành thứ có thể thay thế để test ổn định.

Lợi ích

  • Dễ test: thay thế phụ thuộc bằng bản giả (fake, stub, mock) dễ dàng, test nhanh và ổn định.
  • Dễ thay đổi: đổi nhà cung cấp email, lưu trữ, thanh toán mà không đụng logic nghiệp vụ.
  • Thấy rõ phụ thuộc: nhìn constructor là biết class cần gì. Constructor có quá nhiều tham số cho thấy class đang làm quá nhiều việc.
  • Tái sử dụng tốt hơn: class không gắn chặt vào môi trường cụ thể.

Giao diện (interface) làm hợp đồng

Trong các ngôn ngữ có kiểu tĩnh (Java, C#, TypeScript, PHP), bạn nên phụ thuộc vào interface thay vì class cụ thể:

interface Mailer {send(to: string, subject: string, body: string): void;}class WelcomeService {constructor(private mailer: Mailer) {}welcome(user: { name: string; email: string }) {this.mailer.send(user.email, 'Chào mừng', 'Xin chào ' + user.name);}}

Mọi class nào đáp ứng Mailer đều dùng được: bản SMTP, bản dịch vụ email bên thứ ba, bản giả dùng trong test.

DI container: khi nào cần?

Khi ứng dụng lớn dần, việc tạo thủ công chuỗi phụ thuộc (A cần B, B cần C và D...) trở nên rườm rà. Nhiều framework có DI container tự động dựng đối tượng và cung cấp phụ thuộc: Spring (Java), Laravel (PHP), NestJS, ASP.NET Core... Bạn khai báo cách tạo, container lo phần còn lại.

Tuy nhiên, hãy hiểu nguyên lý trước khi dùng container. Với dự án nhỏ, truyền tay qua constructor tại một nơi duy nhất (thường là điểm khởi động ứng dụng, gọi là composition root) là đủ và dễ hiểu.

Những lỗi thường gặp

  1. Dùng container như kho toàn cục (service locator) và để mọi class tự lấy phụ thuộc ra từ đó, nên phụ thuộc lại bị ẩn đi.
  2. Tạo interface cho mọi thứ dù chỉ có một cài đặt và không bao giờ cần thay. Chỉ cần ở những ranh giới có ý nghĩa (mạng, database, thời gian, file).
  3. Tiêm quá nhiều phụ thuộc vào một class, dấu hiệu cần tách trách nhiệm.
  4. Dùng mock quá mức, test gắn chặt vào cách cài đặt thay vì hành vi. Ưu tiên bản giả đơn giản (fake) khi có thể.

Kết luận

DI không phải kỹ thuật cao siêu, nó chỉ là việc "đừng tự tạo những thứ bạn cần, hãy để người khác đưa cho bạn". Thay đổi nhỏ ấy giúp code dễ kiểm thử, dễ bảo trì và linh hoạt hơn hẳn. Hãy thử áp dụng ngay với một class đang gọi trực tiếp database hay dịch vụ ngoài trong dự án của bạn.

Để học thiết kế phần mềm bài bản hơn, bạn có thể xem các khóa học trên codebem.com.

author_photo
Nguyễn Đình Cường

0 comment