Flags Over Objects
เขียนพฤติกรรมไว้นอก object ด้วยการเช็ก flag แทนที่จะให้ object ทำเอง
ปัญหาคืออะไร
หัวข้อที่มีชื่อว่า “ปัญหาคืออะไร”Flags Over Objects เกิดเมื่อ พฤติกรรมของระบบถูกเขียนไว้นอก object ด้วยการตรวจ flag — ไม่ว่าจะเป็น bool (IsPaid, IsShipped), status code, หรือ enum ที่เก็บไว้เป็น public property — แล้วให้ code ที่เรียกใช้ (caller) เป็นคนตัดสินใจว่าจะทำอะไรต่อจาก flag เหล่านั้น แทนที่จะให้ object เป็นคนตัดสินใจเอง
สิ่งนี้ละเมิดหลักการ Tell, Don’t Ask โดยตรง: แทนที่จะ “บอก” object ว่าต้องทำอะไร (order.Ship()) code กลับ “ถาม” สถานะของ object ออกมาก่อน (order.IsPaid, order.IsCancelled) แล้วเอาไปเข้า if เอง ผลลัพธ์คือ interface ของ object กลายเป็น “พูดมาก” (chatty) — เปิด property ให้ดูได้เยอะ แต่แทบไม่มี method ที่บอกเจตนา และตรรกะทางธุรกิจที่ควรอยู่ในที่เดียวกลับกระจัดกระจายไปตามทุกจุดที่มีการเช็ก flag นั้น
ทำไมถึงดูน่าใช้
หัวข้อที่มีชื่อว่า “ทำไมถึงดูน่าใช้”Flag มักเริ่มต้นจากความบริสุทธิ์ใจ เช่น เพิ่ม bool IsPremiumCustomer เข้าไปใน class ที่มีอยู่แล้ว เพราะดูเหมือนเป็นการเปลี่ยนแปลงเล็ก ๆ ที่ไม่ต้องออกแบบอะไรใหม่ เทียบกับการสร้าง class ย่อยหรือ State pattern ที่ดูเหมือน “overkill” สำหรับ use case เดียว ณ ตอนนั้น:
- เพิ่ม property หรือ parameter หนึ่งตัว เร็วกว่าการออกแบบ class hierarchy ใหม่
- Diff ใน pull request เล็ก ดูรีวิวง่าย ไม่กระทบ file อื่น (ในตอนแรก)
- ทีมส่วนใหญ่คุ้นเคยกับ
if/elseบน flag มากกว่ารูปแบบ polymorphism หรือ State - เมื่อมีแค่ 1-2 flag ระบบยังอ่านง่าย ปัญหาจะโผล่มาเมื่อจำนวน flag และจุดที่เช็ก flag เพิ่มขึ้นเรื่อย ๆ
นี่คือกับดักคลาสสิกของ technical debt: เริ่มเล็กและดูไม่มีพิษภัย แต่สะสมความซับซ้อนแบบ conditional complexity ไปเรื่อย ๆ จนวันหนึ่งกลายเป็นภาระ
ทำไมถึงเป็นปัญหา
หัวข้อที่มีชื่อว่า “ทำไมถึงเป็นปัญหา”- Shotgun Surgery — เมื่อ business rule ที่ผูกกับ flag เปลี่ยน (เช่น เงื่อนไขการ ship คำสั่งซื้อ) ต้องไปตามแก้
ifทุกจุดที่เคยเช็ก flag นั้น กระจายอยู่หลาย file หลาย layer ตรงตาม code smell Shotgun Surgery - Invalid state ที่เป็นไปไม่ได้ในทางธุรกิจแต่เป็นไปได้ใน code — เมื่อมี flag หลายตัวที่ควร mutually exclusive กัน (เช่น
IsPaid,IsCancelled,IsShipped) ไม่มีอะไรบังคับว่าจะไม่เกิดIsCancelled == true && IsShipped == trueพร้อมกัน compiler และ object เองไม่ช่วยป้องกันเลย - ตรรกะซ้ำซ้อน (duplicated logic) — ทุกจุดที่ต้องตัดสินใจจาก flag เดียวกัน มักเขียนเงื่อนไขซ้ำ ๆ กันเอง ลืมแก้จุดใดจุดหนึ่งก็กลายเป็น bug ที่ตรวจจับยาก
- Anemic domain model — เมื่อพฤติกรรมทั้งหมดถูกย้ายออกจาก object ไปอยู่ที่ caller, object ก็เหลือแค่ data bag ที่มี flag ให้ set/get ตรงกับอาการของ Anemic Model
- เพิ่ม state ใหม่แล้วต้องแก้ทุกที่ — การเพิ่ม flag หรือ state ใหม่หนึ่งตัว หมายถึงต้องไล่หาทุก
ifที่เกี่ยวข้องกับ flag เดิมมาแก้ ละเมิด Open-Closed Principle ไปในตัว
ตัวอย่าง
หัวข้อที่มีชื่อว่า “ตัวอย่าง”ตัวอย่าง antipattern: Order เก็บสถานะเป็น bool หลายตัว แล้วให้ code ภายนอก (service, controller, ไปจนถึง view) เป็นคนเช็กและตัดสินใจเอง
public class Order{ public bool IsPaid { get; set; } public bool IsShipped { get; set; } public bool IsCancelled { get; set; } public decimal Total { get; set; }}
// ตรรกะการ ship กระจายอยู่คนละที่กับ Order เองpublic class ShippingService{ public void ProcessShipment(Order order) { // ต้องรู้กติกาทุกข้อของ Order เพื่อจะ "ถาม" แทนที่จะ "บอก" if (order.IsPaid && !order.IsShipped && !order.IsCancelled) { order.IsShipped = true; // ...ส่งพัสดุ } }}
// อีกจุดหนึ่งในระบบเช็กเงื่อนไขเดิมซ้ำ แต่พลาดเช็ก IsCancelledpublic class InvoicePrinter{ public void Print(Order order) { if (order.IsPaid && order.IsShipped) { // พิมพ์ใบเสร็จ แม้ order จะถูกยกเลิกไปแล้วก็ยังพิมพ์ได้ เพราะลืมเช็ก IsCancelled } }}ปัญหาชัดเจน: กติกาการ ship ต้องถูกจำและเขียนซ้ำทุกจุดที่แตะ Order ไม่มีอะไรห้าม IsShipped = true ทั้งที่ยังไม่ได้ IsPaid และเมื่อมี business rule ใหม่ เช่น ต้องผ่าน fraud check ก่อน ship ได้ ก็ต้องไล่แก้ทุกจุดที่เคยเช็ก IsPaid && !order.IsShipped
ทางแก้และการ refactor
หัวข้อที่มีชื่อว่า “ทางแก้และการ refactor”ขั้นแรก — ย้ายพฤติกรรมกลับเข้า object (Tell, Don’t Ask) ให้ Order เป็นเจ้าของกติกาการเปลี่ยนสถานะของตัวเอง โดย expose เป็น method ที่บอกเจตนา แทน property ที่ set ได้อิสระ:
public class Order{ public OrderStatus Status { get; private set; } = OrderStatus.Pending; public decimal Total { get; }
public Order(decimal total) => Total = total;
public void MarkAsPaid() { if (Status != OrderStatus.Pending) throw new InvalidOperationException("ชำระเงินได้เฉพาะ order ที่ยัง Pending เท่านั้น"); Status = OrderStatus.Paid; }
public void Ship() { if (Status != OrderStatus.Paid) throw new InvalidOperationException("Ship ได้เฉพาะ order ที่ชำระเงินแล้ว"); Status = OrderStatus.Shipped; }
public void Cancel() { if (Status == OrderStatus.Shipped) throw new InvalidOperationException("ยกเลิก order ที่ ship ไปแล้วไม่ได้"); Status = OrderStatus.Cancelled; }}
public enum OrderStatus { Pending, Paid, Shipped, Cancelled }ตอนนี้ทุก caller แค่เรียก order.Ship() หรือ order.Cancel() ไม่ต้องรู้กติกาภายในเลย ตรรกะการเปลี่ยนสถานะอยู่จุดเดียว และ enum ทำให้ combination ของสถานะที่ผิดพลาดตามข้อ 1-2 เป็นไปไม่ได้ตั้งแต่ต้น
เมื่อ state machine ซับซ้อนขึ้น (แต่ละสถานะมีพฤติกรรมต่างกันมาก ไม่ใช่แค่ guard condition) ให้ยกระดับไปใช้ State pattern เต็มรูปแบบ โดยแยกแต่ละสถานะเป็น class ของตัวเอง implement interface เดียวกัน แล้วให้ Order มอบหมาย (delegate) การตัดสินใจให้ state object ปัจจุบันแทนที่จะ if เทียบ enum เอง วิธีนี้ตรงกับแนวทาง Replace Conditional with Polymorphism — ย้าย conditional ที่กระจายอยู่หลายที่ ไปรวมเป็น method เดียวต่อ1 state
flowchart LR
Pending -- MarkAsPaid --> Paid
Paid -- Ship --> Shipped
Pending -- Cancel --> Cancelled
Paid -- Cancel --> Cancelled