All posts
State Machine, khi logic nghiệp vụ không còn là mớ if-else chồng chéo
patternstate-machine

State Machine, khi logic nghiệp vụ không còn là mớ if-else chồng chéo

Tien Le H.'s avatarTien Le H.
Table of Contents6 sections

Trong bài viết trước, Bảo mật webhook endpoint: từ một endpoint "trần trụi" đến một endpoint đáng tin, lúc bàn về chuyện event cũ ghi đè trạng thái mới, mình có nhắc qua state machine như một trong hai cơ chế giúp chặn những transition vô lý, nhưng chỉ dừng ở vài dòng ngắn gọn, chưa kịp giải thích nó là gì. Để mạch kiến thức được liền lạc, lần này mình dành hẳn một bài riêng cho state machine, không chỉ như một khái niệm lý thuyết, mà như một cách tư duy mình vẫn dùng mỗi khi logic trạng thái của một entity như đơn hàng, hợp đồng, hay ticket hỗ trợ bắt đầu phình to thành một mớ if/else lồng nhau, kèm theo hàng loạt biến boolean như isPaid, isShipped, isCancelled được thêm vào bất cứ khi nào có yêu cầu mới.

Mục tiêu của bài viết không phải để các bạn copy nguyên đoạn code mẫu về dùng, mà để nắm được bản chất của concept này: đây không phải một pattern cao siêu, mà là một cách nhìn khác về logic nghiệp vụ, giúp code dễ đọc hơn, dễ kiểm thử hơn, và quan trọng nhất là khó viết sai hơn. Nắm được bản chất rồi, việc áp dụng vào ngôn ngữ hay framework nào cũng chỉ còn là chi tiết.

Câu chuyện của cây đèn giao thông

Trước khi đi vào định nghĩa kỹ thuật, hãy nghĩ về một cây đèn giao thông. Nó chỉ có ba trạng thái: đỏ, vàng, xanh. Tại bất kỳ thời điểm nào, đèn chỉ có thể ở đúng một trạng thái, không bao giờ vừa đỏ vừa xanh cùng lúc. Việc chuyển từ trạng thái này sang trạng thái khác không xảy ra tùy tiện: xanh chỉ có thể chuyển sang vàng, vàng chỉ có thể chuyển sang đỏ, đỏ chỉ có thể chuyển sang xanh. Không có chuyện đèn đang đỏ bỗng nhảy thẳng sang vàng rồi quay lại đỏ.

Điều thú vị là chính những giới hạn đó lại là thứ khiến hệ thống đáng tin cậy. Bạn không cần viết một hàm kiểm tra "làm sao để biết đèn đang không bị hỏng", vì bản thân cấu trúc của hệ thống đã loại trừ những trạng thái vô nghĩa ngay từ đầu. Đây chính là tinh thần cốt lõi của state machine: thay vì kiểm tra tính hợp lệ bằng vô số câu điều kiện rải rác khắp nơi, bạn định nghĩa rõ ràng những trạng thái nào được phép tồn tại, và những đường chuyển nào được phép xảy ra. Mọi thứ ngoài danh sách đó đơn giản là không thể xảy ra.

Bên trong một state machine gồm những gì

Về mặt kỹ thuật, một state machine được định nghĩa bởi bốn thành phần.

Đầu tiên là tập hợp các trạng thái (states), tức danh sách hữu hạn những "tình trạng" mà hệ thống có thể ở trong đó tại một thời điểm bất kỳ. Thứ hai là sự kiện (events), là những tín hiệu từ bên ngoài kích hoạt khả năng chuyển trạng thái. Thứ ba là các đường chuyển (transitions), quy định rõ từ trạng thái A, khi nhận sự kiện X, hệ thống sẽ đi đến trạng thái B nào. Thứ tư, tùy chọn nhưng rất quan trọng trong thực tế, là guard và action: guard là điều kiện phụ phải thỏa mãn thì đường chuyển mới được thực hiện (ví dụ chỉ cho hoàn tiền nếu đơn hàng chưa quá bảy ngày), còn action là hành động phụ được thực thi khi đường chuyển xảy ra (ví dụ gửi email xác nhận).

