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

Nullable reference types — ไล่ null ออก​จาก domain

ผ่าน​มาสอง​บท เรา​ปั้น​ก้อน​อิฐ​ของ domain Order ไว้​แล้ว — Money, OrderId, Quantity เป็น record ที่​เทียบ​ด้วย​ค่า แก้​ไม่​ได้​หลัง​สร้าง และ​รักษา​กฎ​ของ​ตัวเอง​ด้วย guard พวก​มัน​คือ Value ObjectValue Objectอ็อบเจ็กต์ที่​นิยาม​ด้วย 'ค่า' ไม่มี identity ของ​ตัวเอง สอง​ตัว​ที่​ค่า​เท่า​กัน​ถือว่า​เป็น​สิ่ง​เดียวกัน ควร immutable เปลี่ยนแปลง​ไม่​ได้​หลัง​สร้าง — ใน C# มัก​สร้าง​ด้วย record เช่น Money, OrderIdTactical Design ที่​จะ​ตาม​เรา​ไป​ทั้ง​คอร์ส บท​นี้​เรา​จะ​จัดการ​ศัตรู​เงียบๆ ที่​ทำให้​โปรแกรม C# พัง​บ่อย​ที่สุด แล้ว​ทำให้​มัน​หมด​โอกาส​เข้า​มา​ใน domain ของ​เรา ศัตรู​ตัว​นั้น​คือ null

null คือ​ค่าที่​แปล​ว่า “ไม่มี​อะไร​อยู่​ตรง​นี้” ปัญหา​คือ​ถ้า​เรา​เผลอ​เรียก​อะไร​ก็ตาม​บน​ตัว​ที่​เป็น null (เช่น order.Lines.Count ตอน​ที่ order.Lines เป็น null) โปรแกรม​จะ​พัง​กลาง​อากาศ​ด้วย NullReferenceException — และ​มัน​พัง​ตอน runtime คือ​ตอน​ที่​ผู้​ใช้​กำลัง​ใช้งาน​จริง ไม่ใช่​ตอน​เขียน code คน​ถึง​เรียก null ว่า “ความ​ผิดพลาด​พัน​ล้าน​ดอลลาร์” ของ​วงการ​ซอฟต์แวร์

📦 code ตัวอย่าง (กำลัง​จัด​ทำ)

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

ลอง​ดู​ว่า​ถ้า​ไม่​ระวัง null เลย code จะ​เป็น​ยังไง นี่​คือ ❌ version ดิบ ของ Order — เขียน​เป็น class ธรรมดา​ที่ field เป็น null ได้​แบบ​เงียบๆ:

// ❌ version ดิบ: field ที่เป็น null ได้เงียบ ๆ ไม่มีใครเตือน
public class RawOrder
{
public List<RawLine> Lines; // ไม่ได้กำหนดค่า → ค่าเริ่มต้นคือ null
}
public class RawLine
{
public decimal UnitPrice;
public int Quantity;
}

Lines เป็น​ชนิด List<RawLine> ก็​จริง แต่​ถ้า​เรา​สร้าง RawOrder ขึ้น​มา​โดย​ไม่​ได้​ใส่​ค่า Lines มัน​จะ​เป็น null โดย​อัตโนมัติ — และ​ไม่มี​อะไร​เตือน​เรา​เลย พอ​เผลอ​ไป​ใช้​ก็​พัง​ทันที:

var order = new RawOrder();
int count = order.Lines.Count; // 💥 NullReferenceException — พังตอน runtime

พอ​เจอ​แบบ​นี้​บ่อยๆ คน​ก็​เริ่ม “ป้องกัน​ตัว” ด้วย​การ​โรย​เช็ก null ไป​ทั่ว เพราะ​ไม่​แน่ใจ​ว่า​ตัว​ไหน​เป็น null ได้​บ้าง สุดท้าย​งาน​จริง​ถูก​ดัน​เข้าไป​ซ้อน​ลึก​อยู่​ใน​เงื่อนไข:

// ต้องเดาเองว่าตัวไหน null ได้ แล้วเช็กกระจัดกระจายไปทั่ว
if (order != null && order.Lines != null && order.Lines.Count > 0)
{
// ...งานจริงถูกดันเข้าไปซ้อนอยู่ข้างในสุด
}

