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.
Code TypeScript
Phần tiêu đề “Code TypeScript”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=30000const 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ả
qtyvàquantity,amtvà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.
Database
Phần tiêu đề “Database”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 OrderItembáorelation "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ặcnametườ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 quoteSELECT user, action FROM audit_logs;-- user | action-- app_rw | REFUND ← tên role database, không phải người thao tácNên viết
CREATE TABLE audit_logs (actor_user_id bigint, action text);
SELECT actor_user_id, action FROM audit_logs;Vì sao
userlà từ khoá, không quote thì PostgreSQL hiểu là hàmuser(đồng nghĩacurrent_user): không lỗi, mọi dòng đều ra tên role đang kết nối.order,groupthì nổsyntax errorngay, 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 existNê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 existsNên viết
-- Bảng ngắn hơn, tên index theo mẫu <bảng>_<cột>_idx, dưới 63 byteCREATE 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
NOTICEkhi 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
pgtrả cộtbigint(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
numbertrongaslà lời nói dối, compiler không bắt được. KiểuUserIdriêng buộc mọi chỗ chuyển đổi rõ ràng, và còn chặn nhầmorderIdvào chỗ cầnuserId.
API và event
Phần tiêu đề “API và event”Độ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=42Nê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ôngawait 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 eventVì sao
SendWelcomeEmaillà command: producer đang ra lệnh cho một consumer cụ thể. Thêm SMS hay CRM thì phải sửa producer.- Event
CustomerRegisteredlà 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 raif (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ũ deprecatedexport 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á.
Quy trình review
Phần tiêu đề “Quy trình review”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_CASEReviewer: (PR sau) lại interface có chữ INên làm
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.
Checklist code review
Phần tiêu đề “Checklist code review”- Tên có trả lời “đây là gì / làm gì” mà không cần mở implementation không?
- Có từ rỗng nghĩa (
data,info,manager,utils,handle,process) không? - Động từ (
get/find/fetch/list) có đúng nghĩa đã thống nhất của team không? - Boolean có dạng
is/has/can/shouldvà không phủ định không? - Số có đơn vị (
Ms,Bytes,Cents) trong tên hoặc trong kiểu không? - 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? - 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?
- Tên có dùng đúng thuật ngữ business đang dùng không?
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- Vì sao
SELECT * FROM OrderItemsbáo lỗi khi bảng được tạo bằngCREATE TABLE "OrderItems"? - Một FK từ
order_itemstớiorderskhô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? CreateInvoicevàInvoiceCreatedkhác nhau thế nào về ý nghĩa và về ai có quyền từ chối?findOrdervàgetOrdernê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.