Nếu đọc thêm tài liệu học thuật, bạn sẽ gặp hai biến thể là Moore machine và Mealy machine. Sự khác biệt nằm ở chỗ output được gắn với trạng thái hay gắn với đường chuyển: Moore machine phát sinh output dựa trên trạng thái hiện tại, còn Mealy machine phát sinh output dựa trên cặp trạng thái và sự kiện. Trong lập trình ứng dụng hàng ngày, ranh giới này thường mờ nhạt, và phần lớn framework hiện đại cho phép trộn lẫn cả hai cách tiếp cận. Điều quan trọng hơn cần nhớ là bản chất: trạng thái, sự kiện, đường chuyển, và điều kiện đi kèm.

Một ví dụ cụ thể: vòng đời của đơn hàng

Hãy thử áp dụng vào một bài toán quen thuộc: quản lý trạng thái của một đơn hàng trong hệ thống thương mại điện tử. Một đơn hàng có thể ở các trạng thái: chờ thanh toán, đã thanh toán, đã giao cho đơn vị vận chuyển, đã giao thành công, đã hủy, hoặc đã hoàn tiền. Nếu viết theo kiểu cũ, bạn sẽ có một field status: string và rải rác khắp codebase là những đoạn kiểm tra kiểu if (order.status === 'paid' && !order.isCancelled). Cách này rất dễ dẫn đến tình huống một đơn hàng bị đánh dấu vừa "đã hủy" vừa "đã giao", điều không nên xảy ra trong thực tế nhưng không có gì trong code ngăn cản nó.

Với cách tiếp cận state machine, bạn định nghĩa tường minh bảng chuyển trạng thái trước, rồi để logic tự tuân theo bảng đó.

type OrderState =
  | 'PENDING'
  | 'PAID'
  | 'SHIPPED'
  | 'DELIVERED'
  | 'CANCELLED'
  | 'REFUNDED';

type OrderEvent = 'PAY' | 'SHIP' | 'DELIVER' | 'CANCEL' | 'REFUND';

const transitions: Record<OrderState, Partial<Record<OrderEvent, OrderState>>> = {
  PENDING: { PAY: 'PAID', CANCEL: 'CANCELLED' },
  PAID: { SHIP: 'SHIPPED', CANCEL: 'CANCELLED', REFUND: 'REFUNDED' },
  SHIPPED: { DELIVER: 'DELIVERED' },
  DELIVERED: { REFUND: 'REFUNDED' },
  CANCELLED: {},
  REFUNDED: {},
};

function transition(current: OrderState, event: OrderEvent): OrderState {
  const next = transitions[current][event];
  if (!next) {
    throw new Error(
      `Không thể xử lý sự kiện ${event} khi đơn hàng đang ở trạng thái ${current}`
    );
  }
  return next;
}

Chỉ với đoạn code này, những trạng thái vô lý như "đã giao nhưng lại hủy được" tự động biến mất, vì bảng transitions đơn giản là không định nghĩa đường đi đó. Bạn không cần nhớ để viết một câu điều kiện chặn nó, cấu trúc dữ liệu đã làm việc đó thay bạn.

Khi cần thêm guard, ví dụ chỉ cho hoàn tiền trong vòng bảy ngày kể từ lúc giao hàng, bạn chỉ cần bọc thêm một lớp kiểm tra trước khi gọi transition, thay vì nhét điều kiện đó vào giữa một chuỗi if/else dài:

function canRefund(order: { deliveredAt: Date }): boolean {
  const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
  return Date.now() - order.deliveredAt.getTime() <= sevenDaysMs;
}

Khi số lượng trạng thái và điều kiện tăng lên, việc tự viết bảng chuyển bằng tay sẽ dần trở nên cồng kềnh. Đây là lúc các thư viện chuyên dụng như XState phát huy tác dụng, chúng cung cấp cú pháp khai báo cho toàn bộ máy trạng thái, kèm theo khả năng trực quan hóa sơ đồ và kiểm thử từng đường chuyển một cách độc lập.

pnpm add xstate

State machine trong hệ thống thực tế

Ý tưởng này không chỉ nằm trong sách vở. AWS Step Functions dùng Amazon States Language, một định dạng JSON mô tả workflow dưới dạng các trạng thái và đường chuyển, để điều phối các bước xử lý phân tán, chẳng hạn như chuỗi Lambda function trong một quy trình duyệt đơn hàng nhiều bước. Nếu bạn đã đọc bài viết trước của mình về Saga Pattern, bản chất của một saga cũng chính là một state machine: mỗi bước trong saga là một trạng thái, mỗi lần thành công hoặc thất bại là một sự kiện, và logic bù trừ (compensation) chính là những đường chuyển đặc biệt khi có lỗi xảy ra.