ปัญหา​สอง​ข้อ​ของ version ดิบ​นี้​คือ (1) ไม่มี​ใคร​เตือน ว่า​ตัว​ไหน​เป็น null ได้ เรา​รู้​ตอน​มัน​พัง​ไป​แล้ว และ (2) null-check กระจัดกระจาย ไป​ทุก​ที่​ที่​แตะ​ข้อมูล ทำให้กฎธุรกิจจริงๆ จม​หาย​ไป​ใน​กอง​เงื่อนไข C# สมัย​ใหม่​แก้​ทั้ง​สอง​ข้อ​นี้​ด้วย​เครื่อง​มือสอง​ชิ้น​ที่​เรา​จะ​เรียน​ต่อ​ไป

เครื่องมือ​ชิ้น​แรก​คือ Nullable Reference TypesNullable Reference Typesfeature ที่​ทำให้ compiler ช่วย​เตือน​เมื่อ reference type (เช่น string, class) อาจ​เป็น null โดย​ไม่​ได้​ตรวจ​ก่อน เปิด​ด้วย `<Nullable>enable</Nullable>` — ทำให้ null เป็น​เรื่อง​ที่​ต้อง​ประกาศ​ชัดเจน (`string?`) ไม่ใช่ default ที่​แอบ​ซ่อน bug และ​ผลัก​ให้​เขียน Guard Clause ตรง ๆ แทน​การ​เดาTactical Design (เรียก​ย่อๆ ว่า NRT) เปิด​ใช้​ด้วย​การ​ใส่​บรรทัด​นี้​ใน file ตั้ง​ค่า project (.csproj) — project .NET ใหม่ๆ เปิด​ให้​อยู่​แล้ว​โดย​ปริยาย:

<Nullable>enable</Nullable>

พอ​เปิด NRT ความหมาย​ของ type จะ​เปลี่ยน​ไป​อย่าง​สำคัญ: string ธรรมดา​จะ​แปล​ว่า “ห้าม​เป็น null” ส่วน​ถ้า​จะ​ยอม​ให้​เป็น null ได้​ต้อง​เติม​เครื่องหมาย ? ต่อ​ท้าย กลาย​เป็น string?:

string name = "แกงเขียวหวาน"; // ห้ามเป็น null — ถ้าลองใส่ null compiler เตือนทันที
string? note = null; // เติม ? = ประกาศชัดเจนว่าตัวนี้เป็น null ได้

ทีนี้​ของ​เด็ด​คือ compiler จะ​คอย เตือน​ตั้งแต่​ตอน compile (คือ​ตอน​แปลง code ก่อน​จะ​ได้​รัน​ด้วย​ซ้ำ) ถ้า​เรา​กำลัง​จะ​ใช้​ค่าที่​อาจ​เป็น null โดย​ไม่​เช็กก่อน:

string? note = GetNoteOrNull();
int len = note.Length; // ⚠️ compiler เตือน: note อาจเป็น null ตรงนี้

วิธี​ทำให้​คำ​เตือน​หาย​ไป​อย่าง​ถูกต้อง​คือ​เช็กก่อน​ใช้ — พอ compiler เห็น​ว่า​เรา​เช็ก​แล้ว มัน​จะ​รู้​ว่า​ใน block นั้น note ไม่มี​ทาง​เป็น null:

if (note != null)
{
// ใน block นี้ compiler มั่นใจแล้วว่า note ไม่เป็น null จึงไม่เตือน
int len = note.Length;
}

มี​ทาง​ลัด​อีก​อัน​คือ​เครื่องหมาย ! (เรียก​ว่า null-forgiving operator) เขียน note!.Length เป็นการ​บอก compiler ว่า “เชื่อ​ฉัน​เถอะ ตรง​นี้​ไม่ null หรอก” คำ​เตือน​จะ​หาย — แต่ ถ้า​เรา​เดา​ผิด มัน​ก็​พัง NullReferenceException ตอน runtime อยู่ดี เพราะ ! แค่​ปิดปาก compiler ไม่​ได้​เพิ่ม​ความ​ปลอดภัย​จริง ใช้​เท่า​ที่​จำ​เป็นจริงๆ เท่านั้น

