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

Factory & Repository — สร้าง​ของ​ใหม่ กับ​หา​ของ​เก่า

ตลอด​เจ็ด​บท​ที่​ผ่าน​มา​เรา​ไล่​เติม​เนื้อ​ให้ Order ที​ละ​ชั้น — Value Object, Entity, Aggregate, state machine, Domain Event, Domain Service, Specification — จน​ตอน​นี้​มัน​เป็น domain ที่​รักษา​กฎ​ธุรกิจ​ของ​ตัวเอง​ครบ​แล้ว คำถาม​ที่​เหลือ​อยู่​สอง​ข้อ​คือ​คำถาม​ที่สุด​โต่ง​ที่สุด​ใน​วงจร​ชีวิต​ของ object ทุก​ตัว: “ของ​ใหม่​เกิด​ขึ้น​มา​ได้​ยังไง​ให้​ถูกต้อง​ตั้งแต่​แรก” และ “ของ​เก่า​ที่​เคย​สร้าง​ไป​แล้ว​จะ​หา​เจอ​ได้​ยังไง” สอง​คำถาม​นี้​คือ​หน้าที่​ของ Factory กับ Repository ตาม​ลำดับ — และ​เป็น​บท​ปิด​ท้าย​ของ​คอร์ส​นี้

📦 code ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เกี่ยวข้อง​กับ path FoodOrdering.Application/Orders/ (IOrderRepository, IMenuCatalog, OrderFactory ตัว​ใหม่) และ FoodOrdering.Domain/Orders/ (Order.Place ที่​เป็น factory method อยู่​ใน​ตัว​มัน​เอง​แล้ว​ตั้งแต่​บท​ที่ 1)

Factory — สร้าง aggregate ที่​ซับซ้อน​ให้​ถูกต้อง​แต่​แรก​เกิด

หัวข้อ​ที่​มีชื่อ​ว่า “Factory — สร้าง aggregate ที่​ซับซ้อน​ให้​ถูกต้อง​แต่​แรก​เกิด”

Eric Evans จัด​ให้ FactoryFactoryตัว​ที่​ห่อ​หุ้ม​ตรรกะ​การ 'สร้าง' Aggregate ที่​ซับซ้อน และ​รับประกัน​ว่าอ็อบเจ็กต์ใหม่​ถูกต้อง​ตาม invariant ตั้งแต่​แรก​เกิด — 'สร้าง​ของ​ใหม่' (repository หา 'ของ​เก่า')Tactical Design เป็น​หนึ่ง​ใน tactical building block ดั้งเดิม​ของ DDD คู่​กับ Entity, Value Object, Aggregate และ Repository — หลักการ​ของ​มัน​คือ “แยก​การ​ใช้​ออก​จาก​การ​สร้าง” (separate use from construction): เมื่อ​การ​สร้าง object ตัว​หนึ่ง​ซับซ้อน​พอ (ต้อง​ประกอบ​หลาย​ส่วน​เข้า​ด้วย​กัน ต้อง​พิสูจน์ invariant ตั้งแต่​ต้น หรือ​ต้อง​พึ่ง​ข้อมูล​จาก​ที่​อื่น) ตรรกะ​การ​สร้าง​นั้น​สมควร​มี​ที่​อยู่​ของ​ตัวเอง แยก​ออก​จาก​ตรรกะ​การ “ใช้งาน” ตาม​ปกติ​ของ object

จริงๆ แล้ว​คอร์ส​นี้​มี Factory อยู่​แล้ว​ตั้งแต่​บท​ที่ 1 โดยที่​เรา​ไม่​ได้​เรียก​มัน​ด้วย​ชื่อ​นี้ตรงๆ — ก่อน​อื่น recall ชิ้น​ส่วน​ย่อย​ที่ Order ต้อง​ใช้​จาก Domain Reference ให้​ครบ​ก่อน แล้ว​ค่อย recall Order เต็มๆ:

// recall จาก Domain Reference (คอร์ส Clean Architecture .NET + คอร์สนี้บทที่ 1–2)
public sealed record OrderId(Guid Value);
public sealed record OrderLineId(Guid Value);
public sealed record ProductId(Guid Value);
public sealed record Quantity(int Value);
public sealed record Money(decimal Amount, string Currency) // guard: Amount<0 → "จำนวนเงินต้องไม่ติดลบ"; Currency!="THB" → "ตอนนี้รองรับเฉพาะสกุลเงิน THB"
{
public static Money Thb(decimal amount) => new(amount, "THB");
public static Money operator +(Money a, Money b); // ต่างสกุลเงิน → InvalidOperationException "บวกเงินต่างสกุลกันไม่ได้"
public static Money operator *(Money unitPrice, int quantity);
}
// recall จากบทที่ 3 — OrderLine ถูกยกระดับเป็น entity เทียบด้วย identity แล้ว ไม่ใช่ record เทียบด้วยค่าแบบบทที่ 1–2
public sealed class OrderLine : Entity<OrderLineId> // Entity<TId>.Equals/GetHashCode เทียบด้วย Id ล้วน ๆ ไม่แตะ ProductId/Quantity/UnitPrice เลย
{
public ProductId ProductId { get; }
public Quantity Quantity { get; }
public Money UnitPrice { get; }
public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice)
: base(id)
{
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
// recall จากบทที่ 6 — สัญญากลางที่ domain event ทุกตัวต้องเคารพ
public interface IDomainEvent
{
DateTimeOffset OccurredOn { get; }
}
// recall จากบทที่ 6 — OrderPlaced ผูกกับ IDomainEvent แล้ว
public sealed record OrderPlaced(OrderId OrderId, Money Total, DateTimeOffset OccurredOn) : IDomainEvent;
// recall จาก FoodOrdering.Domain/Orders/Order.cs (คอร์ส Clean Architecture .NET บทที่ 2 + คอร์สนี้บทที่ 1 และ _domainEvents ยกระดับเป็น List<IDomainEvent> ในบทที่ 6)
public sealed class Order
{
private readonly List<OrderLine> _lines = new();
private readonly List<IDomainEvent> _domainEvents = new();
public OrderId Id { get; }
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
public Money Total { get; private set; }
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
private Order(OrderId id, IEnumerable<OrderLine> lines)
{
Id = id;
_lines.AddRange(lines);
Total = _lines.Aggregate(Money.Thb(0), (sum, line) => sum + line.UnitPrice * line.Quantity.Value);
}
public static Order Place(OrderId id, IReadOnlyList<OrderLine> items)
{
if (items.Count == 0)
throw new InvalidOperationException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้");
var order = new Order(id, items);
order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, DateTimeOffset.UtcNow));
return order;
}
}

Order.Place(...) คือ factory method ใน​ความหมาย​ที่​ตรง​ตัว​ที่สุด — method static ที่​เป็น​ทาง​เดียว​ที่​จะ​ได้ Order มา (constructor เป็น private) และ​รับประกัน​ว่า Order ทุก​ตัว​ที่​หลุด​ออก​จาก​มัน​ผ่าน invariant ①(ตะกร้า​ต้อง​ไม่​ว่าง) มา​แล้ว​เสมอ ไม่มี​ทาง​สร้าง Order ที่​ผิด​กฎ​หลุด​ออก​มา​ได้​เลย​แม้แต่​ตัว​เดียว — DevIQ เรียก​รูปแบบ​คลาสสิก​ของ​แนวคิด​นี้​ว่า Factory Method (ของ​เดิม​มา​จาก Gang of Four ตัดสิน​ใจ​ว่า​จะ​สร้าง subclass ไหน ส่วน​ใน DDD ความหมายกว้าง​กว่า​นั้น​เล็กน้อย — Factory คือ​ของ​ใด​ก็ตาม​ที่​ห่อ​หุ้ม​ตรรกะ​การ​สร้าง​เพื่อ​รักษา invariant ตั้งแต่​กำเนิด ไม่​จำเป็น​ต้อง​มี subclass เกี่ยวข้อง​เลย​ก็ได้)

เมื่อ​การ​สร้าง​ต้อง​พึ่ง​ข้อมูล​จาก​ข้าง​นอก — Factory ที่​ยก​ระดับ​ขึ้น​ไป​อีก​ชั้น

หัวข้อ​ที่​มีชื่อ​ว่า “เมื่อ​การ​สร้าง​ต้อง​พึ่ง​ข้อมูล​จาก​ข้าง​นอก — Factory ที่​ยก​ระดับ​ขึ้น​ไป​อีก​ชั้น”

