8 phút

Tiêm phụ thuộc giúp tăng khả năng kiểm thử và tính mô-đun

Tìm hiểu cách Dependency Injection (DI) giúp mã dễ kiểm thử, tái cấu trúc và mở rộng hơn. Khám phá các mẫu thực tế, ví dụ và những cạm bẫy thường gặp.

Tiêm phụ thuộc giúp tăng khả năng kiểm thử và tính mô-đun

Ý nghĩa của Tiêm Phụ thuộc (không dùng thuật ngữ khó hiểu)

Dependency Injection (DI) là một ý tưởng đơn giản: thay vì một đoạn mã tự tạo ra những thứ nó cần, bạn cung cấp những thứ đó cho nó từ bên ngoài.

Những “thứ nó cần” là các phụ thuộc — ví dụ: kết nối cơ sở dữ liệu, dịch vụ thanh toán, đồng hồ, logger hoặc bộ gửi email. Nếu mã của bạn tự tạo những phụ thuộc này, nó âm thầm khóa vào cách những phụ thuộc đó hoạt động.

Tương tự trong đời thực

Hãy nghĩ đến một máy pha cà phê ở văn phòng. Nó phụ thuộc vào nước, hạt cà phê và điện.

  • Nếu máy được thiết kế chỉ dùng một cartridge nước độc quyền mà nó tự mua, bạn bị bó buộc với nhà cung cấp đó.
  • Nếu máy chấp nhận bất kỳ nguồn nước chuẩn nào, người khác có thể chọn nguồn—nước máy, lọc, đóng chai—mà không cần thay đổi máy.

DI là cách thứ hai: “máy pha cà phê” (lớp/hàm của bạn) tập trung vào việc pha cà phê (nhiệm vụ của nó), trong khi “vật tư” (phụ thuộc) được cung cấp bởi người thiết lập.

DI không phải là gì

DI không yêu cầu dùng một framework cụ thể, và không giống với một DI container. Bạn có thể làm DI thủ công bằng cách truyền phụ thuộc như tham số (hoặc qua constructor) là xong.

DI cũng không phải là “mocking”. Mocking là một cách có thể sử dụng DI trong test, nhưng DI bản thân chỉ là một lựa chọn thiết kế về nơi tạo ra phụ thuộc.

Tại sao khả năng kiểm thử và tính mô-đun cùng cải thiện

Khi phụ thuộc được cung cấp từ bên ngoài, mã của bạn dễ chạy trong nhiều bối cảnh khác nhau: production, unit test, demo, và các tính năng tương lai.

Chính sự linh hoạt này làm cho các module sạch hơn: các phần có thể được thay thế mà không phải đi dây lại toàn bộ hệ thống. Kết quả là test chạy nhanh hơn và rõ ràng hơn (vì bạn có thể thay bằng các bản thay thế đơn giản), và codebase dễ thay đổi hơn (vì các phần ít bị bện vào nhau).

Vấn đề cốt lõi: Coupling chặt làm thay đổi khó khăn

Coupling chặt xảy ra khi một phần mã quyết định trực tiếp phần khác phải dùng gì. Hình thức phổ biến nhất rất đơn giản: gọi new bên trong logic nghiệp vụ.

Cách khởi tạo trực tiếp tạo coupling ẩn

Hãy tưởng tượng một hàm thanh toán gọi new StripeClient()new SmtpEmailSender() bên trong. Ban đầu trông tiện—mọi thứ bạn cần ở ngay đó. Nhưng nó cũng khóa luồng thanh toán vào các triển khai cụ thể đó, chi tiết cấu hình và thậm chí quy tắc tạo chúng (API key, timeout, hành vi mạng).

Sự coupling này là “ẩn” vì nó không rõ ràng từ chữ ký hàm. Hàm trông như chỉ xử lý đơn hàng, nhưng thực chất phụ thuộc vào cổng thanh toán, nhà cung cấp email, và có thể cả kết nối DB nữa.

Tại sao phụ thuộc khó thay thế làm thay đổi chậm

