Anti-Corruption Layer — แปลภาษาที่ขอบ
บทที่แล้วเราวาด context map จนเห็นว่าใครเชื่อมกับใคร บทนี้เจาะจุดที่อันตรายที่สุดของการเชื่อม — รอยต่อที่ model ของอีกฝ่ายพยายามไหลย้อนเข้ามาในบ้านเรา ตอน Ordering ต้องรูดเงินผ่าน payment gateway ภายนอก คำศัพท์ของ gateway (amountMinor, currencyCode, string "SUCCESS") ก็จ่ออยู่หน้าประตู ถ้าเราเผลอเปิดรับมันเข้ามาตรงๆ domain Order ที่เราปั้นมาอย่างดีจะค่อยๆ ปนเปื้อนด้วยภาษาของคนอื่น บทนี้เป็นเรื่องของการ์ดที่ยืนอยู่ตรงประตูนั้น
code เต็มของคอร์สนี้อยู่ที่ repo kaen-food-ordering (กำลังจัดทำ) — บทนี้อยู่ใน module Ordering (port IPaymentGateway กับ PaymentGatewayAdapter) และ module Kitchen (OrderingToKitchenTranslator) สนิปเป็ตข้างล่างตัดมาให้เห็นแก่นของการแปลที่ขอบ ยังไม่ใช่ file เต็ม
ปัญหา: ภาษาต่างด้าวจ่ออยู่หน้าประตู
หัวข้อที่มีชื่อว่า “ปัญหา: ภาษาต่างด้าวจ่ออยู่หน้าประตู”Payment เป็น generic subdomain — เราไม่ปั้น payment gateway เอง แต่ไปเสียบของสำเร็จรูปเจ้าใดเจ้าหนึ่ง สมมติชื่อ AcmePay มันมาพร้อม SDK และ DTO ที่พูดภาษาของ AcmePay ล้วนๆ:
// มาจาก SDK ภายนอก เราแก้ไม่ได้ และไม่ควรพยายามแก้public sealed class AcmeChargeRequest{ public string reference { get; set; } // เขาเรียก id แบบนี้ public long amountMinor { get; set; } // หน่วยสตางค์ ไม่ใช่บาท public string currencyCode { get; set; } // "THB"}
public sealed class AcmeChargeResponse{ public string status { get; set; } // "SUCCESS" | "DECLINED" | ... public string txnId { get; set; }}domain Ordering ของเราพูดคนละภาษา — เรามี AggregateAggregateกลุ่มของ Entity และ Value Object ที่ถือเป็นหนึ่งหน่วยความสอดคล้อง มี root เดียวเป็นประตูเข้า เช่น Order ใน Ordering, Ticket ใน Kitchen, Assignment ใน Delivery — แต่ละ context มี aggregate ของตัวเองที่รักษากฎของตัวเอง ไม่ share aggregate ข้าม contextTactical Design Order ที่ถือ OrderId, มี Money ที่สร้างด้วย Money.Thb(120m) คิดเป็น “บาท” ไม่ใช่ “สตางค์” และเราตัดสินสถานะด้วย OrderStatus กับ method Confirm() ไม่ใช่การเทียบ string "SUCCESS" ที่คนอื่นนิยาม คำถามคือ: ตรงรอยต่อนี้ ใครจะยอมโค้งให้ใคร?
Conformist: ยอมตาม model ภายนอกทั้งดุ้น
หัวข้อที่มีชื่อว่า “Conformist: ยอมตาม model ภายนอกทั้งดุ้น”ทางที่ง่ายที่สุด (และเป็นกับดัก) คือให้ domain ของเรา “ตามใจ” model ของ gateway ทั้งดุ้น — เรียกว่าความสัมพันธ์แบบ ConformistConformistทีมปลายน้ำยอม 'ตามใจ' model ของทีมต้นน้ำทั้งหมดโดยไม่แปลภาษา ใช้เมื่อทีมต้นน้ำไม่มีแรงจูงใจจะรองรับเรา แลกกับความเสี่ยงที่ model ภายนอกจะรั่วเข้ามาStrategic Design ปลายน้ำ (เรา) ยอมรับ model ของต้นน้ำ (AcmePay) มาใช้ตรงๆ โดยไม่แปล ผลคือ vocabulary ต่างด้าวรั่วเข้ามากลาง domain:
public class ConfirmOrderHandler{ private readonly AcmePayClient _acme; // SDK ภายนอกโผล่กลาง code domain private readonly Order _order;
public async Task Handle(CancellationToken ct) { // ภาษาของ AcmePay ปนเข้ามาเต็ม ๆ: amountMinor, currencyCode, string status var req = new AcmeChargeRequest { reference = _order.Id.Value.ToString(), amountMinor = (long)(_order.Total.Amount * 100), currencyCode = "THB", }; var res = await _acme.ChargeAsync(req, ct);
// domain ต้องรู้จัก "SUCCESS" ซึ่งเป็นคำของคนอื่น if (res.status == "SUCCESS") _order.Confirm(); }}code นี้ “ทำงานได้” แต่เพิ่งเปิดประตูให้ AcmePay เดินเข้าห้องนั่งเล่น ปัญหาไม่ได้อยู่ที่บรรทัดใดบรรทัดหนึ่ง แต่อยู่ที่ domain ของเราตอนนี้รู้จักคำว่า amountMinor และ "SUCCESS" — คำที่ไม่เคยอยู่ใน Ubiquitous LanguageUbiquitous Languageภาษากลางที่ทีมและผู้เชี่ยวชาญธุรกิจใช้ร่วมกัน แต่ยึดกับ context เดียว ไม่ใช่ทั้งองค์กร — คำว่า order สื่อความหมายต่างกันในปากคนของ Ordering, Kitchen และ Delivery แม้พูดถึงเหตุการณ์เดียวกันStrategic Design ของ Ordering วันที่ AcmePay เปลี่ยน string เป็น "CAPTURED" หรือย้ายไปคิดหน่วยเป็นบาท เราต้องไล่แก้ทั่ว domain วันที่เราอยากสลับไปใช้ gateway เจ้าอื่น เราต้องรื้อ code ธุรกิจที่ไม่ควรเกี่ยวกับ gateway เลย Conformist ไม่ได้ผิดเสมอไป (บางทีต้นน้ำมั่นคงและเราไม่มีแรงต่อรอง) แต่กับ generic subdomain ที่เราตั้งใจจะสลับผู้ให้บริการได้ มันคือหนี้ที่จ่ายแพงทีหลัง
Anti-Corruption Layer: การ์ดที่ยืนแปลอยู่ตรงประตู
หัวข้อที่มีชื่อว่า “Anti-Corruption Layer: การ์ดที่ยืนแปลอยู่ตรงประตู”ทางออกคือวางชั้นแปลคั่นไว้ที่ขอบ — Anti-Corruption LayerAnti-Corruption Layer (ACL)ชั้นแปลภาษา/model ที่กั้นระหว่าง context สองอัน ไม่ให้ model ของอีกฝ่ายรั่วเข้ามาปนเปื้อน เช่น PaymentGatewayAdapter ที่แปล Money/Order ของ Ordering เป็น DTO ของผู้ให้บริการชำระเงินภายนอกStrategic Design (ACL) ประตูบานเดียวที่เป็น ที่เดียว ที่รู้จักภาษาของ AcmePay ทุกอย่างที่ผ่านประตูนี้เข้ามาถูกแปลเป็นภาษา domain ก่อน ทุกอย่างที่ออกไปถูกแปลกลับเป็นภาษา AcmePay ข้างในบ้านจึงพูดแต่ ubiquitous language ของตัวเอง ไม่มีวันได้ยินคำว่า amountMinor
หัวใจคือ port ฝั่ง domain IPaymentGateway ที่เราออกแบบเองด้วยคำของ Ordering ล้วนๆ แล้วให้ PaymentGatewayAdapter เป็นตัวทำงานจริงที่ implement port นี้ด้วยการแปลไป-กลับ:
// port ฝั่ง domain — พูดภาษา Ordering ล้วน ๆ: OrderId, Money, PaymentResultpublic interface IPaymentGateway{ Task<PaymentResult> ChargeAsync(OrderId orderId, Money amount, CancellationToken ct); Task<PaymentResult> RefundAsync(OrderId orderId, Money amount, CancellationToken ct);}
public record PaymentResult(bool Captured, string GatewayReference);
// อะแดปเตอร์อยู่ชั้น infrastructure — เป็นที่ "เดียว" ที่รู้จัก AcmePaypublic class PaymentGatewayAdapter : IPaymentGateway{ private readonly AcmePayClient _acme; public PaymentGatewayAdapter(AcmePayClient acme) => _acme = acme;
public async Task<PaymentResult> ChargeAsync(OrderId orderId, Money amount, CancellationToken ct) { // แปลขาออก: Money/OrderId ของเรา → DTO ภาษาต่างด้าวของ AcmePay var req = new AcmeChargeRequest { reference = orderId.Value.ToString(), amountMinor = (long)(amount.Amount * 100), // บาท → สตางค์ currencyCode = "THB", };
var res = await _acme.ChargeAsync(req, ct);
// แปลขาเข้า: คำตอบต่างด้าว → ผลลัพธ์ภาษา domain return new PaymentResult( Captured: res.status == "SUCCESS", // "SUCCESS" ตายอยู่แค่ในบรรทัดนี้ GatewayReference: res.txnId); }
public async Task<PaymentResult> RefundAsync(OrderId orderId, Money amount, CancellationToken ct) { var req = new AcmeRefundRequest { reference = orderId.Value.ToString(), amountMinor = (long)(amount.Amount * 100), }; var res = await _acme.RefundAsync(req, ct); return new PaymentResult(res.status == "SUCCESS", res.txnId); }}หมายเหตุ: ไตรภาค DDD-in-Code ส่ง IPaymentGateway มาเป็นแบบ fire-and-forget (Task ChargeAsync(Money amount, CancellationToken ct) ยิงแล้วไม่คืนค่า) — คอร์สนี้ต่อยอดport นั้นให้คืน PaymentResult กลับมา เพราะหัวใจของบทนี้คือให้ ACL แปล คำตอบ ของ gateway กลับเป็นภาษา domain ซึ่งทำไม่ได้เลยถ้า port ไม่มีค่าคืน
สังเกตว่าคำว่า amountMinor และ "SUCCESS" ถูกกักไว้ใน file อะแดปเตอร์ file เดียว domain Order เรียกแค่ IPaymentGateway.ChargeAsync(order.Id, order.Total, ct) แล้วได้ PaymentResult กลับมา มันไม่เคยรู้ว่าปลายสายเป็น AcmePay, Stripe หรือของปลอมตอน test — สลับ gateway = เขียนอะแดปเตอร์ตัวใหม่ที่ implement port เดิม โดยไม่แตะ code ธุรกิจแม้บรรทัดเดียว
flowchart LR
subgraph ORD["Ordering context — ภาษาของเรา"]
MODEL["Order / Money.Thb / OrderId<br/>เรียกผ่าน port IPaymentGateway"]
end
subgraph ACL["Anti-Corruption Layer<br/>PaymentGatewayAdapter"]
OUT["แปลขาออก<br/>Money, OrderId → AcmeChargeRequest"]
IN["แปลขาเข้า<br/>AcmeChargeResponse → PaymentResult"]
end
subgraph FOREIGN["Payment gateway ภายนอก — ภาษาต่างด้าว"]
DTO["AcmeChargeRequest { amountMinor, currencyCode }<br/>AcmeChargeResponse { status, txnId }"]
end
MODEL -->|"Money, OrderId"| OUT
OUT -->|"DTO ต่างด้าว"| DTO
DTO -->|"คำตอบต่างด้าว"| IN
IN -->|"PaymentResult"| MODEL
classDef ours fill:#c7d2fe,stroke:#3730a3,color:#1a1a1f;
classDef membrane fill:#bbf7d0,stroke:#166534,color:#1a1a1f;
classDef foreign fill:#fca5a5,stroke:#7f1d1d,color:#1a1a1f;
class MODEL ours;
class OUT,IN membrane;
class DTO foreign;
คำบรรยายภาพ: ACL คือเยื่อแปล (สีเขียว) ที่คั่นกลางระหว่าง model สองฝั่ง ฝั่งซ้ายคือ Ordering ที่พูด Money/OrderId (น้ำเงิน) ฝั่งขวาคือ payment gateway ที่พูด amountMinor/currencyCode/string status (แดง) ทุกสิ่งที่ข้ามเยื่อนี้ถูกแปลไป-กลับ — ขาออกแปลของเราเป็น DTO ต่างด้าว ขาเข้าแปลคำตอบต่างด้าวกลับเป็น PaymentResult คำศัพท์แดงจึงไม่มีวันข้ามเส้นประเข้าไปในเขตน้ำเงิน
ACL ขาเข้า: กัน Ordering ไม่ให้รั่วเข้า Kitchen
หัวข้อที่มีชื่อว่า “ACL ขาเข้า: กัน Ordering ไม่ให้รั่วเข้า Kitchen”ACL ไม่ได้มีไว้กันแค่ระบบภายนอก — มันกัน model ของ context เพื่อนบ้าน ในบ้านเดียวกันด้วย ตอน Ordering ยืนยันออเดอร์ มันยิง Integration EventIntegration Eventevent ที่สื่อสาร 'ข้าม' Bounded Context เป็น contract แบบ serializable ที่มีแต่ id/ค่าพื้นฐาน (ไม่มี domain object) เช่น OrderConfirmedIntegrationEvent(Guid OrderId, DateTimeOffset ConfirmedAt) ต่างจาก Domain Event ที่อยู่ในโพรเซสเดียวArchitecture ข้ามขอบไปหา Kitchen ย้ำเส้นแบ่งจากบทก่อน: integration event ไม่ใช่ Domain EventDomain Eventสิ่งที่เกิดขึ้นแล้วภายใน context เดียว ทำงาน in-process และอ้างอิง domain object ได้ตรง ๆ เช่น OrderConfirmed ภายใน Ordering — เมื่อจะส่งข้ามขอบเขต context ต้องแปลงเป็น Integration Event ก่อนเสมอ ห้ามส่ง domain event ข้ามไปตรง ๆTactical Design — domain event อย่าง OrderConfirmed อยู่ในโพรเซสของ Ordering เท่านั้นและอ้าง domain object ได้ ส่วน integration event เป็น contract แบบ serializable ที่มีแต่ id/ค่าพื้นฐาน ห้ามแบก domain object ข้ามไป:
public interface IIntegrationEvent { }
public record OrderConfirmedIntegrationEvent( Guid OrderId, DateTimeOffset ConfirmedAt) : IIntegrationEvent;contract ที่ตกลงร่วมกันชิ้นนี้เองคือ Published LanguagePublished Languageภาษากลางที่เอกสารไว้ชัดเจนสำหรับแลกเปลี่ยนข้อมูลข้าม context เช่น สัญญา (contract) ของ integration event อย่าง FoodReadyIntegrationEvent มักมาคู่กับ Open Host ServiceStrategic Design — ภาษากลางที่เอกสารไว้ชัดสำหรับแลกข้อมูลข้าม context (ไม่มีการ์ด DevIQ แยกสำหรับคำนี้ ให้จำว่ามันคือ “รูปแบบสาธารณะที่ทั้งสองฝ่ายยึดถือ” ในเชิง code ก็คือชนิดของ OrderConfirmedIntegrationEvent ) แต่ถ้า Kitchen รับ event นี้แล้วเก็บ OrderId, ConfirmedAt ไว้ใน model ตัวเองตรงๆ พร้อมเริ่มพูดคำว่า “order” กับ “confirmed” ภาษาของ Ordering ก็รั่วเข้า Kitchen ทันที ทั้งที่ Kitchen ควรพูดแค่เรื่อง Ticket
เราจึงวาง ACL ขาเข้าไว้ที่ขอบ Kitchen — OrderingToKitchenTranslator ที่รับ event ภาษา Ordering แล้วพูดกับ domain ด้วยภาษา Kitchen ล้วนๆ:
public record TicketId(Guid Value);
public class Ticket // aggregate ของ Kitchen — รู้จักแค่ Guid OrderId ไม่รู้จัก Order{ public TicketId Id { get; } public Guid OrderId { get; } public TicketStatus Status { get; private set; } // Pending → Accepted → Preparing → Ready
public static Ticket Open(Guid orderId) => new(orderId); public void Accept() { /* Pending → Accepted */ } public void MarkReady() { /* Preparing → Ready */ }}
// ตัวแปลอยู่ที่ขอบ Kitchen — เป็นด่านเดียวที่ได้ยินคำว่า "OrderConfirmed"public class OrderingToKitchenTranslator{ private readonly ITicketRepository _tickets;
public async Task Handle(OrderConfirmedIntegrationEvent e, CancellationToken ct) { // แปล: "order ที่ยืนยันแล้ว" (ภาษา Ordering) → "ตั๋วใหม่ที่รอคิว" (ภาษา Kitchen) var ticket = Ticket.Open(e.OrderId); await _tickets.AddAsync(ticket, ct); }}หลังผ่านตัวแปล domain Kitchen เห็นแต่ Ticket ที่มี Guid OrderId เป็นแค่กุญแจอ้างอิงกลับ — ไม่มี Order, ไม่มี Money, ไม่มีคำว่า “confirmed” ให้ต้องรู้จัก Kitchen รักษา ubiquitous language ของตัวเองไว้ได้สะอาด เพราะภาษาของ Ordering ถูกแปลจบตั้งแต่ที่ขอบ
กฎเหล็ก: code แปลอยู่ที่ขอบ ไม่ใช่ใน domain
หัวข้อที่มีชื่อว่า “กฎเหล็ก: code แปลอยู่ที่ขอบ ไม่ใช่ใน domain”ทั้ง PaymentGatewayAdapter และ OrderingToKitchenTranslator มีจุดร่วมเดียวกัน — มันอยู่ที่ขอบ (ชั้น infrastructure/adapter) ไม่ใช่ในใจกลาง domain นี่คือกฎที่ทำให้ ACL ได้ผลจริง ถ้า code แปลไปแทรกอยู่ใน entity หรือ aggregate — เช่นให้ Order.Confirm() ไปเทียบ res.status == "SUCCESS" เอง — เท่ากับลากภาษาต่างด้าวกลับเข้าไปนั่งกลางบ้านอีกครั้ง แล้ว ACL ก็ไม่เหลือความหมาย
วางให้ถูกที่คือ: domain type ไม่รู้จัก model ภายนอกเลยแม้แต่ชนิดเดียว Order ไม่ import AcmeChargeRequest, Ticket ไม่ import OrderConfirmedIntegrationEvent การพึ่งพาชี้เข้าหา domain ทางเดียว — อะแดปเตอร์รู้จักทั้ง port ของเราและ SDK ของเขา แต่ domain รู้จักแค่ port ของตัวเอง เส้นนี้ตรวจอัตโนมัติได้ด้วย architecture test (เครื่องมือที่เราวางไว้ตั้งแต่บทที่ 3) ว่าไม่มีชนิดใน domain ไหน reference ชนิดต่างด้าวหลุดออกมา
สรุปบทนี้เป็นประโยคเดียว: ACL คือการยอมเขียน code แปลซ้ำซากที่ขอบ เพื่อแลกกับการที่ใจกลาง domain ไม่ต้องรู้จักภาษาของใครนอกจากตัวเอง — และนั่นคือราคาที่ถูกมากเมื่อเทียบกับการไล่ล้างคำต่างด้าวออกจากทั้ง domain ทีหลัง บทหน้าเราจะเอา integration event ที่แนะนำไว้ตรงนี้ไปส่งข้ามขอบให้เชื่อถือได้จริง แม้ระบบล่มกลางคัน
เจาะลึกแนวคิดในบทนี้ต่อได้ที่คลังอ้างอิง DevIQ:
- Anti-Corruption Layer — ชั้นแปลที่กั้นไม่ให้ model ภายนอกรั่วเข้า domain หัวใจของทั้งบท
- Ubiquitous Language — ภาษากลางที่ ACL มีไว้รักษาให้สะอาดในแต่ละ context
- Bounded Context — ขอบที่ ACL ยืนเฝ้าอยู่ตรงรอยต่อ
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3หน้าที่หลักของ Anti-Corruption Layer (ACL) คือรักษาอะไร?