Một đơn hàng thương mại điện tử đi qua các trạng thái: mới tạo, đã thanh toán, đang giao, đã giao, đã huỷ. Mỗi hành động như thanh toán, giao hàng hay huỷ chỉ hợp lệ ở một số trạng thái nhất định, và hành vi cũng khác nhau: huỷ đơn đã thanh toán phải hoàn tiền, huỷ đơn mới tạo thì không. State pattern cho phép đối tượng đổi hành vi khi trạng thái nội tại đổi, và khi đó người ngoài nhìn vào có cảm giác đối tượng đã đổi lớp. Bài này cài State pattern cho đơn hàng theo ba cách, rồi so sánh khi nào nên chọn cách nào để áp dụng State pattern hiệu quả.

Bài tiếp nối Chain of Responsibility: filter chain trong Servlet và Spring Security trong series Java & Design Pattern, và State pattern thuộc nhóm pattern hành vi như bài đó. Khác với chuỗi trách nhiệm, nơi yêu cầu chạy qua nhiều đối tượng, ở đây một đối tượng duy nhất đổi cách phản ứng theo hoàn cảnh. Toàn bộ code đã chạy trên JDK 21 (OpenJDK 21.0.x), máy Apple M4, 16 GB RAM, và output trong bài là output thật.

Vấn đề: switch trạng thái nằm khắp nơi và vai trò của State pattern

Cách viết đầu tiên của hầu hết lập trình viên là một trường status kiểu chuỗi hoặc enum, kèm các phương thức có if hoặc switch kiểm tra trường đó. Với hai trạng thái thì ổn. Khi có năm trạng thái và bốn hành động, bạn phải kiểm tra 20 tổ hợp, và các câu switch giống nhau lặp ở nhiều phương thức. Thêm trạng thái "đang đổi trả" nghĩa là rà lại từng phương thức, và quên một chỗ thì lỗi nghiệp vụ lọt vào production mà không có thông báo nào.

Theo refactoring.guru, State pattern gỡ vấn đề này bằng cách tạo một lớp cho mỗi trạng thái, chuyển hành vi riêng của trạng thái đó vào lớp, còn đối tượng gốc (context) giữ một tham chiếu tới trạng thái hiện tại và uỷ quyền cho nó. Đổi trạng thái chỉ là thay đối tượng trạng thái. Cách nhìn này gắn với khái niệm máy trạng thái hữu hạn: theo Wikipedia, đó là mô hình tính toán trong đó hệ thống luôn ở đúng một trong một số hữu hạn trạng thái, và đầu vào kích hoạt chuyển trạng thái.

Cấu trúc của State pattern

State pattern có ba vai, trình bày trong bảng dưới đây cùng phần tương ứng trong ví dụ.

Vai tròNhiệm vụTrong ví dụ đơn hàng
ContextGiữ trạng thái hiện tại, uỷ quyền hành động, cho phép thay trạng tháiOrder
State (interface)Khai báo các phương thức có hành vi khác nhau theo trạng tháiOrderState
Concrete stateCài đặt hành vi của một trạng thái, có thể kích hoạt chuyển trạng tháiNew, Paid, Shipped...

Phần quan trọng là quyền chuyển trạng thái. Theo refactoring.guru, cả context lẫn các trạng thái cụ thể đều có thể thay trạng thái đang hoạt động. Trong bản cài đặt dưới đây, mỗi trạng thái tự quyết định mình chuyển sang đâu, nên bảng các luật nghiệp vụ nằm rải theo từng lớp, mỗi lớp nhỏ và dễ đọc.

Cài đặt State pattern kinh điển cho đơn hàng