Khi phụ thuộc bị gắn cứng, ngay cả thay đổi nhỏ cũng lan rộng:

  • Chuyển nhà cung cấp (Stripe → Adyen) buộc phải sửa logic nghiệp vụ thay vì hoán đổi một thành phần.
  • Thêm caching, retry, hoặc logging buộc bạn phải lồng mối quan tâm qua nhiều điểm gọi.
  • Nâng cấp thư viện có thể trở thành refactor lớn vì việc tạo đối tượng rải rác.

Coupling chặt thể hiện qua test chậm hoặc flakey

Phụ thuộc gắn cứng khiến unit test thực thi công việc thật: gọi mạng, I/O file, đồng hồ, ID ngẫu nhiên, hoặc tài nguyên chia sẻ. Test trở nên chậm vì không cô lập, và flakey vì kết quả phụ thuộc thời gian, dịch vụ ngoài, hoặc thứ tự thực thi.

Dấu hiệu đau đớn cần chú ý

Nếu bạn thấy các pattern sau, coupling chặt có thể đang tốn của bạn nhiều thời gian:

  • Trạng thái toàn cục được dùng như phụ thuộc ngầm
  • Singleton khó reset giữa các test
  • new rải rác trong logic lõi
  • Code không thể test nếu thiếu DB, web server, hoặc API key thật

Dependency Injection giải quyết điều này bằng cách làm phụ thuộc rõ ràng và có thể hoán đổi — mà không phải viết lại luật nghiệp vụ mỗi khi thế giới thay đổi.

Inversion of Control: tách “Cái gì” khỏi “Cách làm”

Inversion of Control (IoC) là sự chuyển đổi trách nhiệm: lớp nên tập trung vào việc nó cần làm, không phải cách để lấy những thứ nó cần.

Khi một lớp tự tạo phụ thuộc của nó (ví dụ new EmailService() hoặc mở kết nối DB trực tiếp), nó âm thầm ôm hai nhiệm vụ: logic nghiệp vụ thiết lập. Điều đó làm lớp khó thay đổi, khó tái sử dụng và khó test.

Phụ thuộc vào trừu tượng, không phải lớp cụ thể

Với IoC, mã của bạn phụ thuộc vào trừu tượng — như interface hoặc kiểu “hợp đồng” nhỏ — thay vì triển khai cụ thể.

Ví dụ, CheckoutService không cần biết thanh toán qua Stripe, PayPal hay một bộ xử lý giả trong test. Nó chỉ cần “một thứ có thể charge thẻ”. Nếu CheckoutService chấp nhận một IPaymentProcessor, nó có thể làm việc với bất kỳ triển khai nào tuân theo hợp đồng đó.

Điều này giữ logic lõi ổn định ngay cả khi các công cụ nền tảng thay đổi.

Di chuyển việc tạo đối tượng ra khỏi lớp

Phần thực hành của IoC là chuyển việc tạo phụ thuộc ra khỏi lớp và truyền vào (thường qua constructor). Đây là nơi DI xuất hiện: DI là cách phổ biến để đạt IoC.

Thay vì:

  • lớp tự chọn và tạo cộng sự của nó

Bạn có:

  • lớp nhận cộng sự từ bên ngoài

Kết quả là linh hoạt: thay đổi hành vi trở thành quyết định cấu hình, không phải sửa code.

“Composition root”: nơi nối dây diễn ra

Nếu các lớp không tự tạo phụ thuộc, thứ khác phải làm. “Thứ khác” này là composition root: nơi ứng dụng của bạn được lắp ráp — thường là code khởi động.

Composition root là nơi bạn quyết định, “Ở production dùng RealPaymentProcessor; trong test dùng FakePaymentProcessor.” Giữ việc nối dây ở một nơi giảm bất ngờ và giúp phần còn lại của codebase tập trung.

Tại sao điều này quan trọng cho test và refactor

IoC làm unit test đơn giản hơn vì bạn có thể cung cấp các test double nhỏ, nhanh thay vì gọi mạng hay DB thật.

Nó cũng làm refactor an toàn hơn: khi trách nhiệm tách biệt, thay đổi một triển khai hiếm khi buộc bạn chỉnh sửa các lớp sử dụng nó — miễn là trừu tượng không đổi.