Order.Place(...) ใช้ได้​ก็​ต่อ​เมื่อ​มี IReadOnlyList<OrderLine> พร้อม​ราคา​อยู่​แล้ว แต่​ใน​ความ​เป็น​จริง​จุด​เริ่มต้น​ของออเดอร์​คือ ตะกร้า — รายการ (product, quantity) ดิบๆ ที่​ยัง​ไม่รู้​ราคา​ต่อ​หน่วย​เลย​ด้วย​ซ้ำ ต้อง​มี​ใคร​สัก​คน​ไป​ถาม​ราคา​จาก IMenuCatalog ก่อน ค่อย​ประกอบ​เป็น OrderLine แล้ว​ส่ง​ต่อ​ให้ Order.Place(...) — recall port ตัว​ที่​ยัง​ขาด​จาก Domain Reference ก่อน:

// recall จาก FoodOrdering.Application/Orders/IMenuCatalog.cs (คอร์ส Clean Architecture .NET บทที่ 3)
public interface IMenuCatalog
{
Task<Money> GetPriceAsync(ProductId productId, CancellationToken ct);
}

สังเกต​ว่า​ตรรกะ​นี้ มี​อยู่​แล้ว ใน​คอร์ส Clean Architecture .NET — มัน​ถูก​ฝัง​อยู่​กลาง PlaceOrderHandler.Handle(...) (บท​ที่ 3) ปน​อยู่​กับ​งาน “ตัด​เงิน” และ “บันทึก” ที่​ไม่​เกี่ยว​กับ​การ​สร้าง​เลย: foreach วน​ถาม​ราคา​ผ่าน IMenuCatalog ประกอบ​เป็น OrderLine แล้ว​ค่อย​เรียก Order.Place(...) — สาม​ขั้นตอน​นี้​คือ​นิยาม​ของ Factory แบบ​เป๊ะๆ เพียง​แต่​ไม่​เคย​ถูก​ตั้ง​ชื่อ​และ​แยก​ที่​อยู่​ของ​ตัวเอง​มา​ก่อน บท​นี้​แค่​ดึง​มัน​ออก​มา:

// ใหม่ในบทนี้ — ตะกร้าเป็นแค่ input ดิบ ราคายังไม่ถูกล็อก และ quantity ยังเป็น int ธรรมดา
public sealed record CartLine(ProductId ProductId, int Quantity);
public sealed class Cart
{
private readonly List<CartLine> _lines = new();
public IReadOnlyCollection<CartLine> Lines => _lines.AsReadOnly();
public void Add(ProductId productId, int quantity) =>
_lines.Add(new CartLine(productId, quantity));
}
// FoodOrdering.Application/Orders/OrderFactory.cs
public sealed class OrderFactory
{
private readonly IMenuCatalog _menuCatalog;
public OrderFactory(IMenuCatalog menuCatalog)
{
_menuCatalog = menuCatalog;
}
public async Task<Order> CreateFromCartAsync(OrderId id, Cart cart, CancellationToken ct)
{
var items = new List<OrderLine>();
foreach (var line in cart.Lines)
{
var unitPrice = await _menuCatalog.GetPriceAsync(line.ProductId, ct);
items.Add(new OrderLine(new OrderLineId(Guid.NewGuid()), line.ProductId,
new Quantity(line.Quantity), unitPrice));
}
return Order.Place(id, items); // invariant ยังรักษาอยู่ที่ Order.Place เหมือนเดิม — factory ไม่ได้แย่งหน้าที่นี้ไป
}
}

สังเกต​ตำแหน่ง file: OrderFactory วาง​อยู่​ใน FoodOrdering.Application/Orders/ ไม่ใช่ FoodOrdering.Domain/ — เพราะ​มัน​ต้อง​พึ่ง IMenuCatalog ซึ่ง​ประกาศ​อยู่​ที่ FoodOrdering.Application เช่น​กัน (recall บท​ที่ 3) และ Dependency Rule ห้าม FoodOrdering.Domain อ้างอิง​อะไร​ใน FoodOrdering.Application เด็ดขาด — นี่​คือ​บทเรียน​สำคัญ: Factory ไม่​ได้​ต้อง​อยู่​ใน Domain layer เสมอ​ไป ตัว​ที่​ไม่​พึ่ง​อะไร​จาก​ข้าง​นอก​เลย​อย่าง Order.Place(...) อยู่​ใน Domain ได้สบายๆ แต่​พอ​ไหน​ที่​ต้อง​พึ่ง port อย่าง IMenuCatalog (I/O แบบ async) มัน​ก็​ต้อง​ขยับ​ขึ้น​ไป​อยู่​ชั้น​ที่​มอง​เห็น port นั้น — ตรง​นี้​เอง​ที่ Mark Seemann ย้ำ​เรื่อง constructor injection ไว้​ตลอด: OrderFactory รับ IMenuCatalog ผ่าน constructor ตัว​เดียว ไม่มี service locator ไม่มี “new” ของ​แท้​ปน​อยู่​ใน​ตัว​มัน​เอง ทำให้​มัน test ได้​ง่ายพอๆ กับ PlaceOrderHandler ที่​มัน​จะ​ไป​แทนที่ code ข้าง​ใน​บาง​ส่วน

