Ở một thời điểm nào đó, mọi developer đều đụng phải cùng một bức tường. Bạn muốn viết test cho một đoạn code, nhưng code đó bí mật gọi new Database() bên trong chính nó. Dù bạn làm gì, một kết nối database thật vẫn bật lên. Bạn muốn thay đổi email provider, nhưng email SDK đang bị chôn vùi ba lớp sâu. Bạn muốn chạy cùng logic cho cả HTTP request lẫn scheduled job, nhưng framework được gắn chặt đến mức gần như không thể.
Đây là các triệu chứng của một nguyên nhân gốc rễ duy nhất: code tự tạo ra dependency của chính nó. Cách sửa có một cái tên chính thức — Dependency Injection — nhưng ý tưởng đằng sau nó thực ra rất đơn giản đến đáng ngạc nhiên. Bạn ngừng để code tự tìm kiếm collaborator, và đưa chúng vào từ bên ngoài. Chỉ vậy thôi. Mọi thứ khác — IoC container, DI framework, injection token — chỉ là cơ chế xây dựng trên một ý tưởng duy nhất đó.
Hướng dẫn này giải thích từ đầu: vấn đề, nguyên tắc, pattern, và lợi nhuận thực tế trong test. TypeScript được dùng xuyên suốt vì nó đọc rõ ràng cho các kỹ sư đến từ bất kỳ background web hay server nào.
Dependency cứng nhắc: khi code tự gọi new
Đây là một service xử lý đơn hàng. Nhìn bề ngoài có vẻ hợp lý:
// order-service.ts — phiên bản "trước"
class OrderService {
async placeOrder(cart: Cart): Promise<Order> {
const db = new PostgresClient(process.env.DATABASE_URL!) // DB cứng nhắc
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) // vendor cứng nhắc
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!) // vendor cứng nhắc
const order = Order.fromCart(cart)
await stripe.charges.create({ amount: order.total, source: cart.token })
await db.query('INSERT INTO orders …', [order])
await mailer.send({ to: cart.email, template: 'order-confirmed' })
return order
}
}
Ba vấn đề hiện ra ngay khi bạn cố làm việc với đoạn này:
- Không thể test trong cô lập. Bạn không thể chạy
placeOrdertrong unit test mà không cần credential thật, database thật, và Stripe sandbox thật. Mọi test mặc định là một integration test chậm và dễ vỡ. - Ràng buộc cứng nhắc với các hiện thực cụ thể. Nếu business chuyển từ Stripe sang Adyen, bạn mở file này ra mổ xẻ. Nếu staging cần một dummy mailer, không có cách sạch nào để cắm vào.
- Dependency ẩn. Người đọc signature của class thấy
placeOrder(cart)và không biết method đó bí mật cần ba external service. Dependency vô hình cho đến khi bạn đọc từng dòng.
Mỗi khi bạn thấy new SomeExternalThing() bên trong body của method hay constructor — không phải ở composition root — đó là một dependency cứng nhắc. Class vừa chịu trách nhiệm làm việc của chính nó vừa tìm kiếm và xây dựng các công cụ cần thiết. Đó là hai việc, và chúng kéo theo hướng ngược nhau.
Inversion of Control: ai chịu trách nhiệm tạo ra?
Trước khi giải quyết vấn đề bằng code, hãy đặt tên cho nguyên tắc liên quan. Inversion of Control (IoC) là ý tưởng rằng một đoạn code không nên chịu trách nhiệm tự lấy dependency của mình. Thay vào đó, thứ gì đó bên ngoài — một framework, một test harness, một composition root — đảm nhận việc tạo ra và đưa chúng vào.
Bạn có thể đã nghe gọi là Hollywood Principle: "Đừng gọi cho chúng tôi, chúng tôi sẽ gọi cho bạn." Trong code mệnh lệnh cổ điển, logic nghiệp vụ gọi dependency trực tiếp — nó với tay lấy một database. Với IoC, dependency đến với bạn. Quyền kiểm soát mối quan hệ đó bị đảo ngược: bạn không còn quyết định khi nào hay làm thế nào để lấy collaborator; thế giới bên ngoài quyết định và giao chúng.
IoC là một nguyên tắc rộng, không phải một kỹ thuật cụ thể. Các framework hiện thực nó theo nhiều cách:
- Dependency Injection (DI) — truyền collaborator như constructor argument hay method parameter. Dạng phổ biến và rõ ràng nhất.
- Service Locator — một registry toàn cục mà code gọi để lấy dependency theo tên. (Sẽ nói thêm tại sao đây là anti-pattern.)
- Template Method — một base class gọi các hook method mà subclass override, nên base kiểm soát luồng và subclass điền vào chỗ trống.
- Event / Observer — các component publish event; framework định tuyến chúng đến subscriber. Không bên nào biết bên kia tồn tại.
Trong số này, Dependency Injection là minh bạch nhất và dễ test nhất. Đây cũng là cái thường bị đồng nhất với IoC, đó là lý do cần phân biệt hai thuật ngữ: IoC là nguyên tắc, DI là một cách hiện thực nguyên tắc đó.
Dependency Injection: truyền dependency vào
Cơ chế của DI gần như đơn giản đến khó tin khi bạn nhìn thấy. Thay vì khởi tạo collaborator bên trong class, bạn khai báo chúng như parameter và để người gọi cung cấp chúng.
Có ba phong cách — nhưng một người chiến thắng rõ ràng trong hầu hết tình huống:
Constructor injection (ưu tiên)
Dependency được khai báo trong constructor. Đây là lựa chọn chuẩn: mọi dependency đều hiện thị trong signature của class, bắt buộc lúc khởi tạo, và có thể đặt readonly để không gì gán lại sau này.
// order-service.ts — phiên bản "sau" với constructor injection
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Mailer { send(to: string, template: string): Promise<void> }
class OrderService {
constructor(
private readonly payments: PaymentGateway,
private readonly orders: OrderRepository,
private readonly mailer: Mailer,
) {}
async placeOrder(cart: Cart): Promise<Order> {
const order = Order.fromCart(cart) // quy tắc nghiệp vụ thuần
await this.payments.charge(order.total, cart.token)
await this.orders.save(order)
await this.mailer.send(cart.email, 'order-confirmed')
return order
}
}
Đọc to signature của constructor: "Để tạo một OrderService bạn cần một payment gateway, một order repository, và một mailer." Đó là một hợp đồng, viết bằng code thuần, có thể thấy mà không cần đọc từng dòng method body.
Setter / method injection (dùng khi cần)
Dependency được đặt thông qua một method sau khi khởi tạo. Hữu ích cho collaborator tùy chọn hoặc cho các framework khởi tạo object theo hai giai đoạn. Dùng tiết kiệm — nó cho phép object tồn tại ở trạng thái cấu hình nửa vời, đây là nguồn bug tinh tế.
// setter injection — chỉ dùng khi không thể đấu nối lúc khởi tạo
class ReportGenerator {
private formatter: ReportFormatter = new DefaultFormatter()
setFormatter(f: ReportFormatter) { this.formatter = f } // ghi đè tùy chọn
generate(data: ReportData): string {
return this.formatter.format(data)
}
}
Mặc định dùng constructor injection. Mọi dependency mà class luôn luôn cần đều thuộc về constructor. Chỉ với tới setter injection khi dependency thực sự tùy chọn, hoặc khi framework bắt buộc. Đừng bao giờ dùng setter injection như lối thoát để tránh phải suy nghĩ về dependency graph.
Composition root: một nơi duy nhất để đấu nối tất cả
Một khi bạn inject dependency qua constructor, câu hỏi mới xuất hiện: ai thực sự gọi new và đấu nối các mảnh ghép lại? Câu trả lời là composition root: một nơi duy nhất tại entry point của ứng dụng nơi mọi object thật được khởi tạo và inject vào nhau.
Hãy nghĩ nó như nơi duy nhất trong toàn bộ codebase nơi new ConcreteImplementation() được phép xuất hiện tự do. Mọi thứ khác nói chuyện bằng interface. Composition root là seam giữa "hệ thống trừu tượng" và "thế giới thực".
// composition-root.ts — nơi duy nhất các thứ thật được đấu nối
import { PostgresOrderRepository } from './adapters/postgres-order-repository'
import { StripePaymentGateway } from './adapters/stripe-payment-gateway'
import { SendGridMailer } from './adapters/sendgrid-mailer'
import { OrderService } from './domain/order-service'
import { OrderController } from './http/order-controller'
export function buildApp() {
// 1. Xây dựng dependency lá trước (không có dependency riêng)
const db = new PostgresOrderRepository(process.env.DATABASE_URL!)
const stripe = new StripePaymentGateway(process.env.STRIPE_SECRET_KEY!)
const mailer = new SendGridMailer(process.env.SENDGRID_KEY!)
// 2. Xây dựng service, inject dependency vào
const orderSvc = new OrderService(stripe, db, mailer)
// 3. Xây dựng lớp delivery, inject service vào
const orderCtrl = new OrderController(orderSvc)
return orderCtrl
}
Chú ý composition root đọc gần như một công thức: "làm những nguyên liệu này, kết hợp chúng thành service, đặt service vào controller." Chỉ có một nơi trong toàn bộ codebase bạn đến để hiểu mọi thứ kết nối thế nào. Đó là tài sản vô giá khi onboard kỹ sư mới, hay khi cần hoán đổi một nguyên liệu.
Trong test, bạn viết một test-specific composition root — tương đương nhỏ gọn đấu nối fake thay vì adapter thật:
// trong file test — cùng service, collaborator giả
const fakePayments = new InMemoryPaymentGateway()
const fakeOrders = new InMemoryOrderRepository()
const fakeMailer = new NoOpMailer()
const svc = new OrderService(fakePayments, fakeOrders, fakeMailer)
// chạy trong mili-giây; không mạng, không database
DI thủ công vs DI container — điều gì thay đổi và khi nào quan trọng
Tất cả những gì ở trên là manual DI, còn gọi là Pure DI. Bạn tự viết code đấu nối. Với nhiều project — đặc biệt là nhỏ — đây là lựa chọn đúng: là code thuần, không có phép thuật, debug ngay lập tức, và developer mới có thể hiểu toàn bộ đấu nối bằng cách đọc một file.
Khi ứng dụng lớn lên, cách thủ công bắt đầu gây khó chịu. DI container (đôi khi gọi là IoC container) tự động hóa việc đăng ký và giải quyết dependency, và thêm quản lý lifetime.
OrderService với tay ra ngoài để tạo dependency — mỗi mũi tên là một coupling nằm trong body của class. Bên phải, một composition root khởi tạo các object cụ thể và inject chúng vào trong. Các mũi tên đảo ngược. Service chỉ biết về interface; các kiểu cụ thể là chi tiết ở rìa.| Câu hỏi | Manual / Pure DI | DI Container (NestJS, InversifyJS, tsyringe…) |
|---|---|---|
| Các kiểu được đăng ký thế nào? | Bạn viết đấu nối rõ ràng trong composition root. | Decorator, token, hay metadata — container quét và đăng ký tự động. |
| Instance được giải quyết thế nào? | Bằng cách tự gọi new, theo đúng thứ tự. |
Hỏi container về một kiểu; nó xây dựng toàn bộ graph đệ quy. |
| Quản lý lifetime / scope | Bạn quyết định: một instance mỗi module, mỗi request, hay transient — và bạn giữ tham chiếu. | Khai báo: @Singleton(), @RequestScoped(), v.v. Container xử lý việc giải phóng. |
| Khả năng debug | Mọi thứ là code thuần — đặt breakpoint bất kỳ đâu, theo call stack. | Việc giải quyết diễn ra bên trong container; "tại sao tôi nhận instance này?" cần hiểu container nội bộ. |
| Boilerplate | Cao với graph lớn — đấu nối thủ công app 50 class là dài dòng. | Thấp — container duyệt cây dependency và tự động xây dựng tất cả. |
| An toàn lúc build | TypeScript bắt constructor arg bị thiếu lúc compile. | Tùy container; hệ thống dựa trên token có thể lỗi lúc runtime nếu thiếu binding. |
| Phù hợp nhất cho | App nhỏ-vừa, team mới làm quen DI, microservice với scope tập trung. | App lớn với nhiều service, team muốn convention-over-configuration, framework như NestJS nơi DI là trung tâm. |
Ngưỡng mà container bắt đầu đáng công thay đổi tùy tình huống, nhưng hướng dẫn sơ bộ: nếu composition root của bạn cảm thấy đang trở thành gánh nặng bảo trì riêng — bạn luôn phải cuộn nó, quên thêm binding mới, hay chiến đấu với object lifetime — đó là tín hiệu nên với tới container. Cho đến lúc đó, phiên bản thuần túy dễ hiểu và debug hơn.
Lợi ích trong kiểm thử: inject fake, có test nhanh
Lý do DI đáng học — hơn bất kỳ sự thanh lịch kiến trúc nào — là những gì nó làm cho bộ test của bạn. Khi mọi dependency đến qua constructor, viết test nghĩa là viết một composition root nhỏ với fake thay vì adapter thật. Không database, không mạng, không vendor sandbox. Test chạy trong mili-giây và không bao giờ lỗi vì tài khoản Stripe staging có rate limit.
// order-service.test.ts
class InMemoryPaymentGateway implements PaymentGateway {
receipts: Receipt[] = []
async charge(amount: Money, token: string): Promise<Receipt> {
const r = { id: 'fake-receipt', amount }
this.receipts.push(r)
return r
}
}
class InMemoryOrderRepository implements OrderRepository {
store: Order[] = []
async save(order: Order) { this.store.push(order) }
}
class SpyMailer implements Mailer {
sent: string[] = []
async send(to: string) { this.sent.push(to) }
}
// Bản thân test thì thuần túy, nhanh, tất định:
it('xác nhận đơn hàng và thông báo cho khách', async () => {
const payments = new InMemoryPaymentGateway()
const repo = new InMemoryOrderRepository()
const mailer = new SpyMailer()
const svc = new OrderService(payments, repo, mailer)
const order = await svc.placeOrder(testCart)
expect(payments.receipts).toHaveLength(1)
expect(repo.store).toContainEqual(order)
expect(mailer.sent).toContain(testCart.email)
})
Đây là cùng một nhận thức ở trung tâm của Ports & Adapters — in-memory fake chỉ là adapter lúc test, còn hiện thực Stripe hay Postgres thật là production adapter. DI là cơ chế làm cho sự hoán đổi trở nên khả thi; ports & adapters là pattern kiến trúc làm cho ranh giới trở nên có chủ đích và rõ ràng.
Cũng đáng dừng lại ở SOLID ở đây. Chữ "D" là Dependency Inversion Principle: các module cấp cao không nên phụ thuộc vào module cấp thấp; cả hai nên phụ thuộc vào abstraction. Các interface ở trên (PaymentGateway, OrderRepository, Mailer) là những abstraction đó. OrderService là module cấp cao. StripePaymentGateway và PostgresOrderRepository là module cấp thấp. Không bên nào biết trực tiếp về bên kia — chúng gặp nhau tại interface, và composition root nối chúng lại.
Anti-pattern: những cách DI đi sai đường
Ý tưởng đơn giản, nhưng có một vài cách quen thuộc để làm hỏng nó.
Service Locator
Service Locator là registry toàn cục mà code gọi để lấy dependency lúc runtime:
// anti-pattern: Service Locator — trông như DI nhưng không phải
class OrderService {
async placeOrder(cart: Cart) {
const payments = ServiceLocator.get<PaymentGateway>('PaymentGateway')
const repo = ServiceLocator.get<OrderRepository>('OrderRepository')
// … tiếp tục
}
}
Điều này trông sạch hơn new Stripe() inline, nhưng đang che giấu cùng vấn đề. Dependency vẫn vô hình trong signature. Test phải cấu hình registry toàn cục trước khi chạy. Và class có thể yêu cầu bất cứ thứ gì ở bất kỳ thời điểm nào — bạn đã mất đi sự rõ ràng làm cho DI có giá trị.
Over-injection (constructor 10 tham số)
Khi constructor tích lũy bảy, tám, hay mười tham số, đó thường là dấu hiệu class đang làm quá nhiều việc — không phải DI là công cụ sai. Cách sửa là phân rã class thành các collaborator nhỏ hơn, tập trung hơn, mỗi cái có danh sách dependency ngắn. Độ dài constructor là chỉ số sức khỏe hữu ích.
Container toàn cục ẩn
Truy cập DI container trực tiếp từ trong service — thay vì chỉ ở composition root — là pattern Service Locator dưới tên khác. Quy tắc nghiêm ngặt: chỉ composition root mới nói chuyện với container. Service chỉ nói chuyện với interface được inject.
Phép thuật bạn không thể truy vết
Một số framework đấu nối dependency qua decorator và reflect metadata theo cách thực sự khó theo khi có gì đó hỏng. Nếu bạn đang dành nhiều thời gian đọc tài liệu container hơn là đọc logic nghiệp vụ của chính mình, đó là dấu hiệu nên chuyển sang cách đơn giản hơn — hoặc ít nhất thêm một composition root được ghi chú rõ ràng nêu ra các binding chính.
Liều lượng DI phù hợp: từ project solo đến enterprise
Như hầu hết các ý tưởng kiến trúc, DI tăng theo vấn đề. Mục tiêu luôn như nhau — code testable, changeable — nhưng cơ chế phù hợp để đạt được thay đổi khi team và codebase lớn lên.
| Giai đoạn công ty | Cách tiếp cận gợi ý | Hình dạng điển hình |
|---|---|---|
| Solo / startup giai đoạn đầu | Manual DI với một file composition root duy nhất. | Một hàm buildApp(); khoảng 10–20 dòng. Nhanh viết, dễ đọc. |
| Team nhỏ (5–20 kỹ sư) | Vẫn manual DI, có thể chia thành domain module, mỗi cái có hàm đấu nối riêng. | buildOrderModule(), buildBillingModule() — mỗi cái tự chứa, được compose một lần lúc khởi động. |
| Scale-up đang lớn (20–100 kỹ sư) | Container nhẹ bắt đầu đáng công. Cân nhắc tsyringe, InversifyJS, hoặc container tích hợp trong framework. | Decorator trên class, container cấu hình theo module; request-scoped service cho web app. |
| Enterprise / nền tảng lớn | Framework DI đầy đủ gần như cần thiết. NestJS, Spring Boot, hay ASP.NET Core được xây quanh ý tưởng này ở quy mô lớn. | Convention-over-configuration, lifecycle hook, module import/export, multi-tenant scoping. |
Một startup fintech có thể bắt đầu với composition root 30 dòng đấu nối ba service. Hai năm sau, cùng codebase có 60 service: đấu nối thủ công tự trở thành tác vụ bảo trì, và team với tới NestJS hay InversifyJS để xử lý graph tự động. Việc chuyển đổi đơn giản vì pattern cốt lõi — constructor injection, interface, composition root rõ ràng — vẫn như cũ. Container chỉ là trợ lý cơ học, không phải một ý tưởng khác.
Bắt đầu với manual DI và một composition root. Bạn có thể giới thiệu container bất cứ lúc nào file đấu nối trở thành gánh nặng, và việc chuyển đổi là cơ học: bạn thay các lời gọi new rõ ràng bằng registration. Không có gì trong domain, service, hay test phải thay đổi — chỉ có composition root.
Những điều cốt lõi cần nhớ
- Nguyên nhân gốc rễ của code cứng nhắc, không test được là class tự gọi
newvới dependency. Cách sửa là truyền chúng vào. - Inversion of Control là nguyên tắc (người khác chịu trách nhiệm cung cấp dependency). Dependency Injection là cách rõ ràng và dễ test nhất để hiện thực nó.
- Constructor injection là lựa chọn mặc định. Dependency hiện thị trong signature, bắt buộc lúc khởi tạo, và dễ hoán đổi trong test.
- Composition root là nơi duy nhất trong codebase nơi object thật được đấu nối. Mọi thứ khác nói chuyện qua interface.
- Manual DI đơn giản và debug dễ hơn với codebase nhỏ. DI container đáng công khi composition root tự trở thành gánh nặng bảo trì.
- Lợi ích trong kiểm thử là ngay lập tức: inject fake trong test, bỏ qua mạng và database, chạy hàng nghìn ca trong vài giây.
- Đây cũng là Dependency Inversion Principle (chữ "D" trong SOLID) trong thực tế: module cấp cao và cấp thấp đều phụ thuộc vào abstraction, không bao giờ phụ thuộc trực tiếp lẫn nhau.
- Tránh Service Locator (registry toàn cục ẩn), over-injection (constructor mười tham số), và truy cập container bên ngoài composition root.
Với hiểu biết vững chắc về cách dependency chảy, câu hỏi tự nhiên tiếp theo là cách tổ chức file. Trong phần tiếp theo của loạt này chúng ta xem xét chính xác điều đó: Cấu trúc Codebase: Theo Tính năng hay Theo Lớp — và tại sao câu trả lời phụ thuộc vào hình dạng team của bạn không kém gì hình dạng code.