Các phong cách DI phổ biến và khi nào dùng mỗi kiểu

DI không phải một kỹ thuật duy nhất — nó là một tập nhỏ các cách “cho” một lớp những thứ nó phụ thuộc vào (như logger, client DB, hoặc cổng thanh toán). Kiểu bạn chọn ảnh hưởng đến độ rõ ràng, khả năng test, và mức độ dễ lạm dụng.

Constructor injection (mặc định)

Với constructor injection, phụ thuộc là bắt buộc để tạo đối tượng. Lợi ích lớn: bạn không thể quên chúng một cách tình cờ.

Phù hợp nhất khi phụ thuộc:

  • luôn cần để đối tượng hoạt động
  • được chia sẻ giữa nhiều phương thức
  • quan trọng để validate sớm (ví dụ không được null)

Constructor injection thường tạo ra code rõ ràng nhất và test unit đơn giản nhất, vì test có thể truyền fake hoặc mock ngay khi khởi tạo.

Parameter / method injection (cho công việc một lần)

Đôi khi một phụ thuộc chỉ cần cho một thao tác duy nhất — ví dụ một formatter tạm, chiến lược đặc biệt, hoặc giá trị theo request.

Trong trường hợp đó, truyền nó như tham số phương thức. Điều này giữ đối tượng nhỏ hơn và tránh “đẩy” nhu cầu một lần thành field lâu dài.

Property / setter injection (dùng cẩn trọng)

Setter injection tiện khi bạn thực sự không thể cung cấp phụ thuộc khi khởi tạo (một số framework hoặc code legacy). Giá phải trả là nó có thể che giấu yêu cầu: lớp trông có thể dùng được ngay cả khi chưa cấu hình đầy đủ.

Điều này thường dẫn tới bất ngờ runtime (“tại sao undefined?”) và làm test dễ vỡ hơn vì setup dễ bị quên.

Quy tắc đơn giản

  • Nếu lớp không thể hoạt động mà không có nó: constructor injection.
  • Nếu chỉ cần cho một lần gọi: method/parameter injection.
  • Nếu cần nối dây muộn: setter injection, nhưng thêm ràng buộc (tài liệu rõ, validate, hoặc fail-fast).

DI cải thiện unit test như thế nào (Tốc độ, Cô lập, Rõ ràng)

Mở rộng DI tới mobile
Tạo client Flutter nói chuyện với các dịch vụ Go dựa trên DI của bạn.

Unit test hữu ích nhất khi chúng nhanh, lặp lại được, và tập trung vào một hành vi. Khi test unit phụ thuộc DB thật, gọi mạng, file system, hoặc đồng hồ, nó thường chậm và flakey. Thậm chí khi fail, bạn khó biết vì đâu: do code hay do môi trường?

Dependency Injection (DI) khắc phục điều này bằng cách cho phép mã nhận các thứ nó cần (truy cập DB, client HTTP, provider thời gian) từ bên ngoài. Trong test, bạn có thể hoán đổi những phụ thuộc đó bằng các bản thay thế nhẹ.

Tốc độ: giữ test chạy trong bộ nhớ

DB thật hay API add latency và thiết lập. Với DI, bạn có thể tiêm repository trong bộ nhớ hoặc fake client trả kết quả đã chuẩn bị ngay lập tức. Điều đó nghĩa là:

  • nhiều test chạy trong cùng thời gian
  • bạn có xu hướng chạy test thường xuyên hơn
  • pipeline CI nhanh hơn

Cô lập: test từng thứ một

Không DI, mã thường new() phụ thuộc, buộc test phải chạm cả stack. Với DI, bạn có thể tiêm:

  • mocks để kiểm tra tương tác (ví dụ “đã gửi 1 email”)
  • stubs để trả giá trị cố định (ví dụ “user tồn tại”)
  • fakes với hành vi đơn giản (ví dụ store trong bộ nhớ)

Không cần mẹo hay công tắc toàn cục—chỉ là truyền một triển khai khác.

Rõ ràng: Arrange–Act–Assert đơn giản hơn

DI làm setup rõ ràng. Thay vì mò qua config, connection string, hay biến môi trường chỉ để test, bạn đọc test và thấy ngay gì là thật và gì được thay thế.

