Refactoring with AI: Cách yêu cầu AI chuyển từ code \"đống rác\" sang Clean Architecture
7/19/2026 · 13p đọc
title: "Refactoring with AI: Cách yêu cầu AI chuyển từ code "đống rác" sang Clean Architecture"
series: "AI-Native Solutions Architect: Từ Coder đến Kiến trúc sư AI"
season: "Season 1 — AI-Native Coding & Tooling"
order: 5
audience: "Software Engineer hướng tới Solutions Architect"
reading_time: "13 phút"
tags: ["refactoring", "clean-architecture", "ai-native", "nestjs", "layered-architecture", "test-driven-refactor", "solutions-architect"]
Refactoring with AI: Cách yêu cầu AI chuyển từ code "đống rác" sang Clean Architecture
Bạn mở một file orders.controller.ts dài 800 dòng. Trong đó có validate input bằng tay, có query trực tiếp vào TypeORM repository, có logic tính discount, có gọi service gửi email, có cả một đoạn try/catch nuốt lỗi im lặng. Product manager vừa yêu cầu thêm một loại discount mới. Bạn biết chắc: sửa đúng chỗ cần sửa thì dễ, nhưng đảm bảo không đụng vỡ 5 chỗ khác đang âm thầm phụ thuộc vào cùng đoạn code đó — thì không dễ chút nào.
Đây là tình huống rất quen: business logic bị "đổ bê tông" thẳng vào Controller hoặc UI layer, không có ranh giới rõ ràng giữa xử lý request, quy tắc nghiệp vụ, và truy cập dữ liệu. Refactor thủ công đúng cách — tách lớp, viết interface, đảm bảo hành vi cũ không đổi — có thể tốn vài tuần cho một module cỡ trung, và rủi ro breaking change luôn treo lơ lửng vì test coverage ban đầu thường mỏng.
AI coding assistant giải quyết được một phần bài toán này, nhưng theo cách rất khác với kỳ vọng của nhiều người: không phải "viết lại toàn bộ module trong một prompt", mà là làm phụ tá kỷ luật cho một quy trình refactor có kiểm soát, từng bước một, có test canh gác ở mỗi bước. Bài này nói về đúng quy trình đó, kèm ví dụ before/after cụ thể và prompt mẫu bạn có thể copy dùng ngay.
Vấn đề
Code kiểu "đống rác" (spaghetti code) thường không xuất hiện từ một quyết định tệ duy nhất. Nó tích lũy: sprint này thêm một field, sprint sau thêm một điều kiện đặc biệt cho khách VIP, sprint sau nữa vá thêm một nhánh xử lý lỗi — tất cả đều được thêm vào đúng cái function đang chạy được, vì đó là đường ít trở lực nhất. Kết quả là một Controller method vừa parse request, vừa validate nghiệp vụ, vừa gọi thẳng ORM, vừa quyết định gửi thông báo — tất cả trộn lẫn không có ranh giới.
Hệ quả kỹ thuật cụ thể:
- Không test được từng phần riêng lẻ. Muốn unit test logic tính discount, bạn buộc phải mock luôn HTTP request, response object, và database connection — vì chúng nằm chung một hàm.
- Thay đổi một chỗ, vỡ chỗ khác không ngờ tới. Vì không có ranh giới interface, một thay đổi nhỏ trong cách parse input có thể vô tình đổi luôn hành vi validate nghiệp vụ.
- Không tái sử dụng được. Logic tính giá đơn hàng chỉ tồn tại bên trong Controller REST, nên khi cần dùng lại cho một cron job hay một GraphQL resolver, người ta copy-paste thay vì gọi lại.
Đây là lúc nhiều đội nghĩ đến việc nhờ AI "dọn dẹp" toàn bộ file. Nhưng nếu prompt là kiểu "hãy refactor file này theo Clean Architecture", AI assistant hiện tại (kể cả các model mạnh) sẽ trả về một bản viết lại toàn diện — đổi tên biến, gộp logic, tách file — trong một diff khổng lồ mà con người không thể review kỹ trong thời gian hợp lý. Bạn không biết hành vi cũ có được bảo toàn hay không, vì không có gì đối chiếu ngoài "nhìn code thấy hợp lý". Đó chính là rủi ro cao nhất của refactor bằng AI: đổi nhiều thứ cùng lúc mà không có cách nào cô lập được thay đổi nào gây ra vấn đề gì.
Kỹ thuật cốt lõi
Nguyên tắc cốt lõi của refactor có kiểm soát với AI rất đơn giản để phát biểu nhưng dễ bị bỏ qua khi vội: mỗi lần yêu cầu AI chỉ thay đổi một lớp kiến trúc, và luôn có test chạy được trước và sau mỗi bước để xác nhận hành vi bên ngoài không đổi.
Thứ tự tách lớp khuyến nghị cho một Controller đang "ôm" hết mọi thứ:
flowchart TD
A["Bước 0: Viết characterization test\ncho hành vi hiện tại (nếu chưa có)"] --> B["Bước 1: Tách business logic\nra khỏi Controller -> Service layer"]
B --> C["Chạy lại test, xác nhận xanh"]
C --> D["Bước 2: Tách data access\nra khỏi Service -> Repository layer"]
D --> E["Chạy lại test, xác nhận xanh"]
E --> F["Bước 3: Định nghĩa interface/DTO\ngiữa Controller <-> Service <-> Repository"]
F --> G["Chạy lại test, xác nhận xanh"]
G --> H["Bước 4 (tuỳ chọn): áp domain rules,\nvalue object, use-case pattern"]
Vì sao đúng thứ tự này quan trọng:
- Characterization test trước tiên. Nếu module chưa có test, việc đầu tiên không phải là refactor mà là viết test mô tả hành vi hiện tại (kể cả khi hành vi đó có bug) — mục tiêu là có một "lưới an toàn" để phát hiện thay đổi ngoài ý muốn, không phải để sửa bug cùng lúc.
- Tách business logic trước data access. Business logic (validate, tính toán, quy tắc nghiệp vụ) thường dễ tách và dễ test hơn — chỉ cần input/output thuần, không phụ thuộc I/O. Tách nó ra Service layer trước giúp bạn có một lớp trung gian sạch để làm việc tiếp.
- Tách data access sau. Khi business logic đã nằm gọn trong Service, bước tiếp theo là bọc mọi truy vấn database vào Repository, để Service không còn biết gì về TypeORM/query builder — chỉ gọi qua interface.
- Định nghĩa interface/DTO cuối cùng. Khi ranh giới vật lý giữa 3 lớp đã ổn, mới đến việc làm chặt chẽ interface (input/output type, contract) để lớp trên không rò rỉ chi tiết implementation của lớp dưới.
Mỗi bước là một prompt riêng, một diff riêng, một lần chạy test riêng. AI không được phép nhảy sang bước tiếp theo cho tới khi bước trước có test xanh. Đây là điểm khác biệt căn bản so với "refactor toàn bộ trong một lần": bạn luôn có một điểm rollback an toàn ở giữa chừng.
Thực hành
Before: business logic, validation và data access trộn lẫn trong Controller
Đây là một ví dụ điển hình — xử lý tạo đơn hàng, viết theo NestJS, tất cả nằm trong một method của Controller:
// orders.controller.ts (BEFORE — "đống rác")
@Controller('orders')
export class OrdersController {
constructor(
@InjectRepository(Order) private orderRepo: Repository<Order>,
@InjectRepository(Product) private productRepo: Repository<Product>,
@InjectRepository(Customer) private customerRepo: Repository<Customer>,
private mailerService: MailerService,
) {}
@Post()
async createOrder(@Body() body: any) {
// Validate thủ công, không dùng class-validator
if (!body.customerId || !body.items || body.items.length === 0) {
throw new BadRequestException('Thiếu dữ liệu đơn hàng');
}
const customer = await this.customerRepo.findOne({ where: { id: body.customerId } });
if (!customer) {
throw new NotFoundException('Không tìm thấy khách hàng');
}
let total = 0;
const orderItems = [];
for (const item of body.items) {
const product = await this.productRepo.findOne({ where: { id: item.productId } });
if (!product || product.stock < item.quantity) {
throw new BadRequestException(`Sản phẩm ${item.productId} không đủ tồn kho`);
}
// Business logic tính discount trộn thẳng trong Controller
let price = product.price;
if (customer.tier === 'VIP' && item.quantity >= 10) {
price = price * 0.85;
} else if (customer.tier === 'VIP') {
price = price * 0.95;
}
total += price * item.quantity;
product.stock -= item.quantity;
await this.productRepo.save(product); // ghi DB ngay trong vòng lặp
orderItems.push({ productId: product.id, quantity: item.quantity, price });
}
const order = this.orderRepo.create({
customerId: customer.id,
items: orderItems,
total,
status: 'PENDING',
});
await this.orderRepo.save(order);
// Side-effect gửi mail cũng nằm luôn ở đây
try {
await this.mailerService.sendOrderConfirmation(customer.email, order);
} catch (e) {
console.log('Gửi mail thất bại', e);
}
return order;
}
}
Vấn đề rõ ràng: không thể unit test logic tính discount mà không mock cả 3 repository và mailer; không thể tái sử dụng logic này cho một luồng import đơn hàng hàng loạt; và một thay đổi về "quy tắc discount VIP" buộc phải sửa vào đúng method 60 dòng đang gánh mọi trách nhiệm khác.
After: Controller mỏng, Service chứa nghiệp vụ, Repository chứa truy vấn
// orders.controller.ts (AFTER)
@Controller('orders')
export class OrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Post()
async createOrder(@Body() dto: CreateOrderDto): Promise<OrderResponseDto> {
return this.ordersService.createOrder(dto);
}
}
// create-order.dto.ts
export class CreateOrderItemDto {
@IsUUID()
productId: string;
@IsInt()
@Min(1)
quantity: number;
}
export class CreateOrderDto {
@IsUUID()
customerId: string;
@ValidateNested({ each: true })
@Type(() => CreateOrderItemDto)
@ArrayNotEmpty()
items: CreateOrderItemDto[];
}
// orders.service.ts — business logic thuần, không biết gì về TypeORM
@Injectable()
export class OrdersService {
constructor(
private readonly ordersRepository: OrdersRepository,
private readonly productsRepository: ProductsRepository,
private readonly customersRepository: CustomersRepository,
private readonly notificationsService: NotificationsService,
) {}
async createOrder(dto: CreateOrderDto): Promise<OrderResponseDto> {
const customer = await this.customersRepository.findByIdOrFail(dto.customerId);
const products = await this.productsRepository.findByIds(dto.items.map((i) => i.productId));
const orderItems = dto.items.map((item) => {
const product = this.findProductOrThrow(products, item.productId);
this.assertSufficientStock(product, item.quantity);
const price = this.calculateUnitPrice(product, customer, item.quantity);
return { productId: product.id, quantity: item.quantity, price };
});
const total = orderItems.reduce((sum, i) => sum + i.price * i.quantity, 0);
const order = await this.ordersRepository.createWithStockDeduction(
customer.id,
orderItems,
total,
);
await this.notificationsService.notifyOrderCreated(customer, order);
return OrderResponseDto.fromEntity(order);
}
private calculateUnitPrice(product: Product, customer: Customer, quantity: number): number {
if (customer.tier === 'VIP' && quantity >= 10) return product.price * 0.85;
if (customer.tier === 'VIP') return product.price * 0.95;
return product.price;
}
private findProductOrThrow(products: Product[], productId: string): Product {
const product = products.find((p) => p.id === productId);
if (!product) throw new NotFoundException(`Không tìm thấy sản phẩm ${productId}`);
return product;
}
private assertSufficientStock(product: Product, quantity: number): void {
if (product.stock < quantity) {
throw new BadRequestException(`Sản phẩm ${product.id} không đủ tồn kho`);
}
}
}
// orders.repository.ts — chỉ chứa truy vấn, không chứa quy tắc nghiệp vụ
@Injectable()
export class OrdersRepository {
constructor(
@InjectRepository(Order) private readonly orderRepo: Repository<Order>,
@InjectRepository(Product) private readonly productRepo: Repository<Product>,
private readonly dataSource: DataSource,
) {}
async createWithStockDeduction(
customerId: string,
items: { productId: string; quantity: number; price: number }[],
total: number,
): Promise<Order> {
return this.dataSource.transaction(async (manager) => {
for (const item of items) {
await manager.decrement(Product, { id: item.productId }, 'stock', item.quantity);
}
const order = manager.create(Order, { customerId, items, total, status: 'PENDING' });
return manager.save(order);
});
}
}
Lợi ích cụ thể sau khi tách: calculateUnitPrice giờ là pure function, test bằng vài dòng expect(...).toBe(...) không cần mock gì cả; giao dịch trừ tồn kho được gom vào một transaction thay vì save rời rạc trong vòng lặp (sửa luôn một bug tiềm ẩn về race condition — nhưng đây là điểm cần quyết định có chủ đích, không phải side-effect ngẫu nhiên của AI); và OrdersService có thể được gọi lại từ một cron job import đơn hàng mà không cần đụng vào Controller.
Prompt mẫu cho từng bước tách lớp có kiểm soát
Bước 0 — yêu cầu AI viết characterization test trước khi đụng vào code:
Đây là Controller hiện tại xử lý tạo đơn hàng (dán code orders.controller.ts BEFORE ở trên).
Module này CHƯA có test. Trước khi refactor, hãy viết integration test (dùng Jest +
@nestjs/testing) mô tả ĐÚNG hành vi hiện tại của createOrder, bao gồm:
- Case tạo đơn hàng thành công với khách thường và khách VIP (số lượng >=10 và <10).
- Case sản phẩm không đủ tồn kho -> throw BadRequestException.
- Case customerId không tồn tại -> throw NotFoundException.
- Case thiếu items -> throw BadRequestException.
KHÔNG sửa bug nếu phát hiện, kể cả bug rõ ràng (ví dụ log lỗi mail bằng console.log).
Chỉ mô tả hành vi đang có, mục tiêu là tạo lưới an toàn cho bước refactor tiếp theo.
Liệt kê rõ những gì bạn mock (repository, mailer) và giả định input mẫu.
Bước 1 — tách business logic ra Service, giữ nguyên Controller gọi thẳng repository:
Test ở bước trước đã pass (đính kèm file test). Bây giờ hãy tách RIÊNG business logic
(tính discount, validate tồn kho, validate customer) ra một class OrdersService mới,
CHƯA đụng đến cách truy vấn database (Service vẫn được phép inject trực tiếp
Repository<Order>, Repository<Product>, Repository<Customer> như cũ).
Yêu cầu bắt buộc:
1. Controller.createOrder() chỉ còn gọi ordersService.createOrder(dto) và trả kết quả.
2. KHÔNG đổi API contract (request/response shape phải giữ nguyên).
3. KHÔNG tách data access ở bước này — đó là bước sau, đừng làm trước.
4. Sau khi sửa, chạy lại đúng bộ test ở bước 0 và báo cáo kết quả pass/fail,
không tự ý sửa test để cho pass nếu có gì đó fail — báo lại tôi.
Bước 2 — tách data access ra Repository, sau khi bước 1 đã xanh:
Bước 1 đã pass toàn bộ test. Bây giờ tách các lệnh gọi trực tiếp
Repository<Order>/Repository<Product>/Repository<Customer> hiện đang nằm trong
OrdersService ra thành các class riêng: OrdersRepository, ProductsRepository,
CustomersRepository. OrdersService sau bước này KHÔNG được import bất kỳ
TypeORM Repository nào trực tiếp — chỉ được gọi qua các Repository class mới.
Gộp đoạn trừ tồn kho + tạo order hiện đang chạy rời rạc trong vòng lặp thành MỘT
transaction trong OrdersRepository.createWithStockDeduction — nêu rõ đây là một
thay đổi hành vi có chủ đích (transaction) để tôi review riêng, không lẫn vào diff
refactor thuần tuý.
Chạy lại test bước 0, báo cáo kết quả trước khi đề xuất bước tiếp theo.
Bước 3 — định nghĩa DTO/interface chặt chẽ giữa các lớp:
Bước 2 đã pass. Bây giờ thay tham số `body: any` trong Controller bằng CreateOrderDto
dùng class-validator (IsUUID, IsInt, Min, ValidateNested, ArrayNotEmpty theo đúng field
customerId và items[].productId/quantity). Bật ValidationPipe global nếu chưa có.
Định nghĩa OrderResponseDto.fromEntity(order) để Controller không trả thẳng entity ORM.
KHÔNG đổi logic bên trong OrdersService/OrdersRepository ở bước này — chỉ thêm lớp
validate/serialize ở biên. Chạy lại test, xác nhận API contract cũ (response JSON)
không đổi trừ phần bạn cố ý cải thiện, liệt kê rõ nếu response shape có thay đổi.
Mỗi prompt trên đều có 3 đặc điểm chung: (1) tham chiếu tường minh tới kết quả bước trước, (2) giới hạn phạm vi rõ ràng ("KHÔNG đụng vào X ở bước này"), (3) yêu cầu chạy test và báo cáo trước khi đề xuất bước tiếp theo. Đó là cách biến AI từ "người viết lại toàn bộ" thành "người thợ tuân thủ quy trình".
Cạm bẫy thường gặp
Yêu cầu refactor toàn bộ module trong một prompt duy nhất. "Hãy áp dụng Clean Architecture cho toàn bộ module Orders" nghe hiệu quả nhưng tạo ra một diff hàng nghìn dòng không ai review nổi trong một buổi. Khi có bug xuất hiện sau đó, bạn không biết nó đến từ việc tách Service, tách Repository, hay đổi DTO — vì cả ba xảy ra cùng lúc. Luôn chia theo lớp kiến trúc và review từng diff nhỏ.
Refactor mà không có test bao phủ trước, rồi tin vào "code trông sạch hơn". Một Controller đang chạy được trong production, dù xấu, đã mã hoá rất nhiều hành vi ngầm (kể cả những edge case không ai còn nhớ tại sao lại xử lý như vậy). Nếu không viết characterization test trước, bạn không có cách nào phân biệt "code sạch hơn và hành vi giống hệt" với "code sạch hơn nhưng đã âm thầm đổi hành vi ở một edge case hiếm gặp". AI rất giỏi làm code "trông hợp lý" — nhưng "trông hợp lý" không phải là bằng chứng hành vi không đổi.
Để AI tự ý "tiện thể sửa luôn" trong lúc refactor. Rất dễ gặp: bạn yêu cầu tách Repository, AI tách xong và đồng thời "sửa luôn" một điều kiện so sánh mà nó cho là bug. Điều này làm bẩn diff refactor thuần túy với một thay đổi hành vi không được yêu cầu, khó review riêng và khó rollback nếu sai. Luôn yêu cầu AI tách bạch rõ ràng: refactor cấu trúc (không đổi hành vi) và sửa bug (có đổi hành vi) là hai commit khác nhau.
🧭 Góc nhìn Solutions Architect
Nếu module này thất bại sau khi refactor, tôi có thể revert đúng MỘT lớp (chỉ Repository, hoặc chỉ Service) mà không phải revert toàn bộ hay không? Bộ test hiện tại có đủ để phát hiện một thay đổi hành vi tinh vi — ví dụ sai số làm tròn discount ở edge case 9 vs 10 sản phẩm — hay chỉ bao phủ happy path? Ranh giới interface tôi vừa định nghĩa giữa Controller/Service/Repository có thực sự ổn định cho 6 tháng tới, hay tôi đang tách lớp chỉ để "trông giống Clean Architecture" mà không giải quyết được nỗi đau thực sự của team?
🔗 Bài viết liên quan
- AI-Assisted Debugging: Đưa stack trace và logs cho AI đúng cách
- AI & Legacy Code: Document hoá và tài liệu hoá codebase cũ
Bài trước: AI-Assisted Debugging · Bài tiếp theo: AI & Legacy Code