Ngừng Viết Switch-Case Lớn: Dùng Strategy Pattern

Ngừng Viết Switch-Case Lớn: Dùng Strategy Pattern

Hướng dẫn refactor các khối switch-case khổng lồ trong TypeScript bằng Strategy Pattern, giúp code dễ bảo trì, dễ test và tránh merge conflict.

Bạn có đang vật lộn với những khối switch-case khổng lồ phình to ra mỗi khi thêm tính năng mới? Bài viết này sẽ hướng dẫn cách sử dụng Strategy Pattern trong TypeScript để phân chia các khối lệnh rối rắm này thành những đoạn code sạch, dễ test và dễ bảo trì.

Chắc hẳn ai trong chúng ta cũng từng trải qua chuyện này: Bạn bắt đầu một project mới và cần viết một hàm xử lý thanh toán. Bạn gõ một khối switch-case gọn gàng xinh xắn để xử lý hai loại: thẻ tín dụng và PayPal. Mọi thứ chỉ mất cỡ 20 dòng code. Đời vẫn đẹp.

Một năm sau nhìn lại. Tính năng phình to ra, và cái hàm gọn gàng ngày nào giờ đã biến thành một con quái vật 500 dòng code. Nó gánh đủ thứ từ Apple Pay, Google Pay, chuyển khoản ngân hàng, ví Crypto cho tới mua-trước-trả-sau. Mỗi lần công ty yêu cầu thêm một cổng thanh toán mới, bạn lại phải rón rén mở cái file khổng lồ đó ra, cuộn chuột mỏi tay đến dòng số 342, và chèn thêm một cái case mới.

Đây là một cái bẫy kinh điển trong lập trình. Chúng ta thường vô tình để các cấu trúc rẽ nhánh đơn giản phình to thành những "God function" (hàm ôm đồm mọi thứ). Hôm nay, hãy cùng tìm hiểu cách dừng ngay anti-pattern này lại.

Vấn Đề Của Những Khối Rẽ Nhánh Khổng Lồ

Tại sao một chuỗi switch-case hay if-else dài dằng dặc lại tồi tệ đến vậy?

  1. Vi phạm nguyên tắc Open-Closed (OCP): Nguyên tắc này chỉ ra rằng code của bạn nên mở để mở rộng, nhưng đóng để sửa đổi. Khi bạn thêm một phương thức thanh toán mới bằng cách thêm case, bạn đang trực tiếp chỉnh sửa hàm cốt lõi processPayment. Điều này tăng rủi ro làm hỏng luôn cả những logic cũ đang chạy ngon lành.
  2. Ác mộng Merge Conflict: Nếu team có nhiều dev cùng làm các task liên quan đến thanh toán, tất cả sẽ cùng sửa một file, cùng chèn vào một khối switch. Lúc merge code lên Git, conflict là chuyện không thể tránh khỏi và cực kỳ dễ xảy ra sai sót lúc rà soát code.
  3. Thử thách Unit Test: Để viết test cho nhánh PayPal, bạn phải import cả cái hàm khổng lồ đó, và đôi khi phải mock luôn cả những dependency của Stripe hay Crypto dù chả liên quan gì. File test của bạn sẽ trở nên cồng kềnh và dễ vỡ.

Cách Tiếp Cận Functional: Sử Dụng Dictionaries (Object Maps)

Một mớ dây cáp rối rắm nhiều màu sắc tượng trưng cho code rẽ nhánh phức tạp

Trong JavaScript và TypeScript, chúng ta không nhất thiết phải dùng kiến trúc Hướng đối tượng (OOP) nặng nề để áp dụng Strategy Pattern. Bạn hoàn toàn có thể áp dụng phong cách Functional Programming — dùng các Object hay Map để làm từ điển (dictionary) chứa các chiến lược (strategy).

Hãy xem đoạn code "Trước khi refactor":

type PaymentMethod = 'stripe' | 'paypal' | 'crypto';

