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 เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้เกี่ยวข้องกับ path FoodOrdering.Domain/ ทั้งตัว Entity<TId> ที่เป็นฐานร่วม และ folder Orders/
version ดิบ — null-check กระจัดกระจาย แล้วพังตอน runtime
หัวข้อที่มีชื่อว่า “version ดิบ — null-check กระจัดกระจาย แล้วพังตอน runtime”ลองดูว่าถ้าไม่ระวัง 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 types — ให้ compiler ช่วยจับ null ตั้งแต่ยังไม่รัน
หัวข้อที่มีชื่อว่า “Nullable reference types — ให้ compiler ช่วยจับ null ตั้งแต่ยังไม่รัน”เครื่องมือชิ้นแรกคือ 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 ได้อยู่ดี เพราะฉะนั้นเราจึงต้องมีเครื่องมือชิ้นที่สอง
ArgumentNullException.ThrowIfNull — guard บรรทัดเดียว
หัวข้อที่มีชื่อว่า “ArgumentNullException.ThrowIfNull — guard บรรทัดเดียว”เครื่องมือชิ้นที่สองคือ 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 ถูกจับได้ทั้งสองจังหวะ
กัน null ไว้ที่ขอบ ปล่อยให้ domain เป็นเขตปลอด null
หัวข้อที่มีชื่อว่า “กัน null ไว้ที่ขอบ ปล่อยให้ domain เป็นเขตปลอด 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 — Entity ที่เราปั้นไว้บทก่อน
หัวข้อที่มีชื่อว่า “ทวน OrderLine — Entity ที่เราปั้นไว้บทก่อน”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— ซึ่ง guardidให้เราแล้ว เราจึงไม่ต้องเช็กidซ้ำในนี้ เหลือแค่ guard อีกสามตัวที่หัว ctorMoney 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 อีกต่อไป
ถ้า 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:
- 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 เตือนตอนไหน?