Đây là bài thực hành trọn vẹn nhất của module: bạn sẽ cùng biến một "god function" 600 dòng — thứ mà đội nào cũng né tránh đụng vào — thành một kiến trúc validator / repository / service rõ ràng, có test bao phủ, mà không làm gãy một dòng nghiệp vụ nào.
Vì sao refactor quy mô lớn thường thất bại (và AI thay đổi điều đó thế nào)
Hầu hết kỹ sư senior đều từng chứng kiến ít nhất một lần: một khối code khổng lồ, chạy đúng trong production suốt nhiều năm, nhưng không ai dám sửa vì "không biết nó còn phụ thuộc vào cái gì". Refactor quy mô lớn (large-scale refactor) thất bại không phải vì đội ngũ thiếu kỹ năng, mà vì bài toán này có một tổ hợp rủi ro rất đặc trưng.
Thứ nhất là kiểu tiếp cận "big-bang rewrite" — viết lại toàn bộ từ đầu trong một nhánh riêng, rồi merge một phát ăn ngay. Cách này gần như luôn thất bại vì phạm vi thay đổi quá lớn để review, quá lớn để test, và quá lớn để rollback an toàn nếu có sự cố. Thứ hai, và nghiêm trọng hơn, là refactor trên một codebase không có safety net — không có test nào pin (ghim) lại hành vi hiện tại, nên không ai biết chắc "sửa xong có còn giống cũ không". Thứ ba là mất mát business logic tinh vi: những đoạn code cũ thường chứa vô số nhánh if-else xử lý edge case mà không ai còn nhớ lý do tồn tại — một khách hàng VIP nào đó, một loại mã giảm giá đã ngừng bán nhưng vẫn còn hiệu lực với đơn cũ, một quy tắc làm tròn tiền tệ đặc biệt cho một quốc gia. Refactor "dọn dẹp" theo trực giác rất dễ vô tình xóa mất các quy tắc này. Thứ tư là scope creep: bắt đầu từ "tách một hàm" rồi lan sang "đổi luôn ORM", "đổi luôn kiến trúc thư mục" — và cuối cùng không bao giờ xong. Kết quả của bốn yếu tố này cộng lại là nỗi sợ tập thể (team fear): ai cũng biết đoạn code cần refactor, nhưng nó cứ bị đẩy lùi vô thời hạn trong backlog.
AI agent thay đổi hẳn bài toán kinh tế học (economics) của việc này. Trước đây, viết characterization test (test đặc tả lại hành vi hiện có) cho một hàm 600 dòng có thể tốn cả tuần công sức thủ công — không ai đủ kiên nhẫn để làm, nên người ta bỏ qua bước đó và refactor "liều". Với agentic coding tool, việc đọc toàn bộ hàm, liệt kê mọi nhánh logic, và sinh ra bộ test pin hành vi chỉ mất vài chục phút. Khi chi phí của "làm đúng quy trình" giảm xuống gần bằng chi phí của "làm ẩu", refactor an toàn trở thành lựa chọn mặc định thay vì lựa chọn xa xỉ. Bài viết này sẽ đi qua toàn bộ quy trình đó, từng bước một, trên một ví dụ thực tế.
Mẹo: Trước khi bắt tay refactor bất kỳ god function nào, hãy tự hỏi "Nếu tôi xóa sạch hàm này ngay bây giờ, hệ thống CI có báo đỏ ở đâu không?" — nếu câu trả lời là "không đâu cả", bạn chưa có safety net và chưa nên refactor.
Bối cảnh: Một route handler 600 dòng
Để bài học có tính thực chiến, chúng ta sẽ dùng xuyên suốt một ví dụ duy nhất: route handler POST /orders/checkout trong một backend Node.js/Express + TypeScript của một hệ thống thương mại điện tử. Đây là kiểu code rất quen thuộc — nó bắt đầu như một hàm nhỏ xử lý đơn hàng, rồi qua nhiều sprint, nhiều feature request gấp gáp, nó phình to thành 600 dòng làm tất cả mọi thứ: validate input thủ công, gọi thẳng vào database bằng raw SQL/ORM query, tính toán giá và áp dụng mã giảm giá, kiểm tra tồn kho, gọi payment gateway, ghi log, và định dạng response — tất cả trong một hàm callback duy nhất.
Dưới đây là một đoạn trích tiêu biểu (không phải toàn bộ 600 dòng) để bạn hình dung mức độ trộn lẫn trách nhiệm (mixed responsibilities):
router.post('/orders/checkout', async (req, res) => {
// --- Validation trộn lẫn ngay đầu handler ---
const { userId, items, couponCode, shippingAddress, paymentMethodId } = req.body;
if (!userId || typeof userId !== 'string') {
return res.status(400).json({ error: 'userId is required' });
}
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: 'items must be a non-empty array' });
}
for (const item of items) {
if (!item.sku || typeof item.quantity !== 'number' || item.quantity <= 0) {
return res.status(400).json({ error: `invalid item: ${JSON.stringify(item)}` });
}
}
if (!shippingAddress || !shippingAddress.country || !shippingAddress.postalCode) {
return res.status(400).json({ error: 'shippingAddress is incomplete' });
}
// --- Truy vấn DB trực tiếp ---
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
if (user.rows.length === 0) {
return res.status(404).json({ error: 'user not found' });
}
let subtotal = 0;
const lineItems = [];
for (const item of items) {
const product = await db.query('SELECT * FROM products WHERE sku = $1', [item.sku]);
if (product.rows.length === 0) {
return res.status(400).json({ error: `product not found: ${item.sku}` });
}
const p = product.rows[0];
if (p.stock_quantity < item.quantity) {
return res.status(409).json({ error: `insufficient stock for ${item.sku}` });
}
const lineTotal = p.price_cents * item.quantity;
subtotal += lineTotal;
lineItems.push({ sku: item.sku, quantity: item.quantity, unitPrice: p.price_cents, lineTotal });
}
// --- Logic nghiệp vụ: coupon, thuế, phí ship ---
let discount = 0;
if (couponCode) {
const coupon = await db.query('SELECT * FROM coupons WHERE code = $1 AND active = true', [couponCode]);
if (coupon.rows.length > 0) {
const c = coupon.rows[0];
if (c.type === 'PERCENT') {
discount = Math.floor(subtotal * (c.value / 100));
} else if (c.type === 'FIXED') {
discount = Math.min(c.value, subtotal);
}
// Quy tắc ẩn: coupon FREESHIP không giảm subtotal mà chỉ set 1 flag dùng ở dưới
}
}
const shippingFee = shippingAddress.country === 'VN' ? 0 : 150000;
const taxableAmount = subtotal - discount;
const tax = Math.round(taxableAmount * 0.1);
const total = taxableAmount + tax + shippingFee;
// ... (còn khoảng 400 dòng nữa: gọi payment gateway, cập nhật tồn kho,
// insert order + order_items trong transaction, gửi email, format response)
});
Chỉ với đoạn trích này, bạn đã thấy ít nhất năm trách nhiệm khác nhau bị trộn vào một chỗ: validate input, truy vấn dữ liệu, tính giá, áp dụng khuyến mãi, và xử lý địa lý cho phí ship. Và đáng chú ý nhất là dòng comment "Quy tắc ẩn" — đây chính xác là loại chi tiết nghiệp vụ dễ bị đánh rơi nhất khi refactor theo cảm tính. Suốt bài viết này, chúng ta sẽ tách dần hàm này ra thành các layer riêng biệt: validator, repository (lớp truy cập dữ liệu), service (lớp nghiệp vụ), và cuối cùng compose lại bằng dependency injection (tiêm phụ thuộc) trong controller.
Mẹo: Khi tìm một ví dụ thật để luyện tập kỹ năng này trong công việc, hãy chọn route/handler nào có review comment kiểu "đừng đụng vào, không ai hiểu nó nữa" — đó chính là ứng viên lý tưởng và cũng là nơi AI agent tạo ra giá trị lớn nhất.
Phase 1: Characterization — Hiểu trước khi thay đổi
Nguyên tắc sống còn của refactor an toàn: không được thay đổi code trước khi hiểu và ghi lại đầy đủ hành vi hiện tại của nó. Đây là bước mà hầu hết các đợt refactor thủ công bỏ qua, vì làm characterization (đặc tả hành vi hiện có) thủ công cho một hàm 600 dòng cực kỳ tốn thời gian và dễ nản. Nhưng bỏ qua bước này đồng nghĩa với việc bạn refactor "bằng cảm giác" — và cảm giác thì không phát hiện được quy tắc ẩn như dòng "coupon FREESHIP" ở trên.
Characterization gồm hai sản phẩm đầu ra: (1) một tài liệu mô tả bằng ngôn ngữ tự nhiên về input, output, side effect (tác dụng phụ như ghi DB, gọi API ngoài), edge case, và các quy tắc nghiệp vụ ngầm định; và (2) một bộ characterization test — test không kiểm tra "đúng theo spec" mà kiểm tra "đúng như hành vi thực tế hiện tại", kể cả những chỗ hành vi đó trông có vẻ là bug. Mục đích của characterization test không phải để đánh giá code tốt hay xấu, mà để tạo ra một "tấm lưới an toàn" — nếu sau này bạn tách code ra nhiều layer mà một test nào đó đỏ, bạn biết ngay hành vi đã bị thay đổi ngoài ý muốn.
Ở phase này, AI agent phát huy giá trị rõ rệt nhất: nó có thể đọc toàn bộ 600 dòng trong vài giây, lần theo từng nhánh điều kiện, và liệt kê ra những thứ mà một lập trình viên đọc lướt rất dễ bỏ sót.
Bước 1: Sinh tài liệu characterization
Vì sao cần làm: Trước khi viết bất kỳ test nào, bạn cần một bản đồ đầy đủ về hàm — nếu không, bạn sẽ chỉ test được những nhánh mình "nhớ ra", còn AI agent có thể quét toàn bộ đường đi của luồng thực thi (execution path) một cách có hệ thống, kể cả các nhánh lỗi ít khi được để ý.
Prompt ví dụ để dán vào agent (Claude Code hoặc công cụ tương đương):
Read the full implementation of the POST /orders/checkout route handler in
src/routes/orders.ts (lines 1-612). Do not modify any code yet.
Produce a characterization document in Markdown with these sections:
1. Inputs — every field read from req.body, req.params, req.headers, with
type and whether it is required or optional.
2. Outputs — every possible HTTP status code and response body shape,
listing the exact condition that triggers each one.
3. Side effects — every database read, database write, external API call
(payment gateway, email service), and in what order they happen.
4. Implicit business rules — any behavior that is not obvious from a
surface read, e.g. special-cased coupon types, country-based shipping
rules, rounding behavior, or anything commented as a workaround.
5. Edge cases and error paths — empty arrays, missing fields, race
conditions on stock, duplicate coupon usage, partial failures (e.g.
payment succeeds but DB insert fails).
Be exhaustive. If something is ambiguous or looks like a possible bug,
flag it explicitly instead of guessing what it "should" do.
Với ví dụ của chúng ta, tài liệu characterization sinh ra sẽ phải ghi rõ được những điều như: "Coupon loại FREESHIP không làm giảm subtotal, chỉ set cờ miễn phí ship — khác với coupon PERCENT/FIXED", hay "Nếu shippingAddress.country === 'VN', shippingFee luôn bằng 0 bất kể coupon", hay "Thứ tự kiểm tra tồn kho xảy ra tuần tự theo từng item trong mảng items, nghĩa là item đứng sau có thể bị từ chối dù còn hàng, nếu tổng các item trước đã trừ hết một sản phẩm dùng chung SKU giả định — cần xác nhận đây có phải hành vi mong muốn".
Cần kiểm tra trước khi qua bước tiếp theo: Đọc lại tài liệu này cùng với ít nhất một người hiểu nghiệp vụ (product owner hoặc dev đã maintain lâu năm) để xác nhận agent không bỏ sót nhánh nào, và đánh dấu rõ ràng phần nào là "quy tắc cố ý" vs "có thể là bug". Đừng bỏ qua bước review người này — đây là nơi đầu tiên và quan trọng nhất mà human oversight bắt buộc phải có mặt.
Bước 2: Sinh characterization test
Vì sao cần làm: Tài liệu ở Step 1 mới chỉ là mô tả bằng lời — cần biến nó thành test tự động chạy được, để mọi thay đổi sau này đều được máy móc xác minh thay vì phải đọc lại tài liệu bằng mắt mỗi lần.
Prompt ví dụ:
Using the characterization document above, write a suite of characterization
tests for the current POST /orders/checkout handler in
src/routes/orders.test.ts, using Jest and supertest.
Requirements:
- Mock the database layer (pg Pool) and the payment gateway client so no
real I/O happens.
- Cover every status code and response shape listed in the document.
- Include at least one test per implicit business rule identified,
including the FREESHIP coupon behavior and the VN free-shipping rule.
- Name each test after the behavior it pins down, not after the
implementation detail, e.g. "returns 409 when any item has insufficient
stock" rather than "test case 3".
- Do not assert on things the document flagged as ambiguous/possible bugs
without a comment noting the ambiguity.
These tests describe CURRENT behavior, including any quirks — do not
"fix" anything.
Một test mẫu mà agent có thể sinh ra:
describe('POST /orders/checkout (characterization)', () => {
it('applies FREESHIP coupon as a shipping waiver, not a subtotal discount', async () => {
mockDb.products.mockResolvedValueOnce({ rows: [{ sku: 'SKU1', price_cents: 100000, stock_quantity: 5 }] });
mockDb.coupons.mockResolvedValueOnce({ rows: [{ code: 'FREESHIP', type: 'FREESHIP', value: 0, active: true }] });
const res = await request(app).post('/orders/checkout').send({
userId: 'u1',
items: [{ sku: 'SKU1', quantity: 1 }],
couponCode: 'FREESHIP',
shippingAddress: { country: 'US', postalCode: '10001' },
paymentMethodId: 'pm_test',
});
expect(res.status).toBe(200);
expect(res.body.subtotal).toBe(100000); // unchanged
expect(res.body.shippingFee).toBe(0); // waived, not discounted from subtotal
});
});
Cần kiểm tra trước khi qua bước tiếp theo: Chạy toàn bộ suite này lên code hiện tại (chưa refactor gì) và đảm bảo 100% pass — nếu có test đỏ ngay từ đầu, nghĩa là agent hiểu sai hành vi, phải sửa lại test trước khi tiếp tục. Đây chính là baseline (đường mốc) mà mọi bước refactor tiếp theo phải giữ xanh.
Mẹo: Luôn commit riêng characterization document và characterization test suite thành một commit độc lập, trước khi đụng vào bất kỳ dòng logic nào — để sau này nếu cần, bạn có thể diff rõ ràng "đây là baseline, đây là phần đã refactor".
Phase 2: Xác định tầng và lập kế hoạch tách
Có bộ test bảo vệ rồi, bước tiếp theo là lập kế hoạch tách lớp (extraction plan). Đây là lúc bạn nhờ agent đóng vai trò kiến trúc sư: đọc lại cùng 600 dòng đó, nhưng lần này với câu hỏi khác — "Những đoạn code này thuộc về trách nhiệm (responsibility) nào, và ranh giới tự nhiên giữa các trách nhiệm nằm ở đâu?". Khái niệm quan trọng ở đây là seam (đường nối) — một điểm trong code mà tại đó bạn có thể "cắt" ra và thay thế một phần hành vi mà không phải sửa phần còn lại, ví dụ như thay lời gọi DB thật bằng một mock trong test.
Việc xác định layer không chỉ là phân loại code theo cảm tính "cái này trông giống validation" — nó cần đi kèm với đánh giá rủi ro: layer nào càng ít phụ thuộc chéo (cross-dependency) với các phần khác thì càng an toàn để tách ra trước. Validator thường là ứng viên rủi ro thấp nhất vì nó chỉ đọc input và trả về lỗi/không lỗi, không có side effect. Ngược lại, phần gọi payment gateway thường rủi ro cao nhất vì nó có side effect không thể hoàn tác (irreversible) và có thể liên quan đến tiền thật.
Kết quả của phase này là một extraction order — một danh sách các bước tách lớp theo thứ tự từ rủi ro thấp đến rủi ro cao, mỗi bước đều giữ được khả năng chạy lại toàn bộ characterization test suite để xác nhận hành vi không đổi trước khi qua bước kế tiếp. Đây là điểm khác biệt cốt lõi giữa refactor có kỷ luật và "dọn dẹp code" ngẫu hứng: mỗi bước nhỏ, mỗi bước đều verify được, không có bước nào "để sau tính".
Bước 3: Xác định các tầng
Vì sao cần làm: Bạn cần một cái nhìn tổng thể về ranh giới trách nhiệm trước khi bắt đầu di chuyển code, để tránh tình trạng tách nửa vời hoặc tách sai chỗ khiến layer mới vẫn còn rò rỉ logic của layer khác.
Prompt ví dụ:
Analyze src/routes/orders.ts (the checkout handler) and identify distinct
responsibilities currently mixed together in this single function.
For each responsibility, output:
- A name for the layer (e.g. "Validation", "Data access", "Pricing/business logic",
"External payment call", "Response formatting")
- The exact line ranges currently implementing it
- Its current dependencies (what other parts of the function it reads
from or writes to)
- Whether it has side effects (DB writes, network calls) or is pure logic
Do not propose code changes yet — this is an analysis-only pass.
Kết quả kỳ vọng cho ví dụ của chúng ta sẽ liệt kê ra ít nhất năm layer: Validation (dòng ~3–20, thuần túy, không side effect), Data access (các lời gọi db.query rải rác, có side effect đọc), Pricing/business logic (tính subtotal, discount, tax, shipping — thuần túy nếu tách được input ra khỏi truy vấn), External payment call (gọi payment gateway — side effect không thể hoàn tác), và Response formatting (build JSON trả về).
Bước 4: Tạo thứ tự tách
Vì sao cần làm: Biết layer là gì chưa đủ — cần biết tách theo thứ tự nào để mỗi bước luôn có thể chạy lại characterization test suite và có phạm vi thay đổi đủ nhỏ để review dễ dàng.
Prompt ví dụ:
Based on the layer analysis above, propose a safe extraction order for
refactoring this handler into separate modules. For each step in the
order, explain the rationale in terms of risk: prefer extracting layers
with fewer side effects and fewer cross-dependencies first.
Output a numbered list, each item containing:
- What to extract
- Why it is safe/safer to do at this point in the sequence
- What could go wrong, and how the characterization tests would catch it
I expect something close to: validator -> repository -> service ->
dependency-injected controller, but confirm or challenge that ordering
based on the actual code.
Với hàm checkout của chúng ta, agent thường sẽ đề xuất đúng thứ tự: validator trước (không side effect, dễ test độc lập), rồi repository/data access (side effect nhưng có thể mock DB dễ dàng), rồi service layer chứa business logic pricing/discount/inventory (phụ thuộc vào cả validator lẫn repository nên cần tách sau), và cuối cùng là controller lắp ráp mọi thứ qua dependency injection. Phần gọi payment gateway thường được xếp vào service hoặc một "gateway adapter" riêng, luôn ở bước gần cuối vì rủi ro cao nhất.
Cần kiểm tra trước khi qua bước tiếp theo: Đối chiếu extraction order với characterization document — đảm bảo mỗi quy tắc nghiệp vụ ngầm định (như FREESHIP) đã được gán rõ ràng "sẽ nằm ở layer nào" sau khi tách, để không bị rơi rớt lơ lửng giữa hai layer.
Mẹo: Nếu agent đề xuất một thứ tự tách lớp khác với trực giác "sách vở" (ví dụ tách service trước repository), đừng bác bỏ ngay — thường nó dựa trên phân tích dependency thực tế trong code của bạn, có thể chính xác hơn quy tắc chung chung.
Phase 3: Extraction — Tách từng tầng một
Đây là phần "tay làm" của bài thực hành: tách từng layer ra khỏi hàm gốc, theo đúng thứ tự đã lập ở Phase 2, và sau MỖI lần tách đều chạy lại toàn bộ characterization test suite trước khi đi tiếp. Nguyên tắc vàng ở đây là không bao giờ tách hai layer trong cùng một commit. Lý do rất thực tế: nếu bạn tách validator và repository cùng lúc mà một test đỏ, bạn sẽ mất thời gian gấp đôi để tìm xem lỗi nằm ở đâu. Tách từng layer, chạy test, xanh thì commit, rồi mới sang layer tiếp theo — đó là nhịp điệu (rhythm) cần giữ xuyên suốt phase này.
Một điều quan trọng cần lưu ý: khi AI agent tách code ra module mới, nó không chỉ "copy-paste" mà thường sẽ đổi tên biến, thay đổi chữ ký hàm (function signature), hoặc "dọn" luôn những đoạn code nó cho là dư thừa. Đây chính xác là hành vi bạn cần giám sát chặt — mọi thay đổi ngoài phạm vi "di chuyển code sang layer mới" đều là rủi ro tiềm ẩn làm lệch hành vi so với characterization test. Vì vậy ở phase này, review diff kỹ hơn bình thường là bắt buộc, không phải tùy chọn.
Bước 5: Tách validator
Vì sao cần làm: Validator là layer an toàn nhất để bắt đầu — thuần túy, không side effect, dễ unit test độc lập, và tách nó ra giúp hàm gốc ngắn lại ngay lập tức, tạo động lực nhìn thấy tiến triển rõ ràng.
Prompt ví dụ:
Extract the input validation logic (lines ~3-20 of the current handler,
per the layer analysis) into a new module src/validators/checkoutValidator.ts.
Requirements:
- Export a function `validateCheckoutInput(body: unknown): CheckoutInput`
that throws a `ValidationError` (with a `.statusCode = 400` and a
message matching the current error messages exactly) when invalid.
- Do not change any validation rule, error message text, or status code —
this must be behavior-preserving.
- Update the route handler to call this function and keep everything else
unchanged for now.
- Re-run the full characterization test suite after the change and report
the results.
Kết quả:
// src/validators/checkoutValidator.ts
export class ValidationError extends Error {
statusCode = 400;
}
export interface CheckoutInput {
userId: string;
items: { sku: string; quantity: number }[];
couponCode?: string;
shippingAddress: { country: string; postalCode: string };
paymentMethodId: string;
}
export function validateCheckoutInput(body: unknown): CheckoutInput {
const { userId, items, couponCode, shippingAddress, paymentMethodId } = body as any;
if (!userId || typeof userId !== 'string') {
throw new ValidationError('userId is required');
}
if (!Array.isArray(items) || items.length === 0) {
throw new ValidationError('items must be a non-empty array');
}
for (const item of items) {
if (!item.sku || typeof item.quantity !== 'number' || item.quantity <= 0) {
throw new ValidationError(`invalid item: ${JSON.stringify(item)}`);
}
}
if (!shippingAddress || !shippingAddress.country || !shippingAddress.postalCode) {
throw new ValidationError('shippingAddress is incomplete');
}
return { userId, items, couponCode, shippingAddress, paymentMethodId };
}
Cần kiểm tra trước khi qua bước tiếp theo: Chạy toàn bộ characterization suite — mọi test liên quan lỗi 400 phải vẫn xanh với message y hệt. Diff lại route handler để xác nhận nó chỉ gọi validateCheckoutInput và bắt ValidationError, không còn logic validate rải rác nào sót lại.
Bước 6: Tách repository
Vì sao cần làm: Sau validator, bước an toàn tiếp theo là gom mọi truy vấn DB rải rác vào một repository (lớp truy cập dữ liệu) — điều này tạo ra một seam rõ ràng để mock trong test, thay vì phải mock trực tiếp thư viện pg ở khắp nơi.
Prompt ví dụ:
Extract every direct database call in the checkout handler (user lookup,
product lookup per item, coupon lookup, stock update, order insert) into
a repository module src/repositories/orderRepository.ts using the
repository pattern.
Requirements:
- Define an interface `OrderRepository` with one method per query, e.g.
`findUserById`, `findProductBySku`, `findActiveCoupon`,
`decrementStock`, `insertOrder`.
- Implement it with the existing `pg` Pool, preserving the exact SQL
currently used — do not "improve" the queries.
- The route handler should now depend on this interface, not on `db`
directly.
- Re-run the characterization test suite, replacing the raw `pg` mock
with a mock implementation of `OrderRepository`, and confirm all tests
still pass with equivalent behavior.
Kết quả:
// src/repositories/orderRepository.ts
export interface OrderRepository {
findUserById(id: string): Promise<User | null>;
findProductBySku(sku: string): Promise<Product | null>;
findActiveCoupon(code: string): Promise<Coupon | null>;
decrementStock(sku: string, quantity: number): Promise<void>;
insertOrder(order: NewOrder): Promise<Order>;
}
export class PgOrderRepository implements OrderRepository {
constructor(private readonly db: Pool) {}
async findUserById(id: string): Promise<User | null> {
const res = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
return res.rows[0] ?? null;
}
async findProductBySku(sku: string): Promise<Product | null> {
const res = await this.db.query('SELECT * FROM products WHERE sku = $1', [sku]);
return res.rows[0] ?? null;
}
// ...findActiveCoupon, decrementStock, insertOrder theo cùng khuôn mẫu
}
Cần kiểm tra trước khi qua bước tiếp theo: Đảm bảo SQL bên trong mỗi method y hệt SQL gốc — kể cả những chi tiết như active = true trong query coupon. Kiểm tra lại thứ tự gọi các method trong route handler chưa đổi (ví dụ vẫn kiểm tra tồn kho trước khi trừ kho), vì thứ tự side effect cũng là một phần hành vi cần bảo toàn.
Bước 7: Tách service layer
Vì sao cần làm: Đây là layer rủi ro cao nhất trong nhóm "logic thuần túy" vì nó chứa các quy tắc nghiệp vụ tinh vi nhất (pricing, discount, inventory check) — bao gồm cả quy tắc ẩn FREESHIP mà characterization document đã ghi lại ở Phase 1.
Prompt ví dụ:
Extract the business logic (pricing calculation, coupon application,
shipping fee rule, tax calculation, stock sufficiency checks) into a
service module src/services/checkoutService.ts.
Requirements:
- Export a class `CheckoutService` whose constructor accepts an
`OrderRepository` (dependency injection, do not instantiate it inside
the class).
- Expose a method `processCheckout(input: CheckoutInput): Promise<CheckoutResult>`
that contains all pricing/discount/shipping/tax logic currently in the
handler, unchanged.
- Explicitly preserve the FREESHIP-as-waiver-not-discount rule and the
VN-free-shipping rule documented in the characterization doc — add a
code comment referencing each rule so future readers don't "simplify"
them away.
- Re-run the full characterization test suite against this service
(via the route handler, which now just delegates to it) and confirm
all pass.
Kết quả:
// src/services/checkoutService.ts
export class CheckoutService {
constructor(private readonly orders: OrderRepository) {}
async processCheckout(input: CheckoutInput): Promise<CheckoutResult> {
let subtotal = 0;
const lineItems = [];
for (const item of input.items) {
const product = await this.orders.findProductBySku(item.sku);
if (!product) throw new NotFoundError(`product not found: ${item.sku}`);
if (product.stockQuantity < item.quantity) {
throw new ConflictError(`insufficient stock for ${item.sku}`);
}
const lineTotal = product.priceCents * item.quantity;
subtotal += lineTotal;
lineItems.push({ sku: item.sku, quantity: item.quantity, unitPrice: product.priceCents, lineTotal });
}
let discount = 0;
let freeShipping = false;
if (input.couponCode) {
const coupon = await this.orders.findActiveCoupon(input.couponCode);
if (coupon) {
// Preserved rule: FREESHIP waives shipping, does NOT discount subtotal.
if (coupon.type === 'FREESHIP') {
freeShipping = true;
} else if (coupon.type === 'PERCENT') {
discount = Math.floor(subtotal * (coupon.value / 100));
} else if (coupon.type === 'FIXED') {
discount = Math.min(coupon.value, subtotal);
}
}
}
// Preserved rule: VN addresses always ship free, regardless of coupon.
const shippingFee = freeShipping || input.shippingAddress.country === 'VN' ? 0 : 150000;
const taxableAmount = subtotal - discount;
const tax = Math.round(taxableAmount * 0.1);
const total = taxableAmount + tax + shippingFee;
return { lineItems, subtotal, discount, shippingFee, tax, total };
}
}
Cần kiểm tra trước khi qua bước tiếp theo: Đây là bước cần review kỹ nhất trong toàn bộ Phase 3 — đọc lại từng dòng so với bản gốc, đối chiếu với characterization document, và chạy lại toàn bộ test đặc biệt là các test liên quan coupon và shipping. Nếu có bất kỳ test nào đỏ ở bước này, gần như chắc chắn một quy tắc ẩn đã bị đổi khác đi.
Mẹo: Sau mỗi lần extract, yêu cầu agent tự sinh một bảng diff tóm tắt "hành vi trước/sau" cho riêng phần vừa tách — đọc bảng này nhanh hơn nhiều so với đọc lại toàn bộ diff code, và giúp bạn phát hiện sai lệch ngay lập tức.
Phase 4: Dependency Injection và Testing
Sau ba lần tách lớp ở Phase 3, bạn đã có validator, repository, và service tồn tại như các module độc lập — nhưng route handler hiện tại có thể vẫn đang new-up (khởi tạo trực tiếp) các class này ngay bên trong nó, tạo ra sự ràng buộc chặt (tight coupling) giữa controller và implementation cụ thể. Phase cuối cùng này giải quyết vấn đề đó bằng dependency injection (tiêm phụ thuộc): controller không tự tạo ra PgOrderRepository hay CheckoutService, mà nhận chúng từ bên ngoài — qua constructor hoặc factory function — để việc test từng layer độc lập, và việc thay thế implementation (ví dụ đổi ORM sau này), trở nên dễ dàng hơn nhiều.
Đây cũng là lúc bạn viết bộ unit test "thật sự" cho từng layer — khác với characterization test (vốn test qua HTTP end-to-end với mock ở tầng DB/gateway), unit test ở đây kiểm tra trực tiếp từng class/hàm với input/output rõ ràng, nhanh hơn nhiều lần và dễ debug hơn khi đỏ. Cuối cùng, bước xác minh chốt hạ: chạy lại toàn bộ characterization test suite ban đầu lên kiến trúc mới hoàn toàn — đây là bằng chứng cuối cùng, khách quan, rằng hành vi đã được bảo toàn xuyên suốt cả quá trình refactor.
Bước 8: Triển khai dependency injection
Vì sao cần làm: Composing (lắp ráp) các layer bằng constructor injection giúp controller trở thành một lớp "mỏng" chỉ lo điều phối HTTP request/response, còn mọi logic thực sự nằm ở nơi có thể test độc lập, không phụ thuộc Express.
Prompt ví dụ:
Refactor the route handler to use constructor-based dependency injection.
Requirements:
- Create a factory function `createCheckoutHandler(service: CheckoutService)`
that returns an Express RequestHandler.
- The handler should: call validateCheckoutInput, call
service.processCheckout, catch ValidationError/NotFoundError/
ConflictError and map each to its correct status code, and format the
final JSON response — nothing else.
- Wire up the composition root (e.g. src/app.ts or a dedicated
src/container.ts) where the real PgOrderRepository and CheckoutService
instances are created and passed in.
- Show me the final route registration line.
Kết quả:
// src/routes/orders.ts
export function createCheckoutHandler(service: CheckoutService): RequestHandler {
return async (req, res) => {
try {
const input = validateCheckoutInput(req.body);
const result = await service.processCheckout(input);
return res.status(200).json(result);
} catch (err) {
if (err instanceof ValidationError) return res.status(400).json({ error: err.message });
if (err instanceof NotFoundError) return res.status(404).json({ error: err.message });
if (err instanceof ConflictError) return res.status(409).json({ error: err.message });
throw err;
}
};
}
// src/container.ts
const repository = new PgOrderRepository(dbPool);
const checkoutService = new CheckoutService(repository);
router.post('/orders/checkout', createCheckoutHandler(checkoutService));
Cần kiểm tra trước khi qua bước tiếp theo: Route handler cuối cùng nên chỉ còn khoảng 15-20 dòng — nếu vẫn dài hơn nhiều, có logic nào đó chưa được tách hết. Xác nhận lỗi mapping status code (400/404/409) khớp chính xác với characterization document.
Bước 9: Viết bộ unit test cuối cùng
Vì sao cần làm: Characterization test bảo vệ bạn khỏi regression trong lúc refactor, nhưng chúng chậm và khó đọc để hiểu ý đồ thiết kế. Bộ unit test cuối cùng, viết riêng cho từng layer, mới là tài sản kiểm thử lâu dài của codebase — nhanh, rõ ràng, và dễ mở rộng khi có feature mới.
Prompt ví dụ:
Now that validator, repository, and service are separate modules with
dependency injection, write a comprehensive unit test suite for each:
1. checkoutValidator.test.ts — table-driven tests for every valid/invalid
input combination, no mocking needed since it's pure.
2. checkoutService.test.ts — test with a hand-written fake
OrderRepository (not the real Postgres one), covering pricing, all
three coupon types, VN vs non-VN shipping, and stock-insufficient
errors.
3. Keep the existing characterization suite as an integration-level
regression safety net; do not delete it.
Aim for each business rule to have its own named test case so failures
are self-explanatory.
Cần kiểm tra trước khi qua bước tiếp theo: Đếm số lượng test case theo từng quy tắc nghiệp vụ đã liệt kê trong characterization document ở Phase 1 — mỗi quy tắc nên có ít nhất một unit test riêng ánh xạ trực tiếp tới nó. Đây là cách xác nhận độ phủ (coverage) có ý nghĩa, không chỉ coverage theo dòng code.
Bước 10: Kiểm chứng cuối cùng đối chiếu characterization
Vì sao cần làm: Đây là bước "chốt hạ" — bằng chứng khách quan cuối cùng rằng toàn bộ quá trình tách lớp không làm thay đổi hành vi bên ngoài của API, bất kể bên trong đã tổ chức lại hoàn toàn.
Prompt ví dụ:
Run the original characterization test suite (from Phase 1, unmodified)
against the new layered implementation (validator + repository + service
+ DI controller).
Report:
- Pass/fail count, and full diff of any failing assertion (expected vs
actual).
- A sign-off checklist confirming: all original status codes preserved,
all implicit business rules preserved (list each one from the
characterization document individually with a preserved/changed
verdict), and no new side effects introduced (e.g. no extra DB calls).
If anything fails or is ambiguous, stop and flag it — do not silently
adjust the characterization test to make it pass.
Checklist sign-off điển hình sẽ trông như sau:
- [x] Tất cả status code (400/404/409/200) khớp characterization document
- [x] Quy tắc FREESHIP-là-waiver-không-phải-discount được bảo toàn
- [x] Quy tắc VN-luôn-miễn-phí-ship được bảo toàn
- [x] Thứ tự side effect (kiểm tra tồn kho trước khi trừ kho) không đổi
- [x] Không có lời gọi DB/API bên ngoài mới phát sinh
- [ ] Case "item trùng SKU trong cùng đơn" — đã được flag là ambiguous ở Phase 1, cần product owner quyết định trước khi merge
Cần kiểm tra trước khi qua bước tiếp theo (trước khi merge): Nếu checklist có bất kỳ dòng nào chưa tick, refactor CHƯA hoàn tất về mặt an toàn — dừng lại, đưa câu hỏi ambiguous cho người có thẩm quyền nghiệp vụ, đừng tự quyết định thay họ.
Mẹo: Giữ lại characterization test suite vĩnh viễn trong repo sau khi refactor xong, gắn nhãn rõ (ví dụ thư mục
tests/characterization/) — nó vẫn có giá trị như một lớp regression test bổ sung, độc lập với unit test theo layer.
Nơi giám sát của con người là không thể thương lượng
Toàn bộ quy trình mười bước ở trên cho thấy AI agent có thể tăng tốc đáng kể việc đọc code, sinh test, và di chuyển logic — nhưng có một số quyết định mà agent không bao giờ nên được giao toàn quyền, vì bản chất của chúng không phải là kỹ thuật mà là nghiệp vụ và tổ chức.
Đầu tiên là sự mơ hồ trong quy tắc nghiệp vụ (business-rule ambiguity). Ở Step 10, chúng ta gặp trường hợp "item trùng SKU trong cùng đơn" chưa rõ là hành vi cố ý hay bug — chỉ product owner hoặc người nắm rõ lịch sử nghiệp vụ mới trả lời được câu hỏi này, agent không có đủ ngữ cảnh (context) để tự quyết định. Thứ hai, liên quan mật thiết, là ranh giới giữa "bug cần giữ nguyên để tương thích ngược" và "bug cần sửa ngay trong lần refactor này" — đây luôn là một đánh đổi (trade-off) có tính chiến lược, phụ thuộc vào việc khách hàng nào đang dựa vào hành vi hiện tại, không phải chuyện đúng/sai thuần túy kỹ thuật.
Thứ ba, dù agent có thể tự tin nói "tất cả test đã pass", quyết định review code cuối cùng và approve merge vẫn phải là con người — không chỉ để bắt lỗi mà đôi khi AI bỏ sót, mà còn để chịu trách nhiệm giải trình (accountability) khi có sự cố ở production. Không ai có thể "trách" một AI agent trong buổi post-mortem. Thứ tư là chiến lược rollout — một refactor dù được test kỹ đến đâu vẫn nên đi qua canary release hoặc feature flag, triển khai dần cho một phần nhỏ traffic trước, theo dõi metric thực tế (tỷ lệ lỗi, tỷ lệ đơn hàng thành công) rồi mới rollout toàn bộ — đây là quyết định vận hành cần kinh nghiệm và bối cảnh về hệ thống production mà agent không nắm được. AI là một cộng sự cực kỳ mạnh trong toàn bộ quy trình tăng tốc, nhưng nó hỗ trợ ra quyết định chứ không sở hữu quyết định.
Mẹo: Với mọi refactor động vào luồng có tiền thật (payment, checkout, billing), luôn yêu cầu sign-off bằng văn bản (ví dụ comment trên PR) từ một người không tham gia viết refactor — một cặp mắt độc lập luôn bắt được thứ mà cả bạn lẫn AI agent đều đã quá quen mắt để bỏ qua.
Những điểm chính
- Refactor quy mô lớn thất bại chủ yếu vì thiếu safety net và mất mát business logic ngầm định — không phải vì thiếu kỹ năng kỹ thuật.
- AI agent thay đổi bài toán kinh tế học: characterization nhanh và rẻ đủ để trở thành bước bắt buộc thay vì bị bỏ qua.
- Characterization document (Step 1) và characterization test suite (Step 2) là bước nền tảng — pin hành vi hiện tại trước khi động vào bất kỳ dòng code nào.
- Xác định layer (Step 3) và lập extraction order theo rủi ro từ thấp đến cao (Step 4) giúp mỗi bước tách lớp có phạm vi kiểm soát được.
- Tách từng layer một, theo thứ tự validator → repository → service (Step 5-7), luôn chạy lại characterization test suite sau mỗi bước, không bao giờ gộp nhiều layer trong một commit.
- Dependency injection (Step 8) biến controller thành lớp mỏng chỉ điều phối, còn logic thực sự nằm ở nơi test được độc lập.
- Unit test theo layer (Step 9) là tài sản kiểm thử dài hạn; characterization test (Step 10) là bằng chứng cuối cùng cho việc bảo toàn hành vi.
- Quyết định về ambiguity nghiệp vụ, việc giữ hay sửa bug, review/approve merge, và chiến lược rollout luôn phải thuộc về con người có thẩm quyền và chịu trách nhiệm.