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

Aggregate Pattern

กลุ่ม entity ที่​มี root เดียว​คุม​การ​เข้าถึง​และ​ความ​สอดคล้อง

Aggregate คือ กลุ่ม​ของ entity (และ​อาจ​มี value object) ที่​เกี่ยวข้อง​กัน​ใน​เชิง​ธุรกิจ ซึ่ง​ถูก​ปฏิบัติ​เป็น​หน่วย​เดียว​สำหรับ​การ​เปลี่ยนแปลง​ข้อมูล​และ​การ​ทำ​ธุรกรรม แต่ละ aggregate มี entity ราก​เดียว​เรียก​ว่า aggregate root ซึ่ง​เป็น​จุด​เข้า​เพียง​จุด​เดียว​จาก​ภายนอก code ภายนอก​จะ​อ้างอิง​หรือ​เรียก method ได้​เฉพาะ​ผ่าน root เท่านั้น ไม่ใช่​เข้าถึง entity ลูก​โดยตรง

Martin Fowler อธิบาย​ไว้​อย่าง​กระชับ​ว่า aggregate คือ “a cluster of domain objects that can be treated as a single unit” — ตัวอย่าง​คลาสสิก​คือ order พร้อม order line items ที่​แม้​เป็น​คนละ object แต่​ถูก​จัดการ​ร่วม​กัน​เป็น​หนึ่ง​เดียว Fowler ยัง​เน้น​ว่า reference จาก​ภายนอก​ควร​ชี้​ไป​ที่ root เท่านั้น และ​การ​โหลด/บันทึก​ข้อมูล​ควร​ทำ​เป็น​หน่วย aggregate ทั้ง​ก้อน โดย ธุรกรรม​ไม่​ควร​ข้าม​ขอบเขต aggregate

ข้อ​ควร​ระวัง​ที่ Fowler เน้น​ย้ำ​คือ อย่า​สับสน DDD aggregate กับ collection class ทั่วไป (list, map ฯลฯ) — aggregate เป็น​แนวคิด​ทาง domain ที่​มี​ความหมาย​ทาง​ธุรกิจ (order, การ​นัด​ตรวจ​คนไข้, playlist) ไม่ใช่​โครงสร้าง​ข้อมูล​ทั่วไป

สิ่ง​สำคัญ​คือ root ต้อง​เป็น entity เสมอ (ไม่ใช่ value object) เพราะ​ต้อง​มี identifier ไว้​บันทึก​และ​ดึง​ข้อมูล​จาก data store ได้ การ​รวม​กลุ่ม​เป็น aggregate ยัง​ช่วย​เลี่ยง​ปัญหา​การ eager-load ข้อมูล​ทั้ง​ฐาน​ข้อมูล​โดย​ไม่​จำเป็น

Aggregate เป็น​หนึ่ง​ใน tactical pattern หลัก​ของ DDD ร่วม​กับ entity, value object, domain service และ domain event บทบาท​ของ​มัน​คือ​การ​กำหนด consistency boundary — ขอบเขต​ที่ invariant ทาง​ธุรกิจ​ต้อง​เป็น​จริง​เสมอ​หลัง​จบ​ธุรกรรม​หนึ่ง ๆ

Vaughn Vernon สรุป​ไว้​ใน Effective Aggregate Design (IDDD) เป็น​สี่​กฎ​สำคัญ:

  1. ปกป้อง invariant ที่แท้​จริง​ภายใน​ขอบเขต​ความ​สอดคล้อง — หนึ่ง​ธุรกรรม​ควร​แก้ไข​ได้​เพียง1 aggregate เท่านั้น
  2. ออกแบบ aggregate ให้​เล็ก — ยิ่ง aggregate ใหญ่ ยิ่ง​เสี่ยง​ต่อ​การ​ล็อก​ข้อมูล​แย่ง​กัน​และ scale ยาก Vernon ถึง​กับ​เรียก large-cluster aggregate ว่า​เป็น anti-pattern ที่ “จะ​ไม่ perform หรือ scale ได้​ดี​เลย”
  3. อ้างอิง aggregate อื่น​ด้วย identity เท่านั้น — เก็บ​แค่ id ไม่​เก็บ object reference ตรง เพื่อ​ป้องกัน​การ​แก้ไข​หลาย aggregate ใน​ธุรกรรม​เดียว​โดย​ไม่​ตั้งใจ
  4. ใช้ eventual consistency นอก​ขอบเขต — เมื่อ business process ต้อง​ข้าม aggregate ให้​ใช้ Domain Events แทน​การ​ทำ​ธุรกรรม​เดียว​ครอบคลุม​หลาย aggregate

Microsoft Learn (Azure Architecture Center) ยืนยัน​แนวทาง​เดียวกัน: aggregate กำหนด consistency boundary รอบ entity หนึ่ง​ตัว​หรือ​มากกว่า และ “สิ่ง​ที่​ทำให้​เป็น aggregate คือ​ขอบเขต​ธุรกรรม (transactional boundary) ไม่ใช่​จำนวน entity” — แม้ aggregate ที่​มี entity เดียว​ไม่มี​ลูก​เลย​ก็​ยัง​นับ​เป็น aggregate ได้ เอกสาร​เดียวกัน​ยัง​แนะนำ​ว่า​ใน microservices แต่ละ service ไม่​ควร​เล็ก​กว่า1 aggregate และ​ไม่​ควร​ใหญ่​กว่า​หนึ่ง Bounded Context

