Chuẩn lập trình
Trang này dành cho người viết code. Đọc trước khi gõ dòng đầu tiên.
Trang này quy định cách viết code cho ADang: nguyên tắc thiết kế, chuẩn TypeScript, và các mẫu thiết kế được dùng. Mục tiêu không phải viết code đẹp theo lý thuyết, mà là để người thứ hai đọc hiểu và sửa được.
Nguyên tắc bao trùm: đơn giản trước
Trước mọi nguyên tắc khác, ghi nhớ quy mô thật của dự án: một văn phòng, dưới 50 người dùng, 20 chức năng.
Mỗi lớp trừu tượng phải trả lời được câu hỏi: "Nếu bỏ nó đi thì hỏng chuyện gì?" Không trả lời được thì bỏ.
Đây không phải lời khuyên suông. Phần lớn nợ kỹ thuật ở dự án nhỏ không đến từ code cẩu thả, mà từ những lớp trừu tượng được thêm vào "phòng khi cần" rồi không bao giờ cần tới — nhưng ai đọc code cũng phải đi qua chúng.
SOLID — áp dụng cụ thể vào ADang
SOLID là năm nguyên tắc, nhưng không phải nguyên tắc nào cũng đáng áp dụng triệt để ở quy mô này. Dưới đây là mức áp dụng đã cân nhắc cho từng nguyên tắc.
S — Một lớp, một lý do để thay đổi
Áp dụng đầy đủ. Đây là nguyên tắc quan trọng nhất với dự án này.
Lý do: khi nghiệp vụ nhà trường thay đổi, ta cần biết chính xác phải sửa ở đâu.
| Việc | Thuộc về | Không thuộc về |
|---|---|---|
| Kiểm tra bước chuyển trạng thái có hợp lệ | task-transition.rules.ts | Controller |
| Quyết định ai được chuyển bước đó | TasksService | Rules |
| Tính tập phòng ban được xem | ScopeService | Từng service nghiệp vụ |
| Tạo nội dung thông báo | TaskNotificationService | TasksService |
| Gửi thông báo đi | NotificationDeliveryService | Nơi tạo thông báo |
| Đọc ghi cơ sở dữ liệu | TasksRepository | Service |
Ví dụ sai thường gặp:
// SAI — TasksService vừa lo nghiệp vụ, vừa soạn nội dung thông báo,
// vừa biết cách gửi. Đổi câu chữ thông báo phải sửa file nghiệp vụ.
async transition(...) {
// ...cập nhật trạng thái...
await this.mailer.send(user.email, `Việc "${task.title}" đã bị trả lại`);
}
// ĐÚNG — service nghiệp vụ chỉ nói "chuyện gì vừa xảy ra"
async transition(...) {
// ...cập nhật trạng thái...
await this.notifier.onTransition(tx, task, fromStatus, actor, note);
}
Kiểm tra nhanh: mô tả nhiệm vụ của một lớp bằng một câu, không dùng chữ "và". Phải dùng "và" nghĩa là lớp đó nên tách.
O — Mở để mở rộng, đóng để sửa đổi
Áp dụng có chọn lọc — chỉ ở ba chỗ đã biết chắc sẽ mở rộng.
Áp dụng ở mọi nơi là over-engineering. Chỉ áp dụng nơi tài liệu thiết kế đã ghi rõ sẽ có thay đổi:
| Chỗ | Mở rộng sắp tới | Cách làm |
|---|---|---|
| Kênh gửi thông báo | Thêm Zalo ở giai đoạn sau | Interface NotificationChannel, thêm lớp mới, không sửa lớp cũ |
| Định dạng xuất báo cáo | Có thể thêm PDF | Interface ReportExporter |
| Luật chuyển trạng thái | Thêm bước duyệt nhiều cấp | Bảng dữ liệu TRANSITIONS, không phải chuỗi if/else |
Với luật chuyển trạng thái, mở rộng nghĩa là thêm một dòng vào bảng, không phải sửa hàm:
export const TRANSITIONS: Record<string, TransitionRule> = {
'new->in_progress': { allow: ['assignee', 'manager'], requiresNote: false },
'new->cancelled': { allow: ['manager'], requiresNote: true },
'in_progress->pending_review': { allow: ['assignee', 'manager'], requiresNote: false },
// thêm bước mới = thêm một dòng ở đây
};
Ba chỗ trên là hết. Những chỗ khác cứ viết thẳng, khi nào cần mở rộng thật thì tách sau — lúc đó ta đã biết rõ cần tách theo hướng nào.
L — Lớp con phải thay thế được lớp cha
Áp dụng, nhưng ít gặp vì dự án gần như không dùng kế thừa.
Quy tắc thực tế: ưu tiên ghép nối hơn kế thừa. Chỗ duy nhất dùng đa hình là các lớp cài đặt cùng một interface, và ở đó phải giữ đúng giao kèo:
interface NotificationChannel {
readonly code: 'in_app' | 'zalo' | 'email';
// Giao kèo: KHÔNG ném lỗi khi gửi thất bại. Trả về kết quả để nơi gọi ghi vào
// notification_deliveries.error rồi thử lại sau.
send(n: Notification, to: User): Promise<DeliveryResult>;
}
Nếu lớp gửi qua Zalo ném lỗi thay vì trả kết quả, một lần Zalo hỏng sẽ chặn luôn thông báo trong phần mềm. Đó là vi phạm nguyên tắc này theo nghĩa gây hại thật.
I — Đừng ép ai phụ thuộc thứ họ không dùng
Áp dụng nhẹ. Giữ interface nhỏ, đừng gom mọi thứ vào một interface "Service" khổng lồ.
// SAI — nơi chỉ cần đọc vẫn phải biết cả hàm ghi
interface TaskRepository { findById(); list(); create(); update(); delete(); ... }
// ĐÚNG cho ADang — tách theo cách dùng, chỉ khi thật sự có hai nhóm người dùng khác nhau
interface TaskReader { findById(id, scope): Promise<Task | null>; list(q, scope): Promise<Page<Task>>; }
Lưu ý: ở quy mô này, một repository cho một bảng là đủ. Đừng tách interface chỉ vì nguyên tắc.
D — Phụ thuộc vào cái trừu tượng, không phụ thuộc cái cụ thể
Áp dụng có giới hạn — và đây là chỗ dễ đi quá đà nhất.
Quy tắc cho ADang:
| Tầng | Được phụ thuộc | Không được |
|---|---|---|
| Controller | Service | Prisma, cơ sở dữ liệu |
| Service | Repository, interface của hạ tầng | PrismaClient trực tiếp |
| Repository | Prisma | — |
Nghĩa là PrismaClient chỉ xuất hiện trong tầng repository. Service không được import { PrismaClient }.
Nhưng không dựng cả một tầng interface cho mọi repository. Lớp repository cụ thể đã đủ là ranh giới. Chỉ tạo interface khi có từ hai cài đặt trở lên — hiện chỉ có kênh thông báo là thoả điều kiện đó.
Hàm transition() cần chạy trọn vẹn trong một giao dịch. Prisma truyền đối tượng giao dịch qua tham số, nên tầng service có chạm vào kiểu của Prisma ở chữ ký hàm.
Đây là rò rỉ trừu tượng có ý thức. Bọc lại sẽ tốn nhiều công hơn lợi ích thu được ở quy mô này. Ghi ra đây để người sau biết là cố ý, không phải sơ suất.
Chuẩn TypeScript
Cấu hình bắt buộc
// tsconfig.json — những mục không được tắt
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true, // arr[0] có kiểu T | undefined
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
}
}
noUncheckedIndexedAccess hay bị tắt vì gây phiền. Đừng tắt — nó bắt đúng loại lỗi undefined mà người dùng cuối gặp.
Cấm dùng any
// SAI
function parse(data: any) { ... }
// ĐÚNG — không biết kiểu thì dùng unknown rồi thu hẹp
function parse(data: unknown) {
if (!isTaskPayload(data)) throw new AppError(400, 'VALIDATION_FAILED', M.INVALID_BODY);
// ở đây data đã có kiểu
}
Trường hợp buộc phải dùng any (thư viện ngoài thiếu kiểu): thêm // eslint-disable-next-line kèm một dòng giải thích vì sao. Không giải thích thì bị từ chối khi duyệt mã.
Trạng thái dùng kiểu hợp, không dùng enum
// ĐÚNG
export type TaskStatus = 'new' | 'in_progress' | 'pending_review' | 'done' | 'cancelled';
export const TASK_STATUSES = ['new','in_progress','pending_review','done','cancelled'] as const;
// TRÁNH — enum của TypeScript sinh code lúc chạy và không khớp với chuỗi trong cơ sở dữ liệu
export enum TaskStatus { New = 'new', ... }
Cơ sở dữ liệu lưu varchar kèm ràng buộc kiểm tra, không dùng kiểu liệt kê của PostgreSQL. Kiểu hợp của TypeScript khớp trực tiếp với chuỗi đó.
Bắt buộc: danh sách trong TASK_STATUSES phải khớp với ràng buộc chk_task_status trong cơ sở dữ liệu. Sửa một chỗ thì sửa cả hai.
type hay interface
interfacecho hình dạng đối tượng sẽ được cài đặt hoặc mở rộngtypecho mọi thứ còn lại: hợp, giao, kiểu tiện ích, kiểu hàm
null và undefined
| Ý nghĩa | Dùng |
|---|---|
| Cơ sở dữ liệu không có giá trị | null |
| Không truyền tham số, thuộc tính không tồn tại | undefined |
Prisma trả null cho cột rỗng — giữ nguyên null, đừng đổi qua lại.
Khoá chính kiểu BigInt và JSON
Khoá chính dùng bigint. JavaScript không tự chuyển bigint sang JSON — gọi JSON.stringify sẽ ném lỗi.
Quy ước: khoá chính đi ra ngoài dưới dạng chuỗi.
// main.ts — đăng ký một lần cho toàn ứng dụng
(BigInt.prototype as any).toJSON = function () { return this.toString(); };
Nghĩa là giao diện nhận "id": "42", không phải "id": 42. Frontend phải so sánh bằng chuỗi. Ghi rõ trong tài liệu API.
Bất biến
Mặc định dùng readonly cho thuộc tính không đổi sau khi tạo, và as const cho hằng số.
export const PAGE_SIZE_DEFAULT = 20 as const;
export const PAGE_SIZE_MAX = 100 as const;
export const ATTACHMENT_MAX_BYTES = 20 * 1024 * 1024 as const;
Đừng rắc Readonly<T> khắp nơi — chỉ dùng nơi việc sửa nhầm gây hậu quả thật.
Đặt tên
| Loại | Quy ước | Ví dụ |
|---|---|---|
| Tệp | chữ thường, gạch ngang | task-transition.rules.ts |
| Lớp, interface, kiểu | Hoa đầu từ | TasksService, TaskStatus |
| Biến, hàm | thường đầu, hoa các từ sau | overdueWhere, todayVN |
| Hằng số | HOA_GẠCH_DƯỚI | PAGE_SIZE_MAX |
| Trường trong JSON trao đổi | gạch dưới | assignee_id, due_date |
| Thuộc tính trong code | thường đầu, hoa các từ sau | assigneeId, dueDate |
Dữ liệu trao đổi qua mạng dùng gạch dưới (assignee_id), code bên trong dùng hoa các từ sau (assigneeId). Chuyển đổi ở tầng ánh xạ dữ liệu.
Lý do: tham số lọc trong đặc tả API đã dùng gạch dưới (assignee_id, due_from). Trộn hai kiểu trong cùng một API là nguồn lỗi lặt vặt kéo dài.
Xử lý lỗi
Một lớp lỗi duy nhất, kèm mã và thông điệp tiếng Việt:
export class AppError extends Error {
constructor(
readonly httpStatus: number,
readonly code: ErrorCode, // khớp bảng mã lỗi trong đặc tả API
readonly viMessage: string, // hiển thị thẳng cho người dùng
readonly details?: Record<string, unknown>,
) { super(`${code}: ${viMessage}`); }
}
Ba quy tắc:
- Không ném chuỗi, không ném
Errortrần. Luôn dùngAppError. - Không nuốt lỗi.
catchmà không xử lý gì thì đừngcatch. - Thông điệp cho người dùng không lộ chi tiết kỹ thuật. Không có tên bảng, không có câu truy vấn, không có vết ngăn xếp.
Toàn bộ chuỗi tiếng Việt để trong common/i18n/messages.vi.ts, không rải trong code — theo NFR-MAINT-05.
Bất đồng bộ
- Luôn
async/await, không dùng chuỗi.then() - Việc độc lập chạy song song bằng
Promise.all - Không bao giờ để
awaittrong vòng lặp khi các vòng độc lập nhau - Mọi lời hứa đều phải được
awaithoặc xử lý — bật quy tắcno-floating-promises
Các mẫu thiết kế được dùng
Chỉ liệt kê mẫu thật sự dùng trong dự án này, kèm chỗ dùng cụ thể.
Repository — tách truy cập dữ liệu
Dùng cho: mọi bảng có nghiệp vụ.
@Injectable()
export class TasksRepository {
constructor(private readonly prisma: PrismaService) {}
// Mọi hàm đọc công việc BẮT BUỘC nhận tham số phạm vi.
// Không có phiên bản nào lấy tất cả — xem tài liệu kiến trúc.
findById(id: bigint, scope: Scope) {
return this.prisma.task.findFirst({ where: { id, ...taskScopeWhere(scope) } });
}
}
Vì sao: đổi cách truy vấn không lan ra tầng nghiệp vụ, và tập trung được quy tắc "luôn lọc theo phạm vi" vào một chỗ để rà.
Guard — chặn trước khi vào nghiệp vụ
Dùng cho: xác thực và phân quyền. Đây là cơ chế sẵn có của NestJS.
Hai guard nối tiếp, khai báo ở cấp mô-đun để không thể quên:
JwtAuthGuard → xác định người dùng từ phiên đăng nhập
ScopeGuard → tính tập phòng ban được phép, gắn vào yêu cầu
Thứ tự chạy thật của NestJS: Guard → Interceptor → Pipe → Controller. Guard chạy trước khi dữ liệu được kiểm tra định dạng.
Strategy — kênh gửi thông báo
Dùng cho: mở rộng sang Zalo ở giai đoạn sau mà không sửa code cũ.
interface NotificationChannel {
readonly code: DeliveryChannel;
send(n: Notification, to: User): Promise<DeliveryResult>;
}
@Injectable()
export class NotificationDeliveryService {
constructor(private readonly channels: NotificationChannel[]) {}
async deliver(n: Notification, to: User) {
for (const ch of this.channels) {
const result = await ch.send(n, to);
await this.repo.recordDelivery(n.id, ch.code, result); // notification_deliveries
}
}
}
Thêm Zalo = viết thêm một lớp và đăng ký vào mô-đun. Không sửa dòng nào của phần tạo thông báo.
Bảng luật thay cho chuỗi điều kiện
Dùng cho: luật chuyển trạng thái công việc.
Đây không phải mẫu thiết kế kinh điển, nhưng là quyết định quan trọng nhất về cấu trúc nghiệp vụ. Luật để trong dữ liệu, không để trong dòng lệnh:
const rule = TRANSITIONS[`${task.status}->${dto.to}`];
if (!rule) throw new AppError(409, 'TASK_INVALID_TRANSITION', M.invalidTransition(...));
Vì sao: đọc bảng là thấy ngay toàn bộ luật, đối chiếu trực tiếp với BR-05. Viết bằng if/else lồng nhau thì không ai dám sửa.
Đối tượng truyền dữ liệu và ánh xạ
Dùng cho: ranh giới vào ra.
Yêu cầu vào → DTO (kiểm tra bằng class-validator) → kiểu nghiệp vụ
Kiểu nghiệp vụ → hàm ánh xạ → hình dạng phản hồi (gạch dưới, id là chuỗi)
Không trả thẳng đối tượng của Prisma ra ngoài. Làm vậy sẽ lộ password_hash ngay lần đầu ai đó thêm quan hệ vào truy vấn.
Các mẫu cố tình KHÔNG dùng
Ghi ra để người sau không tưởng là thiếu sót:
| Mẫu | Vì sao không dùng |
|---|---|
| Kiến trúc CQRS | Đọc và ghi cùng một mô hình là đủ. Tách ra sẽ nhân đôi số lớp |
| Nguồn sự kiện | task_status_history đã cho đủ dấu vết cần thiết |
| Đơn vị công việc riêng | Giao dịch của Prisma đã làm việc đó |
Kiểu Result thay cho ném lỗi | Bộ lọc lỗi của NestJS đã đủ, và ném lỗi ngắn gọn hơn |
| Tiêm phụ thuộc qua interface cho mọi repository | Chỉ một cài đặt, thêm interface là thêm tệp không sinh giá trị |
| Đối tượng giá trị cho mọi khái niệm | bigint và string đủ dùng ở quy mô này |
Ràng buộc cơ sở dữ liệu — quy ước bắt buộc
Đây là điểm dễ sai nhất của dự án, phát hiện khi thử dựng code thật.
Prisma không tạo được 19 đối tượng mà thiết kế coi là chịu lực:
| Loại | Số lượng | Ví dụ |
|---|---|---|
| Ràng buộc kiểm tra | 10 | chk_task_status, chk_done_has_time |
| Duy nhất có điều kiện | 3 | uq_task_primary_assignee (ép BR-02) |
| Duy nhất theo hàm | 1 | uq_users_org_email — lower(email) |
| Chỉ mục có điều kiện | 5 | idx_tasks_due_open |
Nghĩa là: chỉ khai báo trong schema.prisma thì quy tắc một việc một người phụ trách không được cơ sở dữ liệu bảo vệ.
Quy ước
- Mỗi lần đổi cấu trúc, sinh khung bằng
prisma migrate dev --create-only - Mở tệp SQL vừa sinh và viết tay bổ sung các ràng buộc trên
- Không bao giờ chạy
prisma db push— lệnh này bỏ qua tệp chuyển đổi và xoá mất ràng buộc viết tay - Sau khi chạy chuyển đổi, chạy kịch bản kiểm tra để chắc 19 đối tượng còn nguyên
-- scripts/db/verify-constraints.sql — chạy sau mỗi lần chuyển đổi
SELECT conname FROM pg_constraint WHERE contype = 'c' AND conname LIKE 'chk_%'
UNION ALL
SELECT indexname FROM pg_indexes WHERE indexname LIKE 'uq_%' OR indexname LIKE 'idx_%';
-- Đối chiếu với danh sách trong tài liệu mô hình dữ liệu. Thiếu một dòng là chuyển đổi hỏng.
Việc kiểm tra này chạy trong quy trình tích hợp, không dựa vào trí nhớ.
Kiểm thử
Ở quy mô này, không đặt mục tiêu phần trăm bao phủ. Đặt mục tiêu theo chỗ dễ sai:
| Bắt buộc có kiểm thử | Vì sao |
|---|---|
| Bảng luật chuyển trạng thái | Trái tim nghiệp vụ, sai là hỏng dữ liệu |
| Cách tính trễ hạn (BR-08) | Dính múi giờ, ba trường hợp dễ nhầm |
| Tính phạm vi dữ liệu theo vai trò | Sai là rò dữ liệu giữa các phòng |
| Ánh xạ dữ liệu ra ngoài | Sai là lộ mã băm mật khẩu |
Không bắt buộc: controller chỉ gọi thẳng service, hàm ánh xạ đơn giản.
Một kiểm thử phải đọc được như một câu tiếng Việt:
it('nhân viên không chuyển thẳng việc sang Hoàn thành được', ...)
it('việc nộp trước hạn mà chưa duyệt thì không tính là nhân viên trễ', ...)
it('trưởng phòng phòng A không xem được việc của phòng B', ...)
Bảng kiểm trước khi gửi duyệt mã
- Không có
anynào không kèm giải thích -
PrismaClientkhông xuất hiện ngoài tầng repository - Mọi hàm đọc công việc đều nhận tham số phạm vi
- Không trả thẳng đối tượng Prisma ra ngoài
- Chuỗi hiển thị nằm trong tệp thông điệp, không rải trong code
- Lỗi ném ra là
AppErrorcó mã và câu tiếng Việt - Đổi cấu trúc bảng thì tệp chuyển đổi có phần ràng buộc viết tay
- Thêm trạng thái hoặc loại mới thì sửa cả kiểu hợp lẫn ràng buộc kiểm tra