แยก Ordering เป็น module มีขอบเขต
บท 1 แยกออเดอร์ใบเดียวออกเป็น4 context ที่ต่างเป็นเจ้าของ model ของตัวเอง — Ordering (core), Kitchen (supporting), Delivery (supporting), Payment (generic) คำถามของบทนี้เป็นคำถามที่ตรงมาก: แล้วเราจะจัด4 context นั้นลงใน code จริงยังไง โดยไม่กระโดดไปแตกเป็น microservice ตั้งแต่วันแรก? คำตอบคือปั้นทั้งหมดให้เป็น modular monolith ก่อน — หนึ่งโพรเซส หลาย module ที่มีขอบเขตชัด
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — คอร์สนี้ต่อยอด domain Order เดิมให้กลายเป็น modular monolith 4 module บทนี้เริ่มลากเส้นขอบเขตใน code จริงเป็นครั้งแรก (โครง project + architecture test) ส่วนการเดินสาย event บัสจริงจะมาในบทถัดๆ ไป
Modular monolith: หนึ่งโพรเซส หลาย module มีขอบเขต
หัวข้อที่มีชื่อว่า “Modular monolith: หนึ่งโพรเซส หลาย module มีขอบเขต”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และเขียนลง schemaordering.* - module Kitchen ถือ
Ticket,TicketIdและเขียนลง schemakitchen.* - module Delivery ถือ
Assignment,AssignmentIdและเขียนลง schemadelivery.* - module Payment ถือเรื่อง charge/refund และเขียนลง schema
payment.*
ฐานข้อมูลอาจเป็น server ตัวเดียว แต่ ไม่มี module ไหนแตะตารางของ module อื่นข้ามหัวกัน ไม่มี JOIN ข้าม schema ไม่มีใครอ่าน ordering.orders จากใน code Kitchen โดยตรง ขอบเขตข้อมูลชัดเท่ากับขอบเขต code
กฎเหล็กข้อเดียว: ห้าม reference domain type ข้าม module
หัวข้อที่มีชื่อว่า “กฎเหล็กข้อเดียว: ห้าม reference domain type ข้าม module”ถ้าจะจำกฎของ 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 ก็ละลายกลับเป็นก้อนเดียว:
using Ordering.Domain; // ← ดึง domain type ของ module อื่นเข้ามา
// ใน module Kitchenpublic class KitchenService{ // รับ aggregate ทั้งก้อนของ Ordering เข้ามาเลย public Ticket CreateTicket(Order order) { // ตอนนี้ Kitchen รู้จัก OrderLine, Money.Thb, OrderStatus ของ Ordering ครบ // Ordering ขยับกฎภายในทีเดียว Kitchen พังตามทันที — ขอบเขตหายไปเงียบ ๆ return Ticket.FromOrder(order); }}การข้ามขอบเขตทำได้อยู่ แต่ต้องผ่าน หนึ่งในสองประตูที่อนุญาต เท่านั้น:
1. ผ่าน integration event contract — Integration 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 ตกลงกันไว้
// 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 ที่แปลแล้วเท่านั้น
จับกฎให้อยู่หมัดด้วย Architecture Tests
หัวข้อที่มีชื่อว่า “จับกฎให้อยู่หมัดด้วย Architecture Tests”ปัญหาของกฎแบบ “ห้าม 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] ธรรมดา:
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 อีกแบบ เลือกใช้ตัวไหนก็ได้ หลักการเหมือนกัน
ยังเป็น monolith อยู่ — ยังไม่ใช่ microservice
หัวข้อที่มีชื่อว่า “ยังเป็น monolith อยู่ — ยังไม่ใช่ microservice”ต้องย้ำให้ชัดว่าถึงตรงนี้เรา ยังอยู่ในโพรเซสเดียว ทุก 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:
- 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 ได้บ้าง?