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

Anti-Corruption Layer — แปล​ภาษา​ที่​ขอบ

บท​ที่​แล้ว​เรา​วาด context map จน​เห็น​ว่า​ใคร​เชื่อม​กับ​ใคร บท​นี้​เจาะ​จุด​ที่​อันตราย​ที่สุด​ของ​การ​เชื่อม — รอย​ต่อ​ที่ model ของ​อีก​ฝ่าย​พยายาม​ไหล​ย้อน​เข้า​มา​ใน​บ้าน​เรา ตอน Ordering ต้อง​รูด​เงิน​ผ่าน payment gateway ภายนอก คำ​ศัพท์​ของ gateway (amountMinor, currencyCode, string "SUCCESS") ก็​จ่อ​อยู่​หน้า​ประตู ถ้า​เรา​เผลอ​เปิด​รับ​มัน​เข้า​มาตรงๆ domain Order ที่​เรา​ปั้น​มา​อย่าง​ดี​จะ​ค่อยๆ ปน​เปื้อน​ด้วย​ภาษา​ของ​คน​อื่น บท​นี้​เป็น​เรื่อง​ของ​การ์ด​ที่​ยืน​อยู่​ตรง​ประตู​นั้น

📦 code ตัวอย่าง

code เต็ม​ของ​คอร์ส​นี้​อยู่​ที่ repo kaen-food-ordering (กำลัง​จัด​ทำ) — บท​นี้​อยู่​ใน module Ordering (port IPaymentGateway กับ PaymentGatewayAdapter) และ module Kitchen (OrderingToKitchenTranslator) สนิปเป็ต​ข้าง​ล่าง​ตัด​มา​ให้​เห็น​แก่น​ของ​การ​แปล​ที่​ขอบ ยัง​ไม่ใช่ file เต็ม

Payment เป็น generic subdomain — เรา​ไม่​ปั้น payment gateway เอง แต่​ไป​เสียบ​ของ​สำเร็จรูป​เจ้า​ใด​เจ้า​หนึ่ง สมมติ​ชื่อ AcmePay มัน​มา​พร้อม SDK และ DTO ที่​พูด​ภาษา​ของ AcmePay ล้วนๆ:

ภาษา​ของ AcmePay — DTO ที่​เรา​ไม่​ได้​ออกแบบ
// มาจาก 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" ที่​คน​อื่น​นิยาม คำถาม​คือ: ตรง​รอย​ต่อ​นี้ ใคร​จะ​ยอม​โค้ง​ให้​ใคร?

ทาง​ที่​ง่าย​ที่สุด (และ​เป็น​กับดัก) คือ​ให้ domain ของ​เรา “ตามใจ” model ของ gateway ทั้งดุ้น — เรียก​ว่า​ความ​สัมพันธ์​แบบ ConformistConformistทีม​ปลาย​น้ำ​ยอม 'ตามใจ' model ของ​ทีม​ต้นน้ำ​ทั้งหมด​โดย​ไม่​แปล​ภาษา ใช้​เมื่อ​ทีม​ต้นน้ำ​ไม่มี​แรง​จูงใจ​จะ​รองรับ​เรา แลก​กับ​ความ​เสี่ยง​ที่ model ภายนอก​จะ​รั่ว​เข้า​มาStrategic Design ปลาย​น้ำ (เรา) ยอมรับ model ของ​ต้นน้ำ (AcmePay) มาใช้ตรงๆ โดย​ไม่​แปล ผล​คือ vocabulary ต่างด้าว​รั่ว​เข้า​มากลาง domain:

❌ version ดิบ: Conformist — model ของ gateway รั่ว​เข้า 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 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 นี้​ด้วย​การ​แปล​ไป-กลับ:

✅ ACL: PaymentGatewayAdapter แปล​ที่​ขอบ domain พูด​แต่​ภาษา​ตัวเอง
// port ฝั่ง domain — พูดภาษา Ordering ล้วน ๆ: OrderId, Money, PaymentResult
public 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 — เป็นที่ "เดียว" ที่รู้จัก AcmePay
public 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 ไม่​ได้​มี​ไว้​กัน​แค่​ระบบ​ภายนอก — มัน​กัน 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 ข้าม​ไป:

Integration event เป็น contract ภาษา​กลาง — มี​แต่ id/ค่า​พื้นฐาน
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 ล้วนๆ:

✅ OrderingToKitchenTranslator — แปล event ภายนอก​เป็น​ภาษา 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 ถูก​แปล​จบ​ตั้งแต่​ที่​ขอบ

ทั้ง 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

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

  • Anti-Corruption Layer — ชั้น​แปล​ที่​กั้น​ไม่​ให้ model ภายนอก​รั่ว​เข้า domain หัวใจ​ของ​ทั้ง​บท
  • Ubiquitous Language — ภาษา​กลาง​ที่ ACL มี​ไว้​รักษา​ให้​สะอาด​ใน​แต่ละ context
  • Bounded Context — ขอบ​ที่ ACL ยืน​เฝ้า​อยู่​ตรง​รอย​ต่อ

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

ข้อ 1 / 3

หน้าที่หลักของ Anti-Corruption Layer (ACL) คือรักษาอะไร?