Test mẫu phù hợp DI đọc như sau:

  1. Arrange: tạo service với fake repository và clock stub
  2. Act: gọi phương thức
  3. Assert: kiểm tra giá trị trả về và/hoặc verify tương tác mock

Sự trực tiếp này giảm nhiễu và khiến việc tìm lỗi dễ hơn — chính xác điều bạn muốn ở unit test.

Test seams: làm hành vi có thể hoán đổi có chủ ý

Một test seam là một “khe” có chủ ý trong mã nơi bạn có thể hoán đổi hành vi. Trong production, bạn cắm thứ thật. Trong test, bạn cắm bản thay thế an toàn, nhanh. DI là cách đơn giản để tạo seam mà không cần mẹo.

Seams thường đặt quanh đâu

Seams hữu ích quanh những phần khó kiểm soát trong test:

  • Thời gian (ngày/giờ thay đổi liên tục)
  • File system (chậm, quyền, dọn dẹp)
  • Email/SMS (tác động bên ngoài)
  • Cổng thanh toán (tiền thật, lỗi mạng)

Nếu logic nghiệp vụ gọi trực tiếp những thứ này, test trở nên mong manh: fail vì mạng, timezone, file, v.v.

Interface (hoặc contract) biến seam thành lựa chọn đơn giản

Seam thường là một interface—hoặc trong ngôn ngữ động, một “contract” như “đối tượng này cần có phương thức now()”. Ý chính là phụ thuộc vào cái bạn cần, không phải nó đến từ đâu.

Ví dụ thay vì gọi đồng hồ hệ thống trực tiếp trong service đơn hàng, bạn phụ thuộc vào Clock:

  • Production: SystemClock.now()
  • Test: FakeClock.now() trả thời điểm cố định

Mô hình tương tự cho đọc file (FileStore), gửi email (Mailer) hay charge thẻ (PaymentGateway). Logic lõi không đổi; chỉ triển khai được hoán.

Tại sao seams dẫn đến test tốt hơn

Khi bạn có thể hoán đổi hành vi:

  • Test ít mong manh hơn: không phụ thuộc thời gian thật, mạng thật, hay trạng thái máy chia sẻ.
  • Các trường hợp cạnh dễ kiểm tra: giả lập “thanh toán bị từ chối”, “timeout nhà cung cấp email”, hoặc “cuối tháng” mà không cần setup phức tạp.
  • Lỗi rõ ràng hơn: nếu test fail thường là vì quy tắc nghiệp vụ sai — không phải môi trường.

Seams đặt tốt giảm nhu cầu mock nặng nề ở khắp nơi. Thay vào đó, bạn có vài điểm thay thế sạch giữ test nhanh, rõ ràng, và dự đoán được.

DI giúp mã mô-đun hơn như thế nào

Mô-đun nghĩa là phần mềm được xây từ các phần độc lập có ranh giới rõ: mỗi module có trách nhiệm tập trung và cách tương tác rõ ràng với hệ thống.

Dependency injection (DI) hỗ trợ điều này bằng cách làm ranh giới ấy rõ ràng. Thay vì một module tìm và tạo mọi thứ nó cần, nó nhận phụ thuộc từ bên ngoài. Sự thay đổi nhỏ này giảm lượng kiến thức một module phải biết về module khác.

Giảm coupling bằng thiết kế

Khi code tự tạo phụ thuộc (ví dụ new một client DB trong service), caller và phụ thuộc gắn chặt với nhau. DI khuyến khích phụ thuộc vào interface (hoặc contract) thay vì triển khai cụ thể.

Điều đó có nghĩa module thường chỉ cần biết:

  • nó cần gì (ví dụ PaymentGateway.charge())
  • không cần biết cách triển khai (Stripe vs PayPal vs sandbox)

Kết quả là các module ít phải thay đổi cùng nhau, vì chi tiết nội bộ không rò rỉ qua ranh giới.

Thay phần mà không viết lại callers

