- 1. State Pattern คืออะไร
- 2. ปัญหาที่มักพบก่อนใช้ Pattern
- 3. ส่วนประกอบสำคัญ
- 4. กฎธุรกิจของ Workshop
- 5. เตรียมสภาพแวดล้อม
- 6. โค้ดฉบับสมบูรณ์
- 7. ทดลองรัน
- 8. แนวทางทดสอบ
- 9. State Pattern ต่างจาก Strategy Pattern อย่างไร
- 10. ข้อดี ข้อจำกัด และการเลือกใช้
- 11. ข้อควรพิจารณาเมื่อใช้ในระบบจริง
- 12. แบบฝึกหัดต่อยอด
- สาระสำคัญ
- เอกสารประกอบ
เรียนรู้ Behavioral Design Pattern ผ่าน Workshop ระบบคำสั่งซื้อ พร้อมโค้ด Java แบบไฟล์เดียว ตารางเปลี่ยนสถานะ และแนวทางทดสอบ

#1. State Pattern คืออะไร
State Pattern เป็นรูปแบบการออกแบบในกลุ่ม Behavioral Patterns ที่ช่วยให้ออบเจ็กต์เปลี่ยนพฤติกรรมตามสถานะภายใน โดยแยกพฤติกรรมของแต่ละสถานะไปไว้ในออบเจ็กต์ที่รับผิดชอบสถานะนั้น
ออบเจ็กต์หลักหรือ Context เก็บออบเจ็กต์สถานะปัจจุบัน และส่งต่อคำสั่งให้สถานะนั้นจัดการผ่านอินเทอร์เฟซร่วมกัน เทคนิคนี้ใช้ composition และ polymorphism ไม่ได้เปลี่ยนคลาสจริงของ Context ระหว่างรันโปรแกรม
ลองนึกถึงคำสั่งซื้อ: เมื่อยังไม่ชำระเงิน เราสามารถชำระเงินหรือยกเลิกได้ เมื่อชำระเงินแล้วจึงจัดส่งได้ แต่หลังจัดส่งแล้ว การยกเลิกอาจต้องเป็นกระบวนการคืนสินค้าที่แยกต่างหาก การเรียกเมธอดเดียวกันจึงอาจสำเร็จหรือถูกปฏิเสธตามสถานะ
#2. ปัญหาที่มักพบก่อนใช้ Pattern
หากเก็บสถานะไว้ในตัวแปร แล้วเขียนเงื่อนไขในทุกเมธอด โค้ดอาจมีลักษณะดังนี้:
// ตัวอย่างแนวคิด ไม่ใช่โปรแกรมฉบับสมบูรณ์
public void cancel() {
if (status == Status.NEW || status == Status.PAID) {
status = Status.CANCELLED;
} else {
throw new IllegalStateException("Cannot cancel this order");
}
}
วิธีนี้ไม่ได้ผิดเสมอไป หากมีสถานะและพฤติกรรมน้อย การใช้ enum กับ switch อาจอ่านง่ายกว่า แต่เมื่อหลายเมธอดต้องตรวจสถานะซ้ำ การเพิ่มสถานะใหม่จะกระทบเงื่อนไขหลายจุด และอาจทำให้กฎธุรกิจไม่สอดคล้องกัน
State Pattern ช่วยรวบรวมพฤติกรรมที่เกี่ยวข้องกับสถานะเดียวกันไว้ด้วยกัน เช่น กฎของคำสั่งซื้อที่ชำระเงินแล้วอยู่ใน PaidState
#3. ส่วนประกอบสำคัญ
| องค์ประกอบ | หน้าที่ | ตัวอย่างใน Workshop |
|---|---|---|
| Context | เก็บสถานะปัจจุบันและส่งต่อคำสั่ง | Order |
| State | กำหนดสัญญาพฤติกรรมที่ทุกสถานะใช้ร่วมกัน | OrderState |
| Concrete State | กำหนดพฤติกรรมและการเปลี่ยนสถานะที่อนุญาต | NewState, PaidState, ShippedState, CancelledState |
| Client | สั่งงานผ่าน Context | main() |
เราเพิ่ม BaseState เพื่อกำหนดพฤติกรรมเริ่มต้นว่า “ปฏิเสธคำสั่ง” แล้วให้แต่ละ Concrete State override เฉพาะคำสั่งที่อนุญาต คลาสฐานนี้เป็นทางเลือกในการออกแบบ ไม่ใช่องค์ประกอบที่ Pattern บังคับให้ต้องมี
ในตัวอย่างนี้ State เป็นผู้ตัดสินใจว่าจะเปลี่ยนไปสถานะใด ส่วน Context เป็นผู้เก็บสถานะใหม่ การออกแบบอื่นอาจรวมกฎการเปลี่ยนสถานะไว้ที่ Context ได้เช่นกัน
#4. กฎธุรกิจของ Workshop
ใช้ 4 สถานะ:
NEW: รอชำระเงินPAID: ชำระเงินแล้วSHIPPED: จัดส่งแล้วCANCELLED: ยกเลิกแล้ว
| สถานะปัจจุบัน | pay() |
ship() |
cancel() |
|---|---|---|---|
| NEW | เปลี่ยนเป็น PAID | ปฏิเสธ | เปลี่ยนเป็น CANCELLED |
| PAID | ปฏิเสธ | เปลี่ยนเป็น SHIPPED | เปลี่ยนเป็น CANCELLED |
| SHIPPED | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
| CANCELLED | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
SHIPPED และ CANCELLED เป็นสถานะปลายทาง เฉพาะขอบเขตของ Workshop นี้ ระบบจริงอาจมี DELIVERED, RETURNED หรือ REFUND_PENDING เพิ่มเติม
การยกเลิกหลังชำระเงินในตัวอย่างเป็นเพียงการเปลี่ยนสถานะ ไม่ได้คืนเงินจริง ระบบที่เชื่อมต่อผู้ให้บริการชำระเงินต้องออกแบบขั้นตอนคืนเงินและการจัดการความล้มเหลวเพิ่มเติม
#แผนภาพการเปลี่ยนสถานะ
stateDiagram-v2
[*] --> NEW
NEW --> PAID: pay()
NEW --> CANCELLED: cancel()
PAID --> SHIPPED: ship()
PAID --> CANCELLED: cancel()
SHIPPED --> [*]
CANCELLED --> [*]
เส้นลูกศรแสดงเฉพาะคำสั่งที่อนุญาต คำสั่งอื่นต้องถูกปฏิเสธ และสถานะเดิมต้องไม่เปลี่ยน
#5. เตรียมสภาพแวดล้อม
ใช้ Java 17 เป็นฐานของ Workshop ไม่ต้องใช้ Maven, Gradle หรือไลบรารีเพิ่มเติม ตรวจสอบเครื่องด้วย:
java -version
javac -version
หากต้องการคอมไพล์ด้วย javac ต้องติดตั้ง JDK และตั้งค่า PATH ให้เรียกเครื่องมือได้
สร้างไฟล์ชื่อ StatePatternDemo.java ให้ตรงกับชื่อ public class แล้ววางโค้ดต่อไปนี้
#6. โค้ดฉบับสมบูรณ์
import java.util.Objects;
public class StatePatternDemo {
private interface OrderState {
String name();
void pay(Order order);
void ship(Order order);
void cancel(Order order);
}
private abstract static class BaseState implements OrderState {
@Override
public void pay(Order order) {
throw invalid("pay");
}
@Override
public void ship(Order order) {
throw invalid("ship");
}
@Override
public void cancel(Order order) {
throw invalid("cancel");
}
private IllegalStateException invalid(String action) {
return new IllegalStateException(
"Cannot " + action + " order in " + name() + " state"
);
}
}
public static final class Order {
private final String id;
private OrderState state = new NewState();
public Order(String id) {
this.id = Objects.requireNonNull(id, "id must not be null");
}
public String id() {
return id;
}
public String status() {
return state.name();
}
public void pay() {
state.pay(this);
}
public void ship() {
state.ship(this);
}
public void cancel() {
state.cancel(this);
}
private void transitionTo(OrderState nextState) {
state = Objects.requireNonNull(nextState, "state must not be null");
}
}
private static final class NewState extends BaseState {
@Override
public String name() {
return "NEW";
}
@Override
public void pay(Order order) {
order.transitionTo(new PaidState());
}
@Override
public void cancel(Order order) {
order.transitionTo(new CancelledState());
}
}
private static final class PaidState extends BaseState {
@Override
public String name() {
return "PAID";
}
@Override
public void ship(Order order) {
order.transitionTo(new ShippedState());
}
@Override
public void cancel(Order order) {
// This demo changes state only; a real system must handle refunds.
order.transitionTo(new CancelledState());
}
}
private static final class ShippedState extends BaseState {
@Override
public String name() {
return "SHIPPED";
}
}
private static final class CancelledState extends BaseState {
@Override
public String name() {
return "CANCELLED";
}
}
public static void main(String[] args) {
Order first = new Order("ORD-001");
System.out.println(first.id() + ": " + first.status());
first.pay();
System.out.println(first.id() + ": " + first.status());
first.ship();
System.out.println(first.id() + ": " + first.status());
try {
first.cancel();
} catch (IllegalStateException exception) {
System.out.println(exception.getMessage());
}
System.out.println(first.id() + ": " + first.status());
Order second = new Order("ORD-002");
second.cancel();
System.out.println(second.id() + ": " + second.status());
try {
second.pay();
} catch (IllegalStateException exception) {
System.out.println(exception.getMessage());
}
}
}
#อธิบายจุดสำคัญ
Order ส่งต่อคำสั่งให้ State
เมื่อเรียก order.pay() เมธอดใน Context จะเรียก state.pay(this) พฤติกรรมจึงถูกเลือกจากออบเจ็กต์ State ปัจจุบัน ตัวอย่างเช่น NewState จะเปลี่ยนไป PaidState แต่ CancelledState จะใช้พฤติกรรมปฏิเสธจาก BaseState
BaseState ปฏิเสธคำสั่งที่ไม่ได้อนุญาต
การให้คลาสฐานโยน IllegalStateException ช่วยไม่ให้สถานะใหม่ยอมรับคำสั่งโดยไม่มีการกำหนดกฎไว้ อย่างไรก็ตาม ต้องอ่านตารางกฎธุรกิจและทดสอบให้ครบ เพราะการลืม override อาจทำให้คำสั่งที่ควรอนุญาตถูกปฏิเสธ
Concrete State เก็บพฤติกรรมเฉพาะสถานะ
PaidState อนุญาต ship() และ cancel() ส่วน pay() ซ้ำจะถูกปฏิเสธ สถานะที่เป็นปลายทางไม่จำเป็นต้อง override คำสั่งใดในตัวอย่างนี้
จำกัดสิทธิ์การเปลี่ยนสถานะ
transitionTo() เป็น private และ Concrete State เป็น nested class ภายในคลาสเดียวกัน จึงสามารถเรียกเมธอดนี้ได้ แต่ Client ภายนอกไม่สามารถกำหนดสถานะใหม่เพื่อข้ามกฎธุรกิจได้ หากแยกแต่ละคลาสเป็นไฟล์ ต้องออกแบบขอบเขตการเข้าถึงใหม่ เช่น package-private และควบคุม package ที่เกี่ยวข้อง
ป้องกันค่า null
Objects.requireNonNull() ใช้ตรวจสอบ id และสถานะใหม่ หากรับค่า null จะเกิด NullPointerException พร้อมข้อความที่กำหนดไว้ ดูพฤติกรรม API ได้จาก Oracle Java 17: Objects
แยกข้อมูลคำสั่งซื้อออกจากพฤติกรรมสถานะ
id อยู่ใน Context ส่วน State รับ Context เป็นพารามิเตอร์ แต่ละ Order มีตัวแปร state ของตัวเอง การเปลี่ยนสถานะของคำสั่งซื้อหนึ่งรายการจึงไม่เปลี่ยนสถานะของอีกรายการ
#7. ทดลองรัน
#วิธีที่ 1: คอมไพล์แล้วรัน
javac -encoding UTF-8 StatePatternDemo.java
java StatePatternDemo
javac อ่านซอร์สและสร้าง class files รายละเอียดคำสั่งอยู่ใน Oracle Java 17: javac
#วิธีที่ 2: รันซอร์สไฟล์โดยตรง
สำหรับสภาพแวดล้อม Java 17 ที่รองรับการรันซอร์สไฟล์:
java StatePatternDemo.java
#ผลลัพธ์ที่คาดหวัง
ORD-001: NEW
ORD-001: PAID
ORD-001: SHIPPED
Cannot cancel order in SHIPPED state
ORD-001: SHIPPED
ORD-002: CANCELLED
Cannot pay order in CANCELLED state
สังเกตว่าการเรียก cancel() หลังจัดส่งเกิด exception และ ORD-001 ยังคงเป็น SHIPPED การรับ exception ใน main() ใช้เพื่อให้ Workshop แสดงผลและรันต่อได้ ระบบจริงควรแปลงความผิดพลาดให้เหมาะกับช่องทางเรียกใช้งาน
#8. แนวทางทดสอบ
อย่าทดสอบเฉพาะเส้นทางที่สำเร็จ ควรตรวจทุกคู่ของสถานะและคำสั่งตามตาราง รวม 4 × 3 = 12 กรณี
| สิ่งที่ควรตรวจ | ตัวอย่าง | ผลที่ต้องได้ |
|---|---|---|
| การเปลี่ยนสถานะที่อนุญาต | NEW เรียก pay | PAID |
| คำสั่งที่ผิดลำดับ | NEW เรียก ship | IllegalStateException และยังเป็น NEW |
| การเรียกซ้ำ | PAID เรียก pay | IllegalStateException และยังเป็น PAID |
| สถานะปลายทาง | CANCELLED เรียก pay | IllegalStateException และยังเป็น CANCELLED |
| ความเป็นอิสระของ Context | ชำระเงิน Order A | Order B ยังเป็น NEW |
โค้ดในบทความผ่านการรันบน OpenJDK 17 ด้วยคำสั่ง java StatePatternDemo.java และผ่านการตรวจพฤติกรรมทั้ง 12 คู่สถานะ/คำสั่ง พร้อมตรวจว่า Order สองรายการไม่เปลี่ยนสถานะตามกัน ไม่ได้ทดสอบการเชื่อมต่อฐานข้อมูล การชำระเงินจริง หรือการทำงานพร้อมกัน
#9. State Pattern ต่างจาก Strategy Pattern อย่างไร
ทั้งสอง Pattern มักใช้ Context เก็บออบเจ็กต์ผ่านอินเทอร์เฟซ และส่งต่อการทำงาน แต่มีเจตนาต่างกัน
| ประเด็น | State Pattern | Strategy Pattern |
|---|---|---|
| จุดประสงค์ | เปลี่ยนพฤติกรรมตามสถานะในวงจรชีวิต | เลือกวิธีหรืออัลกอริทึมสำหรับงาน |
| ตัวอย่าง | สถานะคำสั่งซื้อ NEW, PAID, SHIPPED | วิธีคำนวณค่าส่งปกติ ด่วน หรือรับที่ร้าน |
| การเปลี่ยนตัวดำเนินการ | เกิดจากเหตุการณ์และกฎการเปลี่ยนสถานะ | มักเลือกโดย Client หรือการตั้งค่า |
| ความสัมพันธ์ระหว่างตัวดำเนินการ | State อาจรู้จัก State ถัดไป | Strategy โดยทั่วไปทำงานได้อย่างเป็นอิสระ |
ประเด็นเรื่องผู้เลือกหรือความรู้จักกันเป็นแนวโน้มในการออกแบบ ไม่ใช่ข้อห้ามตายตัว สิ่งที่ใช้แยก Pattern คือเจตนาของระบบ
#10. ข้อดี ข้อจำกัด และการเลือกใช้
#ข้อดี
- รวมพฤติกรรมและกฎของสถานะเดียวกันไว้ในที่เดียว
- ลดเงื่อนไขตรวจสถานะซ้ำใน Context
- ทดสอบพฤติกรรมแต่ละสถานะได้ชัดเจน
- ทำให้การเรียกคำสั่งที่ไม่ถูกต้องแสดงความผิดพลาดอย่างสม่ำเสมอ
#ข้อจำกัด
- จำนวนคลาสเพิ่มตามจำนวนสถานะ
- หาก State รู้จัก State ถัดไป การเปลี่ยน flow ยังอาจต้องแก้ State เดิม
- การเพิ่มคำสั่งใหม่ใน State interface อาจกระทบหลาย implementation
- การแยกคลาสไม่ได้ทำให้ระบบ thread-safe หรือจัดการ transaction ให้อัตโนมัติ
State Pattern ช่วยด้านการขยายโค้ดได้ แต่ไม่ได้รับประกันว่าจะเพิ่มสถานะใหม่โดยไม่แก้โค้ดเดิมเลย โดยเฉพาะเมื่อ transition ที่เข้าไปยังสถานะใหม่ถูกกำหนดไว้ใน Concrete State เดิม
#เมื่อใดเหมาะกับ State Pattern
เหมาะเมื่อแต่ละสถานะมีพฤติกรรมต่างกันอย่างมีนัยสำคัญ เช่น เครื่องเล่นสื่อ ระบบเอกสารอนุมัติ ticket workflow หรือคำสั่งซื้อที่มีกฎหลายขั้นตอน
หากสถานะเป็นเพียงป้ายกำกับ ใช้ enum อาจเพียงพอ หากมี workflow จำนวนมากและเปลี่ยนกฎบ่อย ควรพิจารณาตาราง transition หรือ state machine framework ตามความซับซ้อนจริง
#11. ข้อควรพิจารณาเมื่อใช้ในระบบจริง
- Concurrency: ตัวอย่างนี้ออกแบบสำหรับการเรียกตามลำดับในเธรดเดียว หาก pay และ cancel เข้ามาพร้อมกัน ต้องควบคุมการอ่าน ตรวจสอบ และบันทึกสถานะให้เป็นหน่วยเดียว
- Persistence: บันทึกรหัสสถานะ เช่น NEW ในฐานข้อมูล แล้ว map กลับเป็น State object อย่าใช้ชื่อคลาสเป็นสัญญาข้อมูลระยะยาวโดยไม่มีเหตุผล
- Side effects: การเรียกบริการชำระเงินหรือขนส่งอาจสำเร็จแต่การบันทึกสถานะล้มเหลว ต้องกำหนด transaction boundary และวิธีติดตามหรือกู้คืน
- Idempotency: Workshop ปฏิเสธ pay ซ้ำ แต่ระบบจริงอาจต้องตอบผลเดิมให้คำขอที่ retry ด้วย idempotency key เดิม
- Audit: บันทึกสถานะก่อนและหลัง เหตุการณ์ ผู้ดำเนินการ และเวลา เพื่อให้ตรวจสอบการเปลี่ยนสถานะได้
ข้อเหล่านี้เป็นงานออกแบบเพิ่มเติม State Pattern รับผิดชอบการจัดโครงสร้างพฤติกรรมตามสถานะ
#12. แบบฝึกหัดต่อยอด
- เพิ่ม
DeliveredStateและคำสั่งdeliver()โดยอนุญาตเฉพาะ SHIPPED - เพิ่ม
RefundPendingStateสำหรับการยกเลิกหลังชำระเงิน และกำหนดเหตุการณ์เมื่อคืนเงินสำเร็จหรือล้มเหลว - เขียน unit tests ด้วย JUnit ให้ครอบคลุมทุกคู่สถานะ/คำสั่ง รวมกรณีที่ต้องคงสถานะเดิม
- แยกโค้ดเป็น package และไฟล์ต่าง ๆ โดยยังป้องกันไม่ให้ Client ตั้งสถานะเอง
- เปรียบเทียบโค้ดแบบ State Pattern กับ enum + switch แล้วอธิบายว่าระดับความซับซ้อนใดเหมาะกับแต่ละแบบ
#สาระสำคัญ
State Pattern แยก “สิ่งที่ทำได้ในแต่ละสถานะ” ออกจาก Context ที่เก็บข้อมูลและรับคำสั่ง ก่อนเขียนคลาสควรกำหนดตาราง transition ให้ชัดเจน แล้วตรวจทั้งเส้นทางที่อนุญาตและเส้นทางที่ต้องปฏิเสธ
#เอกสารประกอบ
- Oracle Java 17 — Objects: พฤติกรรมของ requireNonNull ที่ใช้ในตัวอย่าง
- Oracle Java 17 — javac: การคอมไพล์ Java