จุด​สำคัญ​ที่​ต้อง​จำ: NRT ช่วย​ตอน compile time เท่านั้น มัน​คือ​คำ​เตือน​ของ compiler ไม่ใช่​ยาม​ที่​ยืน​เฝ้า​ตอน​โปรแกรม​ทำงาน​จริง ข้อมูล​ที่มา​จาก​โลก​ภายนอก (เช่น JSON ที่​ถอดรหัส​มา หรือ code เก่า​ที่​ไม่​ได้​เปิด NRT) ยัง​แอบ​ยัด null เข้า​มา​ตอน runtime ได้​อยู่ดี เพราะ​ฉะนั้น​เรา​จึง​ต้อง​มี​เครื่องมือ​ชิ้น​ที่​สอง

เครื่องมือ​ชิ้น​ที่​สอง​คือ Guard ClauseGuard Clauseเงื่อนไข​ตรวจสอบ​ข้อมูล​ไว้​ต้น ๆ ของ constructor หรือ method แล้ว​โยน exception ทันที​ถ้า​ไม่​ผ่าน เช่น `if (amount < 0) throw new ArgumentException(...)` — วิธี​ที่ Value Object และ Entity ใช้​รักษา invariant ของ​ตัวเอง​ตั้งแต่​วินาที​ที่​สร้าง ไม่​ปล่อย​ให้​อ็อบเจ็กต์ผิด​รูป​หลุด​ออก​ไปTactical Design — เงื่อนไข​ตรวจ​ข้อมูล​ไว้​ต้นๆ ของ method หรือ constructor แล้ว​โยน exception ทันที​ถ้า​ไม่​ผ่าน มัน​คือ “ยาม” ตัว​จริง​ที่​ทำงาน​ตอน runtime สมัย​ก่อน​เรา​ต้อง​เขียน​ยาวๆ แบบ​นี้:

// แบบเดิม: เขียนเองยาว ๆ ทุกครั้ง
if (order == null)
throw new ArgumentNullException(nameof(order));

C# สมัย​ใหม่​ย่อ​ทั้งหมด​นี้​ให้​เหลือ​บรรทัด​เดียว​ด้วย ArgumentNullException.ThrowIfNull:

public void Process(Order order)
{
ArgumentNullException.ThrowIfNull(order); // ถ้า order เป็น null → โยน ArgumentNullException ทันที
// จากบรรทัดนี้ลงไป order ไม่มีทางเป็น null แล้ว เขียนกฎธุรกิจได้สบายใจ
}

ThrowIfNull เก่ง​กว่า​ที่​เห็น: มัน​ไม่​ต้อง​ให้​เรา​พิมพ์​ชื่อ parameter ("order") เอง​เลย แต่​ไป​ดึง​ชื่อ​จาก code ที่​เรา​ส่ง​เข้าไป​ให้​อัตโนมัติ (ผ่าน​ความ​สามารถ​ชื่อ CallerArgumentExpression) เวลา​โยน error ข้อความ​จึง​บอก​ได้​ว่า order นี่แหละ​ที่​เป็น null การ​รวม NRT (เตือน​ตอน compile) เข้า​กับ ThrowIfNull (กัน​ตอน runtime) ทำให้ null ถูก​จับ​ได้​ทั้ง​สอง​จังหวะ

คำถาม​สำคัญ​คือ ควร​วาง guard พวก​นี้​ไว้​ที่ไหน คำ​ตอบ​คือ​วาง​ไว้​ที่ “ขอบ” (edge) ของ​ระบบ — จุด​ที่​ข้อมูล​จาก​โลก​ภายนอก​ไหล​เข้า​มา เช่น controller ที่​รับ HTTP request, ตัว​ถอดรหัส JSON, หรือ code ที่​อ่าน​จาก​ฐาน​ข้อมูล ที่​ตรง​นั้น​แหละ​ที่ null โผล่​มา​ได้ ถ้า​เรา​ตั้ง​ด่าน​กัน​ไว้​ตั้งแต่​ขอบ อะไร​ก็ตาม​ที่​ผ่าน​เข้าไป​ถึง domain ได้ ก็​การันตี​แล้ว​ว่า​ไม่​เป็น null — domain ข้าง​ใน​จึง​กลาย​เป็น “เขต​ปลอด null” ที่​ไม่​ต้อง​เช็ก null ซ้ำๆ ทุก​บรรทัด​แบบ version ดิบ