มี​อีก​เส้นทาง​หนึ่ง​ที่ Order ถือ​กำเนิด​ขึ้น​มา​ใน​หน่วย​ความ​จำ ซึ่ง ไม่​ผ่าน Order.Place(...) เลย นั่น​คือ​ตอน​ที่​ระบบ​โหลด​ออเดอร์​เก่า​กลับ​มา​จาก​ฐาน​ข้อมูล — เรียก​ว่า reconstitution (สร้าง​คืน) ตรง​ข้าม​กับ creation (สร้าง​ใหม่)

ทำไม​ถึง​ห้าม​ผ่าน Order.Place(...) ตอน​โหลด​ของ​เก่า? เพราะ Place(...) ทำ​สอง​อย่าง​ที่​ไม่​ควร​เกิด​ซ้ำ: มัน​เช็ก invariant ① ใหม่​ทั้งหมด (ทั้ง​ที่​แถว​นี้​เคย​ผ่าน​มา​แล้ว​ตอน​บันทึก​ครั้ง​แรก) และ​มัน Add เหตุการณ์ OrderPlaced เข้า _domainEvents (ทั้ง​ที่​ออเดอร์​นี้​ถูก “วาง” ไป​นาน​แล้ว ไม่ใช่​เพิ่ง​วาง​ตอน​นี้) ถ้า reconstitution เรียก Place(...) ซ้ำ ระบบ​จะ​ยิง OrderPlaced ปลอม​ออก​ไป​ทุก​ครั้ง​ที่​แค่​อ่านออเดอร์​เก่า​กลับ​มา​ดู ซึ่ง​พัง​ทันที

สิ่ง​ที่ reconstitution ทำจริงๆ คือ​ใช้ private constructor ตัว​เดียวกัน (private Order(OrderId id, IEnumerable<OrderLine> lines) ที่ recall ไว้​ข้าง​บน) ผ่าน​กลไก​พิเศษ​ของ ORM — EF Core ใช้ reflection เข้าถึง private constructor และ backing field โดยตรง​เพื่อ “ประกอบ​ร่าง” Order ให้​ตรง​กับ​แถว​ใน​ฐาน​ข้อมูล โดย​ไม่​ต้อง​พิสูจน์ invariant ซ้ำ เพราะ​แถว​นั้น​พิสูจน์​ตัวเอง​ไป​แล้ว​ตอน​ถูก​บันทึก​ครั้ง​แรก — creation ต้อง​พิสูจน์​ตัวเอง​ใหม่​ทุก​ครั้ง (born valid) ส่วน reconstitution ไว้ใจ​ข้อมูล​ที่มา​จาก​แหล่ง​ที่​เชื่อถือ​ได้​อยู่​แล้ว (reborn trusted) กลไก​นี้​เอง​คือ​จุด​ที่ EF Core adapter (EfOrderRepository — คอร์ส Clean Architecture .NET) ทำงาน​อยู่​เบื้องหลัง คอร์ส​นี้​ไม่​ลง​รายละเอียด EF mapping ซ้ำ แค่​ต้อง​เข้าใจ​ว่า reconstitution เป็นการ​บายพาส invariant โดย​ตั้งใจ ไม่ใช่​รู​รั่ว​ที่​หลุด​ออก​ไป​โดย​ไม่มี​ใคร​ควบคุม