Ở tầng giao diện người dùng, các thư viện như XState được dùng để quản lý trạng thái phức tạp của một component, ví dụ một form nhiều bước với các trạng thái đang nhập, đang gửi, gửi thành công, gửi thất bại. Trong game, AI của nhân vật NPC thường được mô hình hóa bằng state machine với các trạng thái như đứng yên, tuần tra, truy đuổi, tấn công. Điểm chung của tất cả các ứng dụng này là chúng đều xử lý một thực thể có vòng đời rõ ràng, nơi thứ tự và điều kiện chuyển đổi quan trọng không kém gì bản thân dữ liệu.

Cái giá phải trả: khi state machine trở thành gánh nặng

State machine không phải là công cụ vạn năng, và có một cái bẫy khá phổ biến gọi là bùng nổ trạng thái (state explosion). Nếu một entity có nhiều khía cạnh độc lập, ví dụ trạng thái thanh toán, trạng thái vận chuyển, và trạng thái thông báo, việc gộp tất cả vào một state machine phẳng sẽ khiến số lượng trạng thái tăng theo cấp số nhân: ba trạng thái thanh toán nhân với bốn trạng thái vận chuyển nhân với hai trạng thái thông báo đã tạo ra hai mươi bốn tổ hợp, phần lớn trong số đó không thực sự có ý nghĩa nghiệp vụ riêng biệt.

Cách xử lý phổ biến là dùng statechart, tức state machine phân cấp (hierarchical) hoặc song song (parallel), nơi mỗi khía cạnh được mô hình hóa như một state machine con độc lập, thay vì gộp chung vào một tầng phẳng duy nhất. Nhưng nếu bạn chỉ đang xử lý một cờ bật/tắt đơn giản, ví dụ một checkbox "đã đọc" hay "chưa đọc", việc áp dụng cả một framework state machine cho nó là sự phức tạp hóa không cần thiết. Công cụ nào cũng có chi phí sử dụng, và state machine tỏa sáng nhất khi số lượng trạng thái đủ nhiều và luật chuyển đổi đủ tinh vi để việc viết tay bằng if/else trở nên rủi ro.

Khi nào nên dùng, khi nào nên bỏ qua

Nếu entity của bạn có một vòng đời rõ ràng, có nhiều hơn ba hoặc bốn trạng thái, và có những luật chuyển đổi mà vi phạm chúng sẽ gây ra lỗi nghiệp vụ nghiêm trọng, đó là tín hiệu tốt để dùng state machine. Nó giúp bạn viết ít bug hơn không phải vì bạn cẩn thận hơn, mà vì cấu trúc đã loại bỏ khả năng viết sai ngay từ thiết kế. Nó cũng giúp việc trao đổi với người không rành kỹ thuật dễ dàng hơn, vì một sơ đồ trạng thái thường dễ hiểu hơn nhiều so với một đoạn code đầy điều kiện lồng nhau.

Ngược lại, nếu bạn chỉ đang xử lý một hoặc hai cờ boolean độc lập, không có ràng buộc thứ tự phức tạp giữa chúng, việc đưa vào một thư viện state machine chỉ làm tăng độ phức tạp mà không mang lại lợi ích tương xứng. Như với hầu hết các công cụ thiết kế phần mềm, câu hỏi không phải là "công cụ này có tốt không", mà là "vấn đề mình đang giải quyết có đủ phức tạp để xứng đáng với chi phí của công cụ này không".

Lần tới khi bạn thấy mình đang thêm một biến boolean mới vào một entity đã có sẵn ba bốn cờ trạng thái khác, đó có thể là lúc đáng để dừng lại và tự hỏi: liệu một state machine tường minh có giúp mọi thứ rõ ràng hơn không.


Cảm ơn các bạn đã dành thời gian đọc hết đống chữ này. Chắc sẽ nghiên cứu đổi cách thức truyền tải để dễ dàng tiếp cận hơn và nắm bắt nhanh hơn.