Một codebase mô-đun cho phép bạn hoán đổi một thành phần mà không phải viết lại mọi người gọi nó. DI biến điều này thành thực tế:

  • Thay sender email thật bằng sender hàng đợi
  • Chuyển repository từ file sang DB
  • Thêm decorator caching quanh một service hiện có

Trong mỗi trường hợp, callers vẫn dùng cùng hợp đồng. Việc “nối dây” thay đổi ở một nơi (composition root), thay vì sửa khắp nơi.

Dễ làm việc song song giữa các team

Ranh giới phụ thuộc rõ giúp các team làm việc song song. Một team có thể xây triển khai mới phía sau interface đã thống nhất trong khi team khác vẫn phát triển tính năng phụ thuộc vào interface đó.

DI cũng hỗ trợ refactor dần: bạn có thể tách module, tiêm nó và thay dần — không cần big-bang rewrite.

Ví dụ đơn giản trước và sau

Giữ toàn quyền kiểm soát mã nguồn
Tạo ứng dụng rồi xuất mã nguồn để phù hợp với workflow repo hiện có của bạn.

Thấy DI trong mã sẽ khiến bạn hiểu nhanh hơn bất kỳ định nghĩa nào. Đây là ví dụ nhỏ về tính năng thông báo.

Trước: lớp tự tạo phụ thuộc

Khi một lớp gọi new bên trong, nó quyết định triển khai nàocách tạo ra nó.

class EmailService {
  send(to, message) {
    // talks to real SMTP provider
  }
}

class WelcomeNotifier {
  notify(user) {
    const email = new EmailService();
    email.send(user.email, "Welcome!");
  }
}

Đau đầu khi test: unit test có nguy cơ kích hoạt hành vi gửi email thật (hoặc phải stub global lúng túng).

test("sends welcome email", () => {
  const notifier = new WelcomeNotifier();
  notifier.notify({ email: "[email protected]" });
  // Hard to assert without patching EmailService globally
});

Sau: tiêm phụ thuộc

Bây giờ WelcomeNotifier chấp nhận bất kỳ đối tượng nào có hành vi cần thiết.

class WelcomeNotifier {
  constructor(emailService) {
    this.emailService = emailService;
  }

  notify(user) {
    this.emailService.send(user.email, "Welcome!");
  }
}

Test trở nên nhỏ, nhanh và rõ ràng.

test("sends welcome email", () => {
  const fakeEmail = { send: vi.fn() };
  const notifier = new WelcomeNotifier(fakeEmail);

  notifier.notify({ email: "[email protected]" });

  expect(fakeEmail.send).toHaveBeenCalledWith("[email protected]", "Welcome!");
});

Thêm triển khai mới đơn giản hơn

Muốn SMS sau này? Bạn không chạm WelcomeNotifier. Chỉ cần truyền một triển khai khác:

const smsService = { send: (to, msg) => {/* SMS provider */} };
const notifier = new WelcomeNotifier(smsService);

Đây là lợi ích thực tế: test không còn vật lộn với chi tiết tạo đối tượng, và hành vi mới được thêm bằng cách hoán đổi phụ thuộc thay vì viết lại code hiện có.

DI thủ công vs DI Container: chọn mức độ phù hợp

DI có thể đơn giản như “truyền thứ bạn cần vào nơi dùng nó”. Đó là DI thủ công. DI container tự động hóa việc nối dây đó. Cả hai đều có chỗ đứng — vấn đề là chọn mức tự động phù hợp với ứng dụng.

Nối dây thủ công: rõ ràng và dễ hiểu

Với manual DI, bạn tạo đối tượng và truyền phụ thuộc qua constructor (hoặc tham số). Nó đơn giản:

  • Bạn thấy rõ chính xác cái gì được tạo ở đâu.
  • Không có magic ẩn khi có lỗi.
  • Tốt cho app nhỏ, script, dịch vụ vài thành phần và khi refactor ban đầu.

Nối dây thủ công cũng ép thói quen thiết kế tốt. Nếu một đối tượng cần bảy phụ thuộc, bạn sẽ cảm nhận ngay — thường là dấu hiệu cần tách trách nhiệm.

DI container: đỡ boilerplate, quản lý vòng đời tốt hơn