flowchart LR
  subgraph EDGE["ขอบระบบ — ที่ที่ null โผล่ได้"]
    REQ["request / JSON / ฐานข้อมูล<br/>field อาจหายไป = null"]
    GUARD["Guard Clause<br/>ArgumentNullException.ThrowIfNull"]
    REQ --> GUARD
  end
  subgraph DOMAIN["domain Order — เขตปลอด null"]
    PLACE["Order.Place(id, lines)"]
    INNER["OrderLine · Money · Quantity<br/>ไม่มีทางเป็น null"]
    PLACE --> INNER
  end
  GUARD -->|"ผ่านด่านแล้วเท่านั้น"| PLACE
  GUARD -.->|"ถ้าเป็น null → หยุดที่นี่"| REJECT["โยน exception ทิ้ง ❌"]

คำ​บรรยาย​ภาพ: null เกิด​ขึ้น​ที่​ขอบ​ระบบ​เสมอ — ข้อมูล​จาก request, JSON หรือ​ฐาน​ข้อมูล​ที่ field อาจ​หาย​ไป เรา​ตั้ง Guard Clause ไว้​ตรง​นั้น​เป็น​ด่าน​เดียว: อะไร​ที่​เป็น null โดน​โยน​ทิ้ง​ตั้งแต่​ขอบ ส่วน​อะไร​ที่​ผ่าน​ด่าน​เข้าไป​ใน domain Order ได้ ก็​ไม่มี​ทาง​เป็น null อีก domain จึง​เขียน​กฎ​ธุรกิจ​ได้​สะอาด ไม่​ต้อง​โรย if (x == null) ซ้ำ​กระจัดกระจาย นี่​คือ​ความ​ต่าง​จาก version ดิบ​ที่​เช็ก null อยู่​ทุก​ที่​แต่​ก็​ยัง​หลุด

OrderLine ที่​เรา​ปั้น​ไว้​บท​ก่อน (บรรทัด​สินค้า​หนึ่ง​บรรทัด เช่น “แกง​เขียวหวาน 2 ที่ ราคา​ที่​ละ 120 บาท”) คือ​ของ​ที่ Order เก็บ​ไว้​ข้าง​ใน บท​นี้​เรา​จะ​ทวน​มัน​อีก​ที คราว​นี้​เน้น​มุม​กัน null: OrderLine เป็น entity ไม่ใช่ Value Object — มัน​มี “ตัวตน” (identity) ของ​ตัวเอง คือ​มี​เลข​ประจำ​ตัว​ที่​แยก​มัน​ออก​จาก​บรรทัด​อื่น แม้​สอง​บรรทัด​จะ​สั่ง​ของ​เหมือนกันเป๊ะ เรา​ก็​ยัง​ถือว่า​เป็น​คนละ​บรรทัด (เจาะ​ลึก​ความ​ต่าง entity กับ Value Object ที่​คอร์ส DDD in Code)

เพราะ entity ทุก​ตัว​ต้อง​มี Id เรา​จึง​มี class ฐาน​ร่วม​ชื่อ Entity<TId> ให้ entity ทุก​ตัว​สืบทอด — บท​ก่อน​วาง​โครง​ไว้​แล้ว บท​นี้​เติม guard กัน null ที่​หัว ctor ให้​แน่น​ขึ้น ไม่มี​ทาง​สร้าง entity ที่​ไม่มี Id ได้​เลย:

namespace FoodOrdering.Domain;
// class ฐานของ entity ทุกตัว: สิ่งที่ทำให้เป็น entity คือมี "ตัวตน" (Id)
public abstract class Entity<TId> where TId : notnull
{
public TId Id { get; }
protected Entity(TId id)
{
// guard ที่หัว ctor: ไม่มีทางสร้าง entity ที่ไม่มี Id ได้เลย
ArgumentNullException.ThrowIfNull(id);
Id = id;
}
}

