Bỏ qua để đến nội dung
Knowledge Hub

Lỗi đặt tên hay gặp: từ code, database tới API và event Trung cấp

Thời gian đọc ước tính: 12 phút

Trang này gom những cái tên gây bug thật, không chỉ “khó đọc”. Các hành vi PostgreSQL bên dưới đã chạy trên PostgreSQL 17; hành vi pg (node-postgres) đã chạy với pg 8.23.

Số không có đơn vị: timeout 30 là 30 gì?

Hay gặp

// .env: TIMEOUT=30 (người cấu hình nghĩ là giây)
const timeout = Number(process.env.TIMEOUT);
await fetch(paymentUrl, { signal: AbortSignal.timeout(timeout) });

Nên viết

// .env: PAYMENT_API_TIMEOUT_MS=30000
const paymentApiTimeoutMs = Number(process.env.PAYMENT_API_TIMEOUT_MS);
if (!(paymentApiTimeoutMs >= 100 && paymentApiTimeoutMs <= 60_000)) {
throw new Error('PAYMENT_API_TIMEOUT_MS must be 100..60000');
}
await fetch(paymentUrl, { signal: AbortSignal.timeout(paymentApiTimeoutMs) });

Vì sao

  • AbortSignal.timeout() nhận mili giây: mọi call bị huỷ sau 30 ms, rơi vào retry, trông như “provider chậm”.
  • Đơn vị trong tên làm sai lệch lộ ra ngay ở dòng cấu hình; validate khoảng chặn giá trị vô lý lúc khởi động.

Tham số boolean trần và tên phủ định

Hay gặp

await invoiceService.issue(order, true, false);
function issue(order: Order, notSkipEmail: boolean, noRetry: boolean) {
if (!notSkipEmail) return; // gửi hay không gửi?
}

Nên viết

await invoiceService.issue(order, { shouldEmailCustomer: true, shouldRetry: false });
function issue(order: Order, options: { shouldEmailCustomer: boolean; shouldRetry: boolean }) {
if (!options.shouldEmailCustomer) return;
}

Vì sao

Ở call site, true, false không nói gì; đổi thứ tự hai tham số vẫn compile. Tên phủ định (notSkipEmail) cộng ! thành phủ định ba lần: người sửa sau đảo sai một lần là khách không nhận hoá đơn.

Từ rỗng nghĩa và viết tắt tự chế

Hay gặp

class OrderManager {
processData(data: any, info: any) {
const qty = data.items.length;
const cstmrAmt = info.amt * qty;
}
}

Nên viết

class SettlementReconciliationService {
reconcile(report: SettlementReport, fees: FeeSchedule) {
const lineItemCount = report.lineItems.length;
const customerFeeCents = fees.perItemCents * lineItemCount;
}
}

Vì sao

  • Manager, data, info đúng với mọi thứ nên class hút thêm việc tới khi thành 3.000 dòng.
  • Repo có cả qty và quantity, amt và amount: grep một từ sẽ sót chỗ kia, refactor sửa thiếu.

get trả null: caller tin là luôn có

Hay gặp

async getUser(id: string): Promise<User | null> {
return this.repo.findOneBy({ id });
}
// ở nơi khác, người gọi tin vào chữ "get"
const user = await usersService.getUser(id);
await mailer.send(user!.email);

Nên viết

async findUserById(id: string): Promise<User | null> {
return this.repo.findOneBy({ id });
}
async getUserById(id: string): Promise<User> {
const user = await this.findUserById(id);
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}

Vì sao

Team đã quy ước get là “bắt buộc có”. Hàm get trả null khiến caller thêm ! cho qua type check, và production nổ TypeError: Cannot read properties of null thay vì 404 rõ ràng.

ORM sinh bảng và cột camelCase có quote

Hay gặp

model OrderItem {
id BigInt @id @default(autoincrement())
orderId BigInt
createdAt DateTime @default(now())
}
// Bảng thật: "OrderItem" ("orderId", "createdAt")

Nên viết

