ข้าม​ไป​ยัง​เนื้อหา

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 ไป​เรื่อย ๆ จน​วัน​หนึ่ง​กลาย​เป็น​ภาระ

  1. Shotgun Surgery — เมื่อ business rule ที่​ผูก​กับ flag เปลี่ยน (เช่น เงื่อนไข​การ ship คำ​สั่ง​ซื้อ) ต้อง​ไป​ตาม​แก้ if ทุก​จุด​ที่​เคย​เช็ก flag นั้น กระจาย​อยู่​หลาย file หลาย layer ตรง​ตาม code smell Shotgun Surgery
  2. Invalid state ที่​เป็น​ไป​ไม่​ได้​ใน​ทาง​ธุรกิจ​แต่​เป็น​ไป​ได้​ใน code — เมื่อ​มี flag หลาย​ตัว​ที่​ควร mutually exclusive กัน (เช่น IsPaid, IsCancelled, IsShipped) ไม่มี​อะไร​บังคับ​ว่า​จะ​ไม่​เกิด IsCancelled == true && IsShipped == true พร้อม​กัน compiler และ object เอง​ไม่​ช่วย​ป้องกัน​เลย
  3. ตรรกะ​ซ้ำซ้อน (duplicated logic) — ทุก​จุด​ที่​ต้อง​ตัดสิน​ใจ​จาก flag เดียวกัน มัก​เขียน​เงื่อนไข​ซ้ำ ๆ กันเอง ลืม​แก้​จุด​ใด​จุด​หนึ่ง​ก็​กลาย​เป็น bug ที่​ตรวจ​จับ​ยาก
  4. Anemic domain model — เมื่อ​พฤติกรรม​ทั้งหมด​ถูก​ย้าย​ออก​จาก object ไป​อยู่​ที่ caller, object ก็​เหลือ​แค่ data bag ที่​มี flag ให้ set/get ตรง​กับ​อาการ​ของ Anemic Model
  5. เพิ่ม 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;
// ...ส่งพัสดุ
}
}
}
// อีกจุดหนึ่งในระบบเช็กเงื่อนไขเดิมซ้ำ แต่พลาดเช็ก IsCancelled
public 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

ขั้น​แรก — ย้าย​พฤติกรรม​กลับ​เข้า 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