อธิบาย​ของ​ใหม่​ที​ละ​ส่วน:

  • abstract class แปล​ว่า class นี้​เป็น​แค่ “แม่​แบบ” สร้างตรงๆ ไม่​ได้ ต้อง​มี class อื่น​มา​สืบทอด (OrderLine, Order) ไป​ใช้
  • Entity<TId> ตรง <TId> คือ type parameter — เป็น​ช่องว่าง​ให้ class ลูก​มา​เติม​ว่า Id ของ​ฉัน​เป็น type อะไร OrderLine จะ​เติม​เป็น OrderLineId ส่วน Order เติม​เป็น OrderId วิธี​นี้​ทำให้ Id ของ​แต่ละ entity เป็น​คนละ type กัน สลับ​กัน​ไม่​ได้
  • where TId : notnull เป็น​เงื่อนไข​ที่​บอกว่า type ที่มา​เติม​ช่อง TId ต้อง​เป็น​ชนิด​ที่​ห้าม​เป็น null — สอดคล้อง​กับ NRT ว่า “ตัวตน​ของ entity ต้อง​มี​อยู่​จริง​เสมอ”
  • protected Entity(TId id) คือ constructor และ ArgumentNullException.ThrowIfNull(id) คือ guard ที่​หัว ctor — ตัวอย่าง​ของ “กัน null ที่​จุด​สร้าง” ที่​เรา​เพิ่ง​เรียน (จำ​ไว้​ว่า NRT เตือน​แค่​ตอน compile ด่าน runtime ตัว​นี้​จึง​ยัง​จำเป็น เผื่อ​มี​ใคร​ฝืน​คำ​เตือน​ส่ง null เข้า​มา)

ทีนี้​ปั้น OrderLine โดย​สืบทอด​จาก Entity<OrderLineId> สังเกต​ว่า​มัน​เป็น class ไม่ใช่ record — เพราะ entity มี identity เรา​ไม่​ต้องการ​ให้​มัน​เทียบ​ด้วย​ค่า​แบบ Value Object ก่อน​อื่น​เพิ่ม Value Object สอง​ตัว​ที่ OrderLine ต้อง​ใช้ ทั้ง​คู่​เป็น Strongly-typed IDStrongly-typed IDการ​ห่อ id ดิบ (เช่น Guid หรือ int) ด้วย type เฉพาะ​ของ​มัน​เอง เช่น `OrderId`, `CustomerId` แทน​การ​ส่ง Guid เปล่า ๆ ไปมา — compiler จะ​จับ​ได้​ทันที​ถ้า​เผลอ​ส่ง CustomerId ไป​ที่ parameter ที่​ต้องการ OrderIdTactical Design คือ​ห่อ Guid ดิบ​ด้วย type เฉพาะ​ของ​มัน:

namespace FoodOrdering.Domain.Orders;
public record OrderLineId(Guid Value);
public record ProductId(Guid Value);

แล้ว​ประกอบ​เป็น OrderLine:

namespace FoodOrdering.Domain.Orders;
public sealed class OrderLine : Entity<OrderLineId>
{
public ProductId ProductId { get; }
public Quantity Quantity { get; }
public Money UnitPrice { get; }
public OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice)
: base(id) // ส่ง id ต่อให้ ctor ของ Entity ซึ่งจะ guard id ให้เอง
{
// guard ที่หัว ctor: ทุกส่วนของบรรทัดต้องมีจริง ห้าม null
ArgumentNullException.ThrowIfNull(productId);
ArgumentNullException.ThrowIfNull(quantity);
ArgumentNullException.ThrowIfNull(unitPrice);
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
// ราคารวมของบรรทัดนี้ = ราคาต่อหน่วย × จำนวน
public Money Total => new(UnitPrice.Amount * Quantity.Value, UnitPrice.Currency);
}
  • sealed บอกว่า class นี้​ห้าม​มี​ใคร​มา​สืบทอด​ต่อ — ปิดตาย​ไว้​ให้​ปลอดภัย
  • : Entity<OrderLineId> คือ​การ​สืบทอด บอกว่า Id ของ OrderLine เป็น​ชนิด OrderLineId
  • : base(id) หลัง ctor คือ​การ​ส่ง id ต่อ​ขึ้น​ไป​ให้ ctor ของ Entity — ซึ่ง guard id ให้​เรา​แล้ว เรา​จึง​ไม่​ต้อง​เช็ก id ซ้ำ​ใน​นี้ เหลือ​แค่ guard อีก​สาม​ตัว​ที่​หัว ctor
  • Money Total => ... คำนวณ​ราคา​รวม​ของ​บรรทัด UnitPrice.Amount เป็น decimal คูณ Quantity.Value ที่​เป็น int ได้​ผล​เป็น decimal แล้ว​ห่อ​กลับ​เป็น Money