RepositoryRepositoryตัว​ที่​ทำให้​เข้าถึง Aggregate ราวกับ​เป็น collection ใน​หน่วย​ความ​จำ ซ่อน​รายละเอียด​ฐาน​ข้อมูล — interface (contract) อยู่​วงใน, implementation อยู่ Infrastructure; 1 repository ต่อ1 Aggregate RootTactical Design ทำให้การ​เข้าถึง aggregate ที่​มี​อยู่​แล้ว​รู้สึก​เหมือน​คุย​กับ in-memory collection ทั้ง​ที่​ข้าง​หลัง​อาจ​เป็น​ฐาน​ข้อมูล, cache หรือ HTTP call ก็ได้ — domain ไม่​จำเป็น​ต้อง​รู้ (และ​ไม่​ควร​รู้) เลย​ว่า​ข้าง​หลัง​คือ​อะไร หลักการ​นี้​เรียก​ว่า persistence ignorance — recall contract ตัว​จริง​จาก Course A:

// recall จาก FoodOrdering.Application/Orders/IOrderRepository.cs (คอร์ส Clean Architecture .NET บทที่ 3)
public interface IOrderRepository
{
Task SaveAsync(Order order, CancellationToken ct);
Task<Order?> FindAsync(OrderId id, CancellationToken ct);
}

interface นี้​ประกาศ​อยู่​ใน FoodOrdering.Application — วงใน ตาม Dependency Inversion Principle เดิม​ที่​วาง​ไว้​ตั้งแต่​คอร์ส​ก่อน — ส่วน adapter ที่ implement มัน​จริง​ด้วย EF Core (EfOrderRepository) อยู่​ใน FoodOrdering.Infrastructure (วงนอก) คอร์ส​นี้ ไม่​ย้อน​ไป​สอน EF mapping ซ้ำ เพราะ​นั่น​คือ​ของ​ที่ Course A ทำ​ไป​แล้ว สิ่ง​ที่​บท​นี้​เพิ่ม​ให้​คือ​ทำให้ contract ตัว​นี้ สื่อ​ความหมาย​และ​ใช้​ซ้ำ​ได้​ดี​ขึ้น

Vaughn Vernon ใน Implementing Domain-Driven Design (และ​ตัวอย่าง code จริง​ใน IDDD_Samples) เน้น​ว่า repository ที่​ดี​ควร​มี method หน้าตา​แบบ collection — Save/Add, Find/Get — ไม่ใช่​ชื่อ​ที่​หลุด​จาก​ภาษา domain เช่น InsertRow หรือ ExecuteQuery และ​ควร​มี หนึ่ง aggregate rootAggregate RootEntity หนึ่ง​ตัว​ที่​เป็น 'ประตู​เดียว' เข้า​สู่ Aggregate การ​อ้างอิง​จาก​ภายนอก​ทำได้​กับ root เท่านั้น root จึง​ควบคุม​กฎ​ความ​ถูกต้อง​ของ​ทั้ง​กลุ่มTactical Design ต่อ1 repository เท่านั้น — OrderLine ไม่มี IOrderLineRepository ของ​ตัวเอง เพราะ​มัน​ไม่ใช่ aggregate root มัน​เข้าถึง​ได้​ทาง​เดียว​คือ​ผ่าน Order เท่านั้น (ตาม​ที่​บท​ที่ 4 วาง​กฎ​ไว้​ว่า aggregate root เป็น​ประตู​เดียว) ยิ่ง​มี repository เยอะ​เท่า​ไหร่​ที่​ไม่​ตรง​กับ aggregate root ยิ่ง​เป็น​สัญญาณ​ว่า​เส้น​แบ่ง aggregate อาจ​วาง​ผิด​ที่

ปัญหา​ของ IOrderRepository ตอน​นี้​คือ​มัน​หา Order ได้​แค่​ที​ละ​ตัว​ผ่าน OrderId เท่านั้น — ถ้า​อยาก​ได้ “ออเดอร์​ทั้งหมด​ที่​ยัง​ยกเลิก​ได้” ล่ะ? ทาง​ที่​มัก​เกิด​ขึ้น​คือ​เพิ่ม method ใหม่​ที​ละ​อัน​ไป​เรื่อยๆ (FindCancellableAsync, FindConfirmedAsync, …) แล้ว​เขียน​เงื่อนไข Status ซ้ำ​ทุก​ที่ ทั้ง​ที่​เงื่อนไข​เดียวกัน​นี้​เคย encode ไว้​แล้ว​ใน​บท​ที่ 7 recall ชิ้น​ส่วน​ที่​เกี่ยวข้อง:

// recall จากบทที่ 5 — Order มี state machine แล้ว
public enum OrderStatus { Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled }
// recall จากบทที่ 5 — เพิ่มเข้าไปใน Order (สมาชิกอื่นเหมือนที่ recall ไว้ข้างบนทุกตัว)
public sealed class Order
{
// ... เหมือนเดิม
public OrderStatus Status { get; private set; } = OrderStatus.Placed;
}
// recall จากบทที่ 7
public interface ISpecification<T>
{
bool IsSatisfiedBy(T candidate);
}
// recall จากบทที่ 7 — เข้ารหัส invariant ⑥ (ยกเลิกได้ก่อนถึงขั้น PickedUp เท่านั้น)
public sealed class CancellableOrderSpec : ISpecification<Order>
{
public bool IsSatisfiedBy(Order candidate) =>
candidate.Status is OrderStatus.Placed or OrderStatus.Confirmed or OrderStatus.Preparing;
}

CancellableOrderSpec ตัว​นี้​ถูก​ใช้​เป็น guard ตอน​เรียก Order.Cancel() อยู่​แล้ว (บท​ที่ 7) — แทนที่​จะ​เขียน​เงื่อนไข Status แบบ​เดียวกัน​ซ้ำ​อีกรอบ​ตอน query เรา​แค่​ให้ IOrderRepository รับ specificationSpecificationpattern ห่อ 'กฎ​การ​คัดเลือก/เงื่อนไข' เป็น​อ็อบเจ็กต์ที่​มี IsSatisfiedBy(...) นำ​มา​ประกอบ (and/or/not) และ​ใช้​ซ้ำ​ได้ เช่น EligiblePromotionSpec, CancellableOrderSpecTactical Design เข้าไปตรงๆ ได้​เลย:

// ต่อยอด IOrderRepository (recall interface เดิมจากด้านบนแล้วเพิ่ม method นี้เข้าไป)
public interface IOrderRepository
{
Task SaveAsync(Order order, CancellationToken ct);
Task<Order?> FindAsync(OrderId id, CancellationToken ct);
// ใหม่ในบทนี้ — spec-driven query
Task<IReadOnlyList<Order>> FindAsync(ISpecification<Order> spec, CancellationToken ct);
}
// ตัวอย่างการใช้งาน — เช่นใน background job ที่หาออเดอร์ที่ยังยกเลิกได้ทั้งหมด
public async Task<IReadOnlyList<Order>> GetCancellableOrdersAsync(
IOrderRepository orders, CancellationToken ct)
{
return await orders.FindAsync(new CancellableOrderSpec(), ct);
}

นี่​คือ​แนวทาง​เดียว​กับ library Specification ของ Ardalis (Steve Smith) ที่​ให้ repository รับ specification เป็น parameter แล้ว​ให้ adapter ฝั่ง Infrastructure เป็น​คน​แปลง​มัน​เป็น query จริง (เช่น LINQ ต่อ IQueryable<Order> ใน EfOrderRepository) — Domain/Application ไม่​ต้อง​รู้​เลย​ว่า​ข้าง​หลัง​แปล​เป็น SQL แบบ​ไหน รู้​แค่​ว่า​กฎ “ยกเลิก​ได้​ไหม” ถูก​เขียน​ไว้ ที่​เดียว แล้ว​ใช้​ซ้ำ​ได้​ทั้ง​ตอน​รักษา invariant และ​ตอน query — ปิด​วงจร​ที่​บท​ที่ 7 เปิด​ไว้​พอดี

flowchart LR
  Cart["Cart<br/>(ตะกร้า ยังไม่ล็อกราคา)"] -->|"Factory<br/>(ตั้งราคาผ่าน IMenuCatalog)"| Order["Order<br/>เกิดมาถูกต้องแล้ว (born valid)"]
  Order <-->|"Repository<br/>(SaveAsync / FindAsync)"| Storage[("ที่เก็บข้อมูล")]

คำ​บรรยาย​ภาพ: Factory สร้าง​ของ​ใหม่ — แปลง Cart ให้​กลาย​เป็น Order ที่​ผ่าน invariant ตั้งแต่​แรก​เกิด ส่วน Repository หา​ของ​เก่า — คุย​กับ​ที่​เก็บ​ข้อมูล​ราวกับ Order ทุก​ตัว​นั่ง​รอ​อยู่​ใน collection เดียวกัน​อยู่​แล้ว สอง​ตัว​นี้​ไม่​เคย​ทำ​หน้าที่​ของ​กัน​และ​กัน​เลย — Factory ไม่​เคย “หา” อะไร มัน​สร้าง​อย่าง​เดียว ส่วน Repository ไม่​เคย “สร้าง” อะไร​ใหม่ มัน​หา​ของ​ที่​มี​อยู่​แล้ว​อย่าง​เดียว

