Chuyển tới nội dung chính

Chuẩn lập trình

Tài liệu kỹ thuật

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ệcThuộ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.tsController
Quyết định ai được chuyển bước đóTasksServiceRules
Tính tập phòng ban được xemScopeServiceTừng service nghiệp vụ
Tạo nội dung thông báoTaskNotificationServiceTasksService
Gửi thông báo điNotificationDeliveryServiceNơi tạo thông báo
Đọc ghi cơ sở dữ liệuTasksRepositoryService

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ớiCách làm
Kênh gửi thông báoThêm Zalo ở giai đoạn sauInterface NotificationChannel, thêm lớp mới, không sửa lớp cũ
Định dạng xuất báo cáoCó thể thêm PDFInterface ReportExporter
Luật chuyển trạng tháiThêm bước duyệt nhiều cấpBả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ộcKhông được
ControllerServicePrisma, cơ sở dữ liệu
ServiceRepository, interface của hạ tầngPrismaClient trực tiếp
RepositoryPrisma

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 đó.

Ngoại lệ đã biết: giao dịch

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

  • interface cho hình dạng đối tượng sẽ được cài đặt hoặc mở rộng
  • type cho mọi thứ còn lại: hợp, giao, kiểu tiện ích, kiểu hàm

nullundefined

Ý nghĩaDù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ạiundefined

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ạiQuy ướcVí dụ
Tệpchữ thường, gạch ngangtask-transition.rules.ts
Lớp, interface, kiểuHoa đầu từTasksService, TaskStatus
Biến, hàmthường đầu, hoa các từ sauoverdueWhere, todayVN
Hằng sốHOA_GẠCH_DƯỚIPAGE_SIZE_MAX
Trường trong JSON trao đổigạch dướiassignee_id, due_date
Thuộc tính trong codethường đầu, hoa các từ sauassigneeId, dueDate
Quy ước đặt tên JSON — đã chốt

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:

  1. Không ném chuỗi, không ném Error trần. Luôn dùng AppError.
  2. Không nuốt lỗi. catch mà không xử lý gì thì đừng catch.
  3. 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ờ để await trong vòng lặp khi các vòng độc lập nhau
  • Mọi lời hứa đều phải được await hoặc xử lý — bật quy tắc no-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ẫuVì 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ệntask_status_history đã cho đủ dấu vết cần thiết
Đơn vị công việc riêngGiao dịch của Prisma đã làm việc đó
Kiểu Result thay cho ném lỗiBộ 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 repositoryChỉ 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ệmbigintstring đủ 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ạiSố lượngVí dụ
Ràng buộc kiểm tra10chk_task_status, chk_done_has_time
Duy nhất có điều kiện3uq_task_primary_assignee (ép BR-02)
Duy nhất theo hàm1uq_users_org_emaillower(email)
Chỉ mục có điều kiện5idx_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

  1. Mỗi lần đổi cấu trúc, sinh khung bằng prisma migrate dev --create-only
  2. Mở tệp SQL vừa sinh và viết tay bổ sung các ràng buộc trên
  3. 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
  4. 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áiTrá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àiSai 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ó any nào không kèm giải thích
  • PrismaClient khô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à AppError có 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