Giao diện OrderState khai báo bốn hành động, mỗi hành động có cài đặt mặc định là ném ngoại lệ. Nhờ vậy mỗi trạng thái cụ thể chỉ ghi đè những hành động hợp lệ với nó, còn mọi hành động khác tự động bị cấm. Đây là chi tiết thực dụng: danh sách "được phép" ngắn hơn nhiều so với danh sách "bị cấm".

    /** Giao diện State: chỉ khai báo những hành động có hành vi khác nhau theo trạng thái. */
    interface OrderState {
        String name();
        default void pay(Order o)     { throw new IllegalTransition(name(), "thanh toán"); }
        default void ship(Order o)    { throw new IllegalTransition(name(), "giao hàng"); }
        default void deliver(Order o) { throw new IllegalTransition(name(), "xác nhận đã nhận"); }
        default void cancel(Order o)  { throw new IllegalTransition(name(), "huỷ"); }
    }

    static class New implements OrderState {
        public String name() { return "NEW"; }
        public void pay(Order o)    { o.moveTo(new Paid()); }
        public void cancel(Order o) { o.moveTo(new Cancelled()); }
    }
    static class Paid implements OrderState {
        public String name() { return "PAID"; }
        public void ship(Order o)   { o.moveTo(new Shipped()); }
        public void cancel(Order o) { System.out.println("    (hoàn tiền cho khách)"); o.moveTo(new Cancelled()); }
    }
    static class Shipped implements OrderState {
        public String name() { return "SHIPPED"; }
        public void deliver(Order o) { o.moveTo(new Delivered()); }
    }
    static class Delivered implements OrderState { public String name() { return "DELIVERED"; } }
    static class Cancelled implements OrderState { public String name() { return "CANCELLED"; } }

Hành động cancel của Paid in thêm dòng hoàn tiền, còn cancel của New thì không: cùng một lời gọi, hành vi khác nhau, và không có if nào kiểm tra trạng thái. Chạy java ClassicState.java cho State pattern kinh điển:

Đơn A: [NEW, PAID, SHIPPED, DELIVERED]
    (hoàn tiền cho khách)
Đơn B: [NEW, PAID, CANCELLED]
Đơn C: Không thể huỷ khi đơn ở trạng thái SHIPPED
Đơn D: Không thể giao hàng khi đơn ở trạng thái NEW

Đơn C đã ở trạng thái SHIPPED nên huỷ bị từ chối với thông báo rõ ràng, và đơn D chưa thanh toán nên không giao được. Thêm trạng thái mới cho State pattern chỉ cần thêm một lớp và sửa các trạng thái trỏ tới nó, đúng tinh thần Open/Closed mà bài Nguyên lý SOLID trong Java qua ví dụ thực tế đã phân tích.

So sánh State pattern với Strategy

State pattern và Strategy có sơ đồ lớp gần như giống nhau: context uỷ quyền cho một đối tượng qua interface. Khác biệt nằm ở ý đồ. Theo refactoring.guru, trong Strategy các đối tượng trợ giúp độc lập và không biết nhau, còn trong State các trạng thái cụ thể có thể biết nhau và kích hoạt chuyển trạng thái, nên State có thể xem là phần mở rộng của Strategy. Nói gọn: Strategy do client chọn từ bên ngoài, còn State pattern do chính đối tượng tự đổi theo vòng đời của nó. Bài Strategy pattern với lambda và functional interface bàn kỹ phía còn lại.

State pattern bằng enum và bảng chuyển trạng thái

Cách cài State pattern kinh điển có nhược điểm: luật chuyển trạng thái bị chia nhỏ trong nhiều lớp, muốn nhìn toàn cảnh phải mở cả năm file. Với State pattern áp dụng cho đơn hàng, thứ bạn thường cần là một bảng dữ liệu "ở trạng thái X, sự kiện Y dẫn tới Z". Bảng này khớp với khái niệm bảng chuyển trạng thái trong lý thuyết máy trạng thái. Trong Java, enum kết hợp EnumMap là công cụ hợp lý: theo Javadoc của EnumMap, nó biểu diễn nội bộ bằng mảng, gọn và hiệu quả, và thường nhanh hơn HashMap (tài liệu nói "likely, though not guaranteed").

    enum Status { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }
    enum Event  { PAY, SHIP, DELIVER, CANCEL }

    static final Map<Status, Map<Event, Status>> TABLE = new EnumMap<>(Status.class);
    static {
        TABLE.put(Status.NEW,     new EnumMap<>(Map.of(Event.PAY, Status.PAID, Event.CANCEL, Status.CANCELLED)));
        TABLE.put(Status.PAID,    new EnumMap<>(Map.of(Event.SHIP, Status.SHIPPED, Event.CANCEL, Status.CANCELLED)));
        TABLE.put(Status.SHIPPED, new EnumMap<>(Map.of(Event.DELIVER, Status.DELIVERED)));
        TABLE.put(Status.DELIVERED, new EnumMap<>(Event.class));
        TABLE.put(Status.CANCELLED, new EnumMap<>(Event.class));
    }

    static Status next(Status from, Event e) {
        Status to = TABLE.get(from).get(e);
        if (to == null) throw new IllegalStateException(e + " không hợp lệ khi đơn ở " + from);
        return to;
    }

