Aggregate & Aggregate Root – กำแพงป้องกัน Domain ไม่ให้เละ

ตอนที่ 5 ของซีรีส์ Domain-Driven Design ฉบับระบบธุรกิจจริง
Theme หลัก: ระบบบริหารพนักงาน (Staff / Employee Management System)

หลังจากตอนที่ 4 เราแยก Entity และ Value Object ได้แล้ว

ขั้นต่อไปที่สำคัญไม่แพ้กันคือคำถามนี้:

ใครมีสิทธิ์แก้ใครได้บ้าง?

ถ้าไม่กำหนดขอบเขตให้ชัด Domain จะเริ่ม “เละ” แบบไม่รู้ตัว

DDD ใช้แนวคิดนี้เพื่อแก้ปัญหา: Aggregate


ปัญหาที่เจอบ่อยในระบบพนักงาน

ลองดูสถานการณ์คุ้น ๆ:

  • หน้าจอหนึ่งแก้ ShiftRequest.status
  • อีกหน้าหนึ่งแก้ approvedBy
  • Batch job มาแก้ approvedAt

ทุกอย่างเขียนถูก type

แต่สุดท้ายเกิดคำถามว่า:

  • กฎยังครบไหม?
  • ใครเป็นคนรับผิดชอบ?
  • invariant ถูกทำลายไปหรือเปล่า?

นี่คือสัญญาณว่า ยังไม่มี Aggregate


Aggregate คืออะไร

Aggregate คือ:

กลุ่มของ Entity และ Value Object ที่ต้องถูกดูแลเป็นหน่วยเดียวกัน

โดยมี Aggregate Root เป็นประตูทางเข้าเพียงทางเดียว

หลักการสำคัญ:

  • แก้ข้อมูลภายใน aggregate ได้ ผ่าน root เท่านั้น
  • Root เป็นผู้ปกป้องกฎ (invariants)

Aggregate Root คืออะไร

Aggregate Root คือ Entity ตัวหลักที่:

  • มี identity
  • เป็นจุดเดียวที่ภายนอกเรียกใช้ได้
  • รับผิดชอบความถูกต้องของทั้ง aggregate

ในระบบพนักงาน ตัวอย่าง Aggregate Root:

  • ShiftRequest
  • Staff

ตัวอย่าง Aggregate: ShiftRequest

มาดูโครงสร้างแบบง่าย:

class ShiftRequest {
  final ShiftRequestId id;
  ShiftRequestStatus status;
  StaffId staffId;
  WorkingPeriod period;
  Approval approval;

  void approve(Staff approver) {
    if (status != ShiftRequestStatus.pending) {
      throw Exception('Only pending request can be approved');
    }

    approval = Approval.by(approver.id);
    status = ShiftRequestStatus.approved;
  }
}

Approval อาจเป็น Value Object ภายใน aggregate

ภายนอก ไม่ควร ไปแก้ approval ตรง ๆ


Invariant: กฎที่ห้ามถูกละเมิด

Invariant คือกฎที่ต้องเป็นจริง เสมอ ภายใน aggregate

ตัวอย่าง invariant ของ ShiftRequest:

  • request ที่ approved แล้ว ห้ามกลับเป็น pending
  • request หนึ่งรายการ อนุมัติได้เพียงครั้งเดียว
  • approver ต้องมีสิทธิ์ตามกฎธุรกิจ

กฎเหล่านี้:

ต้องถูกบังคับโดย Aggregate Root

ไม่ใช่ controller หรือ service


ทำไม “ห้ามข้าม Aggregate”

ข้อผิดพลาดที่พบบ่อย:

request.approval.approvedBy = approverId; // ❌

โค้ดนี้:

  • ข้าม root
  • bypass กฎ
  • ทำลาย invariant

ถ้าจำเป็นต้องเปลี่ยน:

request.approve(approver); // ✅

ขนาดของ Aggregate สำคัญมาก

Aggregate ที่ดีควร:

  • เล็กที่สุดเท่าที่จำเป็น
  • ไม่ดึงทุกอย่างมารวมกัน

ตัวอย่างที่ควรแยก:

  • Staff ไม่ควรเป็น aggregate เดียวกับ ShiftRequest
  • ใช้ StaffId อ้างอิงแทน object เต็ม

เหตุผล:

  • performance
  • concurrency
  • ลด coupling

Repository ทำงานกับ Aggregate

Repository ควร:

  • load / save ทั้ง aggregate
  • ไม่ expose object ภายใน
abstract class ShiftRequestRepository {
  ShiftRequest? findById(ShiftRequestId id);
  void save(ShiftRequest request);
}

Repository ไม่ควรมี method อย่าง:

  • updateApprovalStatus()
  • setApprovedBy()

นั่นคือการรั่วของ domain


สรุปตอนที่ 5

  • Aggregate = ขอบเขตของความถูกต้อง
  • Aggregate Root = ผู้เฝ้าประตู
  • Invariant ต้องอยู่ใน aggregate

ถ้าคุณแก้ entity ไหนก็ได้จากที่ไหนก็ได้

นั่นแปลว่า domain ของคุณยังไม่มีเจ้าของ


ตอนต่อไป

ตอนที่ 6: Domain Service – เมื่อ logic ไม่ควรอยู่ใน Entity

เราจะดูกรณีที่ logic บางอย่างไม่เหมาะจะอยู่ใน entity แต่ก็ไม่ควรไปอยู่ใน service ทั่วไป