Hàm và side effect: guard clause, bỏ flag, lõi thuần Trung cấp
Thời gian đọc ước tính: 6 phút
Trang này trả lời: các commit refactor 1–3 trong chuỗi commit trông thế nào trong code, và vì sao mỗi bước làm lần sửa sau rẻ hơn.
Đang tải diagram…
Guard clause thay cho if lồng
Phần tiêu đề “Guard clause thay cho if lồng”Refactoring tương ứng trong catalog của Fowler: Replace Nested Conditional with Guard Clauses. Các trường hợp loại trừ thoát sớm, đường chính nằm sát lề trái.
// Before: đường chính nằm ở tầng thụt thứ baif (items && items.length > 0) { if (user) { /* ... 20 dòng ... */ } else throw new Error('error');}return 0;
// Afterif (cmd.lines.length === 0) return { ok: false, error: 'EMPTY_CART' };const customer = await this.orders.findCustomer(cmd.userId);if (!customer) return { ok: false, error: 'CUSTOMER_NOT_FOUND' };// đường chính từ đây, không thụt lềMột tầng trừu tượng trong một hàm
Phần tiêu đề “Một tầng trừu tượng trong một hàm”Hàm vừa gọi placeOrder cấp nghiệp vụ vừa nối chuỗi SQL là trộn hai tầng. Người đọc phải đổi “độ zoom” liên tục. Hàm điều phối chỉ nên gọi các bước có tên; chi tiết nằm trong các bước đó.
export function quoteTotalVnd(input: QuoteInput, now: Date): number { if (input.lines.length === 0) return 0;
const subtotal = subtotalVnd(input.lines); const discounted = applyCoupon(subtotal, input.couponCode, now); return discounted + shippingFeeVnd(discounted, input.shipping);}Đây là Extract Function. Dừng tách khi tên hàm mới không nói được gì hơn thân hàm, đó chính là điểm Ousterhout phản đối việc tách quá tay.
Bỏ boolean flag, gom tham số
Phần tiêu đề “Bỏ boolean flag, gom tham số”Fowler gọi đây là Remove Flag Argument và Introduce Parameter Object.
| Before | After | Lý do |
|---|---|---|
dryRun: boolean |
Hai method: quote() và placeOrder() |
Hai hành vi khác nhau (có/không side effect) xứng đáng có hai tên |
express: boolean |
shipping: 'standard' | 'express' |
Thêm same_day không phải đổi chữ ký thành hai boolean |
| 5 tham số rời | PlaceOrderCommand |
Call site đọc được theo tên field |
export type ShippingMethod = 'standard' | 'express';
export interface QuoteInput { lines: readonly CartLine[]; couponCode?: string; shipping: ShippingMethod;}
export interface PlaceOrderCommand extends QuoteInput { userId: string; idempotencyKey: string;}
// Call site: tự giải thíchawait checkout.placeOrder({ userId, lines, shipping: 'express', idempotencyKey });Khi số biến thể vận chuyển hoặc khuyến mãi tăng và mỗi cái có công thức riêng, chuyển sang Strategy. Đừng mở rộng union bằng switch rải khắp nơi.
Magic number → hằng số có tên và đơn vị
Phần tiêu đề “Magic number → hằng số có tên và đơn vị”Tên tốt chứa đơn vị: _VND, _MS, _SECONDS, _HOUR. Lỗi kinh điển: TIMEOUT = 30 mà nửa team hiểu là giây, nửa kia hiểu là mili giây.
export const FREE_SHIPPING_THRESHOLD_VND = 500_000;export const SHIPPING_FEE_VND = { standard: 30_000, express: 50_000 } as const;export const VIP10_PRICE_FACTOR = 0.9;// Giờ theo timezone của process (giữ nguyên hành vi cũ); đổi sang giờ VN là một commit riêng.export const VIP10_CUTOFF_HOUR = 12;
export type ShippingMethod = keyof typeof SHIPPING_FEE_VND;SHIPPING_FEE_VNDlàm nguồn cho cả typeShippingMethod: thêm phương thức mới mà quên phí thì không compile.- Không phải số nào cũng cần hằng số.
0,1,index + 1rõ nghĩa tại chỗ. Refactoring tương ứng: Replace Magic Literal. - Giá trị nghiệp vụ hay đổi (ngưỡng freeship) nên ra config hoặc bảng DB. Hằng số chỉ là bước đầu để có một chỗ cần sửa.
Side effect ở rìa, lõi thuần
Phần tiêu đề “Side effect ở rìa, lõi thuần”Ý tưởng functional core, imperative shell (Gary Bernhardt):
- Core: phần quyết định (tính tiền, chọn trạng thái) là hàm thuần.
- Shell: phần thực thi (DB, HTTP, đồng hồ, log) mỏng và nằm ngoài.
- Trong catalog của Fowler, bước này gần với Split Phase và Separate Query from Modifier.
PlaceOrderCommandController validate DTO rồi gửi một PlaceOrderCommand (parameter object) vào service. Không còn boolean express/dryRun.
@Injectable()export class CheckoutService { constructor( @Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository, @Inject(PAYMENT_PORT) private readonly payments: PaymentPort, @Inject(CLOCK) private readonly clock: Clock, ) {}
quote(input: QuoteInput): number { return quoteTotalVnd(input, this.clock.now()); }
async placeOrder(cmd: PlaceOrderCommand): Promise<PlaceOrderResult> { if (cmd.lines.length === 0) return { ok: false, error: 'EMPTY_CART' }; const customer = await this.orders.findCustomer(cmd.userId); if (!customer) return { ok: false, error: 'CUSTOMER_NOT_FOUND' };
const totalVnd = quoteTotalVnd(cmd, this.clock.now()); const charge = await this.charge(cmd, totalVnd); if (charge.status === 'declined') return { ok: false, error: 'PAYMENT_DECLINED' };
const order = await this.orders.save({ customerId: customer.id, totalVnd, paymentId: charge.paymentId }); return { ok: true, orderId: order.id, totalVnd }; }}- Lợi ích đo được: demo golden master chạy 10.000 case trong một process ts-node, không mock gì, vì
quoteTotalVndkhông chạm I/O. - Test của
placeOrderchỉ cần vài case cho điều phối (declined, lỗi hạ tầng, thành công) với fake port. placeOrderở đây đã chứa commit 4 (không lưu order khi charge lỗi) và đổireturn 0cho giỏ rỗng thành lỗi có tên. Đó là thay đổi hành vi, nên đi trong commitfix:riêng, kèm cập nhật test.
Method charge và kiểu PlaceOrderResult được giải thích ở trang tiếp theo.
Tự kiểm tra: quote() và placeOrder() khác nhau ở điểm nào khiến chúng đáng có hai tên thay vì một flag dryRun?
Tiếp theo: Lỗi, comment, DRY