ผลลัพธ์: เมื่อ OrderLine หนึ่ง​ตัว​เกิด​ขึ้น​ได้​สำเร็จ เรา​การันตี​ได้​เลย​ว่า​ทุก field ของ​มัน — Id, ProductId, Quantity, UnitPrice — มี​ค่า​จริง​ครบ ไม่มี null ปน​อยู่​แม้แต่​ช่อง​เดียว

Order.Place — รักษา invariant ว่า​ออเดอร์​ต้อง​มี​อย่าง​น้อยหนึ่ง​บรรทัด

หัวข้อ​ที่​มีชื่อ​ว่า “Order.Place — รักษา invariant ว่า​ออเดอร์​ต้อง​มี​อย่าง​น้อยหนึ่ง​บรรทัด”

ตอน​นี้​มา​ถึง​ตัวเอก​ของ​บท ใน​ภาษา DDD Order คือ aggregate — ก้อน​ใหญ่​ที่​ห่อ OrderLine หลายๆ บรรทัด​ไว้​ด้วย​กัน​และ​คุม​กฎ​ของ​ทั้ง​ก้อน (เจาะ​ลึก aggregate ที่​คอร์ส DDD in Code) กฎ​ข้อ​สำคัญ​ที่สุด​ของ​เรา​คือ invariant ①: “ตะกร้า​ว่างเปล่า สร้าง​ออเดอร์​ไม่​ได้” — ออเดอร์​ที่​ไม่มี​สินค้า​เลย​ไม่มี​ความหมาย​ใน​ธุรกิจ เรา​จะ​ทำให้​มัน “เป็น​ไป​ไม่​ได้” ที่​จะ​สร้าง​ขึ้น​มา

เคล็ด​ลับ​คือ​ทำ constructor ให้​เป็น private แล้ว​เปิด​ทาง​สร้าง​ทาง​เดียว​ผ่าน static factory ชื่อ Place ที่​มี guard คุม​อยู่ ใคร​จะ​สร้าง Order ต้อง​ผ่าน​ด่าน​นี้​เท่านั้น:

namespace FoodOrdering.Domain.Orders;
public sealed class Order : Entity<OrderId>
{
// backing field ส่วนตัว: เก็บบรรทัดจริงไว้ ไม่ให้ภายนอกแตะตรง ๆ
private readonly List<OrderLine> _lines;
// เปิดให้ภายนอก "อ่าน" ได้อย่างเดียว จะ Add/Remove ข้ามกฎไม่ได้
public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
// ราคารวมทั้งออเดอร์ = ผลรวมของทุกบรรทัด
public Money Total => Money.Thb(_lines.Sum(line => line.Total.Amount));
// ctor เป็น private: สร้าง Order ตรง ๆ จากข้างนอกไม่ได้
private Order(OrderId id, List<OrderLine> lines) : base(id)
{
_lines = lines;
}
// factory เดียวที่ใช้สร้าง Order ได้ — มี guard รักษา invariant คุมอยู่
public static Order Place(OrderId id, IReadOnlyList<OrderLine> lines)
{
// guard ที่หัว method: กัน null ที่ขอบ ก่อนแตะข้อมูลใด ๆ
ArgumentNullException.ThrowIfNull(lines);
// invariant ①: ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้
if (lines.Count == 0)
throw new ArgumentException("ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้", nameof(lines));
return new Order(id, lines.ToList()); // id ถูก guard ต่อที่ ctor ของ Entity
}
}