model OrderItem {
id BigInt @id @default(autoincrement())
orderId BigInt @map("order_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
@@map("order_items")
}

Vì sao

  • App chạy bình thường. Nhưng SELECT orderId FROM OrderItem báo relation "orderitem" does not exist, vì tên không quote bị gập về chữ thường.
  • Mọi query viết tay, BI tool, script vận hành phải quote từng tên. TypeORM mặc định cũng dùng tên property làm tên cột ("createdAt"), nên cần naming strategy hoặc name tường minh.
  • Đổi sau khi đã có dữ liệu và dashboard thì rất đắt: cấu hình từ migration đầu tiên.

Cột tên user: query vẫn chạy, dữ liệu sai

Hay gặp

CREATE TABLE audit_logs ("user" text, action text);
-- Script export viết vội, quên quote
SELECT user, action FROM audit_logs;
-- user | action
-- app_rw | REFUND ← tên role database, không phải người thao tác

Nên viết

CREATE TABLE audit_logs (actor_user_id bigint, action text);
SELECT actor_user_id, action FROM audit_logs;

Vì sao

  • user là từ khoá, không quote thì PostgreSQL hiểu là hàm user (đồng nghĩa current_user): không lỗi, mọi dòng đều ra tên role đang kết nối.
  • order, group thì nổ syntax error ngay, còn đỡ hơn. Tên cột cụ thể (actor_user_id) tránh từ khoá và nói rõ vai trò.

Số ít, số nhiều lệch giữa entity và migration

Hay gặp

// migration viết tay: CREATE TABLE customers (...)
@Entity()
export class Customer {
@PrimaryGeneratedColumn() id: number;
}
// Runtime: relation "customer" does not exist

Nên viết

// Một quy ước: bảng số nhiều, ghi tường minh
@Entity('customers')
export class Customer {
@PrimaryGeneratedColumn() id: number;
}

Vì sao

DefaultNamingStrategy của TypeORM lấy tên class đổi sang snake_case làm tên bảng: Customer thành customer. Lỗi chỉ hiện khi code chạm tới entity đó, thường là sau deploy. Trộn số ít, số nhiều trong cùng schema còn bắt mọi người phải nhớ từng bảng.

Tên dài hơn 63 byte bị cắt rồi đụng nhau

Hay gặp

CREATE INDEX payment_provider_settlement_reconciliation_line_items_amount_cents_idx
ON payment_provider_settlement_reconciliation_line_items (amount_cents);
CREATE INDEX payment_provider_settlement_reconciliation_line_items_amount_cents_currency_idx
ON payment_provider_settlement_reconciliation_line_items (amount_cents, currency);
-- NOTICE: ... will be truncated to "..._line_items_amount_ce"
-- ERROR: relation "..._line_items_amount_ce" already exists

Nên viết

-- Bảng ngắn hơn, tên index theo mẫu <bảng>_<cột>_idx, dưới 63 byte
CREATE INDEX settlement_lines_amount_cents_idx
ON settlement_lines (amount_cents);
CREATE INDEX settlement_lines_amount_cents_currency_idx
ON settlement_lines (amount_cents, currency);

Vì sao

  • PostgreSQL chỉ phát NOTICE khi cắt, migration tool thường nuốt mất. Hai tên trùng 63 byte đầu thành cùng một tên, migration fail giữa chừng.
  • Thêm một check trong CI: tên identifier mới phải ≤ 63 byte.

id bigint về Node là string, so sánh với number

Hay gặp

const { rows } = await pool.query('SELECT id, owner_id FROM orders WHERE id = $1', [orderId]);
const order = rows[0] as { id: number; owner_id: number };
// currentUser.id là number (lấy từ JWT)
if (order.owner_id !== currentUser.id) throw new ForbiddenException();

Nên viết

type UserId = string & { readonly __brand: 'UserId' };
const toUserId = (raw: string | number): UserId => String(raw) as UserId;
const order = rows[0] as { id: string; owner_id: string };
if (toUserId(order.owner_id) !== toUserId(currentUser.id)) {
throw new ForbiddenException();
}

Vì sao

  • pg trả cột bigint (int8) dạng string vì JavaScript không có số nguyên 64-bit an toàn: '42' !== 42, chủ đơn cũng bị 403.
  • Kiểu number trong as là lời nói dối, compiler không bắt được. Kiểu UserId riêng buộc mọi chỗ chuyển đổi rõ ràng, và còn chặn nhầm orderId vào chỗ cần userId.

Động từ trong path, dùng GET cho việc có tác dụng phụ

Hay gặp

@Get('cancelOrder')
cancel(@Query('id') id: string) {
return this.ordersService.cancel(id);
}
// Link trong email admin: https://shop.example/api/cancelOrder?id=42

Nên viết

@Post(':id/cancel')
@HttpCode(200)
cancel(@Param('id') id: string) {
return this.ordersService.cancel(id);
}
// POST /orders/42/cancel (hoặc POST /orders/42:cancel)

Vì sao

RFC 9110 định nghĩa GET là safe: spider, pre-fetch, công cụ kiểm tra link được phép gọi mà không sợ gây hại. Một URL GET huỷ đơn sẽ bị gọi bởi những thứ đó, và đơn bị huỷ không ai bấm. Tên path là danh từ; hành động không phải CRUD thì dùng POST với sub-resource hoặc :action.

Event đặt tên như command

Hay gặp

// Customer service, sau khi đăng ký thành công
await producer.send({
topic: 'customer.events',
messages: [{ key: customer.id, value: JSON.stringify({ type: 'SendWelcomeEmail', customerId: customer.id }) }],
});

Nên viết

await producer.send({
topic: 'customer.customer-registered.v1',
messages: [{ key: customer.id, value: JSON.stringify({ customerId: customer.id, registeredAt }) }],
});
// Email service tự quyết gửi welcome email; SMS, CRM cũng nghe cùng event

Vì sao

  • SendWelcomeEmail là command: producer đang ra lệnh cho một consumer cụ thể. Thêm SMS hay CRM thì phải sửa producer.
  • Event CustomerRegistered là sự thật đã xảy ra; consumer mới chỉ cần subscribe, producer không đổi.

Event đặt theo bảng thay vì theo nghiệp vụ

Hay gặp

publish('OrderUpdated', { orderId, before: oldRow, after: newRow });
// Consumer: so before/after để đoán chuyện gì xảy ra
if (e.before.status !== e.after.status && e.after.status === 9) { /* huỷ? */ }

Nên viết

publish('OrderCancelled', { orderId, reason, cancelledAt });
publish('OrderShippingAddressChanged', { orderId, newAddress, changedAt });

Vì sao

Mỗi consumer tự viết lại logic diff và phải biết 9 nghĩa là huỷ. Khi team order thêm cột hay đổi mã trạng thái, consumer hiểu sai mà không ai báo lỗi.

Đổi tên field JSON trong PR refactor

PR hay gặp

// PR: "refactor: clean up naming"
export class OrderResponse {
order_ID: string;
orderId: string;
}

Nên làm

// Giai đoạn chuyển tiếp: trả cả hai, đánh dấu field cũ deprecated
export class OrderResponse {
orderId: string;
/** @deprecated dùng orderId; xoá ở /v2 */
order_ID: string;
}

Vì sao

Trong code nội bộ, IDE đổi tên an toàn. Với hợp đồng public (JSON, topic, cột team khác đọc), client cũ vỡ ngay khi deploy. Tách đổi tên hợp đồng thành kế hoạch migration riêng: trả song song, theo dõi ai còn dùng, rồi mới xoá.

Tranh luận casing trong mọi PR

Review hay gặp

Reviewer: interface nên bỏ chữ I nhé
Reviewer: hằng số này phải CONSTANT_CASE
Reviewer: (PR sau) lại interface có chữ I

Nên làm

eslint.config.js
rules: {
'@typescript-eslint/naming-convention': ['error',
{ selector: 'typeLike', format: ['PascalCase'] },
{ selector: 'interface', format: ['PascalCase'], custom: { regex: '^I[A-Z]', match: false } },
{ selector: 'enumMember', format: ['UPPER_CASE'] },
],
}

Vì sao

Thứ máy kiểm được (casing, tiền tố, độ dài) thì đưa vào linter và CI. Thời gian review dành cho thứ máy không kiểm được: tên có đúng nghĩa nghiệp vụ không.

  1. Tên có trả lời “đây là gì / làm gì” mà không cần mở implementation không?
  2. Có từ rỗng nghĩa (data, info, manager, utils, handle, process) không?
  3. Động từ (get/find/fetch/list) có đúng nghĩa đã thống nhất của team không?
  4. Boolean có dạng is/has/can/should và không phủ định không?
  5. Số có đơn vị (Ms, Bytes, Cents) trong tên hoặc trong kiểu không?
  6. Bảng/cột/constraint mới là snake_case, dưới 63 byte, có tên constraint và index tường minh trong migration không?
  7. Endpoint, JSON field, event, topic mới có theo đúng quy ước của API/hệ thống đó không, và có phải đổi tên một hợp đồng đã public không?
  8. Tên có dùng đúng thuật ngữ business đang dùng không?
  1. Vì sao SELECT * FROM OrderItems báo lỗi khi bảng được tạo bằng CREATE TABLE "OrderItems"?
  2. Một FK từ order_items tới orders không đặt tên sẽ có tên mặc định gì? Vì sao vẫn nên viết tên ra trong migration?
  3. CreateInvoice và InvoiceCreated khác nhau thế nào về ý nghĩa và về ai có quyền từ chối?
  4. findOrder và getOrder nên khác nhau ở điểm nào trong một team?

Tiếp theo: quay về Tổng quan đặt tên hoặc xem cụm Code review.