Điểm lợi lớn nhất của State pattern dạng bảng là bảng là dữ liệu, nên ta kiểm tra được chính sơ đồ. Đoạn main chạy một chuỗi sự kiện, rồi tính tập trạng thái tới được từ NEW, tìm các trạng thái kết thúc, và in sơ đồ dạng Graphviz DOT để vẽ lại:

NEW --PAY--> PAID
PAID --SHIP--> SHIPPED
SHIPPED --DELIVER--> DELIVERED
Lỗi: CANCEL không hợp lệ khi đơn ở SHIPPED
Tới được từ NEW: [NEW, PAID, SHIPPED, DELIVERED, CANCELLED]
Trạng thái kết thúc: [DELIVERED, CANCELLED]
digraph order {
  NEW -> PAID [label="PAY"];
  NEW -> CANCELLED [label="CANCEL"];
  PAID -> SHIPPED [label="SHIP"];
  PAID -> CANCELLED [label="CANCEL"];
  SHIPPED -> DELIVERED [label="DELIVER"];
}

Có thể dán đoạn DOT vào Graphviz để được sơ đồ trạng thái cho tài liệu. Đây là điều mà cách kinh điển không cho miễn phí. Đổi lại, State pattern dạng bảng không cho mỗi trạng thái mang dữ liệu riêng (số vận đơn chỉ có khi đã giao đi) và hành vi kèm theo như hoàn tiền phải đặt ở chỗ khác, thường là một lớp dịch vụ nhận kết quả chuyển trạng thái. Với máy trạng thái chỉ cần kiểm tra luật chuyển, đây là biến thể State pattern nhẹ nhất, và nhiều hệ thống đơn hàng thực tế dừng ở đây.

State pattern hiện đại với sealed interface và record

Từ Java 17, JEP 409 chính thức đưa sealed class vào ngôn ngữ, với mục tiêu để trình biên dịch kiểm tra các mẫu có đầy đủ hay không. Java 21 bổ sung pattern matching cho switch và record pattern. Ghép lại, ta có một cách mô hình hoá thứ ba: mỗi trạng thái là một record bất biến mang đúng dữ liệu của nó, và chuyển trạng thái là một hàm thuần (trạng thái, sự kiện) -> trạng thái mới.


    sealed interface OrderState permits New, Paid, Shipped, Delivered, Cancelled {}
    record New(String id) implements OrderState {}
    record Paid(String id, BigDecimal amount) implements OrderState {}
    record Shipped(String id, BigDecimal amount, String trackingNo) implements OrderState {}
    record Delivered(String id, LocalDate on) implements OrderState {}
    record Cancelled(String id, String reason) implements OrderState {}

    sealed interface Event permits Pay, Ship, Deliver, Cancel {}
    record Pay(BigDecimal amount) implements Event {}
    record Ship(String trackingNo) implements Event {}
    record Deliver(LocalDate on) implements Event {}
    record Cancel(String reason) implements Event {}

    /** Hàm thuần: (trạng thái, sự kiện) -> trạng thái mới. Không có default nên thêm trạng thái mới là compiler báo. */
    static OrderState apply(OrderState s, Event e) {
        return switch (s) {
            case New n -> switch (e) {
                case Pay p    -> new Paid(n.id(), p.amount());
                case Cancel c -> new Cancelled(n.id(), c.reason());
                case Ship x    -> throw bad(s, e);
                case Deliver x -> throw bad(s, e);
            };
            case Paid p -> switch (e) {
                case Ship sh  -> new Shipped(p.id(), p.amount(), sh.trackingNo());
                case Cancel c -> new Cancelled(p.id(), c.reason() + " (hoàn " + p.amount() + ")");
                case Pay x     -> throw bad(s, e);
                case Deliver x -> throw bad(s, e);
            };
            case Shipped sh -> switch (e) {
                case Deliver d -> new Delivered(sh.id(), d.on());
                case Pay x     -> throw bad(s, e);
                case Ship x    -> throw bad(s, e);
                case Cancel x  -> throw bad(s, e);
            };
            case Delivered d -> throw bad(s, e);
            case Cancelled c -> throw bad(s, e);
        };
    }