แกะ​ที​ละ​ส่วน:

  • Backing FieldBacking Fieldfield ส่วนตัว (private) ที่​เก็บ​ข้อมูล​จริง​อยู่​ข้าง​หลัง property หรือ collection ที่​เปิด​ให้​ภายนอก​เห็น​แบบ​อ่าน​อย่าง​เดียว เช่น `private readonly List<OrderLine> _lines` กับ property `IReadOnlyList<OrderLine> Lines` — วิธี​ที่ Aggregate รักษา​ไม่​ให้ code ภายนอก​มา Add/Remove ตรง ๆ โดย​ข้าม​กฎ​ธุรกิจTactical Design private readonly List<OrderLine> _lines คือ field ส่วนตัว​ที่​เก็บบรรทัดจริงๆ ส่วน Lines ที่​เปิด​ออก​ไป​ข้าง​นอก​เป็น​ชนิด IReadOnlyCollection<OrderLine> คือ “อ่าน​ได้​อย่าง​เดียว” ภายนอก​จึง​เรียก​ดู​บรรทัด​ได้ แต่​จะ​แอบ Add หรือ Remove ข้าม​กฎ​ธุรกิจ​ไม่​ได้ (รายละเอียด​การ​ห่อ collection แบบ​นี้​เก็บ​ไว้​เจาะ​ลึก​บท​หลัง)
  • Total ใช้ .Sum(...) รวม​ราคา​ของ​ทุก​บรรทัด — เป็น​ตัวอย่าง​เล็กๆ ของ LINQ ที่​เรา​จะ​เรียน​เต็มๆ ทีหลัง ตอน​นี้​อ่าน​ว่า “บวก​ค่า line.Total.Amount ของ​ทุก​บรรทัด​เข้า​ด้วย​กัน” ก็พอ
  • private Order(...) constructor เป็น private แปล​ว่า code ข้าง​นอก​เรียก new Order(...) ตรงๆ ไม่​ได้ ต้อง​ผ่าน Place เท่านั้น — วิธี​นี้​ทำให้​ไม่มี​ทาง​เลี่ยง guard ได้​เลย
  • static Order Place(...) คำ​ว่า static แปล​ว่า​เรียก​จาก​ตัว type ได้​เลย เขียน Order.Place(id, lines) โดย​ยัง​ไม่มี Order ก้อน​ไหน​อยู่​ก่อน ข้าง​ใน​มี guard สอง​ด่าน: ThrowIfNull(lines) กัน null แล้ว​ตามด้วยเช็ก lines.Count == 0 เพื่อ​รักษา invariant ① ถ้า​ผ่าน​ทั้ง​สอง​ด่าน​ถึง​จะ​ได้ Order ที่​ถูก​กฎ​ออก​ไป

ลอง​ใช้งาน​จริง:

var line = new OrderLine(
new OrderLineId(Guid.NewGuid()),
new ProductId(Guid.NewGuid()),
new Quantity(2),
Money.Thb(120));
var order = Order.Place(new OrderId(Guid.NewGuid()), new[] { line });
// order.Total = 240 บาท และ order.Lines มีหนึ่งบรรทัด
// var empty = Order.Place(new OrderId(Guid.NewGuid()), new OrderLine[0]);
// 💥 โยน ArgumentException "ตะกร้าว่างเปล่า สร้างออเดอร์ไม่ได้"
// var broken = Order.Place(new OrderId(Guid.NewGuid()), null!);
// 💥 โยน ArgumentNullException ที่ ThrowIfNull ทันที

เห็น​ภาพ​ครบ​วง: null และ​ตะกร้า​ว่าง​ถูกกัน​ไว้​ที่ Place ซึ่ง​เป็น​ขอบ​ทาง​เข้า​ของ aggregate เมื่อ Order ก้อน​หนึ่ง​มี​ตัวตน​อยู่​ใน domain ได้ เรา​พูด​ได้​เต็ม​ปากว่า​มัน​มี Lines อย่าง​น้อยหนึ่ง​บรรทัด​เสมอ และ​ทุก​บรรทัด​ก็​ไม่มี null ปน — domain ของ​เรา​จึง​เป็น​เขต​ปลอด null จริงๆ ไม่​ต้อง​เขียน code กฎ​ธุรกิจ​โดย​ระแวง​ว่า​ตัว​ไหน​จะ​เป็น null อีก​ต่อ​ไป

null ไม่ใช่ 'ค่า​ว่าง' ใน domain