ใน​ทาง​ปฏิบัติ aggregate root ยัง​รับผิดชอบ​รับประกัน ความ​สอดคล้อง (consistency) ของ​ทั้ง​กลุ่ม​ด้วย​การ​ไม่​เปิดเผย​ลูก​ของ​มัน​ตรง ๆ แต่​ควบคุม​การ​เข้าถึง​เอง — เป็น​ตัวอย่าง​ที่​ชัด​ของ Single Point of Enforcement และ repository ควร​มี​ความ​สัมพันธ์​แบบ 1:1 กับ aggregate root เท่านั้น ไม่ใช่​กับ entity ลูก​ภายใน

ตัวอย่าง​คลาสสิก​คือ Order ที่​มี OrderLine เป็น​ลูก โดย Order เป็น aggregate root ที่​ควบคุม​การ​เพิ่ม/ลบ​รายการ และ​ตรวจ invariant เช่น ยอด​รวม​ต้อง​ไม่​ติดลบ หรือ order ที่​ถูก submit แล้ว​ห้าม​แก้ไข​อีก

// Aggregate root — จุดเข้าเพียงจุดเดียวจากภายนอก
public class Order
{
private readonly List<OrderLine> _lines = new();
public Guid Id { get; }
public OrderStatus Status { get; private set; }
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public Order(Guid id)
{
Id = id;
Status = OrderStatus.Draft;
}
// ทางเดียวที่จะเพิ่มรายการได้ — ผ่าน root เท่านั้น
public void AddLine(Guid productId, int quantity, decimal unitPrice)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("แก้ไข order ที่ยืนยันแล้วไม่ได้");
if (quantity <= 0)
throw new ArgumentException("จำนวนต้องมากกว่าศูนย์");
// ผูกความสัมพันธ์กับ Product ด้วย identity เท่านั้น (Vernon rule 3)
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
public decimal Total => _lines.Sum(l => l.Quantity * l.UnitPrice);
public void Submit()
{
if (_lines.Count == 0)
throw new InvalidOperationException("order ว่างเปล่าส่งไม่ได้");
Status = OrderStatus.Submitted;
// แจ้ง aggregate อื่นแบบ eventual consistency (Vernon rule 4)
DomainEvents.Raise(new OrderSubmitted(Id, Total));
}
}
// entity ลูกภายใน aggregate — ไม่มี repository ของตัวเอง
public class OrderLine
{
public Guid ProductId { get; }
public int Quantity { get; }
public decimal UnitPrice { get; }
internal OrderLine(Guid productId, int quantity, decimal unitPrice)
{
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
public enum OrderStatus { Draft, Submitted }

ข้อสังเกต​สำคัญ​จาก code: OrderLine ไม่มี constructor แบบ public ที่​เรียก​จาก​ภายนอก​ได้ (ใช้ internal แทน) และ Order ไม่​เปิด List<OrderLine> ตรง ๆ แต่​ห่อ​ด้วย IReadOnlyCollection — ทั้ง​สอง​จุด​คือ​กลไก​ที่​ทำให้ root เป็น single point of enforcement จริง ๆ ไม่ใช่​แค่​ชื่อ

ใน​ระดับ diagram ความ​สัมพันธ์​ระหว่าง aggregate กับ​ส่วนประกอบ​ต่าง ๆ เป็น​ดังนี้:

classDiagram
    class Order {
        Guid Id
        OrderStatus Status
        AddLine
        Submit
    }
    class OrderLine {
        Guid ProductId
        int Quantity
        decimal UnitPrice
    }
    class OrderRepository {
        Save
        GetById
    }
    class Product {
        Guid Id
    }
    class OrderSubmitted {
        Guid OrderId
        decimal Total
    }

    Order o-- OrderLine : aggregate root ถือ
    OrderRepository ..> Order : จัดการทั้งก้อน
    Order ..> Product : อ้างด้วย ProductId เท่านั้น
    Order --> OrderSubmitted : raise เมื่อ submit

จาก diagram: OrderRepository คุย​กับ Order (root) เท่านั้น ไม่มี OrderLineRepository แยก​ต่างหาก ส่วน​ความ​สัมพันธ์​กับ Product ซึ่ง​เป็น aggregate อีก​ตัว​หนึ่ง เชื่อม​กัน​ด้วย id ไม่ใช่ object reference ตรง

  • Entity vs AggregateEntity คือ​หน่วย​ที่​มี identity เดี่ยว ส่วน aggregate คือ​กลุ่ม​ของ entity (บาง​ครั้ง​มี​แค่ entity เดียว) ที่​ถูก​มอง​เป็น​หน่วย​เดียว​เพื่อ​วัตถุประสงค์​ด้าน consistency — aggregate ทุก​ตัว​มี root เป็น entity แต่ entity ทุก​ตัว​ไม่​จำเป็น​ต้อง​เป็น aggregate root
  • Value Object — ภายใน aggregate มัก​มี Value Object ประกอบ​อยู่​ด้วย (เช่น Money, Address) เพราะ value object ไม่มี identity จึง​ไม่​จำเป็น​ต้อง​มี repository ของ​ตัวเอง​และ​ถูก​จัดการ​โดย root เสมอ
  • RepositoryRepository Pattern ควร​มี​ขอบเขต​ตรง​กับ aggregate root เสมอ คือ1 repository ต่อ​หนึ่ง​ชนิด aggregate root ไม่ใช่​ต่อ entity ลูก
  • Domain Events — เมื่อ business process ต้อง​ข้าม​ขอบเขต aggregate (rule ข้อ 4 ของ Vernon) กลไก​ที่​ใช้​เชื่อม​มัก​เป็น Domain Events แทน​ธุรกรรม​ข้าม aggregate
  • Bounded ContextBounded Context เป็น​ขอบเขต​ที่​ใหญ่​กว่า อาจ​มี​หลาย aggregate อยู่​ภายใน ขณะ​ที่ aggregate คือ​ขอบเขต​ความ​สอดคล้อง​เชิง​ธุรกรรม​ที่​เล็ก​กว่า​และ​แคบ​กว่า​มาก