Bỏ qua để đến nội dung
Knowledge Hub

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…

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ứ ba
if (items && items.length > 0) {
if (user) { /* ... 20 dòng ... */ } else throw new Error('error');
}
return 0;
// After
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' };
// đường chính từ đây, không thụt lề

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.

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ích
await 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_VND làm nguồn cho cả type ShippingMethod: 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 + 1 rõ 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.

Ý 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.
Sau refactor: shell gom input và làm side effect, core chỉ tính toán
Sau refactor: shell gom input và làm side effect, core chỉ tính toánBước 1/5: Controller validate DTO rồi gửi một PlaceOrderCommand (parameter object) vào service. Không còn boolean express/dryRun.Imperative shell:I/O, đồng hồ, lỗi hạtầngFunctional core:thuần, test không cầnmockControllerDTO → commandCheckoutServiceđiều phốiPaymentPortadapter cổng TTOrderRepositoryadapter, cũng là shellquoteTotalVnd()input + now → số tiềnPlaceOrderCommand
Controller → CheckoutServicePlaceOrderCommand
Bước 1/5

Controller 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ì quoteTotalVnd không chạm I/O.
  • Test của placeOrder chỉ 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à đổi return 0 cho giỏ rỗng thành lỗi có tên. Đó là thay đổi hành vi, nên đi trong commit fix: 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