ถ้า field ไหน “ไม่มี​ค่า​ก็ได้” จริงๆ ใน​เชิง​ธุรกิจ (เช่น หมายเหตุ​ถึง​ร้าน) ให้​ประกาศ​มัน​เป็น string? ตรงๆ เพื่อ​บอก​ทุก​คน​ว่า “ตัว​นี้​ว่าง​ได้​นะ” ส่วน​อะไร​ที่​ธุรกิจ​บอกว่า “ต้อง​มี​เสมอ” (เช่น บรรทัด​สินค้า​ของออเดอร์) ให้​ใช้ guard กัน​ไว้​ไม่​ให้​เป็น null เลย ความ​ต่าง​นี้​คือ​หัวใจ: null ควร​เป็นการ​ตัดสิน​ใจ​ที่​เรา​ประกาศ​ชัด ไม่ใช่​ค่า default ที่​แอบ​ซ่อน bug ไว้​เงียบๆ

บท​นี้​เรา​ไล่ null ออก​จาก domain ด้วย​สอง​เครื่องมือ​ที่​ทำงาน​คู่​กัน — nullable reference types เตือน​เรา​ตั้งแต่​ตอน compile และ guard clause (ArgumentNullException.ThrowIfNull กับ​เช็ก​ค่า​ว่าง) กัน​ไว้​ตอน runtime ที่​ขอบ​ระบบ พร้อม​กัน​นั้น​เรา​ก็ได้​เสริม​ชิ้น​ส่วน​ของ domain ที่​ปั้น​ไว้​บท​ก่อน​ให้​แน่น​ขึ้น​ด้วย: class ฐาน Entity<TId>, entity OrderLine, และ aggregate Order ที่​มี factory Place รักษา invariant ①

บท​ต่อๆ ไป​จะ​ต่อยอด​จาก​ตรง​นี้:

  • บท​ถัด​ไป — pattern matching และ enum OrderStatus ขับ​เคลื่อน​สถานะ​ของ Order ให้​เปลี่ยน​ได้​เฉพาะ​เส้นทาง​ที่​ถูก​กฎ
  • บทหลังๆ — LINQ กับ​การ​ห่อ collection เต็ม​รูป (backing field ที่​เรา​เพิ่ง​เห็น​แวบๆ) และ async/await ที่​กลาย​เป็น​รูปร่าง​ของ port อย่าง IPaymentGateway และ IOrderRepository

ส่วน​แนวคิด DDD ที่​โผล่​มา​เบาๆ ใน​บท​นี้ — entity มี identity ต่าง​จาก Value Object ยังไง และ aggregate คุม invariant ของ​ทั้ง​ก้อน​อย่างไร — เจาะ​ลึก​เต็มๆ ที่​คอร์ส DDD in Code ส่วน​เรื่อง “ขอบ​ระบบ” ที่​เรา​กัน null ไว้ (controller, adapter) จะ​เป็น​รูป​เป็น​ร่าง​ที่​คอร์ส Clean Architecture .NET คอร์ส​นี้​โฟกัส​ที่ “ไวยากรณ์ C# ที่​ทำให้​มัน​เกิด​ขึ้น​ได้” ก่อน


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

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

  • Guard Clause — pattern การ​ตรวจ​เงื่อนไข​ที่​ผิด​ตั้งแต่​ต้น method แล้ว​ออก​ทันที ลด​เงื่อนไข​ซ้อน​ลึก​แบบ version ดิบ
  • Fail Fast — หลักการ “ถ้า​จะ​พัง​ให้​พัง​ให้​เร็ว​และ​ชัด​ที่สุด” เหตุผล​เบื้องหลัง​การ​โยน exception ทันที​ที่​ขอบ แทนที่​จะ​ปล่อย​ให้ null ไหล​ลึก​เข้าไป​แล้ว​พัง​ไกล​จาก​ต้นเหตุ
  • Null Object — อีก​ทาง​เลือก​ใน​การ​จัดการ “ค่า​ว่าง” โดย​ใช้ object ที่​ใช้งาน​ได้​จริง​แทน null เพื่อ​กำ​จัดการ​เช็ก null ที่​กระจัดกระจาย

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

ข้อ 1 / 3

ในโหมด nullable reference types (NRT) ที่เปิดด้วย `<Nullable>enable</Nullable>` — `string` กับ `string?` ต่างกันยังไง และ compiler เตือนตอนไหน?