Khi số lượng thành phần tăng, nối dây thủ công có thể thành việc lặp đi lặp lại. DI container giúp:

  • Xây đồ thị đối tượng tự động.
  • Quản lý vòng đời (singleton vs per-request vs transient).
  • Tập trung đăng ký (ví dụ hoán đổi service thật bằng test double ở môi trường nhất định).

Container tỏa sáng trong các app có ranh giới và vòng đời rõ ràng — web app, dịch vụ dài hạn, hoặc hệ thống nhiều tính năng dùng cơ sở hạ tầng chung.

Đừng để container che dấu vấn đề thiết kế

Container có thể làm một thiết kế bị coupling nặng trông gọn vì wiring biến mất. Nhưng vấn đề vẫn ở đó:

  • quá nhiều phụ thuộc mỗi lớp
  • ownership mơ hồ (ai chịu trách nhiệm tạo/giải phóng tài nguyên?)
  • pattern “service locator” làm phụ thuộc ẩn và test khó hơn

Nếu thêm container làm code khó đọc hoặc dev không biết phụ thuộc của từng lớp, bạn đã đi quá xa.

Cách tiếp cận cân bằng

Bắt đầu với manual DI để giữ mọi thứ minh bạch khi cấu trúc module. Thêm container khi việc nối dây lặp lại nhiều hoặc quản lý vòng đời phức tạp.

Quy tắc thực dụng: dùng manual DI bên trong lõi/nghiệp vụ, và (tuỳ chọn) một container ở biên ứng dụng (composition root) để lắp ráp mọi thứ. Cách này giữ thiết kế rõ ràng mà vẫn giảm boilerplate khi dự án lớn.

Những lỗi thường gặp (và cách tránh)

Lập bản đồ composition root của bạn
Dùng Chế độ Lập kế hoạch để phác thảo các seams, giao diện và composition root trước khi viết mã.

DI có thể làm code dễ test và dễ thay đổi — nhưng chỉ khi dùng có kỷ luật. Dưới đây là các cách DI thường sai, và thói quen giúp giữ nó hữu ích.

Over-injection (constructor 12 tham số)

Nếu một lớp cần danh sách dài phụ thuộc, thường nó làm quá nhiều việc. Đó không phải là lỗi DI — DI chỉ phơi bày mùi thiết kế.

Quy tắc: nếu bạn không thể mô tả công việc của lớp trong một câu, hoặc constructor cứ lớn dần, hãy tách lớp, rút ra collaborator nhỏ hơn, hoặc gom các thao tác liên quan sau một interface duy nhất (cẩn trọng — đừng tạo “god services”).

Service Locator: DI che giấu phụ thuộc thực

Service Locator thường là gọi container.get(Foo) trong business code. Nó tiện nhưng làm phụ thuộc ẩn: bạn không thể biết lớp cần gì khi đọc constructor.

Test khó vì bạn phải thiết lập state global (locator) thay vì cung cấp bộ fake cục bộ rõ ràng. Ưu tiên truyền phụ thuộc rõ ràng (constructor injection là đơn giản nhất).

Lỗi runtime ẩn: đăng ký thiếu và vòng lặp phụ thuộc

DI container có thể lỗi runtime khi:

  • một phụ thuộc chưa được đăng ký
  • đăng ký chọn sai triển khai cho môi trường hiện tại
  • hai service phụ thuộc lẫn nhau (trực tiếp hoặc gián tiếp), tạo vòng

Những lỗi này khó chịu vì chỉ xuất hiện khi wiring chạy.

Các biện pháp thực tế

Giữ constructor nhỏ và tập trung. Nếu danh sách phụ thuộc dài, đó là dấu hiệu refactor.

Thêm integration test cho wiring. Một test nhẹ build container hoặc nối dây thủ công có thể bắt lỗi đăng ký thiếu và vòng sớm — trước khi lên production.

Cuối cùng, giữ việc tạo đối tượng ở một chỗ (thường là startup/composition root) và tránh gọi container trong business logic. Sự tách này giữ lợi ích chính của DI: rõ ràng về phụ thuộc của từng phần.

Các bước thực tế để đưa DI vào codebase hiện có

