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

แยก Ordering เป็น module มี​ขอบเขต

บท 1 แยก​ออเดอร์​ใบ​เดียว​ออก​เป็น4 context ที่​ต่าง​เป็น​เจ้าของ model ของ​ตัวเอง — Ordering (core), Kitchen (supporting), Delivery (supporting), Payment (generic) คำถาม​ของ​บท​นี้​เป็น​คำถาม​ที่​ตรง​มาก: แล้ว​เรา​จะ​จัด4 context นั้น​ลง​ใน code จริง​ยังไง โดย​ไม่​กระโดด​ไป​แตก​เป็น microservice ตั้งแต่​วัน​แรก? คำ​ตอบ​คือ​ปั้น​ทั้งหมด​ให้​เป็น modular monolith ก่อน — หนึ่ง​โพรเซส หลาย module ที่​มี​ขอบเขต​ชัด

📦 code ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — คอร์ส​นี้​ต่อยอด domain Order เดิม​ให้​กลาย​เป็น modular monolith 4 module บท​นี้​เริ่ม​ลาก​เส้น​ขอบเขต​ใน code จริง​เป็น​ครั้ง​แรก (โครง project + architecture test) ส่วน​การ​เดินสาย event บัส​จริง​จะ​มาในบทถัดๆ ไป

Modular MonolithModular Monolithสถาปัตยกรรม​ที่​ทุก context เป็น module/assembly แยก​กัน​ชัดเจน​แต่​รัน​ใน​โพรเซส​เดียวกัน แต่ละ module เป็น​เจ้าของ model/schema ของ​ตัวเอง ห้าม​อ้างอิง domain type ข้าม module ตรง ๆ เชื่อม​ได้​เฉพาะ​ผ่าน integration event หรือ query ที่​แปล​แล้วArchitecture คือ​สถาปัตยกรรม​ที่​ทุก Bounded ContextBounded Contextขอบเขต​ที่ ubiquitous language และ model หนึ่ง​ชุด​มี​ความหมาย​คง​เส้น​คง​วา นอก​ขอบเขต​นี้​คำ​เดียวกัน​อาจ​หมาย​ถึง​คนละ​อย่าง เช่น 'order' ใน Ordering กับ 'ticket' ใน Kitchen คือ​มุมมอง​ของ​สิ่ง​เดียวกัน​คนละ modelStrategic Design กลาย​เป็น module/assembly แยก​กัน​ชัดเจน (ใน​โลก .NET ก็​คือ​คนละ project) แต่​ยัง compile รวม​และ​รัน​อยู่​ใน โพรเซส​เดียวกัน deploy ที​เดียว​จบ ไม่มี network hop ระหว่าง module มัน​อยู่​ตรง​กลาง​ระหว่าง​สอง​ขั้ว: ไม่ใช่​ก้อน​โคลน​ที่​ทุก​อย่าง​ปน​กัน​แบบ big ball of mud และ​ก็​ยัง​ไม่ใช่​ระบบ​กระจาย​ที่​แตก​เป็น​หลาย service ให้​ต้อง​แบก​ต้นทุน​เครือข่าย​ตั้งแต่​วัน​แรก

หัวใจ​ของ​มัน​คือ แต่ละ module เป็น​เจ้าของ​สอง​อย่าง​เต็ม​ตัว — model (code domain ของ​ตัวเอง) และ schema (ตาราง​ของ​ตัวเอง​ใน​ฐาน​ข้อมูล):

  • module Ordering ถือ Order, OrderLine, Money.Thb และ​เขียน​ลง schema ordering.*
  • module Kitchen ถือ Ticket, TicketId และ​เขียน​ลง schema kitchen.*
  • module Delivery ถือ Assignment, AssignmentId และ​เขียน​ลง schema delivery.*
  • module Payment ถือ​เรื่อง charge/refund และ​เขียน​ลง schema payment.*

ฐาน​ข้อมูล​อาจ​เป็น server ตัว​เดียว แต่ ไม่มี module ไหน​แตะ​ตาราง​ของ module อื่น​ข้าม​หัว​กัน ไม่มี JOIN ข้าม schema ไม่มี​ใคร​อ่าน ordering.orders จาก​ใน code Kitchen โดยตรง ขอบเขต​ข้อมูล​ชัด​เท่ากับ​ขอบเขต code

