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

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 ตัวอย่าง (กำลัง​จัด​ทำ)

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​เกี่ยวข้อง​กับ path FoodOrdering.Domain/Orders/

ก่อน​จะ​เห็น​ของดี ดู​ของ​แย่​ก่อน สมมติ​เรา​เขียน 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

เปลี่ยน​คำ​ว่า 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)

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 เอง​เลย ค่าที่​ขาด​ไม่​ได้​จะ​ถูก​จับ​ได้​ก่อน​โปรแกรม​จะ​รัน​ด้วย​ซ้ำ

พอ​ทุก 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 ใช้ได้​กับ record (และ struct) เท่านั้น

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 ได้​เลย

ทีนี้​เอา​ทุก​อย่าง​มา​ประกอบ​ร่าง​เป็น Order ตัว​จริง เป้าหมาย​คือ​ทำให้ Order มี ทาง​เดียว ที่​จะ​เกิด และ​ทาง​นั้น​ตรวจ​กฎ​ก่อน​เสมอ กลไก​มี​สอง​ชิ้น:

  1. ทำ constructor เป็น private — ปิด​ไม่​ให้​ข้าง​นอก​เรียก new Order(...) ได้​เลย
  2. เปิด 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 ไว้​ที่​ขอบ ให้ domain Order เป็น​เขต​ปลอด null
  • บทถัดๆ ไป — pattern matching ขับ​เคลื่อน OrderStatus ที่​เรา​แอบ​เปิด​ตัว​ไว้​ให้​เป็น state machine, type system ทำให้​สถานะ​ที่​ผิด​เป็น​ไป​ไม่​ได้ และ async/await กลาย​เป็น​รูปร่าง​ของ port

ส่วน​การ​เจาะ​ลึก​ว่า factory, aggregate และ invariant ใน​เชิง DDD ออกแบบ​ยังไง​ให้​ลึก​กว่า​นี้ เก็บ​ไว้​ที่​คอร์ส DDD in Code — คอร์ส​นี้​โฟกัส​ที่ “ไวยากรณ์ C# ที่​ทำให้​มัน​เกิด​ขึ้น​ได้” ก่อน


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

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

  • Make Illegal States Unrepresentable — หลักการ​เบื้องหลัง​ทั้ง​บท​นี้: ออกแบบ type ให้ “สถานะ​ที่​ผิด​กฎ เขียน​แทน​ไม่​ได้​เลย” — immutability + private ctor + factory คือ​เครื่องมือ​ที่​ทำให้ Order ผิด​รูป​ไม่มี​ทาง​มี​อยู่​จริง
  • Factory Method — pattern เบื้องหลัง Order.Place(...): ให้ method เป็น​ผู้รับผิดชอบ​การ​สร้าง​อ็อบเจ็กต์ แทน​การ​เปิด constructor ให้​เรียกตรงๆ

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

ข้อ 1 / 3

property ที่เป็น init-only (`{ get; init; }`) ต่างจาก setter ปกติ (`{ get; set; }`) ยังไง?