Bản State pattern này có hai điểm đáng chú ý. Thứ nhất, Shipped mang trackingNo còn New thì không: trạng thái không hợp lệ không thể biểu diễn được, vì không có đối tượng nào "đã giao đi mà thiếu số vận đơn". Thứ hai, switch không có nhánh default. Theo tài liệu pattern matching của Oracle, switch dùng pattern phải đầy đủ, và với sealed type thì trình biên dịch biết đủ các kiểu con được phép nên không cần default. Output khi chạy:

Pay[amount=1250000]  =>  Paid[id=DH-1001, amount=1250000]
Ship[trackingNo=VN123456]  =>  Shipped[id=DH-1001, amount=1250000, trackingNo=VN123456]
Deliver[on=2024-03-14]  =>  Delivered[id=DH-1001, on=2024-03-14]
Cancelled[id=DH-1002, reason=khách đổi ý (hoàn 300000)]
Lỗi: Cancel không hợp lệ ở Delivered

Để thấy lợi ích, tôi thêm thử trạng thái Returned (đổi trả) vào danh sách permits mà chưa sửa hàm apply (tôi chép file ra một thư mục tạm tmp/ để thử, nên đường dẫn trong thông báo bắt đầu bằng tmp/). Trình biên dịch từ chối ngay:

