jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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:

  1. Module khác chỉ import từ modules/ordering/index.ts, không chui vào infrastructure/.
  2. Domain không import Express, ORM, Redis hay logger.
  3. Giao tiếp đồng bộ qua public API; giao tiếp bất đồng bộ qua event có schema và version.
  4. 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ả 503Retry-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ỏiVí 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í?

PatternDùng khiTránh khi
Port/adapterCó dependency bên ngoài hoặc nhiều cách triển khaiHàm nội bộ thuần, không có boundary
RepositoryCần ngôn ngữ query theo domain và tách persistenceChỉ bọc từng method ORM 1:1
Unit of WorkMột invariant cần nhiều write nguyên tửWorkflow có network call dài
CacheĐọc lặp, staleness định lượng đượcDữ liệu phải strong-consistent
QueueCông việc có thể hoàn tất sau response, cần retry/bufferClient cần kết quả ngay trong cùng request
RetryLỗi tạm thời, operation idempotentLỗi vĩnh viễn hoặc side effect không dedup
Circuit breakerDependency lỗi kéo dài và cần bảo vệ tài nguyênTraffic thấp, không đủ tín hiệu hoặc không có fallback

Pattern được chấp nhận khi có constraint, failure modemetric đ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

  1. Tạo public API cho module; cấm import trực tiếp vào infrastructure/.
  2. Cài ConfirmOrder với OrderRepositoryPaymentGateway port; lắp adapter ở main.ts.
  3. Validate input và env bằng Zod.
  4. Thêm timeout, tối đa một retry có jitter cho payment; giới hạn concurrency dependency.
  5. Cache order summary với TTL jitter và invalidation sau write.
  6. Enqueue email xác nhận bằng idempotency key; worker đóng êm khi nhận SIGTERM.
  7. 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ả 400 không bị retry; 503 chỉ 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õ.
  • SIGTERM khô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


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.