async function processPayment(method: PaymentMethod, amount: number) {
  switch (method) {
    case 'stripe':
      // 50 dòng code setup và gọi API Stripe
      console.log(`Đang xử lý $${amount} qua Stripe...`);
      break;
    case 'paypal':
      // 40 dòng code logic của PayPal
      console.log(`Đang xử lý $${amount} qua PayPal...`);
      break;
    case 'crypto':
      // 60 dòng code logic Web3
      console.log(`Đang xử lý $${amount} qua Crypto...`);
      break;
    default:
      throw new Error('Phương thức thanh toán không hợp lệ');
  }
}

Thay vì dùng switch, chúng ta sẽ tách logic của từng nhánh ra thành một hàm riêng biệt, và map chúng lại bằng kiểu Record của TypeScript:

type PaymentMethod = 'stripe' | 'paypal' | 'crypto';
type PaymentHandler = (amount: number) => Promise<void>;

const handleStripe: PaymentHandler = async (amount) => {
  console.log(`Đang xử lý $${amount} qua Stripe...`);
};

const handlePaypal: PaymentHandler = async (amount) => {
  console.log(`Đang xử lý $${amount} qua PayPal...`);
};

const handleCrypto: PaymentHandler = async (amount) => {
  console.log(`Đang xử lý $${amount} qua Crypto...`);
};

// Cấu trúc Strategy Map
const paymentStrategies: Record<PaymentMethod, PaymentHandler> = {
  stripe: handleStripe,
  paypal: handlePaypal,
  crypto: handleCrypto,
};

async function processPayment(method: PaymentMethod, amount: number) {
  const handler = paymentStrategies[method];
  if (!handler) {
    throw new Error(`Không tìm thấy strategy cho ${method}`);
  }
  await handler(amount);
}

Lợi ích của cú refactor này là khổng lồ. Hàm processPayment giờ đã thực sự "đóng để sửa đổi". Nếu cần thêm apple_pay, bạn chỉ cần viết một hàm handleApplePay và đăng ký nó vào object paymentStrategies. Hàm gốc không bị đụng chạm tới một chữ nào.

Vẫn Giữ Được "Exhaustive Check" Của TypeScript

Nhiều anh em thích switch vì TypeScript hỗ trợ exhaustive check (kiểm tra toàn diện) — nếu bạn thêm một giá trị mới vào union type PaymentMethod, TypeScript sẽ báo lỗi đỏ lòm nếu bạn quên không handle nó trong switch.

Tin vui là cách dùng Record của chúng ta cũng làm được y hệt! Vì ta khai báo kiểu là Record<PaymentMethod, PaymentHandler>, nếu bạn thêm 'apple_pay' vào type gốc, TypeScript sẽ lập tức báo lỗi ở cục paymentStrategies cho đến khi bạn bổ sung key tương ứng vào đó. Bạn có được sự an toàn tuyệt đối của type system mà code vẫn cực kỳ sạch sẽ.

Cách Tiếp Cận Hướng Đối Tượng (OOP): Class và Interface

Các hộp lưu trữ được sắp xếp gọn gàng trên kệ gỗ

Nếu các strategy của bạn cực kỳ phức tạp, cần lưu giữ state (trạng thái) riêng hoặc gọi đến nhiều dependency bên ngoài (như database, logger, API client), thì cách triển khai OOP lại tỏa sáng rực rỡ.

Đầu tiên, hãy định nghĩa một interface chung:

interface IPaymentStrategy {
  pay(amount: number): Promise<boolean>;
}

Tiếp theo, tạo các class cụ thể implement interface này:

class StripeStrategy implements IPaymentStrategy {
  constructor(private readonly apiKey: string) {}

  async pay(amount: number): Promise<boolean> {
    // Logic gọi Stripe API phức tạp ở đây
    console.log(`Thanh toán Stripe với key ${this.apiKey}`);
    return true;
  }
}

class PaypalStrategy implements IPaymentStrategy {
  constructor(private readonly clientId: string) {}