tmp/SealedState.java:23: error: the switch expression does not cover all possible input values
        return switch (s) {
               ^
1 error

Đây là bảo vệ mà hai biến thể State pattern trước không có ở mức biên dịch: thêm trạng thái mà quên xử lý thì build thất bại thay vì lỗi lúc chạy. Cần lưu ý, theo cùng tài liệu Oracle, nếu chỉ biên dịch lại riêng interface sealed mà không biên dịch lại nơi dùng switch, chương trình có thể ném MatchException lúc chạy, nên hãy build toàn bộ module cùng nhau. Hàm apply ở đây cũng viết hơi dài vì liệt kê tường minh các tổ hợp không hợp lệ; đó là cái giá của việc để trình biên dịch kiểm tra từng ô trong bảng.

Chọn biến thể nào của State pattern

Tiêu chíLớp trạng tháiEnum + bảngSealed + record
Hành vi khác nhau theo trạng tháiRất phù hợpPhải đặt ở nơi khácĐặt trong hàm hoặc dịch vụ
Dữ liệu riêng của từng trạng tháiCó, nhưng đối tượng thay đổi đượcKhôngCó, bất biến
Nhìn toàn cảnh luật chuyểnRải trong nhiều lớpMột bảng, vẽ được sơ đồMột hàm
Kiểm tra lúc biên dịchKhôngMột phần (enum)Có, switch đầy đủ
Phù hợp khiMỗi trạng thái nhiều logicChỉ cần kiểm tra luậtTrạng thái mang dữ liệu, ưu tiên an toàn

Không có cách nào đúng tuyệt đối. Quy tắc tôi áp dụng cho State pattern: bắt đầu bằng enum và bảng cho đến khi hành vi theo trạng thái phình to, lúc đó chuyển sang lớp trạng thái hoặc sealed interface. Nếu trạng thái của State pattern được lưu trong cơ sở dữ liệu, hãy lưu tên enum (dạng chuỗi), đừng lưu số thứ tự, để việc thêm trạng thái ở giữa không làm lệch dữ liệu cũ.

Khi nào không nên dùng State pattern

Theo refactoring.guru, State pattern có thể là quá mức cần thiết khi máy trạng thái chỉ có vài trạng thái hoặc hiếm khi thay đổi. Một cờ boolean paid không cần năm lớp. Hãy cân nhắc State pattern khi thoả cả hai điều kiện: số trạng thái từ khoảng bốn trở lên, và các hành động thật sự có hành vi khác nhau theo trạng thái chứ không chỉ "được hay không".

Với quy trình có điều kiện rẽ nhánh phức tạp, trạng thái lồng nhau, trạng thái song song, bộ đếm giờ hoặc cần lưu máy trạng thái xuống kho dữ liệu, bạn có thể dùng thư viện. Tài liệu Spring Statemachine mô tả các khái niệm như guard (điều kiện bảo vệ transition), action chạy khi chuyển hoặc khi vào ra trạng thái, trạng thái phân cấp và vùng song song. Đổi lại bạn nhận thêm một khung phụ thuộc và đường cong học; hãy kiểm tra tình trạng bảo trì và phiên bản tương thích với Spring Boot đang dùng trước khi chọn.

Lỗi thường gặp với State pattern

  • Chuyển trạng thái không nguyên tử: hai request cùng thanh toán một đơn. Tôi thử với 8 luồng, mỗi luồng gọi pay trên cùng một đơn. Bản "kiểm tra rồi đổi" bình thường trừ tiền cả 8 lần, bản dùng compareAndSet chỉ trừ một lần.
Không đồng bộ: số lần trừ tiền = 8
compareAndSet: số lần trừ tiền = 1
Không đồng bộ: số lần trừ tiền = 8
compareAndSet: số lần trừ tiền = 1
Không đồng bộ: số lần trừ tiền = 8
compareAndSet: số lần trừ tiền = 1

Kết quả lặp lại ba lần đều giống nhau (file DoublePay.java trong thư mục code). Trong ứng dụng có cơ sở dữ liệu, hiệu ứng tương tự xảy ra giữa nhiều tiến trình, nên cần cập nhật có điều kiện, ví dụ UPDATE orders SET status='PAID' WHERE id=? AND status='NEW' và kiểm tra số dòng bị ảnh hưởng, hoặc khoá lạc quan bằng cột phiên bản.

  • Trạng thái giữ dữ liệu của context: nếu mỗi lần chuyển bạn tạo đối tượng trạng thái mới, mọi dữ liệu dùng chung phải nằm trong context, đừng giấu trong trạng thái cũ vì nó sẽ mất.
  • Không ghi lịch sử chuyển trạng thái: đơn hàng cần truy vết ai đổi, lúc nào. Ghi mỗi lần chuyển như trong history của ví dụ đầu, hoặc lưu bảng sự kiện.
  • Nuốt lỗi chuyển không hợp lệ: bắt ngoại lệ rồi bỏ qua làm mất tín hiệu. Hãy trả mã lỗi rõ ràng cho client.
  • Dùng chuỗi tự do làm trạng thái trong State pattern: gõ sai "SHIPED" không bị bắt. Dùng enum hoặc sealed type.

Checklist cho State pattern

  • Liệt kê trạng thái và sự kiện, vẽ sơ đồ trước khi viết code; ghi rõ trạng thái kết thúc.
  • Chọn biến thể State pattern: lớp trạng thái, enum và bảng, hoặc sealed và record, theo bảng so sánh ở trên.
  • Mặc định cấm mọi chuyển trạng thái, chỉ mở những chuyển được liệt kê.
  • Bảo đảm chuyển trạng thái nguyên tử khi có song song.
  • Ghi lại lịch sử và viết test cho từng chuyển hợp lệ lẫn không hợp lệ.
  • Khi thêm trạng thái, để trình biên dịch hoặc test chỉ ra các chỗ cần sửa.

Để chạy lại ví dụ, dùng java trực tiếp trên từng file Java trong thư mục code của bài (JDK 21), hoặc chạy runall.sh.

Bài tiếp theo

State pattern thay đổi hành vi theo trạng thái bên trong. Bài tiếp theo chuyển sang cách duyệt qua một tập hợp mà không lộ cấu trúc bên trong của nó: Iterator pattern trong Java: Iterable, Spliterator và Stream.