ถ้า​จะ​จำ​กฎ​ของ modular monolith แค่​ข้อ​เดียว ให้​จำ​ข้อ​นี้: code ใน module หนึ่ง​ต้อง​ไม่ reference domain type ของ​อีก module ตรงๆ module Kitchen จับ Order ของ Ordering มา​ถือ​ไม่​ได้ ต่อ​ให้​มัน​ดู​สะดวก​แค่​ไหน​ก็ตาม

เพราะ​ทันที​ที่​เอื้อม​ข้าม​ไป​หยิบ AggregateAggregateกลุ่ม​ของ Entity และ Value Object ที่​ถือ​เป็น​หนึ่ง​หน่วย​ความ​สอดคล้อง มี root เดียว​เป็น​ประตู​เข้า เช่น Order ใน Ordering, Ticket ใน Kitchen, Assignment ใน Delivery — แต่ละ context มี aggregate ของ​ตัวเอง​ที่​รักษา​กฎ​ของ​ตัวเอง ไม่ share aggregate ข้าม contextTactical Design ของ​อีก​ฝ่าย​มา​ทั้ง​ก้อน ขอบเขต​ที่​เรา​อุตส่าห์​แยก​ใน​บท 1 ก็​ละลาย​กลับ​เป็น​ก้อน​เดียว:

❌ version ดิบ: Kitchen เอื้อม​ข้าม​ไป​จับ Order ของ Ordering ตรง ๆ
using Ordering.Domain; // ← ดึง domain type ของ module อื่นเข้ามา
// ใน module Kitchen
public class KitchenService
{
// รับ aggregate ทั้งก้อนของ Ordering เข้ามาเลย
public Ticket CreateTicket(Order order)
{
// ตอนนี้ Kitchen รู้จัก OrderLine, Money.Thb, OrderStatus ของ Ordering ครบ
// Ordering ขยับกฎภายในทีเดียว Kitchen พังตามทันที — ขอบเขตหายไปเงียบ ๆ
return Ticket.FromOrder(order);
}
}

การ​ข้าม​ขอบเขต​ทำได้​อยู่ แต่​ต้อง​ผ่าน หนึ่ง​ใน​สอง​ประตู​ที่​อนุญาต เท่านั้น:

1. ผ่าน integration event contractIntegration EventIntegration Eventevent ที่​สื่อสาร 'ข้าม' Bounded Context เป็น contract แบบ serializable ที่​มี​แต่ id/ค่า​พื้นฐาน (ไม่มี domain object) เช่น OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) ต่าง​จาก Domain Event ที่​อยู่​ใน​โพรเซส​เดียวArchitecture คือ contract แบบ serializable ที่​มี​แต่ id กับ​ค่า​พื้นฐาน (Guid, DateTimeOffset, decimal) ไม่มี domain object ของ​ใคร​ติด​ไป​ด้วย​เลย ตรง​นี้​ต้อง​แยก​ให้​ขาด​จาก Domain EventDomain Eventสิ่ง​ที่​เกิด​ขึ้น​แล้ว​ภายใน context เดียว ทำงาน in-process และ​อ้างอิง domain object ได้​ตรง ๆ เช่น OrderConfirmed ภายใน Ordering — เมื่อ​จะ​ส่ง​ข้าม​ขอบเขต context ต้อง​แปลง​เป็น Integration Event ก่อน​เสมอ ห้าม​ส่ง domain event ข้าม​ไป​ตรง ๆTactical Design: domain event อย่าง OrderConfirmed อยู่​ใน​โพรเซส​เดียว​และ​อ้าง domain object ของ Ordering ได้ตรงๆ แต่​มัน​ห้าม​ข้าม​ขอบเขต — พอ​จะ​ส่ง​ข้าม​ไป​หา Kitchen ต้อง​แปลง​เป็น integration event ก่อน​เสมอ contract ตัว​นี้​คือ Message ContractMessage Contractรูปแบบ​ข้อมูล (schema) ของ integration event ที่​ตกลง​กัน​ระหว่าง​ผู้​ส่ง​และ​ผู้รับ ต้อง stable และ​มี​แต่ id/ค่า​พื้นฐาน เพราะ​เปลี่ยน​แล้วกระทบ​ทุก context ที่ subscribe อยู่ — ใน​ทาง​ปฏิบัติ​ของ code คือ Published Language นั่นเองArchitecture ที่​ทั้ง2 module ตกลง​กัน​ไว้