DI dễ áp dụng khi bạn coi đó là chuỗi refactor nhỏ, ít rủi ro. Bắt đầu nơi test chậm hoặc flakey, và nơi thay đổi hay lan tỏa qua nhiều mã.

Checklist nhanh: chỗ DI mang lại lợi ích trước

Tìm các phụ thuộc làm code khó test hoặc khó lý giải:

  • I/O: file system, DB, network
  • Thời gian: “now”, timezone, scheduler
  • Ngẫu nhiên: UUID, số ngẫu nhiên, shuffle
  • API/SDK ngoài: cổng thanh toán, email, analytics, feature flags

Nếu một hàm không thể chạy mà không ra khỏi process, đó thường là ứng viên tốt.

Refactor theo bước (mẫu có thể lặp)

  1. Chọn một seam: chọn một phụ thuộc bên ngoài bạn đang new hoặc gọi trực tiếp.
  2. Extract interface/contract: định nghĩa hành vi bạn thực sự cần (thường 1–3 phương thức). Giữ nhỏ.
  3. Tạo triển khai thật: bọc phụ thuộc hiện có phía sau interface đó.
  4. Tiêm nó: truyền interface qua constructor hoặc tham số hàm. Chọn cách đơn giản nhất phù hợp.
  5. Cập nhật wiring production: tạo triển khai thật ở một nơi (entry point/factory/composition root) và truyền vào.
  6. Cập nhật test: thay real bằng fake/stub/mock trả giá trị dự đoán.

Cách tiếp cận này giữ mỗi thay đổi có thể review và bạn có thể dừng sau bất kỳ bước nào mà không làm hỏng hệ thống.

Giữ module cô đọng khi tiêm phụ thuộc

DI có thể làm mọi thứ phụ thuộc lẫn nhau nếu bạn tiêm quá nhiều.

Quy tắc: tiêm các khả năng, không phải chi tiết. Ví dụ, tiêm Clock thay vì “SystemTime + TimeZoneResolver + NtpClient”. Nếu một lớp cần năm dịch vụ không liên quan, nó có thể làm quá nhiều — hãy tách trách nhiệm.

Cũng tránh truyền phụ thuộc xuyên nhiều tầng “chỉ để phòng” — tiêm nơi sử dụng; tập trung wiring ở một nơi.

Ghi chú về DI khi dùng generator hoặc scaffold

Nếu bạn dùng code generator hoặc workflow “vibe-coding” để nhanh chóng tạo tính năng, DI càng có giá trị vì nó bảo toàn cấu trúc khi dự án lớn. Ví dụ, khi team dùng Koder.ai để tạo frontend React, service Go, và backend PostgreSQL từ spec chat-driven, giữ composition root rõ ràng và interface thân thiện với DI giúp mã sinh ra dễ test, dễ refactor và dễ hoán đổi tích hợp (email, thanh toán, lưu trữ) mà không viết lại logic lõi.

Quy tắc vẫn vậy: giữ việc tạo đối tượng và wiring môi trường ở biên, và để business code tập trung vào hành vi.

Cái cần đo sau khi thay đổi

Bạn nên chỉ ra những cải tiến cụ thể:

  • Test đơn vị nhanh hơn (ít chờ DB/mạng/ thời gian)
  • Test cô lập hơn (ít setup global và state chia sẻ)
  • Ranh giới sạch hơn (hợp đồng rõ giữa module)
  • Dễ thay đổi hơn (hoán đổi client API hoặc chiến lược lưu trữ với ít chỉnh sửa)

Nếu cần bước tiếp theo, hãy document composition root của bạn và giữ nó đơn giản: một file nối dây phụ thuộc, trong khi phần còn lại của code tập trung vào hành vi.

Câu hỏi thường gặp

What is dependency injection in plain terms?

Dependency Injection (DI) nghĩa là mã của bạn nhận những thứ nó cần (cơ sở dữ liệu, logger, đồng hồ, client thanh toán) từ bên ngoài thay vì tự tạo chúng bên trong.

Về thực tế, điều đó thường trông như truyền các phụ thuộc vào constructor hoặc tham số hàm để chúng rõ ràng và có thể hoán đổi.

How is DI different from Inversion of Control (IoC)?

