init / required / with — สร้างครั้งเดียวแล้วแช่แข็ง
บทที่แล้วเราปั้น Money, OrderId, Quantity เป็น record แล้วได้ของแถมมาข้อหนึ่งฟรีๆ คือ “แก้ค่าไม่ได้หลังสร้าง” คุณสมบัตินี้มีชื่อว่า ImmutabilityImmutabilityคุณสมบัติ 'เปลี่ยนแปลงไม่ได้หลังสร้าง' ของอ็อบเจ็กต์ — ถ้าจะ 'เปลี่ยนค่า' ต้องสร้างอ็อบเจ็กต์ใหม่แทนของเดิม หัวใจของ Value Object ที่ทำให้ share ค่าข้ามที่ต่าง ๆ ได้อย่างปลอดภัยโดยไม่ต้องกลัวใครมาแก้ข้างหลังTactical Design — มันคือหัวใจที่ทำให้ Value ObjectValue Objectอ็อบเจ็กต์ที่นิยามด้วย 'ค่า' ไม่มี identity ของตัวเอง สองตัวที่ค่าเท่ากันถือว่าเป็นสิ่งเดียวกัน ควร immutable เปลี่ยนแปลงไม่ได้หลังสร้าง — ใน C# มักสร้างด้วย record เช่น Money, OrderIdTactical Design share ไปใช้ที่ไหนก็ได้อย่างสบายใจ เพราะไม่มีใครแอบมาแก้ค่าข้างหลังเราได้
แต่ record ไม่ใช่ทุกอย่างใน domain บางอย่าง — อย่าง Order — เป็น type ที่มีกฎเยอะ มีตัวตน และเราอยากคุมให้แน่นว่ามัน “เกิดมาถูกต้อง” ตั้งแต่วินาทีแรก บทนี้จะเก็บเครื่องมือสามชิ้นที่ทำให้อ็อบเจ็กต์แช่แข็งได้แม้ไม่ใช่ record — init, required, และ with — แล้วเอามาประกอบร่างเป็น pattern สำคัญ: private constructor + static factory ที่ทำให้ Order มีทางเดียวที่จะเกิด และทางนั้นตรวจกฎก่อนเสมอ
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain/Orders/
version ดิบ: property ที่ใครก็ mutate ได้
หัวข้อที่มีชื่อว่า “version ดิบ: property ที่ใครก็ mutate ได้”ก่อนจะเห็นของดี ดูของแย่ก่อน สมมติเราเขียน Order แบบ ❌ version ดิบ ด้วย property ที่มี set เปิดโล่ง:
// enum: type ที่จำกัดค่าไว้เป็นชุดคงที่ — สถานะของออเดอร์ (7 ค่านี้จะตามเราไปทั้งคอร์ส)public enum OrderStatus{ Placed, Confirmed, Preparing, PickedUp, Delivered, Rejected, Cancelled}
// ❌ version ดิบ: ทุก property มี set เปิด ใครก็เขียนทับได้ตลอดเวลาpublic class RawOrder{ public OrderId Id { get; set; } public OrderStatus Status { get; set; } public decimal Total { get; set; }}คำว่า set ใน { get; set; } แปลว่า “อ่านก็ได้ เขียนทับก็ได้ ตอนไหนก็ได้” ฟังดูสะดวก แต่มันเปิดประตูให้สร้างออเดอร์ที่ผิดกฎได้สบายๆ:
var order = new RawOrder();order.Status = OrderStatus.Delivered; // ส่งของแล้ว! ทั้งที่ยังไม่เคยสั่งด้วยซ้ำorder.Total = -999; // ยอดรวมติดลบก็ยัดเข้าไปได้order.Id = null; // ออเดอร์ไม่มีเลขประจำตัวก็ยังมีชีวิตอยู่ได้ทุกบรรทัดนี้ “ถูกกฎของภาษา” แต่ “ผิดกฎของธุรกิจ” ทั้งหมด ปัญหาคือ RawOrder ไม่มีกลไกอะไรกันเลย — ใครหยิบไปก็เปลี่ยนสภาพมันได้ตามใจ เราต้องการตรงกันข้าม: อยากให้ค่าถูกตั้ง ครั้งเดียวตอนสร้าง แล้ว ล็อก ทันที นั่นคืองานของ init
init — ตั้งค่าตอนเกิด แล้วล็อก
หัวข้อที่มีชื่อว่า “init — ตั้งค่าตอนเกิด แล้วล็อก”เปลี่ยนคำว่า set เป็น init ก็ได้ Init-only SetterInit-only Settersetter แบบ `init` ที่กำหนดค่าได้ครั้งเดียวตอนสร้างอ็อบเจ็กต์ (หรือใน object initializer) แล้วหลังจากนั้นเปลี่ยนไม่ได้อีก — เครื่องมือหลักที่ทำให้ property เป็น immutable โดยไม่ต้องพึ่ง constructor ยาว ๆTactical Design ทันที — property ที่กำหนดค่าได้ เฉพาะตอนสร้างอ็อบเจ็กต์ แล้วหลังจากนั้นเขียนทับไม่ได้อีกเลย ลองดูกับ record เก็บตัวเลือกการจัดส่ง (ยังไม่มีกฎอะไรพิเศษ):
public record DeliveryOptions{ public string Note { get; init; } = ""; // ค่าเริ่มต้นเป็น string ว่าง public bool LeaveAtDoor { get; init; }}ตอนสร้าง เรากำหนดค่าผ่าน object initializer — วงเล็บปีกกา { } ต่อท้าย new ที่ระบุค่าให้แต่ละ property ตามชื่อ:
var opts = new DeliveryOptions { Note = "ไม่เอาผัก", LeaveAtDoor = true };
// opts.LeaveAtDoor = false; // ❌ compile error: init-only ตั้งได้ครั้งเดียวตอนสร้างนี่คือความต่างจาก set ปกติแบบชัดๆ: set แก้ได้ตลอดชีวิตของอ็อบเจ็กต์ ส่วน init เปิดหน้าต่างให้เขียนแค่ช่วง “กำลังเกิด” เท่านั้น พอเกิดเสร็จหน้าต่างปิด — property กลายเป็นอ่านอย่างเดียว นี่แหละเครื่องมือชิ้นแรกที่ให้ immutability โดยไม่ต้องเขียน constructor ยาวๆ เอง (init ใช้ได้ทั้งกับ class และ record)
required — บังคับว่าต้องมีค่านี้
หัวข้อที่มีชื่อว่า “required — บังคับว่าต้องมีค่านี้”init ล็อกค่าได้ก็จริง แต่ยังไม่ได้บังคับว่า “ต้องใส่” — ถ้าลืมใส่ Note มันก็แค่เป็น string ว่าง ไม่เป็นไร แต่บางค่าต้องมีเสมอ เช่นที่อยู่จัดส่ง ถ้าไม่มีก็ส่งของไม่ได้ นี่คืองานของ Required MemberRequired Memberkeyword `required` หน้า property ที่บังคับให้ผู้เรียกต้องกำหนดค่าตอนสร้างอ็อบเจ็กต์ ไม่งั้น compiler ฟ้อง — ช่วยรักษา invariant 'ต้องมีค่านี้เสมอ' ตั้งแต่จุดสร้าง โดยไม่ต้องเขียน constructor เองTactical Design — เติม keyword required หน้า property แล้ว compiler จะ ไม่ยอมให้สร้างอ็อบเจ็กต์ ถ้าไม่กำหนดค่านั้น:
public record DeliveryOptions{ public required string Address { get; init; } // ต้องใส่เสมอ ไม่งั้น compile ไม่ผ่าน public string Note { get; init; } = ""; public bool LeaveAtDoor { get; init; }}ทีนี้ compiler กลายเป็นด่านตรวจให้เรา:
// var bad = new DeliveryOptions { Note = "รีบหน่อย" }; // ❌ compile error: ลืมใส่ Address
var ok = new DeliveryOptions { Address = "123 ถ.สุขุมวิท", Note = "รีบหน่อย" }; // ✅จุดที่สวยคือ required รักษากฎ “ต้องมีที่อยู่เสมอ” ให้เราตั้งแต่ตอน compile โดยไม่ต้องเขียน constructor เองเลย ค่าที่ขาดไม่ได้จะถูกจับได้ก่อนโปรแกรมจะรันด้วยซ้ำ
with — “แก้” ด้วยการสร้างก้อนใหม่
หัวข้อที่มีชื่อว่า “with — “แก้” ด้วยการสร้างก้อนใหม่”พอทุก property เป็น init แล้ว คำถามที่ตามมาทันทีคือ “แล้วถ้าอยากเปลี่ยนค่าล่ะ?” ในเมื่อแก้ของเดิมไม่ได้ คำตอบของ record คือ With ExpressionWith Expressionไวยากรณ์ `x with { Property = newValue }` ที่สร้าง record ตัวใหม่โดยคัดลอกทุก field จาก x แล้วเปลี่ยนเฉพาะที่ระบุ — วิธีมาตรฐานในการ 'แก้ไข' Value Object แบบ immutable โดยไม่ต้อง mutate ของเดิมTactical Design — ไวยากรณ์ เดิม with { ฟิลด์ = ค่าใหม่ } ที่ คัดลอกทุก field จากของเดิม แล้วเปลี่ยนเฉพาะที่ระบุ ได้ออกมาเป็นก้อนใหม่ โดยไม่แตะของเดิมเลย:
var basic = new DeliveryOptions { Address = "123 ถ.สุขุมวิท" };
var vip = basic with { LeaveAtDoor = true, Note = "โทรก่อนถึง" };
// basic ยังเหมือนเดิมเป๊ะ — LeaveAtDoor = false, Note = ""// vip เป็นก้อนใหม่ที่ Address ถูกคัดลอกมา แต่ LeaveAtDoor/Note เปลี่ยนตามที่สั่งนี่เรียกว่า การคัดลอกแบบไม่ทำลายของเดิม (non-destructive copy) — เราไม่เคย mutate basic เลย เราสร้าง vip ขึ้นมาใหม่ต่างหาก ของเดิมที่ใครถืออยู่จึงไม่มีทางเปลี่ยนใต้เท้าเขา นี่คือวิธีมาตรฐานในการ “แก้ไข” Value Object ในโลก immutable: ไม่ใช่แก้ที่เดิม แต่สร้างก้อนใหม่ที่ต่างไปนิดเดียว
with เป็นของแถมที่ compiler สร้างให้ record โดยเฉพาะ (ผ่านตัวคัดลอกที่ชื่อ copy-constructor) — class ธรรมดาไม่มีให้ นี่เป็นอีกเหตุผลที่ Value Object เล็กๆ เราชอบเขียนเป็น record
ทำไม with ถึงห้ามใช้กับ Money — ความตึงที่ต้องเข้าใจ
หัวข้อที่มีชื่อว่า “ทำไม with ถึงห้ามใช้กับ Money — ความตึงที่ต้องเข้าใจ”with ดูวิเศษมากจนอยากใช้กับทุกอย่าง คำถามที่เป็นธรรมชาติสุดๆ คือ “งั้นเปลี่ยนราคาด้วย price with { Amount = 90 } ก็ได้สิ?” คำตอบคือ ไม่ได้ และมันตั้งใจให้ไม่ได้ เหตุผลมีสองชั้น และมันคือหัวใจของบทนี้
จำ Money จากบทที่ 1 ได้ไหม — เราจงใจทำ Amount ให้เป็น get-only ({ get; } เฉยๆ ไม่มี init) พร้อม Guard ClauseGuard Clauseเงื่อนไขตรวจสอบข้อมูลไว้ต้น ๆ ของ constructor หรือ method แล้วโยน exception ทันทีถ้าไม่ผ่าน เช่น `if (amount < 0) throw new ArgumentException(...)` — วิธีที่ Value Object และ Entity ใช้รักษา invariant ของตัวเองตั้งแต่วินาทีที่สร้าง ไม่ปล่อยให้อ็อบเจ็กต์ผิดรูปหลุดออกไปTactical Design ที่ห้ามค่าติดลบ:
public record Money(decimal Amount, string Currency){ // guard อยู่ในบรรทัดนี้ — มันคือ "initializer" ที่รันตอน Money เกิดผ่าน constructor เท่านั้น public decimal Amount { get; } = Amount >= 0 ? Amount : throw new ArgumentException("จำนวนเงินติดลบไม่ได้", nameof(Amount));
public static Money Thb(decimal amount) => new(amount, "THB");
// รวมเงินสกุลเดียวกันโดยสร้างก้อนใหม่ผ่าน constructor → guard ทำงานทุกครั้ง public Money Add(Money other) => new(Amount + other.Amount, Currency);}ชั้นที่ 1 — with compile ไม่ผ่านอยู่แล้ว เพราะ Amount เป็น get-only ไม่มี init ให้ with เขียนทับ:
var price = Money.Thb(120);// var cheaper = price with { Amount = 90 }; // ❌ compile error: Amount ไม่มี initชั้นที่ 2 — อันตรายกว่าเดิม ถ้าเราใจอ่อน สมมติเรารำคาญ error แล้วเปลี่ยน Amount เป็น { get; init; } เพื่อให้ with ทำงาน คราวนี้มัน compile ผ่าน แต่ค่าติดลบจะหลุด guard ออกมาเงียบๆ:
// ถ้า Amount เป็น { get; init; } ...// var broken = price with { Amount = -500 }; // ✅ compile ผ่าน แต่ได้ Money ที่ -500 !!ทำไม guard ถึงไม่ทำงาน? เพราะ guard ของเราอยู่ใน initializer (บรรทัด public decimal Amount { get; } = ...) ซึ่งรัน เฉพาะตอน Money เกิดผ่าน constructor จริงๆ เท่านั้น ส่วน with ทำงานคนละทาง: มันคัดลอก field เดิมมาก่อน แล้วค่อยเขียนทับ Amount ผ่าน “ประตูหลัง” (init setter) ที่ ไม่เคยรัน guard ซ้ำ — ค่า -500 เลยไหลผ่านเข้ามาได้โดยไม่มีอะไรกัน
นี่คือความตึงที่ต้องจำ: with เหมาะกับข้อมูลที่ไม่มี guard (เช่น DeliveryOptions ที่เป็นแค่ค่าตั้งค่า/DTO ธรรมดา) แต่ ห้ามใช้กับ Value Object ที่มี guard เพราะมันข้ามด่านตรวจ วิธีที่ถูกในการ “เปลี่ยนค่าเงิน” คือสร้างก้อนใหม่ผ่านทางที่ตรวจ guard เสมอ — factory หรือ method:
var discounted = Money.Thb(90); // สร้างใหม่ผ่าน factory → constructor รัน → guard ทำงาน
// Add (method ใน Money ด้านบน) รวมเงินโดยสร้างก้อนใหม่ผ่าน constructor เสมอvar combined = discounted.Add(Money.Thb(30)); // ได้ Money ก้อนใหม่ 120 บาท, guard ทำงานทุกครั้งนี่คือเหตุผลที่บทที่ 1 เลือก Amount เป็น get-only ตั้งแต่แรก — ไม่ใช่เผลอ แต่เพื่อปิดประตูหลังของ with ไม่ให้มีทางข้าม guard ได้เลย
private ctor + factory: Order เกิดมาถูกต้องเท่านั้น
หัวข้อที่มีชื่อว่า “private ctor + factory: Order เกิดมาถูกต้องเท่านั้น”ทีนี้เอาทุกอย่างมาประกอบร่างเป็น Order ตัวจริง เป้าหมายคือทำให้ Order มี ทางเดียว ที่จะเกิด และทางนั้นตรวจกฎก่อนเสมอ กลไกมีสองชิ้น:
- ทำ constructor เป็น
private— ปิดไม่ให้ข้างนอกเรียกnew Order(...)ได้เลย - เปิด static factory ชื่อ
Order.Place(...)เป็นประตูเดียว — มันตรวจกฎก่อน แล้วค่อยเรียก constructor ที่ซ่อนอยู่ให้
ก่อนอื่นเราต้องมีของในตะกร้า — OrderLine (บรรทัดสินค้าหนึ่งรายการ) OrderLine ต่างจาก Money ตรงที่มัน มีตัวตน (identity) เป็นของตัวเอง เราเรียกอ็อบเจ็กต์แบบนี้ว่า entity และเขียนเป็น class (ไม่ใช่ record) — รายละเอียดเต็มของ entity เก็บไว้บทหน้า ตอนนี้แค่มีไว้ให้ตะกร้าไม่ว่าง:
namespace FoodOrdering.Domain.Orders;
public record OrderLineId(Guid Value);public record ProductId(Guid Value);
// base class เล็ก ๆ ที่ entity ทุกตัวใช้ร่วมกัน — แค่ถือ Id ไว้// Entity<TId> เดี๋ยวเจาะลึกในบท 7 เรื่อง generics — ตอนนี้รู้แค่ว่ามันให้ Id + การเทียบด้วย identity// abstract = สร้างตรง ๆ ไม่ได้ ต้องมี class อื่นมาสืบทอด; <TId> = ใช้ได้กับ id ชนิดใดก็ได้public abstract class Entity<TId>{ protected Entity(TId id) => Id = id; public TId Id { get; }}
public class OrderLine : Entity<OrderLineId>{ public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice) : base(id) // ส่ง id ต่อให้ base class Entity เก็บ { ProductId = productId; Quantity = quantity; UnitPrice = unitPrice; }
public ProductId ProductId { get; } // get-only: ตั้งค่าใน constructor แล้วล็อก public Quantity Quantity { get; } public Money UnitPrice { get; }
// ราคารวมของบรรทัดนี้ = ราคาต่อชิ้น × จำนวน (สร้าง Money ก้อนใหม่ผ่าน factory) public Money Total => Money.Thb(UnitPrice.Amount * Quantity.Value);}ทีนี้ Order เอง — สังเกตสามจุด: readonly field, collection ที่เปิดให้อ่านอย่างเดียว, และ factory ที่ตรวจ invariant:
namespace FoodOrdering.Domain.Orders;
public class Order : Entity<OrderId>{ // readonly: field นี้กำหนดค่าได้ครั้งเดียวใน constructor แล้วเปลี่ยนไม่ได้อีก // ตัวมันเป็น field ส่วนตัวที่ซ่อนอยู่ข้างหลัง เรียกว่า backing field private readonly List<OrderLine> _lines;
// constructor เป็น private — ห้ามใครข้างนอกเรียก new Order(...) // : base(id) ส่ง id ต่อขึ้นไปให้ Entity<OrderId> เก็บเป็น Id (get-only) ให้เอง private Order(OrderId id, List<OrderLine> lines) : base(id) { _lines = lines; }
// เปิดออกไปเป็น "อ่านอย่างเดียว" — ข้างนอกเรียก .Add()/.Remove() ตรง ๆ ไม่ได้ public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
// ยอดรวม คำนวณสด ๆ จากบรรทัดทุกครั้ง ไม่ได้เก็บเป็นค่าที่ใครมาแก้ได้ public Money Total { get { var sum = Money.Thb(0); foreach (var line in _lines) sum = sum.Add(line.Total); // Add สร้าง Money ก้อนใหม่ผ่าน ctor → guard ทำงาน return sum; } }
// factory: ประตูเดียวที่สร้าง Order ได้ และมันตรวจ invariant ก่อนเสมอ public static Order Place(OrderId id, IReadOnlyList<OrderLine> lines) { // invariant ①: ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้ if (lines.Count == 0) throw new ArgumentException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้", nameof(lines));
return new Order(id, lines.ToList()); // เรียก private ctor ได้เพราะอยู่ใน class เดียวกัน }}มีศัพท์ใหม่สองคำ:
readonlyหน้า field แปลว่า “กำหนดค่าได้ครั้งเดียวใน constructor แล้วห้ามเปลี่ยน” — เป็น immutability version ของ field (คู่กับinitที่เป็น version ของ property)_linesเลยถูกล็อกตั้งแต่Orderเกิด- Backing FieldBacking Fieldfield ส่วนตัว (private) ที่เก็บข้อมูลจริงอยู่ข้างหลัง property หรือ collection ที่เปิดให้ภายนอกเห็นแบบอ่านอย่างเดียว เช่น `private readonly List<OrderLine> _lines` กับ property `IReadOnlyList<OrderLine> Lines` — วิธีที่ Aggregate รักษาไม่ให้ code ภายนอกมา Add/Remove ตรง ๆ โดยข้ามกฎธุรกิจTactical Design คือ field ส่วนตัว (
_lines) ที่เก็บข้อมูลจริงไว้ข้างหลัง แล้วเปิดออกไปให้ข้างนอกเห็นแค่แบบอ่านอย่างเดียวผ่านLines— เทคนิคนี้กันไม่ให้ code ภายนอกมาAdd/Removeข้ามกฎธุรกิจของเรา
ผลลัพธ์คือ Order ที่ “เกิดมาถูกต้องเท่านั้น”:
var lines = new List<OrderLine>{ new(new OrderLineId(Guid.NewGuid()), new ProductId(Guid.NewGuid()), new Quantity(2), Money.Thb(60)),};
var order = Order.Place(new OrderId(Guid.NewGuid()), lines); // ✅ Total = 120 บาท
// var empty = Order.Place(new OrderId(Guid.NewGuid()), new List<OrderLine>());// 💥 ArgumentException: ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้
// order.Total = -999; // ❌ compile error: ไม่มี setter// order.Lines.Add(otherLine); // ❌ compile error: Lines อ่านอย่างเดียวเทียบกับ RawOrder version ดิบตอนต้นบท ต่างกันคนละโลก — ตรงนั้นใครก็ยัดค่าผิดๆ เข้าไปได้ ตรงนี้ทางเข้าทุกทางถูกปิดหมด เหลือแค่ประตู Place ที่ตรวจกฎก่อนเสมอ
flowchart TB
subgraph GOOD["ทางที่ถูก — เข้าผ่าน factory Place"]
IN["Order.Place(id, lines)"]
IN --> CHK{"lines ว่างไหม?"}
CHK -->|ว่างเปล่า| ERR["โยน ArgumentException<br/>ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้"]
CHK -->|มีอย่างน้อย 1 บรรทัด| NEW["เรียก private constructor"]
NEW --> OK["Order ที่ถูกต้อง<br/>Total คำนวณเอง · Lines อ่านอย่างเดียว"]
end
subgraph BAD["ทางที่ถูก block — mutate ตรง ๆ"]
M1["new Order(...) จากข้างนอก"]
M2["order.Total = -999"]
M3["order.Lines.Add(...)"]
M1 --> X["ทำไม่ได้ตั้งแต่ compile<br/>ctor เป็น private · ไม่มี setter · Lines อ่านอย่างเดียว"]
M2 --> X
M3 --> X
end
คำบรรยายภาพ: ทางเดียวที่ Order เกิดได้คือผ่าน factory Place ซึ่งตรวจ invariant “ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้” ก่อนจะยอมเรียก constructor ที่ซ่อนอยู่ — ตะกร้าว่างถูกโยนทิ้งตั้งแต่ต้นทาง ส่วนทางลัดทั้งหลายที่พยายาม mutate ตรงๆ (เรียก new เอง, เขียนทับ Total, ยัดของเข้า Lines) ถูก block ตั้งแต่ตอน compile ไม่ต้องรอให้ระเบิดตอนรัน Order ผิดรูปจึงไม่มีทางมีอยู่จริง
สิ่งที่จะทำต่อ
หัวข้อที่มีชื่อว่า “สิ่งที่จะทำต่อ”บทนี้เก็บเครื่องมือแช่แข็งครบชุด — init, required, readonly ให้ immutability, with ให้เรา “แก้” แบบไม่ทำลายของเดิม และ private ctor + factory ทำให้ Order เกิดมาถูกต้องเท่านั้น พร้อม invariant ข้อแรกที่ว่าตะกร้าว่างสร้างออเดอร์ไม่ได้ จุดสำคัญที่สุดที่อยากให้ติดตัวไปคือ ความตึงระหว่าง with กับ guard: with ดีกับข้อมูลไร้กฎ แต่ Value Object ที่มี guard ต้องเปลี่ยนค่าผ่าน factory/method เท่านั้น
- บทถัดไป — nullable reference types ทำให้
nullกลายเป็นเรื่องที่ต้องประกาศชัด แล้ว guard clause (ArgumentNullException.ThrowIfNull) กันnullไว้ที่ขอบ ให้ domainOrderเป็นเขตปลอด null - บทถัดๆ ไป — pattern matching ขับเคลื่อน
OrderStatusที่เราแอบเปิดตัวไว้ให้เป็น state machine, type system ทำให้สถานะที่ผิดเป็นไปไม่ได้ และ async/await กลายเป็นรูปร่างของ port
ส่วนการเจาะลึกว่า factory, aggregate และ invariant ในเชิง DDD ออกแบบยังไงให้ลึกกว่านี้ เก็บไว้ที่คอร์ส DDD in Code — คอร์สนี้โฟกัสที่ “ไวยากรณ์ C# ที่ทำให้มันเกิดขึ้นได้” ก่อน
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Make Illegal States Unrepresentable — หลักการเบื้องหลังทั้งบทนี้: ออกแบบ type ให้ “สถานะที่ผิดกฎ เขียนแทนไม่ได้เลย” — immutability + private ctor + factory คือเครื่องมือที่ทำให้
Orderผิดรูปไม่มีทางมีอยู่จริง - Factory Method — pattern เบื้องหลัง
Order.Place(...): ให้ method เป็นผู้รับผิดชอบการสร้างอ็อบเจ็กต์ แทนการเปิด constructor ให้เรียกตรงๆ
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3property ที่เป็น init-only (`{ get; init; }`) ต่างจาก setter ปกติ (`{ get; set; }`) ยังไง?