Node.js Production Engineering 06 — Patterns cho Codebase lớn
Thiết kế ranh giới module, dependency injection, transaction, cache, queue và resilience để codebase Node.js tiếp tục thay đổi an toàn khi sản phẩm và đội ngũ cùng lớn lên.
Một service thường không trở nên khó bảo trì vì thiếu pattern. Nó trở nên khó bảo trì khi một thay đổi nhỏ lan qua quá nhiều nơi: đổi nhà cung cấp email nhưng phải sửa controller, test và database; thêm field vào đơn hàng nhưng làm hỏng ba worker; một dependency chậm kéo sập toàn bộ request path.
Mục tiêu của pattern trong production không phải làm sơ đồ đẹp. Mục tiêu là kiểm soát ba đại lượng:
change amplification — một yêu cầu nghiệp vụ buộc sửa bao nhiêu module?
blast radius — một lỗi dependency ảnh hưởng bao nhiêu request?
feedback time — mất bao lâu để chứng minh thay đổi vẫn đúng?
Bài này dùng một service đặt hàng làm ví dụ xuyên suốt. Ta sẽ thêm cấu trúc vừa đủ để thay đổi an toàn, đồng thời chỉ rõ lúc nào một abstraction chưa đáng giá.
Sau bài này, bạn sẽ làm được gì?
- Chia code theo capability và xác định API công khai của từng module.
- Đặt dependency injection ở composition root, không biến container thành service locator.
- Chọn đúng ranh giới transaction; tránh generic repository che mất năng lực database.
- Dùng cache và queue với tiêu chí nhất quán, idempotency và khả năng quan sát rõ ràng.
- Thiết kế timeout, retry, bulkhead và circuit breaker từ failure mode thực tế.
- Đo coupling bằng chỉ số và test kiến trúc thay vì dựa vào cảm giác.
Mental model: policy ở trong, mechanism ở ngoài
Một codebase bền thường tách hai loại quyết định:
- Policy: luật nghiệp vụ — đơn hàng nào được xác nhận, giá nào hợp lệ, khi nào hoàn tiền.
- Mechanism: cách thực thi — Express nhận HTTP, PostgreSQL lưu dữ liệu, Redis cache, BullMQ chạy job.
Policy thay đổi theo sản phẩm. Mechanism thay đổi theo hạ tầng. Trộn chúng làm cả hai cùng khó đổi.
HTTP adapter ─┐
CLI adapter ─┼──▶ application use case ─▶ domain policy
queue worker ─┘ │
├──▶ repository port ─▶ PostgreSQL adapter
├──▶ mail port ─▶ provider adapter
└──▶ clock port ─▶ system clock
Không cần framework kiến trúc để bắt đầu. Chỉ cần dependency luôn đi từ adapter vào policy và có một nơi duy nhất lắp ráp chúng.
Module trước, class sau
Folder theo tầng kỹ thuật (controllers/, services/, repositories/) nhìn gọn lúc đầu nhưng làm một capability bị rải khắp cây thư mục. Khi sản phẩm lớn, ưu tiên vertical module:
src/
├── modules/
│ ├── ordering/
│ │ ├── domain/
│ │ ├── application/
│ │ ├── infrastructure/
│ │ └── index.ts # public API của module
│ ├── inventory/
│ └── notifications/
├── platform/ # config, logger, database, telemetry
└── main.ts # composition root
Quy tắc biên:
- Module khác chỉ import từ
modules/ordering/index.ts, không chui vàoinfrastructure/. - Domain không import Express, ORM, Redis hay logger.
- Giao tiếp đồng bộ qua public API; giao tiếp bất đồng bộ qua event có schema và version.
shared/không phải nơi đẩy mọi thứ khó đặt tên. Chỉ đặt primitive thật sự ổn định và không thuộc capability nào.
Decision criteria: tách module khi nhóm code có từ vựng nghiệp vụ, vòng đời thay đổi hoặc owner riêng. Không tách chỉ vì file đã dài 300 dòng.
Dependency injection và composition root
Dependency injection chỉ có một lời hứa: object nhận dependency từ bên ngoài thay vì tự tạo nó.
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
export interface PaymentGateway {
authorize(input: {
orderId: string;
amount: number;
}): Promise<{ paymentId: string }>;
}
export class ConfirmOrder {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentGateway
) {}
async execute(orderId: string): Promise<void> {
const order = await this.orders.findById(orderId);
if (!order) throw new Error('ORDER_NOT_FOUND');
const payment = await this.payments.authorize({
orderId,
amount: order.total,
});
order.confirm(payment.paymentId);
await this.orders.save(order);
}
}
Lắp object graph ở entry point:
// main.ts — composition root
const pool = createPostgresPool(env.DATABASE_URL);
const logger = createLogger(env.LOG_LEVEL);
const orders = new PostgresOrderRepository(pool);
const payments = new RetryingPaymentGateway(
new StripePaymentGateway(env.STRIPE_KEY),
logger
);
const confirmOrder = new ConfirmOrder(orders, payments);
const app = createHttpApp({ confirmOrder, logger });
Container như Awilix, tsyringe hoặc DI của NestJS hữu ích khi object graph lớn, nhưng có hai failure mode:
- Service locator: code gọi
container.resolve()ở mọi nơi, làm dependency bị ẩn và test khó hiểu. - Sai scope: object giữ state theo request lại được đăng ký singleton, gây rò dữ liệu giữa người dùng.
Giữ container trong composition root. Constructor vẫn phải nói rõ class cần gì. Scope mặc định nên là singleton cho stateless adapter, transient cho object có state ngắn; request scope chỉ dùng khi thật sự cần vì nó tăng allocation và làm lifecycle phức tạp.
Repository và transaction boundary
Repository là port theo ngôn ngữ nghiệp vụ, không phải bản sao generic của ORM.
// Có ý nghĩa với use case
interface OrderRepository {
findPendingForCustomer(customerId: string): Promise<Order[]>;
reserveOrderNumber(): Promise<string>;
save(order: Order, tx?: Transaction): Promise<void>;
}
// Thường quá chung, làm mất query semantics và type
interface GenericRepository<T> {
find(filter: Record<string, unknown>): Promise<T[]>;
update(id: string, patch: Partial<T>): Promise<T>;
}
Một transaction nên bao quanh một invariant cần commit nguyên tử, không bao quanh toàn request nếu request còn gọi mạng.
await unitOfWork.run(async (tx) => {
const order = await orders.lockById(command.orderId, tx);
order.reserve(command.quantity);
await orders.save(order, tx);
await inventory.decrement(order.sku, command.quantity, tx);
});
Không gọi payment API trong transaction trên. Network có thể chậm hoặc timeout, giữ lock lâu và làm throughput database sụt. Nếu workflow phải đi qua nhiều hệ thống, mô hình hóa trạng thái trung gian và idempotency; phần 10 sẽ đặt transactional outbox và saga vào đúng ranh giới.
Observability: ghi transaction_duration_ms, số lần deadlock/serialization retry và thời gian chờ pool. Một transaction đúng về code nhưng chờ lock 8 giây vẫn là vấn đề production.
Validation theo từng trust boundary
TypeScript chỉ bảo vệ code đã type-check; JSON từ HTTP, queue hay env vẫn là dữ liệu không tin cậy. Validate ở biên rồi chuyển thành type nội bộ.
import { z } from 'zod';
const ConfirmOrderInput = z.object({
orderId: z.string().uuid(),
expectedVersion: z.number().int().nonnegative(),
});
type ConfirmOrderInput = z.infer<typeof ConfirmOrderInput>;
app.post('/orders/:id/confirm', async (req, res) => {
const input = ConfirmOrderInput.parse({
orderId: req.params.id,
expectedVersion: req.body.expectedVersion,
});
await confirmOrder.execute(input);
res.status(204).end();
});
Không truyền thẳng DTO vào domain. DTO mô tả contract bên ngoài; domain type mô tả invariant bên trong. Hai thứ có thể giống hình dạng hôm nay nhưng có lý do thay đổi khác nhau.
Config cũng là input:
const Env = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']),
DATABASE_URL: z.string().url(),
PAYMENT_TIMEOUT_MS: z.coerce.number().int().positive().max(30_000),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
});
export const env = Env.parse(process.env); // fail fast trước khi nhận traffic
Resilience: budget trước, pattern sau
Mỗi request có một latency budget. Nếu API cam kết p99 dưới 800 ms, không thể cho ba dependency mỗi cái retry ba lần với timeout 2 giây.
request budget 800 ms
├── parse/auth 40 ms
├── database 180 ms
├── payment 350 ms (gồm tối đa một retry)
├── serialize/network 80 ms
└── reserve 150 ms
Timeout và retry có phân loại
Chỉ retry operation idempotent và lỗi tạm thời. Không retry lỗi validation, authentication hay phần lớn 4xx.
const retryable = new Set([408, 429, 502, 503, 504]);
async function fetchJson(url: string, init: RequestInit, attempts = 2) {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(350),
});
if (!response.ok && !retryable.has(response.status)) {
throw new Error(`PERMANENT_HTTP_${response.status}`);
}
if (!response.ok) throw new Error(`TRANSIENT_HTTP_${response.status}`);
return response.json();
} catch (error) {
lastError = error;
if (attempt === attempts || String(error).includes('PERMANENT_HTTP'))
throw error;
const backoff = 50 * 2 ** (attempt - 1);
const jitter = Math.random() * backoff;
await new Promise((resolve) => setTimeout(resolve, backoff + jitter));
}
}
throw lastError;
}
Retry làm tăng tải đúng lúc dependency đang yếu. Luôn giới hạn attempt, thêm jitter, log attempt và đo retry_total{dependency,reason}.
Bulkhead và circuit breaker
- Bulkhead giới hạn concurrency riêng cho từng dependency để payment chậm không chiếm toàn bộ socket/promise của process.
- Circuit breaker fail nhanh khi tỷ lệ lỗi vượt ngưỡng, sau đó cho một lượng request nhỏ thăm dò phục hồi.
Không dùng circuit breaker như phép màu. Cần định nghĩa trạng thái fallback: trả dữ liệu cũ, đưa job vào queue, hay trả 503 có Retry-After. Nếu không có fallback hợp lệ, breaker vẫn hữu ích để bảo vệ tài nguyên nhưng không tạo ra availability từ hư không.
Cache và queue: hai loại bất đồng bộ khác nhau
Cache
Cache đổi độ mới dữ liệu lấy latency và tải thấp hơn. Trước khi thêm cache, viết ra:
| Câu hỏi | Ví dụ quyết định |
|---|---|
| Chấp nhận cũ bao lâu? | Product catalog: 60 giây; số dư: không chấp nhận |
| Ai invalidate? | Use case ghi dữ liệu xóa key liên quan |
| Khi Redis lỗi? | Bỏ qua cache và đọc DB với concurrency limit |
| Làm gì khi stampede? | TTL jitter + single-flight cho key nóng |
| Đo gì? | hit ratio, load latency, stale serve, eviction |
async function cached<T>(
key: string,
ttlSeconds: number,
load: () => Promise<T>
): Promise<T> {
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit) as T;
const value = await load();
const ttlWithJitter =
ttlSeconds + Math.floor(Math.random() * ttlSeconds * 0.1);
await redis.set(key, JSON.stringify(value), 'EX', ttlWithJitter);
return value;
}
Cache-aside có race giữa write và invalidate. Nếu tính đúng đắn quan trọng, cân nhắc version trong key, write-through hoặc bỏ cache ở path đó.
Queue
Queue tách thời điểm chấp nhận công việc khỏi thời điểm hoàn tất công việc. HTTP 202 Accepted chỉ đúng khi API có cách cho client biết trạng thái sau đó.
await emailQueue.add(
'send-order-confirmation',
{ orderId, customerId },
{
jobId: `order-confirmation:${orderId}`,
attempts: 5,
backoff: { type: 'exponential', delay: 1_000 },
removeOnComplete: 1_000,
}
);
jobId hỗ trợ dedup trong queue nhưng không thay thế idempotency ở side effect. Worker vẫn cần khóa nghiệp vụ hoặc bảng processed_jobs, vì job có thể chạy lại sau crash ở thời điểm khó xác định.
const worker = new Worker('email', handleEmailJob, {
connection,
concurrency: 10,
});
worker.on('completed', (job) =>
metrics.jobsCompleted.add(1, { name: job.name })
);
worker.on('failed', (job, error) => {
logger.error({ jobId: job?.id, error }, 'background job failed');
});
process.once('SIGTERM', async () => {
await worker.close(); // ngừng nhận job mới và chờ job đang chạy
});
Theo dõi queue depth, tuổi job lâu nhất, processing latency, retry và failed jobs. Queue còn trống không có nghĩa hệ thống khỏe nếu producer đã ngừng enqueue.
Structured logging và context theo request
Log là event có schema, không phải câu văn tự do. Mỗi dòng nên trả lời được: chuyện gì, ở service nào, request/job nào, entity nào, kết quả và duration.
import { AsyncLocalStorage } from 'node:async_hooks';
import pino from 'pino';
type RequestContext = { requestId: string; traceId?: string };
const context = new AsyncLocalStorage<RequestContext>();
const logger = pino({
level: env.LOG_LEVEL,
base: { service: 'ordering-api' },
redact: ['req.headers.authorization', '*.password', '*.token'],
});
app.use((req, _res, next) => {
context.run(
{ requestId: req.header('x-request-id') ?? crypto.randomUUID() },
next
);
});
function logInfo(fields: Record<string, unknown>, message: string) {
logger.info({ ...context.getStore(), ...fields }, message);
}
Không log payload tùy tiện: PII và secret có thể đi vào hệ thống log lâu hơn database. Đặt redaction test trong CI và sampling cho event volume cao.
Decision table: pattern nào đáng trả chi phí?
| Pattern | Dùng khi | Tránh khi |
|---|---|---|
| Port/adapter | Có dependency bên ngoài hoặc nhiều cách triển khai | Hàm nội bộ thuần, không có boundary |
| Repository | Cần ngôn ngữ query theo domain và tách persistence | Chỉ bọc từng method ORM 1:1 |
| Unit of Work | Một invariant cần nhiều write nguyên tử | Workflow có network call dài |
| Cache | Đọc lặp, staleness định lượng được | Dữ liệu phải strong-consistent |
| Queue | Công việc có thể hoàn tất sau response, cần retry/buffer | Client cần kết quả ngay trong cùng request |
| Retry | Lỗi tạm thời, operation idempotent | Lỗi vĩnh viễn hoặc side effect không dedup |
| Circuit breaker | Dependency lỗi kéo dài và cần bảo vệ tài nguyên | Traffic thấp, không đủ tín hiệu hoặc không có fallback |
Pattern được chấp nhận khi có constraint, failure mode và metric đi cùng. Nếu không viết được ba thứ đó, abstraction có thể đang đến quá sớm.
Checklist review codebase
- Capability có public API và không bị import xuyên tầng.
- Domain/application không import framework hoặc SDK hạ tầng.
- Object graph được lắp ở composition root; dependency hiện rõ trên constructor.
- Transaction bao quanh invariant, không giữ lock qua network call.
- Input HTTP, event và env được validate tại boundary.
- Mỗi outbound call có timeout; retry được phân loại và có budget.
- Cache có staleness budget, invalidation owner và metric hit/miss.
- Job có idempotency key, retry policy, failed-job handling và graceful shutdown.
- Log có request/trace/job id, redaction và retention phù hợp.
- Dashboard thể hiện latency, error, saturation và dependency health.
Lab: làm cho module Ordering thay đổi được
Xây một module ordering có endpoint xác nhận đơn, PostgreSQL, Redis và BullMQ.
Yêu cầu
- Tạo public API cho module; cấm import trực tiếp vào
infrastructure/. - Cài
ConfirmOrdervớiOrderRepositoryvàPaymentGatewayport; lắp adapter ởmain.ts. - Validate input và env bằng Zod.
- Thêm timeout, tối đa một retry có jitter cho payment; giới hạn concurrency dependency.
- Cache order summary với TTL jitter và invalidation sau write.
- Enqueue email xác nhận bằng idempotency key; worker đóng êm khi nhận
SIGTERM. - Gắn structured log và metric cho request, payment, cache và queue.
Acceptance criteria
- Đổi payment adapter sang fake mà không sửa use case hoặc HTTP handler.
- Test kiến trúc fail nếu module khác import
ordering/infrastructure/*. - Payment trả
400không bị retry;503chỉ retry trong latency budget. - Hai lần enqueue cùng order không gửi hai email.
- Redis down không làm request treo; fallback và metric được ghi rõ.
SIGTERMkhông nhận job mới, job đang chạy hoàn tất trong grace period.- Dashboard phân biệt được lỗi app, lỗi payment, cache miss và queue backlog.
Tài liệu chính thức
- Node.js — AsyncLocalStorage
- Node.js — AbortSignal.timeout
- Zod — Basic usage
- Pino — API và redaction
- BullMQ — Idempotent jobs
- BullMQ — Graceful shutdown
Phần tiếp theo
Codebase đã có ranh giới, nhưng ranh giới chỉ có giá trị khi artifact chạy giống nhau ở mọi môi trường và có quy trình phát hành an toàn. Phần 7 chuyển từ cấu trúc trong source code sang contract vận hành: image bất biến, probe, graceful shutdown, migration, rollout, rollback và quan sát deployment.