✅ ข้าม​ขอบเขต​ผ่าน integration event contract (มี​แต่ id + ค่า​พื้นฐาน)
// contract กลางที่ทุก module serialize/deserialize ได้ — ไม่มี domain object ของใคร
public interface IIntegrationEvent { }
public record OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt)
: IIntegrationEvent;
// ใน module Kitchen — รับแต่ id/ค่าพื้นฐาน แล้วสร้าง Ticket ที่ Kitchen เป็นเจ้าของเอง
public class WhenOrderConfirmed
{
public Ticket Handle(OrderConfirmedIntegrationEvent e)
=> new Ticket(orderId: e.OrderId); // ไม่รู้จัก Order ของ Ordering เลยสักนิด
}

2. ผ่าน query ที่​แปล​แล้ว — ถ้า Kitchen ต้องการ “อ่าน” ข้อมูล​บาง​อย่าง​จาก Ordering แบบ synchronous มัน​เรียก query เข้าไป​ได้ แต่​สิ่ง​ที่​ข้าม​กลับ​ออก​มา​ต้อง​เป็น DTO แบนๆ ที่ แปล เป็น​ภาษา​ของ Kitchen แล้ว ไม่ใช่ Order ตัว​จริง ชั้น​แปล​นี้​คือ Anti-Corruption LayerAnti-Corruption Layer (ACL)ชั้น​แปล​ภาษา/model ที่​กั้น​ระหว่าง context สอง​อัน ไม่​ให้ model ของ​อีก​ฝ่าย​รั่ว​เข้า​มา​ปน​เปื้อน เช่น PaymentGatewayAdapter ที่​แปล Money/Order ของ Ordering เป็น DTO ของ​ผู้​ให้​บริการ​ชำระ​เงิน​ภายนอกStrategic Design ย่อๆ — มัน​กัน​ไม่​ให้ model ของ Ordering รั่ว​ข้าม​ขอบเขต​มา​ปน​เปื้อน Kitchen

กล่าว​อีก​แบบ: ข้าม​ขอบเขต​ได้ แต่​ห้าม​พก domain type ข้าม​ไป​ด้วย ข้าม​ได้​แค่ id กับ​ค่า​พื้นฐาน​ที่ serialize ได้​เท่านั้น

flowchart TB
  subgraph PROC["Modular Monolith — โพรเซสเดียว (1 solution)"]
    subgraph ORD["module Ordering (core)"]
      OrdDom["Order, OrderLine, Money.Thb<br/>schema: ordering.*"]
    end
    subgraph KIT["module Kitchen (supporting)"]
      KitDom["Ticket, TicketId<br/>schema: kitchen.*"]
    end
    subgraph DEL["module Delivery (supporting)"]
      DelDom["Assignment, AssignmentId<br/>schema: delivery.*"]
    end
    subgraph PAY["module Payment (generic)"]
      PayDom["charge / refund<br/>schema: payment.*"]
    end
    CON["Contracts ที่ share ได้<br/>IIntegrationEvent<br/>OrderConfirmedIntegrationEvent<br/>FoodReadyIntegrationEvent<br/>OrderDeliveredIntegrationEvent<br/>(มีแต่ id + ค่าพื้นฐาน)"]
  end

  ORD -->|"เผยแพร่/รับผ่าน contract ✅"| CON
  KIT -->|"เผยแพร่/รับผ่าน contract ✅"| CON
  DEL -->|"เผยแพร่/รับผ่าน contract ✅"| CON
  PAY -->|"เผยแพร่/รับผ่าน contract ✅"| CON

  KIT -.->|"อ้าง Order ตรง ๆ ❌ ห้าม"| ORD

  linkStyle 4 stroke:#dc2626,stroke-width:2px;
  classDef ok fill:#c7d2fe,stroke:#3730a3,color:#1a1a1f;
  classDef contract fill:#bbf7d0,stroke:#166534,color:#1a1a1f;
  class OrdDom,KitDom,DelDom,PayDom ok;
  class CON contract;