Inversion of Control (IoC) là khái niệm rộng hơn: một lớp nên tập trung vào việc nó làm, không phải cách nó lấy các cộng sự.

DI là một kỹ thuật phổ biến để đạt được IoC bằng cách chuyển việc tạo phụ thuộc ra bên ngoài và truyền chúng vào.

Why does calling `new` inside business logic cause tight coupling?

Nếu một phụ thuộc được tạo bằng new trong logic nghiệp vụ, thì phụ thuộc đó trở nên khó thay thế.

Hệ quả:

  • bị khóa vào nhà cung cấp (ví dụ Stripe được gắn cứng vào quy trình thanh toán)
  • cấu hình và quy tắc tạo rải rác khắp nơi
  • các test chậm hơn, dễ flakiness vì thực hiện I/O thật (mạng/DB/tệp/ thời gian)
How does DI make unit tests faster and less flaky?

DI giúp test giữ được tính nhanh và xác định vì bạn có thể tiêm các đối tượng thay thế trong test thay vì gọi các hệ thống bên ngoài thật.

Những hoán đổi thông dụng:

  • kho lưu trữ giả/bộ nhớ trong thay vì DB thật
  • đồng hồ stub thay vì thời gian hệ thống
  • mailer mock thay vì gửi email thật
Do I need a DI container to use DI?

DI container là tùy chọn. Bắt đầu với manual DI (truyền phụ thuộc rõ ràng) khi:

  • ứng dụng nhỏ/ vừa
  • đồ thị đối tượng dễ dây bằng tay
  • bạn muốn rõ ràng tối đa

Cân nhắc container khi việc nối dây lặp lại nhiều hoặc cần quản lý vòng đời (singleton/per-request).

When should I use constructor vs method vs setter injection?

Dùng constructor injection khi phụ thuộc là bắt buộc để đối tượng hoạt động và được dùng ở nhiều phương thức.

Dùng method/parameter injection khi chỉ cần cho một lần gọi (ví dụ giá trị theo request hoặc chiến lược một lần).

Tránh setter/property injection trừ khi thực sự cần nối dây muộn; thêm kiểm tra để thất bại sớm nếu thiếu.

What is a “composition root” and where should it live?

Composition root là nơi bạn lắp ráp ứng dụng: tạo các triển khai và truyền chúng vào các service cần chúng.

Đặt gần điểm khởi động ứng dụng (entry point) để phần còn lại của code tập trung vào hành vi thay vì việc nối dây.

What is a test seam, and where should I create one?

Test seam là điểm bạn cố ý mở để hoán đổi hành vi.

Những chỗ tốt cho seams thường là các mối quan tâm khó test:

  • thời gian (Clock.now())
  • I/O (file store, HTTP client)
  • dịch vụ ngoài (thanh toán, email)

DI tạo seam bằng cách cho phép tiêm triển khai thay thế trong test.

What are common DI mistakes and how do I avoid them?

Những lỗi DI phổ biến:

  • Over-injection: constructor có nhiều phụ thuộc thường nghĩa là lớp làm quá nhiều—tách nhiệm vụ ra.
  • Service Locator: gọi container.get() trong business code làm ẩn các phụ thuộc thực; thích truyền tham số rõ ràng.
  • Lỗi nối dây runtime: thiếu đăng ký hoặc vòng phụ thuộc—thêm test kiểm tra wiring.

Giải pháp: giữ constructor nhỏ, tách nhiệm rõ, và thêm một bài test build app (composition root) để phát hiện lỗi sớm.

How can I introduce DI into an existing codebase safely?

Một cách làm an toàn:

  1. Chọn một phụ thuộc khó (DB, đồng hồ, HTTP client).
  2. Định nghĩa một interface/contract nhỏ chỉ với những gì cần.
  3. Bao bọc triển khai hiện tại phía sau interface đó.
  4. Tiêm nó qua constructor hoặc tham số.
  5. Cập nhật wiring ở startup (composition root).
  6. Cập nhật test để dùng fake/stub/mock.

Lặp lại từng seam; bạn có thể dừng sau bất kỳ bước nào mà không cần rewrite lớn.

Related posts