Dependency Injection: phụ thuộc vào abstraction để giảm coupling và test được
Dependency Inversion Principle nói module cấp cao không nên phụ thuộc trực tiếp module cấp thấp; cả hai nên phụ thuộc vào abstraction. Dependency Injection là cách thực hiện: thay vì một class tự tạo ra các dependency của nó (gọi new, import trực tiếp), nó nhận chúng từ ngoài qua constructor. Kết quả là class không còn dính chặt vào implementation cụ thể — đổi database, đổi mail provider, hay thay bằng bản giả khi test, đều không phải sửa class đó. Đây là điều kiện then chốt để unit test được logic mà không chạm hạ tầng thật.
Cơ chế hoạt động
So sánh dependency cứng và dependency được inject. Cách cứng — class tự tạo dependency:
class OrderService {
private repo = new PostgresOrderRepository() // dính chặt vào Postgres
private mailer = new SmtpMailer() // dính chặt vào SMTP
placeOrder(data) { this.repo.save(...); this.mailer.send(...) }
}
Class này không test được nếu không có Postgres và SMTP thật chạy. Cách inject — nhận abstraction qua constructor:
interface OrderRepository { save(order: Order): Promise<void> }
interface Mailer { send(to: string, body: string): Promise<void> }
class OrderService {
constructor(private repo: OrderRepository, private mailer: Mailer) {}
placeOrder(data) { this.repo.save(...); this.mailer.send(...) }
}
OrderService giờ phụ thuộc interface, không phải implementation. Nơi khởi tạo ứng dụng (composition root) quyết định truyền implementation thật vào; test truyền bản giả vào.
Vấn đề gặp trong production
Lợi ích chính: test được không cần hạ tầng. Với DI, test logic đặt đơn hàng bằng cách inject một repository và mailer giả:
const fakeRepo = { save: jest.fn() }
const fakeMailer = { send: jest.fn() }
const svc = new OrderService(fakeRepo, fakeMailer)
await svc.placeOrder(data)
expect(fakeRepo.save).toHaveBeenCalled() // test logic, không chạm DB/SMTP
Test nhanh, không cần dựng Postgres, không gửi mail thật. Đây là khác biệt lớn nhất giữa codebase test được và không.
Failure mode: circular dependency. Khi A inject B và B inject A, container không biết tạo cái nào trước — vòng lặp khởi tạo. Đây là triệu chứng của thiết kế: hai class phụ thuộc lẫn nhau thường nên tách phần chung ra một đơn vị thứ ba, hoặc đảo một chiều phụ thuộc qua event/callback. Một DI container nhiều khi che vòng này tới lúc runtime mới nổ, nên cần phát hiện sớm ở thiết kế.
Failure mode: lạm dụng container / DI quá tay. Đưa mọi thứ qua container, kể cả những phụ thuộc đơn giản ổn định, làm luồng khởi tạo khó lần và "magic" — không nhìn code biết cái gì được tạo ở đâu. DI có giá trị cho phụ thuộc có biến thể hoặc cần thay khi test (repository, client ngoài, service); với một hàm thuần hay một hằng số thì import trực tiếp rõ ràng hơn. Constructor nhận mười dependency là dấu hiệu class làm quá nhiều (vi phạm SRP), không phải lý do để giấu chúng vào container.
Cách debug và monitor
Khi một class "khó test" (phải mock nhiều thứ, hoặc test cần DB/mạng thật), nguyên nhân gần như luôn là dependency cứng — tìm các new SomeService() hay import trực tiếp implementation bên trong class và chuyển sang inject. Circular dependency thường lộ qua lỗi khởi tạo "cannot access before initialization" hoặc một dependency bất ngờ undefined lúc construct — vẽ đồ thị phụ thuộc để tìm vòng. Số tham số constructor là một chỉ số: tăng đều nghĩa là class phình trách nhiệm. Với DI container, bật chế độ kiểm tra vòng lúc khởi động (nhiều framework có) để bắt vòng trước production thay vì lúc chạy.
Tradeoff
DI giảm coupling và làm code test được — đổi implementation (DB, provider) hay thay bản giả khi test mà không sửa class dùng nó, và logic tách khỏi hạ tầng. Cái giá là một lớp gián tiếp (interface + composition root) và, nếu dùng container, một lớp "magic" khiến luồng khởi tạo khó theo dõi. Quy tắc thực tế: inject các phụ thuộc có biến thể hoặc cần thay khi test (repository, client ngoài, gateway); import trực tiếp những thứ thuần và ổn định; giữ số dependency của mỗi class nhỏ (nhiều quá là tín hiệu SRP); và cảnh giác circular dependency như dấu hiệu thiết kế cần tách. Bắt đầu bằng inject qua constructor thủ công, chỉ thêm container khi quy mô thật sự cần.
Câu hỏi phỏng vấn
DI hoạt động thế nào, lợi ích chính là gì, và circular dependency báo hiệu điều gì?
DI thực hiện Dependency Inversion: thay vì một class tự tạo dependency (new, import implementation trực tiếp), nó nhận chúng từ ngoài qua constructor dưới dạng abstraction (interface), nên không còn dính vào implementation cụ thể — composition root quyết định truyền bản thật, test truyền bản giả. Lợi ích chính là test được: inject repository/mailer giả để test logic nghiệp vụ mà không cần DB hay mail server thật, test nhanh và ổn định; đồng thời đổi implementation (Postgres → khác, SMTP → provider khác) không phải sửa class dùng nó. Circular dependency (A cần B, B cần A) báo hiệu lỗi thiết kế — không có thứ tự khởi tạo hợp lệ; cách xử lý là tách phần chung ra đơn vị thứ ba hoặc đảo một chiều qua event/callback. Điểm ăn điểm: chỉ inject phụ thuộc có biến thể/cần mock, import trực tiếp thứ thuần ổn định, và constructor quá nhiều tham số là tín hiệu vi phạm SRP chứ không phải lý do giấu vào container.
Hands-on
Lấy một service thật tự tạo dependency bên trong (new PostgresRepository(), new SmtpMailer()) và thử viết unit test cho logic của nó — cảm nhận việc buộc phải có DB/SMTP thật. Refactor để nhận OrderRepository và Mailer (interface) qua constructor, rồi viết lại test bằng cách inject bản giả và assert tương tác, xác nhận test chạy không cần hạ tầng. Dựng một composition root nối implementation thật vào lúc khởi động. Sau đó cố tình tạo một circular dependency giữa hai service để thấy lỗi khởi tạo, và sửa bằng cách tách phần chung hoặc đảo phụ thuộc qua một event.
Top comments (0)