คำ​บรรยาย​ภาพ: 4 module (น้ำเงิน) อยู่​ใน​โพรเซส​เดียวกัน แต่ละ​อัน​เป็น​เจ้าของ model และ schema ของ​ตัวเอง เส้น​ทึบ​เขียว​คือ​ทิศทาง​ที่ อนุญาต — ทุก module คุย​กัน​ผ่าน​กล่อง Contracts กลาง (เขียว) ที่​มี​แต่ id กับ​ค่า​พื้นฐาน ส่วน​เส้น​ประแดง​คือ​ทิศทาง​ที่ ห้าม — Kitchen เอื้อม​ไป​อ้าง Order ของ Ordering ตรงๆ ประตู​ที่​เปิด​ได้​มี​แค่ integration event contract กับ query ที่​แปล​แล้ว​เท่านั้น

ปัญหา​ของ​กฎ​แบบ “ห้าม reference ข้าม module” คือ​มัน​ไม่​บังคับ​ตัวเอง วัน​หนึ่ง​จะ​มี​คนใน​ทีม (อาจ​เป็น​ตัว​เรา​ตอน​รีบ) พิมพ์ using Ordering.Domain; ใน code Kitchen เพราะ​มัน “แค่​จะ​หยิบ field เดียว” compiler ไม่​บ่น test ธุรกิจ​ก็​ยัง​เขียว แล้ว​ขอบเขต​ก็​เซาะ​พัง​ที​ละ​นิด​โดย​ไม่มี​ใคร​รู้ตัว จน​วัน​ที่​รู้ตัว​ก็​สาย​เกิน​แก้

Architecture TestsArchitecture Teststest อัตโนมัติ​ที่​ตรวจ​กฎ​โครงสร้าง code เช่น Kitchen ต้อง​ไม่ reference ชนิด​ของ Ordering โดยตรง ด้วย​เครื่องมือ​อย่าง NetArchTest/ArchUnitNET ทำให้​ขอบเขต​ของ modular monolith ไม่​หลุด​โดย​ไม่มี​ใคร​รู้Architecture คือ test อัตโนมัติ​ที่​ตรวจ กฎ​โครงสร้าง​ของ code แทน​กฎ​ธุรกิจ มัน​รัน​ใน pipeline เหมือน test ปกติ แต่​สิ่ง​ที่​มัน​ยืนยัน​คือ “module Kitchen ต้อง​ไม่มี dependency ไป​ยัง Ordering.Domain” ใน​โลก .NET ทำได้​ด้วย NetArchTest หรือ ArchUnitNET — เขียน​เป็น [Fact] ธรรมดา:

✅ Architecture Test: บังคับ​ว่า Kitchen ต้อง​ไม่​พึ่ง domain ของ Ordering
using NetArchTest.Rules;
using Xunit;
public class ModuleBoundaryTests
{
[Fact]
public void Kitchen_must_not_depend_on_Ordering_domain()
{
var result = Types
.InAssembly(typeof(Kitchen.Domain.Ticket).Assembly)
.Should()
.NotHaveDependencyOn("Ordering.Domain")
.GetResult();
Assert.True(
result.IsSuccessful,
"Kitchen อ้าง type ใน Ordering.Domain ตรง ๆ — " +
"ข้ามขอบเขตต้องผ่าน integration event หรือ query ที่แปลแล้วเท่านั้น");
}
}

test ตัว​นี้ จับ​ได้​พอดี​กับ using Ordering.Domain; ใน version ดิบ​ด้าน​บน — ทันที​ที่​มี​ใคร​ดึง type ของ Ordering เข้า​มา​ใน assembly ของ Kitchen GetResult() จะ​รายงาน​ว่า​ไม่​ผ่าน Assert.True ล้ม build แดง​ทันที ก่อน pull request จะ​ถูก merge นี่​คือ​ความ​ต่าง​สำคัญ: test ธุรกิจ​ตอบ​ว่า “code ทำงาน​ถูก​ไหม” ส่วน architecture test ตอบ​ว่า “code วาง​ถูก​ที่​ไหม” — มัน​เปลี่ยน​ขอบเขต​จาก​ข้อ​ตกลง​ใน​หัว​คน (ที่​ลืม​ได้) ให้​เป็น​กฎ​ที่​เครื่อง​บังคับ (ที่​ลืม​ไม่​ได้) ArchUnitNET เขียน​กฎ​เดียวกัน​นี้​ใน​สไตล์ fluent อีก​แบบ เลือก​ใช้​ตัว​ไหน​ก็ได้ หลักการ​เหมือน​กัน