ย้อน​ดู​สิ่ง​ที่​คอร์ส​นี้​เติม​ให้ domain Order เดิม​จาก​คอร์ส Clean Architecture .NET ที​ละ​บท: เริ่ม​จาก​ตั้ง​ชื่อ​ผู้ร้าย domain กลวง (บท​ที่ 1) แล้ว​ไล่​แก้​ที​ละ​มุม — Value Object ที่​ทำให้​ค่า “ผิด​ไม่​ได้” (บท​ที่ 2), Entity ที่​เทียบ​ด้วย identity ไม่ใช่​ค่า (บท​ที่ 3), Aggregate ที่​มี root เป็น​ประตู​เดียว​รักษา invariant (บท​ที่ 4), state machine ที่​ทำให้​เปลี่ยน​สถานะ​ผิด​กฎ​ไม่​ได้ (บท​ที่ 5), Domain Event ที่​สื่อสาร​ข้าม aggregate แบบ eventual consistency (บท​ที่ 6), Domain Service กับ Specification ที่​ให้​บ้าน​กับ​กฎ​ที่​ไม่​ได้​เป็น​ของ object ตัว​เดียว (บท​ที่ 7) และ​ปิด​ท้าย​ด้วย Factory กับ Repository ใน​บท​นี้ — วงจร​ชีวิต​ของ aggregate ตั้งแต่​เกิด​จน​ถูก​เก็บ​และ​ค้น​คืน

สิ่ง​ที่​น่า​สังเกต​คือ: object เกือบ​ทั้งหมด​ที่​เรา​สร้าง​ตลอด​คอร์ส​นี้ — Money, Quantity, OrderLine, Order, PromotionEngine, CancellableOrderSpec — เป็น pure type ล้วนๆ ไม่​พึ่ง network, ไม่​พึ่ง​ฐาน​ข้อมูล, ไม่​พึ่ง​เวลา​จริง (นาฬิกา​เข้า​มา​ทาง IClock เป็น port ต่างหาก) นั่น​แปล​ว่า​มัน unit test ได้​ทันที​โดย​ไม่​ต้อง​ใช้ mock framework สัก​ตัว​เดียว — ยกเว้น​แค่​ขอบ​ที่​สัมผัส I/O จริง​อย่าง OrderFactory และ adapter ของ IOrderRepository ที่​ต้อง​ใช้ test double แทน คอร์ส​ถัด​ไป​ใน​ซีรีส์ — testing-in-practice — จะ​พา​ไป​ดู​รายละเอียด​ตรง​นี้​แบบ​เต็มๆ ว่า​ทำไม pure domain model แบบ​นี้​ถึง​ทดสอบ​ได้​เร็ว​และ​มั่นใจ​กว่า​การ mock ทุก​อย่าง​ตั้งแต่​ต้น

ถ้า​อยาก​อ่าน​ต้นตอ​ของ Factory และ Repository ใน​ภาษา DDD แบบ​เจาะ​ลึก​กว่า​นี้ กลับ​ไป​อ่าน​ต่อ​ได้ที่​คอร์ส ddd-patterns บท​ที่ 20 (Factories) และ บท​ที่ 21 (Repositories) — สอง​บท​นั้น​วาง​รากฐาน​เชิง​ทฤษฎี​ไว้​ก่อน ส่วน​คอร์ส​นี้​พา​ลง​มา​เห็น​ว่า​มัน​หน้าตา​เป็นยังไงจริงๆ ใน code C# บน domain เดียวกัน​ตลอด​ทั้ง​ซีรีส์


🔗 อ้างอิง​เพิ่มเติม​ใน DevIQ

เจาะ​ลึก​แนวคิด​ใน​บท​นี้​ต่อ​ได้ที่​คลัง​อ้างอิง DevIQ และ​คอร์ส ddd-patterns:

เช็กความเข้าใจ — บทที่ 8

ข้อ 1 / 3

อะไรคือความต่างหลักระหว่าง Factory กับ Repository ในการจัดการ Order?