Khi một tính năng chạm tới cả chục file, AI agent giỏi nhất cũng sẽ tự mâu thuẫn với chính nó nếu bạn không kiểm soát thứ tự, ngữ cảnh và điểm kiểm chứng.
Thách thức cốt lõi khi generate nhiều file
Khi bạn yêu cầu một AI coding agent (như Claude Code, Cursor) tạo ra một tính năng trọn vẹn — ví dụ một module CRUD hoàn chỉnh — vấn đề không nằm ở việc agent có viết được code đúng cú pháp hay không. Vấn đề nằm ở việc agent phải giữ được một mô hình nhất quán về toàn bộ hệ thống trong suốt quá trình sinh ra hàng chục file, và đó chính là giới hạn thực sự của nó.
Giới hạn đầu tiên là context window (cửa sổ ngữ cảnh) — dung lượng thông tin mà mô hình có thể "nhớ" và tham chiếu trong một lượt xử lý. Với một feature nhỏ, toàn bộ schema, interface, và convention của dự án có thể nằm gọn trong context. Nhưng khi feature lớn dần, agent phải liên tục "quên" các quyết định đã đưa ra ở những file trước đó để nhường chỗ cho file đang viết. Hậu quả là agent tự mâu thuẫn: đặt tên field khác đi giữa file model và file DTO, đổi kiểu dữ liệu của một enum giữa service và controller, hoặc quên mất một convention xử lý lỗi đã thống nhất từ đầu.
Thách thức thứ hai là tính nhất quán về naming và import. Agent có xu hướng "tự sáng tác" — invent ra một tên hàm, một property, hoặc thậm chí một API hoàn toàn không tồn tại trong codebase, chỉ vì cái tên đó nghe hợp lý về mặt ngôn ngữ tự nhiên. Đây là một dạng hallucination (ảo giác — AI tự tin đưa ra thông tin sai) đặc biệt nguy hiểm trong lập trình đa file, vì lỗi này thường không lộ ra ngay ở file được sinh, mà chỉ vỡ lở khi một file khác cố gắng import hoặc gọi đến cái không tồn tại đó.
Thứ ba là rủi ro circular dependency (phụ thuộc vòng) — khi module A import từ module B, và B lại import ngược từ A. Agent, vì sinh code tuần tự theo yêu cầu của bạn, rất dễ tạo ra tình huống này nếu không được cung cấp bức tranh tổng thể về kiến trúc trước khi bắt tay viết từng file.
Cuối cùng, khó khăn lớn nhất là kiểm chứng đúng đắn xuyên biên giới file (cross-file correctness). Một file có thể compile được, test riêng lẻ có thể pass, nhưng tổng thể liên kết giữa các file — luồng dữ liệu từ database lên tới response — vẫn có thể sai. Không có cách nào để "đọc lướt" và biết chắc điều này; bạn buộc phải thiết kế quy trình sinh code sao cho có điểm kiểm chứng ở giữa, thay vì để agent generate toàn bộ 20 file rồi mới kiểm tra một lần ở cuối.
Mẹo: Trước khi giao một task đa file, hãy tự hỏi "context window của agent có đủ để chứa toàn bộ các quyết định thiết kế quan trọng không?" — nếu không, hãy tách nhỏ task hoặc cung cấp một file tóm tắt kiến trúc (architecture summary) để agent tham chiếu lại giữa các bước.
Chiến lược sắp xếp thứ tự generate
Thứ tự bạn yêu cầu agent sinh code không phải là chi tiết vụn vặt — nó quyết định trực tiếp độ chính xác và cả sự "tự tin" của model khi viết từng file. Có hai chiến lược đối lập, và việc chọn đúng chiến lược cho đúng tình huống là kỹ năng quan trọng.
Chiến lược bottom-up (từ dưới lên) bắt đầu từ những thứ ổn định nhất trước: type, interface, schema database, DTO (data transfer object). Đây là cách tiếp cận được khuyến nghị cho phần lớn các feature CRUD hoặc business logic có cấu trúc dữ liệu rõ ràng. Lý do: một khi type và schema đã "chốt", mọi file phía trên (repository, service, controller) đều có một hợp đồng (contract) cụ thể để tuân theo. Agent không còn phải đoán field nào tồn tại, kiểu dữ liệu nào đúng — nó chỉ cần implement dựa trên contract đã có sẵn. Điều này giảm mạnh hallucination vì agent luôn có thể "nhìn lại" định nghĩa type thay vì tự bịa ra.
Chiến lược top-down (từ trên xuống) thì bắt đầu từ entry point — route, controller, hoặc component UI cấp cao nhất — rồi generate dần các lớp phụ thuộc bên dưới. Cách này phù hợp khi bạn đang khám phá một use case mới, muốn nhìn thấy trải nghiệm/API trước rồi mới quyết định cấu trúc dữ liệu bên dưới nên như thế nào. Nhược điểm là agent dễ tạo ra các "lời hứa" (gọi hàm chưa tồn tại, giả định field sẽ có) mà sau đó phải quay lại chỉnh sửa nhiều lớp bên dưới để khớp.
Trong thực tế, cách hiệu quả nhất với các feature phức tạp là kết hợp: dùng phương pháp "scaffold-then-fill" — tạo trước một bộ khung rỗng (scaffold) gồm toàn bộ file cần có, với type, interface, và signature hàm rõ ràng nhưng thân hàm để TODO hoặc throw new Error("not implemented"). Sau đó mới yêu cầu agent lần lượt "fill" từng file. Cách này buộc agent phải cam kết (commit) vào một kiến trúc tổng thể ngay từ đầu, giảm khả năng nó tự thay đổi ý định giữa chừng, đồng thời cho bạn cơ hội review kiến trúc trước khi bất kỳ logic thật nào được viết ra — rẻ hơn nhiều so với review sau khi code đã đầy đủ.
Ví dụ prompt thực tế để bắt đầu theo bottom-up:
We're building a "Product" CRUD feature. Do NOT write implementation logic yet.
Step 1: Generate only the following, in this order:
1. `src/types/product.types.ts` — Product entity type, CreateProductDto, UpdateProductDto
2. `src/db/schema/product.schema.ts` — Postgres table schema matching the above types
Stop after these two files and show me the result before continuing.
Mẹo: Với feature có domain logic phức tạp (nhiều state, nhiều rule nghiệp vụ), hãy chọn bottom-up. Với feature UI-first hoặc thử nghiệm nhanh (prototype), top-down tiết kiệm thời gian hơn — nhưng luôn lên kế hoạch dọn dẹp lại contract sau đó.
Hướng dẫn agent duy trì tính nhất quán xuyên file
Muốn agent không "lạc trôi" giữa hàng chục file, bạn phải chủ động cung cấp các ràng buộc rõ ràng thay vì hy vọng nó tự suy luận đúng convention của team. Đây là nơi kỹ năng prompt engineering thể hiện rõ giá trị nhất trong lập trình đa file.
Kỹ thuật đầu tiên và hiệu quả nhất là dùng file có sẵn làm "style reference" (tham chiếu phong cách). Thay vì mô tả bằng lời convention đặt tên, xử lý lỗi, cấu trúc thư mục, hãy trỏ thẳng agent vào một file thực tế trong repo đã tuân theo đúng convention đó, và yêu cầu nó bắt chước 1:1. Model xử lý ví dụ cụ thể tốt hơn nhiều so với mô tả trừu tượng.
Before writing any code, read `src/modules/order/order.service.ts` and
`src/modules/order/order.controller.ts` as the canonical style reference
for this codebase: naming convention, error handling (we throw `AppError`
subclasses, never raw `Error`), and the repository pattern used.
Now generate the equivalent files for the "Product" module, following
the exact same structure, naming pattern, and error-handling approach.
Kỹ thuật thứ hai là cung cấp một "manifest" — danh sách tường minh toàn bộ file mà agent sẽ động tới, kèm vai trò của từng file. Điều này giúp agent hình dung được toàn cảnh trước khi viết dòng code đầu tiên, giảm khả năng nó quên mất một file cần cập nhật (ví dụ quên đăng ký route mới vào router chính, hoặc quên export type mới ra index.ts).
Manifest for this task — files you will create or modify, in order:
1. src/types/product.types.ts (new)
2. src/db/schema/product.schema.ts (new)
3. src/repositories/product.repository.ts (new)
4. src/services/product.service.ts (new)
5. src/controllers/product.controller.ts (new)
6. src/routes/index.ts (MODIFY — register new product routes)
7. src/dto/product.dto.ts (new)
Confirm you understand this manifest, then summarize your plan for each
file in 1-2 sentences BEFORE writing any code.
Yêu cầu agent tóm tắt kế hoạch trước khi thực thi là bước quan trọng thường bị bỏ qua. Việc bắt agent "nói ra" kế hoạch bằng ngôn ngữ tự nhiên trước khi viết code hoạt động như một bước tự-kiểm-tra (self-check): nếu kế hoạch nó tóm tắt có chỗ sai hoặc thiếu, bạn phát hiện và sửa ngay ở giai đoạn rẻ nhất — trước khi có dòng code nào được viết ra — thay vì phải phát hiện qua việc đọc diff của 8 file sau đó.
Cuối cùng, đừng ngại lặp lại các ràng buộc quan trọng ở mỗi bước, kể cả khi bạn đã nói ở bước trước. Agent xử lý mỗi lượt như một "vòng" tương đối độc lập trong bối cảnh dài; nhắc lại constraint cốt lõi (ví dụ: "always use the Result<T, E> pattern, never throw for expected errors") ở đầu mỗi prompt lớn giúp giảm drift đáng kể.
Mẹo: Giữ một file
CONVENTIONS.mdngắn gọn trong repo (hoặc trong CLAUDE.md/AGENTS.md) liệt kê các quy tắc bắt buộc — cách xử lý lỗi, cách đặt tên, pattern nào dùng, pattern nào cấm — và yêu cầu agent đọc file này ở đầu mỗi task lớn. Đỡ phải gõ lại mỗi lần.
Dùng TypeScript và type-checking làm công cụ kiểm chứng
Một trong những đòn bẩy mạnh nhất khi làm việc với AI agent trong dự án TypeScript là biến trình biên dịch (compiler) thành một vòng phản hồi tự động, khép kín. Đây không chỉ là công cụ kiểm tra lỗi cho con người — nó là "trọng tài" khách quan mà chính agent có thể dùng để tự sửa lỗi của mình, không cần bạn can thiệp thủ công.
Cách vận hành cụ thể: sau khi agent sinh xong một loạt file, chạy tsc --noEmit (kiểm tra type mà không xuất file build) hoặc bật strict: true trong tsconfig.json để bắt được tối đa lỗi. Toàn bộ output lỗi — với đường dẫn file, số dòng, và message cụ thể — được đưa ngược lại cho agent kèm yêu cầu tự sửa. Đây chính là vòng lặp "generate → verify → self-correct" mà không cần bạn đọc từng dòng code để tìm lỗi bằng mắt.
Run `npx tsc --noEmit` and here is the output:
src/services/product.service.ts:24:5 - error TS2322: Type 'string' is not
assignable to type 'number'.
src/controllers/product.controller.ts:41:12 - error TS2345: Argument of
type 'CreateProductDto' is not assignable to parameter of type 'Product'.
Fix these type errors. Do not change the shared type definitions in
product.types.ts — fix the implementation to match the existing contract.
Điểm mấu chốt trong prompt trên là câu cuối: "đừng sửa type definition, hãy sửa implementation cho khớp." Nếu không nhắc rõ, agent có xu hướng chọn đường tắt — nới lỏng type (thêm any, đổi type gốc cho khớp với chỗ sai) thay vì sửa đúng chỗ logic bị lệch. Type chung (shared type) đóng vai trò như một hợp đồng bất biến — nó chính là cơ chế ràng buộc (constraint) giúp giữ toàn bộ các file liên quan nhất quán với nhau, ngay cả khi agent không còn "nhớ" toàn bộ ngữ cảnh của các file khác trong context window.
Kỹ thuật này không chỉ giới hạn ở TypeScript. Với Python, mypy hoặc pyright đóng vai trò tương đương. Với Go, việc go build thất bại đã tự nhiên là một vòng kiểm chứng vì ngôn ngữ có type tĩnh mạnh. Với các ngôn ngữ ít strict hơn, ESLint kèm các rule type-aware (@typescript-eslint với parserOptions.project), hoặc linter tĩnh phân tích luồng dữ liệu, có thể đóng vai trò gần tương tự — dù không mạnh bằng type-checker thực sự. Nguyên tắc chung: bất kỳ công cụ nào có thể trả về lỗi có cấu trúc (file, dòng, message rõ ràng) đều có thể trở thành một "cảm biến" (feedback signal) để đưa ngược vào agent.
Một điều quan trọng cần nhớ: type-checking bắt được lỗi về hình dạng dữ liệu (shape), không bắt được lỗi logic nghiệp vụ. Code có thể pass tsc --noEmit hoàn toàn nhưng vẫn tính sai giá tiền hoặc query sai điều kiện. Vì vậy type-check nên là lớp kiểm chứng đầu tiên, nhanh và rẻ — không phải lớp duy nhất.
Mẹo: Thiết lập một lệnh duy nhất (ví dụ
npm run verifychạytsc --noEmit && eslint . && vitest run) và yêu cầu agent tự chạy lệnh này sau mỗi file quan trọng thay vì đợi bạn nhắc — hầu hết agent hiện đại (Claude Code, Cursor) có thể tự thực thi và đọc output.
Phân rã feature lớn thành task theo từng file
Một feature lớn — như toàn bộ một module quản lý đơn hàng — không nên được giao cho agent như một câu lệnh mơ hồ duy nhất ("build the order management feature"). Cách làm chuyên nghiệp là phân rã nó thành một danh sách task cụ thể, mỗi task ánh xạ 1:1 hoặc vài-task:1-file, có thứ tự phụ thuộc rõ ràng, và có điểm checkpoint để dừng lại kiểm tra.
Bước đầu tiên là viết một file task/spec — có thể là markdown thuần, hoặc theo format spec-driven development nếu team bạn đã áp dụng (xem thêm ở Module 3). File này liệt kê từng file cần tạo, mục đích của nó, input/output kỳ vọng, và phụ thuộc vào file nào khác. Đây không phải overhead thừa — nó chính là artifact giúp cả agent và con người review cùng một checklist.
## Task: Product CRUD module
- [ ] 1. `product.types.ts` — Product, CreateProductDto, UpdateProductDto
(no dependency)
- [ ] 2. `product.schema.ts` — DB schema, depends on (1)
- [ ] 3. `product.repository.ts` — CRUD data access, depends on (1), (2)
- [ ] 4. `product.service.ts` — business logic + validation, depends on (3)
- [ ] 5. `product.controller.ts` — HTTP layer, depends on (4)
- [ ] 6. `product.routes.ts` — wiring, depends on (5)
- [ ] 7. `product.service.test.ts` — unit tests, depends on (4)
Thứ tự phụ thuộc (dependency ordering) ở đây không chỉ để "cho gọn" — nó phản ánh chính xác thứ tự mà agent nên generate để tránh forward-reference tới thứ chưa tồn tại. Khi giao task cho agent, hãy giao từng nhóm nhỏ (1-3 file có liên quan chặt) thay vì cả danh sách 7 mục cùng lúc, và sau mỗi nhóm, chạy checkpoint: type-check, chạy test liên quan, đọc lướt diff.
Checkpoint giữa các bước generate quan trọng không kém bản thân việc phân rã. Một sai lầm phổ biến là phân rã task rất tốt trên giấy, nhưng sau đó vẫn để agent chạy hết cả 7 file liên tục không dừng — lúc đó bạn quay lại đúng vấn đề ban đầu: lỗi tích lũy không bị phát hiện sớm. Nguyên tắc thực dụng: checkpoint sau mỗi lớp kiến trúc (architecture layer) — sau khi xong data layer, sau khi xong service layer, sau khi xong presentation layer — chứ không nhất thiết sau từng file riêng lẻ.
Với các tính năng thực sự lớn (nhiều resource, nhiều màn hình), nên tổ chức file task theo module con, và cân nhắc giao mỗi module con cho một "phiên" agent riêng (fresh context) thay vì kéo dài một phiên duy nhất qua hàng chục file — vì càng về sau trong một phiên dài, hiệu ứng "quên" quyết định cũ càng rõ.
Mẹo: Đặt checkbox (
- [ ]/- [x]) ngay trong file task, và yêu cầu agent tự tick khi hoàn thành mỗi mục — đây là cách rẻ tiền để bạn (và agent, nếu quay lại phiên sau) biết chính xác đã làm tới đâu.
Hands-On: Generate một module CRUD hoàn chỉnh
Phần này đi qua từng bước cụ thể để dựng một resource "Product" hoàn chỉnh trong stack Node.js + Express + TypeScript + PostgreSQL, áp dụng toàn bộ nguyên tắc đã nói ở trên: bottom-up ordering, style reference, checkpoint bằng type-check, và task decomposition.
Bước 0 — Chuẩn bị. Đảm bảo bạn đã có ít nhất một module tương tự trong repo (ví dụ order hoặc user) để làm style reference, và tsconfig.json đã bật strict: true. Tạo file task tại docs/tasks/product-crud.md theo mẫu ở section trước.
Bước 1 — Types và schema (bottom-up, layer nền tảng).
Read src/modules/order/order.types.ts and src/db/schema/order.schema.ts
as style reference. Now create:
1. src/modules/product/product.types.ts — Product entity (id, name, price,
stock, categoryId, createdAt, updatedAt), CreateProductDto (no id/timestamps),
UpdateProductDto (all fields optional except nothing required)
2. src/db/schema/product.schema.ts — Postgres table `products`, matching
the types above, with a foreign key to `categories.id`
Follow the exact naming and schema style of the order module. Stop here.
Kiểm tra: đọc lại 2 file, xác nhận field khớp với business requirement thật (ví dụ có cần sku không, có cần deletedAt cho soft-delete không). Đây là lúc rẻ nhất để sửa — trước khi bất kỳ layer nào khác phụ thuộc vào nó.
Bước 2 — Repository layer.
Using product.types.ts and product.schema.ts as the contract, create
src/modules/product/product.repository.ts with methods: create, findById,
findAll (with pagination), update, delete (soft delete — set deletedAt).
Follow the repository pattern in src/modules/order/order.repository.ts
exactly (same query builder, same error wrapping).
Run `npx tsc --noEmit` after and fix any type errors before finishing.
Pitfall thường gặp ở bước này: agent quên áp dụng soft-delete convention nếu order module dùng hard delete — đây chính là lý do style reference phải khớp đúng pattern bạn thực sự muốn, không phải pattern "gần đúng".
Bước 3 — Service layer (business logic + validation).
Create src/modules/product/product.service.ts. It depends on
product.repository.ts (step 2). Business rules:
- price must be > 0
- stock must be >= 0
- categoryId must reference an existing category (check via CategoryRepository)
Throw `ValidationError` (from src/errors) for rule violations, never raw Error.
Follow order.service.ts for error-handling and logging conventions.
Run tsc --noEmit and `npm test -- product.service` after.
Bước 4 — DTO và validation schema (nếu dùng Zod/class-validator).
Create src/modules/product/product.validation.ts using Zod, mirroring the
style of src/modules/order/order.validation.ts. Export
createProductSchema and updateProductSchema matching CreateProductDto /
UpdateProductDto exactly. If the Zod schema and the TS type ever diverge,
flag it — do not silently pick one.
Bước 5 — Controller và route.
Manifest for this step:
1. src/modules/product/product.controller.ts (new)
2. src/routes/index.ts (MODIFY — register product routes at /api/products)
Create product.controller.ts following order.controller.ts's pattern
(async handler wrapper, standard response envelope). Then modify
src/routes/index.ts to register the new routes. Show me a summary of the
routes.ts change before applying it since it's a shared file.
Checkpoint sau bước 5: chạy tsc --noEmit toàn repo (không chỉ file mới) — đây là lúc dễ phát hiện agent có làm hỏng import ở routes/index.ts hay không, vì đây là file dùng chung, rủi ro conflict cao nhất trong cả task.
Bước 6 — Test.
Write unit tests for product.service.ts using vitest, following the
structure of order.service.test.ts (arrange-act-assert, mock repository
via vi.fn()). Cover: happy path create/update/delete, price <= 0 rejection,
invalid categoryId rejection. Run the tests and paste me the result.
Kết quả kỳ vọng: 7 file mới/sửa, tsc --noEmit sạch, test suite pass, và route mới gọi được qua Postman/curl (curl -X POST localhost:3000/api/products -d '{"name":"Test","price":10,"stock":5,"categoryId":1}'). Nếu bất kỳ checkpoint nào ở giữa fail, dừng lại sửa ngay tại đó — đừng để agent generate tiếp lên trên một layer đã sai.
Pitfall tổng kết cả bài hands-on: lỗi phổ biến nhất không nằm ở từng file riêng lẻ mà ở "điểm nối" — route chưa được đăng ký, DTO lệch với type gốc, service quên gọi validation trước khi ghi database. Đây đúng là các vấn đề mà toàn bộ 5 phần lý thuyết ở trên nhắm tới giải quyết.
Mẹo: Sau khi hoàn thành cả module, yêu cầu agent tự viết một đoạn tóm tắt "what changed and why" cho commit message hoặc PR description — agent vừa generate code nên nó là nguồn thông tin tốt nhất để giải thích chính các quyết định nó đã đưa ra.
Những điểm chính
- Giới hạn context window khiến agent dễ "quên" quyết định cũ khi sinh code qua nhiều file — cần chủ động quản lý bằng manifest, file convention, và checkpoint.
- Chọn đúng thứ tự generate: bottom-up (type/schema trước) cho feature có domain logic phức tạp; top-down cho prototype nhanh; kết hợp bằng scaffold-then-fill cho feature lớn.
- Style reference cụ thể (trỏ agent vào file thật trong repo) hiệu quả hơn nhiều so với mô tả convention bằng lời; luôn yêu cầu agent tóm tắt kế hoạch trước khi viết code.
- Type-checking (
tsc --noEmit, mypy, go build) là vòng phản hồi tự động rẻ nhất để agent tự sửa lỗi hình dạng dữ liệu — nhưng không thay thế được test cho lỗi logic nghiệp vụ. - Phân rã feature lớn thành task 1:1 với file, có thứ tự phụ thuộc và checkpoint theo layer, giúp phát hiện lỗi sớm thay vì dồn hết vào cuối.
- Trong ví dụ CRUD thực tế, phần lớn lỗi nằm ở "điểm nối" giữa các file (route chưa đăng ký, DTO lệch type) chứ không nằm trong logic từng file riêng lẻ — đây là lý do kiểm chứng xuyên file quan trọng hơn kiểm chứng từng file.