ต้อง​ย้ำ​ให้​ชัด​ว่า​ถึง​ตรง​นี้​เรา ยัง​อยู่​ใน​โพรเซส​เดียว ทุก module compile รวม​กัน deploy ก้อน​เดียว เรียกหา​กัน​แบบ in-process (การ publish integration event ตอน​นี้​จึง​เป็น​แค่​การ​ส่ง​ข้อความ​ใน​หน่วย​ความ​จำ ยัง​ไม่มี network, ยัง​ไม่มี message broker) modular monolith กับ microservice ต่าง​กัน​ตรง​นี้​ล้วนๆ: เรา​ได้ ขอบเขต​ที่​ชัด​เท่ากับ microservice — module เป็น​เจ้าของ model และ schema ของ​ตัวเอง คุย​กัน​ผ่าน contract เท่านั้น — โดย ยัง​ไม่​ต้อง​แบก​ต้นทุน​ของ​ระบบ​กระจาย อย่าง network latency, eventual consistency ข้าม​เครื่อง, หรือ deploy ที่​ต้อง​ประสาน​หลาย service

นี่​คือ​กลยุทธ์​ตั้งใจ ไม่ใช่​ความ​ขี้เกียจ: ลาก​เส้น​ขอบเขต​ให้​คม​และ​บังคับ​ด้วย architecture test ตั้งแต่​ตอน​ยัง​เป็น monolith ทำให้​วัน​ที่​เรา​ตัดสิน​ใจแตก module ใด module หนึ่ง​ออก​เป็น service จริง (ตอน​ท้าย​คอร์ส) เส้น​แบ่ง​พร้อม​อยู่​แล้ว การ​แตก​จึง​เป็น​แค่​การ​เปลี่ยน “ท่อ” ของ integration event จาก in-memory ไป​เป็น message broker ไม่ใช่​การ​ผ่าตัด​ก้อน​โคลน​ที่​พัน​กัน​ยุ่ง

เมื่อ​เรา​ต่อ integration event บัส​จริง​ใน​โพรเซส เรา​จะ​ตั้ง​ค่า Wolverine ด้วย MultipleHandlerBehavior.Separated เพื่อ​ให้​แต่ละ module มี local queue ของ​ตัวเอง — handler ของ Kitchen กับ​ของ Delivery ไม่​ปน​คิว​กัน แม้​จะ subscribe integration event ตัว​เดียวกัน การ​แยก​คิว​ระดับ module แบบ​นี้​คือ​สิ่ง​ที่​ทำให้​ขอบเขต​ที่​บท​นี้​ลาก​ไว้​ยัง​อยู่​ครบ​ตอน runtime ไม่ใช่​แค่​ตอน compile

บท 4 จะ​ลงมือ​ต่อ integration event บัส​ตัว​นั้น​จริง​ใน​โพรเซส ให้ module เริ่ม​ส่ง-รับ event คุย​กัน​ตาม​สัญญา​ที่​ลาก​ไว้​และ​บท 6 จะ​ทำให้การ​ส่ง event เชื่อถือ​ได้​จริง​ด้วย transactional outbox — ทั้ง​สอง​บท​ต่อยอด​จาก​กฎ​เหล็ก​ข้อ​เดียว​ที่​เรา​ตั้ง​ไว้​ใน​บท​นี้: ข้าม​ขอบเขต​ได้​เฉพาะ​ผ่าน contract เท่านั้น


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

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

  • Bounded Context — ขอบเขต​ที่ model หนึ่ง​ชุด​และ​ภาษา​หนึ่ง​ชุด​คง​ความหมาย​ไว้ ซึ่ง module แต่ละ​อัน​ใน​บท​นี้​ห่อ​ไว้​หนึ่ง​ต่อ​หนึ่ง
  • Stable Dependencies Principle — หลัก​เรื่อง​ทิศทางการ​พึ่งพา ที่ architecture test คอย​บังคับ​ไม่​ให้​ไหล​ผิด​ทางข้าม module
  • Big Ball of Mud — สภาพ​ที่ modular monolith จะ​เสื่อม​ไป​เป็น ถ้า​ปล่อย​ให้ reference ข้าม module ได้​ตามใจ​โดย​ไม่มี test บังคับ​ขอบเขต

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

ข้อ 1 / 3

ใน modular monolith code ของ module หนึ่งอ้างอิงอะไรข้ามขอบเขตไปยังอีก module ได้บ้าง?