Áp Dụng Dependency Injection Trong Ứng Dụng Express.js Để Tăng Tính Linh Hoạt Và Khả Năng Kiểm Thử

Khi dự án Express.js mở rộng, việc quản lý các thành phần như dịch vụ, bộ xử lý cơ sở dữ liệu hay lớp nghiệp vụ thường trở nên rối rắm nếu phụ thuộc vào khởi tạo trực tiếp hoặc gọi `require()` tại nhiều nơi. Dependency Injection (DI) giúp tách biệt việc tạo đối tượng khỏi việc sử dụng chúng — từ đó cải thiện khả năng kiểm thử, tái sử dụng và bảo trì mã nguồn.

Tại sao DI lại quan trọng trong Express?

Một đoạn mã điển hình không áp dụng DI có thể trông như sau:

const express = require('express');
const app = express();
const pg = require('pg');

app.get('/products', async (req, res) => {
  const client = await pg.connect();
  try {
    const result = await client.query('SELECT * FROM products');
    res.json(result.rows);
  } finally {
    client.release();
  }
});

Vấn đề ở đây là:

  • Logic truy vấn bị nhúng cứng vào route handler → khó thay thế khi test
  • Không thể kiểm soát vòng đời kết nối (ví dụ: dùng connection pool chung)
  • Mỗi lần cần truy cập dữ liệu đều phải viết lại logic kết nối → vi phạm nguyên tắc DRY

Ba cách triển khai DI phổ biến trong Express

1. Truyền dependency qua constructor (ưu tiên)

Đây là cách rõ ràng và dễ kiểm soát nhất:

// src/services/ProductRepository.js
class ProductRepository {
  constructor(pool) {
    this.pool = pool;
  }

  async findAll() {
    const { rows } = await this.pool.query('SELECT * FROM products');
    return rows;
  }
}

module.exports = ProductRepository;
// src/routes/productRoutes.js
function setupProductRoutes(productRepo) {
  const router = express.Router();

  router.get('/', async (req, res) => {
    try {
      const products = await productRepo.findAll();
      res.status(200).json(products);
    } catch (err) {
      res.status(500).json({ message: 'Failed to fetch products' });
    }
  });

  return router;
}

module.exports = setupProductRoutes;
// src/app.js
const express = require('express');
const { Pool } = require('pg');
const ProductRepository = require('./services/ProductRepository');
const setupProductRoutes = require('./routes/productRoutes');

const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

const productRepo = new ProductRepository(pool);
const productRouter = setupProductRoutes(productRepo);

app.use('/api/products', productRouter);

2. Gắn dependency vào request object

Sử dụng middleware để tiêm service vào `req` — phù hợp với các trường hợp cần scope theo yêu cầu:

// src/middleware/inject.js
const { Pool } = require('pg');
const ProductRepository = require('../services/ProductRepository');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const productRepo = new ProductRepository(pool);

function injectProductRepository(req, res, next) {
  req.dependencies = {
    productRepo
  };
  next();
}

module.exports = { injectProductRepository };
// src/routes/productRoutes.js
const { injectProductRepository } = require('../middleware/inject');

const router = express.Router();

router.get('/', async (req, res) => {
  try {
    const products = await req.dependencies.productRepo.findAll();
    res.json(products);
  } catch (err) {
    res.status(500).send(err.message);
  }
});

module.exports = router;

3. Container đơn giản dựa trên factory function

Giúp tập trung hóa việc khởi tạo và quản lý lifecycle của các dependency:

// src/container.js
const { Pool } = require('pg');
const ProductRepository = require('./services/ProductRepository');
const OrderService = require('./services/OrderService');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

module.exports = {
  getProductRepository: () => new ProductRepository(pool),
  getOrderService: () => new OrderService(pool)
};
// src/routes/productRoutes.js
const container = require('../container');
const productRepo = container.getProductRepository();

// ... định nghĩa route sử dụng productRepo

Cấu trúc thư mục khuyến nghị cho ứng dụng Express có DI

src/
├── config/          # Cấu hình môi trường
├── db/              # Kết nối và pool cơ sở dữ liệu
├── services/        # Các lớp nghiệp vụ (business logic)
├── repositories/    # Lớp truy cập dữ liệu (data access layer)
├── routes/          # Định nghĩa route + handler
├── middleware/      # Middleware chung (auth, logging, DI injection)
├── container.js     # Quản lý dependency
└── app.js           # Entry point chính

Viết unit test dễ dàng hơn nhờ DI

Khi service được thiết kế để nhận dependency qua constructor, bạn có thể dễ dàng truyền mock vào trong test:

// __tests__/ProductRepository.test.js
const ProductRepository = require('../src/services/ProductRepository');

describe('ProductRepository', () => {
  it('should return list of products', async () => {
    const mockPool = {
      query: jest.fn().mockResolvedValue({ rows: [{ id: 1, name: 'Laptop' }] })
    };

    const repo = new ProductRepository(mockPool);
    const result = await repo.findAll();

    expect(result).toEqual([{ id: 1, name: 'Laptop' }]);
    expect(mockPool.query).toHaveBeenCalledWith('SELECT * FROM products');
  });
});

Một số lưu ý thực tiễn khi áp dụng DI

  • Tránh tiêm quá mức: Các module ổn định như lodash, uuid không cần DI trừ khi có nhu cầu đặc biệt về mock hoặc thay đổi hành vi.
  • Quản lý vòng đời: Với các tài nguyên như database pool, đảm bảo chỉ khởi tạo một lần toàn cục — không tạo mới mỗi lần gọi service.
  • Tránh circular reference: Nếu hai service phụ thuộc lẫn nhau, hãy cân nhắc chuyển logic chung sang một service thứ ba hoặc dùng lazy-loading thông qua hàm getter.

Thẻ: expressjs dependency-injection nodejs software-architecture unit-testing

Đăng vào ngày 11 tháng 6 lúc 16:00