  async pay(amount: number): Promise<boolean> {
    // Logic gọi PayPal API phức tạp ở đây
    console.log(`Thanh toán PayPal với client ${this.clientId}`);
    return true;
  }
}

Cuối cùng, dùng một class Context hoặc Dependency Injection (rất phổ biến nếu bạn code framework như NestJS) để inject và thực thi strategy phù hợp. Đây là ví dụ tiêm phụ thuộc thủ công (Manual DI):

class PaymentProcessor {
  private strategy: IPaymentStrategy;

  constructor(strategy: IPaymentStrategy) {
    this.strategy = strategy;
  }

  setStrategy(strategy: IPaymentStrategy) {
    this.strategy = strategy;
  }

  async executePayment(amount: number) {
    return await this.strategy.pay(amount);
  }
}

// Cách dùng:
const processor = new PaymentProcessor(new StripeStrategy('sk_test_...'));
await processor.executePayment(100);

// Chuyển strategy ngay lúc runtime mà không ảnh hưởng core business logic
processor.setStrategy(new PaypalStrategy('client_...'));
await processor.executePayment(200);

Điểm Ăn Tiền Nhất: Khả Năng Dễ Test (Testability)

Dù bạn chọn cách Functional hay OOP, lợi ích lớn nhất vẫn là khả năng test biệt lập (Isolated Testing).

Trở lại với kỉ nguyên của "God function", để test thành công nhánh PayPal, bạn phải vượt qua ải setup môi trường, mock các dependency của bọn Stripe, Crypto (dù chả dùng tới), làm file test phình to khủng khiếp.

Nhưng với Strategy Pattern, PaypalStrategy (hoặc hàm handlePaypal) được tách ra một file riêng rẽ. Bạn có thể ung dung viết unit test chọc ngoáy vào mọi ngóc ngách của nó mà không màng thế sự xung quanh. Hàm cốt lõi processPayment giờ chỉ cần đúng một test case duy nhất để đảm bảo nó gọi đúng cái map là xong.

Khi Nào KHÔNG Nên Dùng Strategy Pattern?

Cũng giống như mọi Design Pattern khác trên đời, Strategy là một công cụ, không phải là tôn giáo. Đừng nhắm mắt áp dụng nó ở mọi nơi.

Nếu cái switch-case của bạn chỉ đơn thuần là toggle giao diện (chuyển đổi theme light, dark, system) hoặc tính toán vài ba con số đơn giản, thì việc vác Strategy ra là hành động "over-engineering" (làm quá vấn đề). Nó sẽ đẻ ra một đống boilerplate code thừa thãi và làm logic bị rời rạc.

Cá nhân mình luôn dùng Quy tắc số 3 (Rule of Three): Hãy đợi cho đến khi bạn có ít nhất 3 case phân nhánh rõ rệt, hoặc khi một block logic bắt đầu trở nên quá phức tạp cần phải viết unit test riêng, thì hẵng lôi Strategy Pattern ra để dọn dẹp.

Lời Kết

Những khối điều kiện khổng lồ là mảnh đất màu mỡ cho bug, merge conflict và technical debt (nợ kỹ thuật). Bằng cách nhận ra thời điểm một khối rẽ nhánh bắt đầu quá tải và chặt nó ra bằng Strategy Pattern — dù là theo kiểu object map gọn nhẹ hay OOP bài bản — bạn sẽ giúp code TypeScript của mình dẻo dai, dễ bảo trì và dễ thở hơn rất nhiều.

Đừng bỏ chung tất cả logic vào một giỏ nữa. Hãy chia để trị, và để các Strategy làm những việc nặng nhọc thay bạn.

NT

viết bởi

Nguyên Tech

0

Phản hồi

Đang tải bình luận…

Lattice.

Một không gian để viết dài, đọc chậm, và trò chuyện thật — không thuật toán, không quảng cáo.

© 2026 · Lattice · Đà Nẵng (16°03′ N, 108°12′ E) · v0.1 · system + ink + indigo