Một bài viết tổng hợp cách hiểu về DI, IoC và cách NestJS hiện thực hoá chúng — đi từ vấn đề gốc, qua nguyên lý, tới cơ chế thực tế.
1. Vấn đề bắt đầu từ đâu?
Hãy nhìn một class bình thường:
class CommentsController { constructor() { this.commentsService = new CommentsService(); // tự tạo bên trong } create(body) { return this.commentsService.create(body.comment_text); } }
Nhìn thì ổn, nhưng nó kéo theo 3 vấn đề:
- Khó test:
CommentsControllertựnew CommentsService()bên trong, nên không thể thay bằng bản giả (mock) khi test. - Phụ thuộc chặt (tight coupling):
CommentsControllerbiết quá rõ cách tạoCommentsService. NếuCommentsServiceđổi cách khởi tạo, phải sửa cả bên trongCommentsController. - Khó thay đổi implementation: muốn đổi sang
CommentsServiceV2phải sửa code gọinew, thay vì chỉ "đưa" bản khác vào.
Gốc rễ: một class vừa lo logic nghiệp vụ, vừa lo tạo ra thứ nó cần dùng.
2. Dependency Injection (DI) — giải pháp
Ý tưởng: đừng để class tự tạo dependency, hãy đưa (inject) nó từ bên ngoài vào.
class CommentsController { constructor(commentsService) { // nhận từ bên ngoài, không tự tạo this.commentsService = commentsService; } create(body) { return this.commentsService.create(body.comment_text); } }
Giờ đây CommentsController chỉ cần biết: "tôi sẽ nhận được 1 object có method .create()" — không quan tâm nó được tạo ra thế nào.
// Lúc chạy thật const controller = new CommentsController(new CommentsService()); // Lúc test — dễ dàng thay bằng mock const fakeService = { create: (text) => ({ id: 1, text }) }; const controller = new CommentsController(fakeService);
| Không DI | Có DI | |
|---|---|---|
| Test | Khó, phụ thuộc thật bị khoá cứng | Dễ, chỉ cần đưa mock vào |
| Thay implementation | Sửa code bên trong class | Đưa object khác vào lúc khởi tạo |
| Mức độ phụ thuộc | Chặt | Lỏng |
| Ai tạo dependency | Class tự lo | Bên ngoài (caller) lo |
Về mặt kỹ thuật, DI có thể hiện thực bằng class + constructor (gán vào this.xxx) hoặc bằng closure (hàm "nhớ" biến trong scope cha) — cả hai đều giải quyết cùng một vấn đề: giữ lại một giá trị để dùng nhiều lần mà không cần truyền lại mỗi lần gọi.
3. IoC — nguyên lý đứng sau DI
IoC (Inversion of Control – Đảo ngược quyền điều khiển) là nguyên lý tổng quát hơn, còn DI chỉ là một kỹ thuật cụ thể để hiện thực IoC.
Bình thường, code tự chủ động kiểm soát: tự tạo object, tự quyết định khi nào làm gì. IoC đảo ngược điều đó — giao quyền tạo/điều phối object cho một bên khác (framework/container).
Ví dụ đời thường:
- Không IoC: bạn đói → tự đi chợ → tự nấu → tự ăn (bạn kiểm soát toàn bộ).
- Có IoC: bạn chỉ nói "tôi đói" → nhà hàng tự lo nguyên liệu, tự nấu, tự mang ra → bạn chỉ việc ăn (quyền kiểm soát bị chuyển sang nhà hàng).
Mối quan hệ giữa 3 khái niệm:
IoC (nguyên lý) "Đừng để class tự kiểm soát việc tạo dependency của nó" ↓ được hiện thực bằng DI (kỹ thuật) "Đưa dependency vào từ bên ngoài, qua constructor" ↓ được vận hành bởi IoC Container (công cụ) "Hệ thống tự động tạo, quản lý, và inject dependency"
Nói ngắn gọn: IoC là "tại sao", DI là "làm bằng cách nào", Container là "ai thực sự làm việc đó".
4. Tự viết một "mini NestJS" để hiểu bản chất
Decorator (@Injectable(), @Module()) thực chất không làm gì cả ngoài việc gắn nhãn (metadata) lên class. Toàn bộ phép màu nằm ở container đọc lại các nhãn đó.
// Mini decorator — chỉ gắn nhãn function Injectable() { return (target) => { target.__injectable = true; }; } function Module(options) { return (target) => { target.__metadata = options; }; } // Mini IoC Container class MiniContainer { constructor(moduleClass) { this.instances = new Map(); this.metadata = moduleClass.__metadata; } resolve(targetClass) { if (this.instances.has(targetClass)) { return this.instances.get(targetClass); } const deps = (targetClass.__deps || []).map((dep) => this.resolve(dep)); const instance = new targetClass(...deps); this.instances.set(targetClass, instance); return instance; } bootstrap() { this.metadata.providers.forEach((p) => this.resolve(p)); return this.metadata.controllers.map((c) => this.resolve(c)); } }
resolve() tự đệ quy xuống các dependency trong constructor, tạo từ dưới lên, và cache lại để không tạo trùng (đây cũng là lý do vì sao service trong Nest mặc định là singleton).
5. Ví dụ đầy đủ: dependency lồng 3 tầng
CommentsController cần CommentsService CommentsService cần DatabaseService DatabaseService cần ConfigService
@Injectable() class ConfigService { getDbUrl() { return 'postgres://localhost:5432/mydb'; } } @Injectable() class DatabaseService { constructor(private configService: ConfigService) {} connect() { return `Connected to ${this.configService.getDbUrl()}`; } } @Injectable() class CommentsService { constructor(private databaseService: DatabaseService) {} create(text) { this.databaseService.connect(); return { id: 1, text }; } } @Controller('comments') class CommentsController { constructor(private commentsService: CommentsService) {} @Post() create(@Body() body) { return this.commentsService.create(body.comment_text); } } @Module({ controllers: [CommentsController], providers: [CommentsService, DatabaseService, ConfigService], }) class CommentsModule {}
Thứ tự Nest tạo (từ dưới lên):
① ConfigService (không cần gì → tạo ngay) ② DatabaseService (cần ConfigService → đã có từ ①) ③ CommentsService (cần DatabaseService → đã có từ ②) ④ CommentsController (cần CommentsService → đã có từ ③)
Không ai trong số này tự viết new — Nest đọc constructor của từng class, dựng thành cây phụ thuộc, rồi tự lắp ráp theo đúng thứ tự.
Khi request thật tới (POST /comments), lời gọi chảy xuyên suốt qua các tầng:
Controller.create() → Service.create() → DatabaseService.connect() → ConfigService.getDbUrl()
Mỗi tầng chỉ gọi thẳng vào cái nó cần qua this.xxx, không biết gì về các tầng sâu hơn.
6. Tổng kết
- Vấn đề gốc: class tự tạo dependency của nó → khó test, phụ thuộc chặt, khó thay đổi.
- DI: đưa dependency vào từ bên ngoài (qua constructor hoặc closure), thay vì tự
newbên trong. - IoC: nguyên lý tổng quát — đảo ngược quyền kiểm soát việc tạo/điều phối object sang cho một bên khác.
- IoC Container: cơ chế cụ thể thực thi IoC — tự đọc metadata (
@Injectable,@Module), tự dựng cây phụ thuộc, tự tạo và inject theo đúng thứ tự. - NestJS: dùng decorator để gắn metadata, dùng container để tự động hoá toàn bộ quy trình DI — dù dependency lồng bao nhiêu tầng, mỗi class chỉ cần khai báo thứ nó cần trực tiếp, phần còn lại Nest lo hết.
