แตก 1 context ออกเป็น service จริง
บทที่แล้วเราตั้งกฎไว้ว่า แตกเมื่อมี force จริงเท่านั้น — และตอนนี้ force มาถึง Delivery จริงๆ: โหลดของฝั่งจัดส่งพุ่งไม่พร้อมกับที่เหลือ (ช่วงมื้อเที่ยงไรเดอร์ทั้งเมืองออนไลน์พร้อมกัน) และมีทีมแยกที่ควรเป็นเจ้าของมันเต็มตัว ปล่อยของตามจังหวะตัวเองได้ ทั้งคอร์สที่ผ่านมาเราลงแรงทำBounded ContextBounded Contextขอบเขตที่ ubiquitous language และ model หนึ่งชุดมีความหมายคงเส้นคงวา นอกขอบเขตนี้คำเดียวกันอาจหมายถึงคนละอย่าง เช่น 'order' ใน Ordering กับ 'ticket' ใน Kitchen คือมุมมองของสิ่งเดียวกันคนละ modelStrategic Designให้ขอบคม — แต่ละ context เป็นเจ้าของ schema ตัวเอง คุยกันผ่านIntegration EventIntegration Eventevent ที่สื่อสาร 'ข้าม' Bounded Context เป็น contract แบบ serializable ที่มีแต่ id/ค่าพื้นฐาน (ไม่มี domain object) เช่น OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) ต่างจาก Domain Event ที่อยู่ในโพรเซสเดียวArchitectureล้วน — จนตอนนี้การแตกเหลือแค่งานเล็กๆ งานเดียว บทสุดท้ายนี้จะ ดึง Delivery ออกจากModular MonolithModular Monolithสถาปัตยกรรมที่ทุก context เป็น module/assembly แยกกันชัดเจนแต่รันในโพรเซสเดียวกัน แต่ละ module เป็นเจ้าของ model/schema ของตัวเอง ห้ามอ้างอิง domain type ข้าม module ตรง ๆ เชื่อมได้เฉพาะผ่าน integration event หรือ query ที่แปลแล้วArchitectureให้เป็น service คนละโพรเซสจริงๆ แล้วดูว่ามีอะไรเปลี่ยนบ้าง และที่สำคัญกว่า — มีอะไร ไม่ เปลี่ยนเลย
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้แยก module Delivery ออกจาก host ก้อนเดิมเป็น project ที่ deploy แยก โดยเพิ่ม RabbitMQ transport ของ WolverineFx เข้าไปทั้งสองฝั่ง สแนปเพ็ตในบทเน้นให้เห็น “เส้นแบ่งระหว่างสิ่งที่แก้กับสิ่งที่ไม่แตะ” ตัวจริงใน repo จะมี config ครบกว่านี้
จุดตั้งต้น: Delivery ในฐานะ module ของ monolith
หัวข้อที่มีชื่อว่า “จุดตั้งต้น: Delivery ในฐานะ module ของ monolith”ทวนภาพจากบท 6 ก่อน ตอนอยู่ใน monolith เมื่อ Ordering ยืนยันออเดอร์ handler ของมันเขียน state กับ event ลงTransactional OutboxTransactional Outboxpattern แก้ dual-write problem: เขียน event ลงตาราง outbox ใน transaction เดียวกับข้อมูลจริง แล้วให้ relay แยกต่างหากส่ง event ออกไปหลัง commit เท่านั้น — WolverineFx ทำสิ่งนี้ให้บน EF Core ผ่าน IDbContextOutbox<T>Architectureในธุรกรรมเดียว แล้ว relay ของ Wolverine ก็ปล่อย OrderConfirmedIntegrationEvent เข้า in-process queue ให้ handler ของ Delivery รับไปเปิดงานรับ-ส่ง (Assignment) — ทั้งหมดเกิดในโพรเซสเดียว เป็นการส่งข้อความภายในเครื่อง
handler ฝั่ง Delivery หน้าตาแบบนี้ และมันคือหัวใจของบทนี้ทั้งบท เพราะเดี๋ยวเราจะยกมันข้ามเส้น process โดย ไม่แตะแม้บรรทัดเดียว:
// FoodOrdering.Delivery/Integration/WhenOrderConfirmed.cspublic class WhenOrderConfirmed{ public async Task Handle(OrderConfirmedIntegrationEvent evt, DeliveryDbContext db) { // idempotent guard ระดับ domain: ออเดอร์นี้มีงานรับ-ส่งแล้วหรือยัง var already = await db.Assignments.AnyAsync(a => a.OrderId == evt.OrderId); if (already) return;
db.Assignments.Add(Assignment.Create(evt.OrderId)); // สถานะ Unassigned await db.SaveChangesAsync(); }}สังเกตว่า handler ตัวนี้รับแค่ OrderConfirmedIntegrationEvent (ที่มี Guid OrderId ล้วน) กับ DeliveryDbContext ของตัวเอง — มันไม่รู้จัก Order ของ Ordering, ไม่รู้ว่าใครส่ง event มา, และไม่รู้ด้วยซ้ำว่าตอนนี้ตัวเองรันอยู่ในโพรเซสเดียวกับผู้ส่งหรือคนละเครื่อง มันเห็นแค่ สัญญา กับ DB ของตัวเอง เท่านั้น
สิ่งที่ไม่เปลี่ยน: handler, contract, และเส้นขอบ
หัวข้อที่มีชื่อว่า “สิ่งที่ไม่เปลี่ยน: handler, contract, และเส้นขอบ”นี่คือผลตอบแทนของงานที่ลงแรงมาหกบท เมื่อ Delivery ข้ามเส้น process ออกไป สามอย่างนี้ อยู่เหมือนเดิมทุกประการ:
- ตัว handler — code ด้านบนคือ file เดียวกันก่อนและหลังแตก ไม่มี
if (distributed)ไม่มีการเปลี่ยน signature เพราะมันพึ่งพาแค่สัญญากับ DbContext ของตัวเอง ทั้งสองอย่างเดินทางข้ามเครื่องไปด้วยกันได้ - สัญญา (contract) —
OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt)เป็นMessage ContractMessage Contractรูปแบบข้อมูล (schema) ของ integration event ที่ตกลงกันระหว่างผู้ส่งและผู้รับ ต้อง stable และมีแต่ id/ค่าพื้นฐาน เพราะเปลี่ยนแล้วกระทบทุก context ที่ subscribe อยู่ — ในทางปฏิบัติของ code คือ Published Language นั่นเองArchitectureที่แบนราบ serialize ได้ มีแต่ id กับค่าพื้นฐานมาตั้งแต่บท 4 อยู่แล้ว มันเคยเป็นแค่ “ข้อความในโพรเซส” วันนี้กลายเป็น “ข้อความบนสาย” โดยที่รูปร่างไม่ต้องเปลี่ยนเลย เพราะมันถูกออกแบบให้พร้อมข้ามสายมาตั้งแต่ต้น - เส้นขอบ / Anti-Corruption LayerAnti-Corruption Layer (ACL)ชั้นแปลภาษา/model ที่กั้นระหว่าง context สองอัน ไม่ให้ model ของอีกฝ่ายรั่วเข้ามาปนเปื้อน เช่น PaymentGatewayAdapter ที่แปล Money/Order ของ Ordering เป็น DTO ของผู้ให้บริการชำระเงินภายนอกStrategic Design — Delivery ไม่เคยเอื้อมเข้าไปใน model ของ Ordering อยู่แล้ว ตัวแปลที่ขอบ (บท 5) จบภาษาของ Ordering ตั้งแต่ที่รอยต่อ ทำให้ข้างใน Delivery พูดแต่
Assignmentล้วน เส้นนี้กัน model รั่วได้เท่ากันไม่ว่าอีกฝ่ายจะอยู่ห่างไปกี่เครื่อง
กล่าวอีกแบบ: สิ่งที่ทำให้แตกได้ถูกทำเสร็จไปหมดแล้วในบทก่อนๆ — สัญญา id-only, การเป็นเจ้าของ schema แยกกัน, ACL ที่ขอบ, outbox ที่มัด state กับ event ให้ atomic บทนี้ไม่ได้แก้ model มันแค่เปลี่ยนว่าข้อความเดินทางด้วยอะไร
สิ่งที่เปลี่ยน: hosting กับ transport config
หัวข้อที่มีชื่อว่า “สิ่งที่เปลี่ยน: hosting กับ transport config”สามสิ่งที่เปลี่ยนจริงล้วนอยู่ที่ ขอบนอกสุด — ไม่มีอันไหนแตะ domain: (1) hosting/deployment — จาก1 process กลายเป็นสอง, (2) transport config — เพิ่มMessage BrokerMessage Brokerตัวกลางที่รับ-ส่งข้อความระหว่าง context ที่แยกเป็น service จริงแล้ว เช่น RabbitMQ ทำให้ผู้ส่งไม่ต้องรู้จักผู้รับโดยตรง (decoupled) — เป็นสิ่งที่ต้องมีเมื่อแตกจาก modular monolith ไปเป็น distributed serviceArchitectureเข้ามาเป็นตัวกลาง, (3) ปลายทางที่ outbox ผลักข้อความไป — จาก in-process queue เป็น RabbitMQ exchange
เริ่มที่ Ordering host เดิม — เราไม่ลบอะไรจากบท 6 เลย แค่ ต่อท้าย transport ใหม่เข้าไป:
builder.UseWolverine(opts =>{ var pg = builder.Configuration.GetConnectionString("ordering")!;
// --- ของเดิมจากบท 6: durable outbox บน EF Core ยังอยู่ครบ ไม่แก้ --- opts.Services.AddDbContextWithWolverineIntegration<OrderingDbContext>(x => x.UseNpgsql(pg)); opts.PersistMessagesWithPostgresql(pg, "wolverine"); opts.Policies.AutoApplyTransactions();
// --- ของใหม่ในบทนี้: transport ข้ามเครื่อง --- var rabbit = builder.Configuration.GetConnectionString("rabbit")!; opts.UseRabbitMq(rabbit) .AutoProvision() // สร้าง exchange/queue ให้เองถ้ายังไม่มี .UseConventionalRouting(); // route ตามชนิดข้อความอัตโนมัติ ไม่ต้อง map ทีละใบ});แล้วตั้ง โพรเซสใหม่ สำหรับ Delivery service — มันมี DB กับ outbox ของตัวเอง (แยกจาก Ordering คนละฐานข้อมูล) แล้ว subscribe สัญญาเดิมผ่าน transport เดียวกัน:
builder.UseWolverine(opts =>{ var pg = builder.Configuration.GetConnectionString("delivery")!;
// Delivery เป็นเจ้าของ DB + outbox ของตัวเอง opts.Services.AddDbContextWithWolverineIntegration<DeliveryDbContext>(x => x.UseNpgsql(pg)); opts.PersistMessagesWithPostgresql(pg, "wolverine"); opts.Policies.AutoApplyTransactions();
// transport เดียวกัน — conventional routing พา OrderConfirmedIntegrationEvent // เข้า WhenOrderConfirmed ของ Delivery ให้เอง โดยผู้ส่งไม่ต้องรู้จัก Delivery เลย var rabbit = builder.Configuration.GetConnectionString("rabbit")!; opts.UseRabbitMq(rabbit).AutoProvision().UseConventionalRouting();});และนี่คือจุดที่ outbox กลายเป็น relay ออก broker: บรรทัด outbox.SaveChangesAndFlushMessagesAsync() ในฝั่ง Ordering ยังทำงานคำเดิมเป๊ะ — เซฟ state กับ event ใน transaction เดียว แล้ว flush หลัง commit สิ่งที่ต่างคือ ปลายทางของ flush เดิม relay ผลักข้อความเข้า in-process queue ตอนนี้มันผลักออก RabbitMQ exchange แทน ที่ handler ที่เรียก PublishAsync ไม่รู้เลยว่าปลายทางย้ายไปคนละเครื่อง — มันเห็นแค่ outbox เหมือนเดิม transactional outbox ที่เราวางไว้ในบท 6 จึงกลายเป็นสะพานที่ทำให้ข้อความข้ามสายได้อย่างเชื่อถือได้ทันที ไม่ต้องประกอบใหม่
route ในเครื่อง “และ” ข้ามเครื่อง พร้อมกัน
หัวข้อที่มีชื่อว่า “route ในเครื่อง “และ” ข้ามเครื่อง พร้อมกัน”มีจุดหนึ่งที่ต้องระวัง: ตอนอยู่ใน monolith OrderConfirmedIntegrationEvent มี handler ในเครื่องสองตัว — Kitchen กับ Delivery — เรา fan-out ให้ทั้งคู่ด้วย opts.MultipleHandlerBehavior = MultipleHandlerBehavior.Separated; (Wolverine แยกคิวให้แต่ละ handler แล้วยิงข้อความใบเดียวเข้าทั้งสอง) พอเราแตก Delivery ออกไป Kitchen ยังอยู่ในเครื่อง แต่ Delivery ไปอยู่นอกเครื่องแล้ว — event ใบเดียวกันต้องไปให้ถึงทั้งสองปลายทางที่คนละที่
ปกติ conventional routing จะส่งข้อความออก broker ก็ต่อเมื่อไม่มี handler ในเครื่อง ซึ่งจะทำให้ Delivery (นอกเครื่อง) อดรับ เพราะ Kitchen (ในเครื่อง) ยัง “ดูด” event ไว้ ทางแก้คือบรรทัดเดียว:
// additive = route "ทั้ง" ในเครื่อง (Kitchen) และออก broker (Delivery service) พร้อมกันopts.Policies.ConventionalLocalRoutingIsAdditive();ConventionalLocalRoutingIsAdditive() เปลี่ยนกติกาจาก “ในเครื่องหรือข้ามเครื่อง อย่างใดอย่างหนึ่ง” เป็น “ในเครื่อง และ ข้ามเครื่อง” — Kitchen ที่ยังเป็น module ในเครื่องก็รับ event ต่อไปเหมือนเดิม ขณะที่ Delivery ที่แยกออกไปก็รับผ่าน RabbitMQ ด้วย นี่คือเสน่ห์ของการแตกทีละ context: เราไม่ต้องแตกทุกอันพร้อมกัน ตัวที่ยังอยู่ในเครื่องกับตัวที่แยกออกไปอยู่ร่วมกันได้บนสัญญาใบเดียวกัน
เพราะข้อความข้ามสายจริงแล้ว At-least-once deliveryAt-least-once Deliveryการรับประกันของ message broker ว่าข้อความจะถูกส่งถึงผู้รับ 'อย่างน้อยหนึ่งครั้ง' แต่อาจซ้ำได้ในกรณีล่ม/retry ผู้รับจึงต้องเป็น idempotent consumer เพื่อรองรับความซ้ำนี้Architecture ก็เลิกเป็นทฤษฎี — RabbitMQ retry เมื่อเน็ตกระตุก ข้อความเดิมมาซ้ำได้จริงทุกวัน แต่เราเตรียมไว้แล้ว: WhenOrderConfirmed เป็นIdempotent ConsumerIdempotent Consumerผู้รับ event ที่ออกแบบให้ประมวลผลข้อความเดิมซ้ำได้โดยผลลัพธ์เหมือนเดิม (ไม่ใช่ผลซ้ำซ้อน) จำเป็นเพราะ at-least-once delivery การันตีแค่ 'ส่งถึงอย่างน้อยหนึ่งครั้ง' ไม่ใช่ 'พอดีหนึ่งครั้ง'Architectureที่เช็ก Assignments.AnyAsync(...) ก่อนทุกครั้ง ข้อความซ้ำจึงไม่ทำให้เกิดงานรับ-ส่งซ้ำ — guard ตัวเดิมที่เขียนไว้ตอนยังเป็น monolith กลายเป็นเกราะที่จำเป็นจริงตอนข้ามเครื่อง
flowchart TB
subgraph BEFORE["ก่อนแตก · โพรเซสเดียว = modular monolith"]
direction TB
OB1["Ordering handler<br/>outbox.SaveChangesAndFlushMessagesAsync"]
Q1["in-process queue ของ Wolverine"]
DH1["Delivery handler<br/>WhenOrderConfirmed → Assignment.Create"]
OB1 -->|"OrderConfirmedIntegrationEvent"| Q1
Q1 -->|"in-process dispatch"| DH1
end
subgraph AFTER["หลังแตก · Delivery เป็น service คนละโพรเซส"]
direction TB
OB2["Ordering host: handler เดิม<br/>code ไม่แก้แม้บรรทัดเดียว"]
BROKER{{"RabbitMQ<br/>message broker"}}
DH2["Delivery service: handler เดิม<br/>code ไม่แก้แม้บรรทัดเดียว"]
OB2 -->|"OrderConfirmedIntegrationEvent · contract เดิม"| BROKER
BROKER -->|"ยิงข้ามเครือข่าย · at-least-once"| DH2
end
BEFORE ==>|"เปลี่ยนแค่ transport + hosting · boundary/contract คงเดิม"| AFTER
classDef same fill:#bbf7d0,stroke:#166534,color:#1a1a1f;
classDef infra fill:#fde68a,stroke:#92400e,color:#1a1a1f;
class OB1,DH1,OB2,DH2 same;
class Q1,BROKER infra;
คำบรรยายภาพ: กล่องเขียวคือ สิ่งที่ไม่เปลี่ยน — handler ฝั่ง Ordering และ Delivery เป็น code ชุดเดิมทั้งก่อนและหลังแตก กล่องเหลืองคือ สิ่งที่เปลี่ยน — เดิมข้อความวิ่งผ่าน in-process queue (บน) พอแตกแล้วมันวิ่งผ่าน RabbitMQ ข้ามเครือข่ายแทน (ล่าง) สัญญา OrderConfirmedIntegrationEvent ที่แล่นบนลูกศรเป็นใบเดิมไม่เปลี่ยนรูป และเส้นขอบ context ก็อยู่ที่เดิม — การแตกจึงเป็นการสลับ ตัวกลางลำเลียง ไม่ใช่การรื้อ model หรือขยับเส้นขอบ
ปิดวงทั้งเส้นทาง
หัวข้อที่มีชื่อว่า “ปิดวงทั้งเส้นทาง”ถอยมามองทั้งการเดินทางบนเว็บนี้ มันปิดวงพอดี:
- Event Storming — ทั้งทีมยืนหน้าผนัง Post-it เรียง event บนไทม์ไลน์ จนเห็นว่าตรงรอยต่อของ event คำศัพท์เปลี่ยนชุดและคนที่คุยเปลี่ยนหน้า นั่นคือจุดที่ หลาย context โผล่ขึ้นมาให้เห็น — เราได้ Ordering, Kitchen, Delivery, Payment มาจากตรงนั้น
- ไตรภาคปั้น code — เอา Ordering ที่เป็น core มาลงมือปั้นจริง: model domain ที่ถือกฎธุรกิจ, port/อะแดปเตอร์, domain event, และ test ทั้งพีระมิด — จนได้ context หนึ่งที่แน่นและเชื่อถือได้
- คอร์สนี้ — เอา4 context นั้นมา ต่อให้ถึงกัน ด้วย context map, integration event, ACL, และ outbox จนสื่อสารข้ามขอบได้อย่างเชื่อถือได้ แล้วปิดท้ายด้วยการ แตกหนึ่งในนั้นออกเป็น service จริง
เส้นทางทั้งหมดคือสี่จังหวะ: ค้นให้เจอ → สร้างให้ถือกฎ → ต่อให้ถึงกัน → แตกเมื่อจำเป็น และประเด็นที่อยากทิ้งไว้เป็นบทสุดท้ายคือ — จังหวะสุดท้ายถูกเพราะสามจังหวะแรกทำมาถูก การแตก Delivery วันนี้เป็นแค่การเพิ่ม transport กับตั้งโพรเซสใหม่ ไม่ใช่การนั่งเลาะ model ที่พันกัน ก็เพราะเราไม่เคยปล่อยให้มันพันกันตั้งแต่แรก ขอบเขตที่ลากไว้คมตั้งแต่ Event Storming คือสิ่งที่ทำให้วันแตกจริงกลายเป็นวันธรรมดา ไม่ใช่วันรื้อใหญ่ — และนั่นคือทั้งหมดที่ strategic design พยายามซื้อไว้ให้เรา
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Strangler Fig Pattern — วิธีค่อยๆ เลาะ context ออกจาก monolith ทีละตัวอย่างปลอดภัย แบบที่บทนี้แตก Delivery ออกไปโดยไม่แตะตัวที่เหลือ
- Adapter — แก่นของบทนี้: สลับ transport หลัง port เดียวกัน (in-process → RabbitMQ) โดย code ที่เรียกใช้ไม่รู้ตัว
- Anti-Corruption Layer — เส้นขอบที่แปลภาษาที่รอยต่อ ทำให้ Delivery ไม่เคยผูกกับ model ของ Ordering จึงยกข้ามเครื่องได้ง่าย
- Bounded Context — เส้นที่เราลากไว้คมตลอดคอร์ส และเป็นเหตุผลว่าทำไมการแตกวันนี้ถึงถูก
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3ตอนแตก Delivery ออกจาก monolith เป็น service จริง อะไรเปลี่ยนและอะไรอยู่เหมือนเดิม?