Animated Architecture Patterns

Anti-Corruption Layer

Layer ที่คอยแปล กันไม่ให้ model ของระบบ legacy รั่วเข้ามาใน domain ใหม่

หรือที่เรียกกันว่า ACL, Anticorruption Layer

Orders service new bounded context CUST_INQ("0000042") getCustomer(42) Customer its model id 42 name Ada Lovelace status ACTIVE customer_id BIGINT CUST_NO 0000042 NM_LAST LOVELACE STAT_CD A CUST_NO CHAR(7) Legacy ERP its own model and protocol SOAP / XML API CUST_MST its record CUST_NO 0000042 NM_LAST LOVELACE NM_FRST ADA STAT_CD A 01 Customers service takes over customer data getCustomer(id) Customer same terms id 42 name Ada Lovelace status ACTIVE boundary between the two models no translation at the boundary every ERP change can break Orders Anti-corruption layer the only place that knows both models +1 hop = extra latency on every call adapter in Orders' terms translator facade in the ERP's terms Orders' model ERP's model id CUST_NO name NM_FRST + NM_LAST status = ACTIVE STAT_CD = A STAT_CD = 01 updated anti-corruption layer retired nothing left to translate CUST_INQ CUST_MST the ERP's model takes over sees only its own model the ERP is not modified nothing changes here new release: A becomes 01 one mapping row changes, nothing else one more service to run, scale and monitor speaks the new model message in Orders' model message in the ERP's model

ขั้นที่ 1 · Model ของ legacy รั่วเข้ามา พอไม่มีอะไรคั่นกลาง Orders service ตัวใหม่ก็เรียก legacy ERP ตรง ๆ เลยต้องพูดภาษาของ ERP ไปด้วย ชื่อ รหัส และรูปแบบของ legacy (CUST_NO, STAT_CD = A, key ที่เติมศูนย์ข้างหน้า) ก็เลยโผล่เข้ามาในโค้ด ใน model และใน database ของมันเอง ตอนนี้ model ใหม่มีหน้าตาตาม model เก่าไปแล้ว และ ERP เปลี่ยนอะไรก็อาจทำ service ใหม่พังได้

ขั้นที่ 2 · แปลตรงรอยต่อ ใส่ anti-corruption layer คั่นไว้ตรงกลางระหว่างสองฝั่ง Orders ถาม getCustomer(42) ด้วยภาษาของตัวเอง: adapter รับการเรียก facade เรียก legacy ด้วยภาษาของ ERP เอง แล้ว translator ก็แปลงคำตอบทีละ field สิ่งที่ได้กลับมาคือ Customer ที่สะอาด ไม่มีชื่อจาก legacy หลุดเข้ามาใน model ใหม่เลย

ขั้นที่ 3 · Legacy เปลี่ยน ก็จบแค่ตรงนี้ ERP เปลี่ยนรหัส: ลูกค้าที่ active ตอนนี้เป็น STAT_CD = 01 แทน A แค่แก้ mapping ใน translator แถวเดียวให้ตรงกัน ส่วน Orders service กับ model ของมันไม่ต้องแตะเลย และก็ยังทำงานได้เหมือนเดิม layer นี้เป็นที่เดียวที่รู้จักทั้งสอง model เลยเป็นที่เดียวที่ต้องแก้

ขั้นที่ 4 · ทำให้บางไว้ แล้วค่อยเลิกใช้ ที่ต้องแลกคือทุกการเรียกมี hop เพิ่มอีกหนึ่ง และมี component เพิ่มอีกตัวที่ต้องรัน ต้อง scale และต้อง monitor เลยควรทำ layer ให้บางไว้: ทำแค่แปล ไม่ใส่ business rule ส่วนถ้าเป็นงาน migration ตัว layer นี้ก็เป็นของชั่วคราว: พอ capability ด้านลูกค้าย้ายไปอยู่ที่ Customers service ตัวใหม่ที่พูด model ใหม่ได้เองอยู่แล้ว ก็เลิกใช้ layer นี้ไปพร้อมกับเลิกพึ่ง ERP ได้เลย แต่ถ้าวางไว้หน้าระบบของ third party หรือ partner ที่เราคุมไม่ได้ มันก็อยู่ถาวร

ปัญหา

service ใหม่ไม่ค่อยได้เริ่มต้นแบบตัวคนเดียว มันต้องใช้ข้อมูลและพฤติกรรมที่ยังอยู่ในระบบ legacy, packaged product หรือ API ของ partner และระบบนั้นก็มี model เป็นของตัวเอง: record แบบ CUST_MST, STAT_CD = A, key ที่เติมศูนย์ข้างหน้า, interface ที่เป็น SOAP envelope หรือไฟล์ fixed-width ถ้าโค้ดใหม่เรียกมันตรง ๆ คำศัพท์พวกนั้นก็ย้ายเข้ามาด้วย ชื่อ field ของ legacy โผล่ใน class ของ service ใหม่ รหัสของ legacy โผล่ในคำสั่ง if และรูปแบบของ legacy ก็โผล่ในคอลัมน์ของ database ตอนนี้ model ใหม่มีหน้าตาตาม model เก่าไปแล้ว สองระบบต้องเปลี่ยนไปพร้อมกันเท่านั้น และทุกการอัปเกรดฝั่ง legacy ก็เป็นความเสี่ยงของฝั่งใหม่

ทำงานยัง⁠ไง

pattern นี้มาจาก Domain-Driven Design (2003) ของ Eric Evans ที่มันเป็นหนึ่งในความสัมพันธ์ที่ bounded context สองตัวมีต่อกันได้บน context map: ทีม downstream ที่ต้องใช้อีกระบบหนึ่ง แต่ไม่ต้องการ model ของระบบนั้น จะสร้าง layer มาคั่นแยก แล้วคุยกับอีกระบบผ่าน layer นั้นเท่านั้น (ชื่อนี้มักถูกย่อเป็น ACL และไม่เกี่ยวอะไรกับ access control list)

layer นี้มีหน้าที่สามอย่าง ปกติก็แบ่งเป็นสามส่วนเล็ก ๆ

  • Adapter: implement interface ที่ service ใหม่ต้องการ ด้วยภาษาของ model ใหม่ (getCustomer(id) คืนค่าเป็น Customer) โดยส่ง request ที่เทียบเท่ากันไปที่ระบบ legacy มันเป็นส่วนเดียวที่ service ใหม่เคยเห็น และมีให้แค่สิ่งที่ service นั้นต้องใช้ ไม่ใช่ทุกอย่างที่ระบบ legacy ทำได้
  • Translator: แปลงสิ่งที่ข้ามรอยต่อ ทั้งสองทิศ: ชื่อ (CUST_NO กลายเป็น id), โครงสร้าง (NM_FRST กับ NM_LAST กลายเป็น name), รหัส (STAT_CD = A กลายเป็น ACTIVE), รูปแบบ หน่วย และ error
  • Facade: หน้าบ้านที่ง่ายกว่าสำหรับ interface ของ legacy แต่ยังใช้ภาษาของระบบ legacy เอง: เรียกครั้งเดียวแบบธรรมดา แทนที่จะต้องไล่ SOAP envelope, session และ paging เป็นลำดับ มันไม่แปลอะไรเลย และระบบ legacy ที่ interface ง่ายอยู่แล้วก็ไม่ต้องมี facade

(Azure และ AWS ใช้คำว่า facade และ adapter แบบหลวม ๆ กว่านี้ หมายถึง layer ทั้งก้อน)

service ใหม่เห็นแค่ model ของตัวเอง ระบบ legacy ไม่ถูกแก้อะไรเลย และ layer ก็เป็นที่เดียวที่รู้จักทั้งสองฝั่ง พอฝั่ง legacy เปลี่ยนรหัสหรือรูปแบบ ก็แก้แค่ mapping เดียว ส่วน domain ไม่ต้องเปลี่ยน

ไอเดียเดียวกันนี้ใช้ได้ทุกทิศ

  • ระบบใหม่เรียก legacy (ในไดอะแกรม): layer ยืนอยู่หน้าระบบ legacy
  • Legacy เรียกระบบใหม่: พอแยก capability หนึ่งออกมาแล้ว ส่วนที่เหลือของ monolith ก็ยังเรียกมันแบบเดิม คำแนะนำของ AWS แสดง layer ไว้ ข้างใน monolith เป็น class ที่อยู่หลัง interface เดิม ตัวเรียกที่มีอยู่เลยไม่ต้องแตะเลย ในขณะที่ layer แปลไปเป็น API ของ service ใหม่
  • Event และ data feed: consumer ที่แปลง message ของ legacy, แถวจาก change data capture หรือไฟล์ที่มาทุกคืน ให้เป็น domain event ก็เป็น anti-corruption layer เหมือนกัน ให้ publish CustomerActivated ไม่ใช่สำเนาของแถวที่เปลี่ยน

ใช้ตอนไหนดี

  • งาน migration ที่กินเวลาหลาย release และระหว่างนั้น service ใหม่ต้องทำงานกับระบบ legacy ไปด้วย มันมักมาคู่กับ strangler fig
  • การ integrate กับระบบของ third party, partner หรือ packaged system ที่คุณไม่ได้คุม model และไม่อยากให้ model นั้นเข้ามาอยู่ในโค้ด
  • context ฝั่ง downstream เป็นหัวใจของธุรกิจ model ของมันเลยคุ้มที่จะปกป้อง

context map ของ Evans ยังมีคำตอบแบบอื่นด้วย และคุ้มที่จะดูก่อนจะลงมือสร้าง layer

  • Conformist: รับ model ของ upstream มาใช้ตามที่เป็น ไม่แปลอะไรเลย เหมาะตอนที่ model นั้นดีพออยู่แล้ว หรือการ integrate เล็กเกินกว่าจะคุ้มกับการมี layer
  • Open host service with a published language: ฝั่ง upstream มี API ที่สะอาดและมีเอกสารให้ตัวเดียว พร้อมรูปแบบข้อมูลที่ใช้ร่วมกันสำหรับ consumer ทุกตัว เหมาะตอนที่คุณเป็นเจ้าของหรือมีอิทธิพลต่อระบบ upstream ได้ หรือตอนที่มันมี consumer เยอะ: แก้ model ครั้งเดียวที่ต้นทาง แทนที่จะแก้ครั้งละ consumer
  • Separate ways: ไม่ integrate เลย เหมาะตอนที่การ integrate จะแพงกว่าคุณค่าของ feature

อย่าใช้ anti-corruption layer ตอนที่สอง model ใกล้กันอยู่แล้ว (ไม่มีอะไรให้แปล ทำให้ layer เป็นแค่ hop เพิ่ม) หรือตอนที่การ conform ก็แค่ถูกกว่า

ได้อะไร เสียอะไร

  • Latency ทุกการเรียกต้องจ่ายค่าแปล และถ้า layer เป็น service แยก ก็ต้องจ่ายค่า network hop ด้วย ให้ตั้ง latency budget ไว้ แล้วเทสต์เทียบกับมันก่อนขึ้น production
  • มีของต้องรันเพิ่มอีกตัว layer ที่แยกออกมาต้องมี pipeline, configuration, monitoring และ alerting ของตัวเอง เหมือน service อื่น ๆ
  • Scaling และ availability layer ต้อง scale ตามตัวเรียก ไม่อย่างนั้นมันจะกลายเป็นคอขวด และตอนที่มันล่ม ระบบ legacy ก็เข้าถึงไม่ได้ ใส่ timeout, retry และ circuit breaker ให้การเรียกของมัน
  • Consistency การแปลไม่ได้ทำให้สองระบบ consistent กัน operation ที่เขียนทั้งสองฝั่งยังต้องมีการเรียกแบบ idempotent และ compensation (แบบ saga) และต้องมีคนคอยดู drift
  • Mapping ที่ทำข้อมูลหาย model ไม่เคยตรงกันพอดี ตัดสินใจให้ชัดว่าจะทิ้งอะไร ใช้ค่า default กับอะไร หรือปฏิเสธอะไร เพราะ layer คือที่ที่การตัดสินใจพวกนั้นไปลงเอย
  • ของชั่วคราวมักกลายเป็นของถาวร ถ้า layer มีอยู่แค่เพื่องาน migration ให้บันทึกไว้เป็นหนี้ที่มีเจ้าของ แล้วลบทิ้งเมื่อตัวเรียกตัวสุดท้ายย้ายออกไปแล้ว

ข้อควรรู้ตอนลงมือทำ

  • อยู่ที่ไหน เป็น module ข้างใน service ใหม่: ถูกที่สุด ไม่มี network hop และใช้ได้ดีถ้ามี consumer ตัวเดียว ส่วนแบบที่เป็น service แยกของตัวเอง (อย่างในภาพ): ตอนที่หลาย service ต้องใช้ capability เดียวกันของ legacy หรือ protocol ของ legacy ต้องการตำแหน่งใน network หรือ runtime แบบพิเศษ แล้วแบบที่อยู่ข้างใน legacy monolith: ตอนที่โค้ด legacy เป็นตัวเรียก
  • หนึ่ง layer ต่อหนึ่ง upstream context "integration layer" ตัวเดียวที่ใช้ร่วมกันสำหรับทุกอย่าง จะกลายเป็นคอขวดและเป็น dependency ของทุกทีม ทำ layer เล็ก ๆ หนึ่งตัวต่อหนึ่งระบบ upstream และให้ทีมที่ใช้มันเป็นเจ้าของ
  • อะไรควรอยู่ในนั้น: การแปลชื่อ โครงสร้าง รหัส และหน่วย, การปรับ protocol, การ map fault ของ legacy ไปเป็น domain error, การ validate สิ่งที่ส่งกลับมา และบางทีก็ cache สำหรับการ lookup ที่ช้าหรือโดน rate limit อะไรไม่ควรอยู่: business rule และ orchestration ส่วน rule ที่ดูเหมือนต้องใช้ทั้งสอง model ควรอยู่ใน domain และทำงานกับข้อมูลที่แปลแล้ว
  • เจอของที่ไม่รู้จักให้พังเสียงดัง รหัสที่ mapping ไม่เคยเห็น (STAT_CD = 07) ควรโยน error และยิง alert ไม่ใช่ปล่อยผ่านไปเป็น string หรือถอยไปใช้ค่า default
  • เทสต์ mapping ด้วย response ของ legacy ที่บันทึกไว้ และด้วย contract test กับ interface ของ legacy การเปลี่ยนฝั่ง legacy จะได้โผล่มาเป็นเทสต์ที่ fail ใน layer แทนที่จะเป็นข้อมูลเสียใน domain
  • เฝ้าดูมัน ส่ง correlation ID ข้าม hop ไปด้วย (distributed tracing) log การแปลที่ล้มเหลวในรูปแบบ structured และติดตาม latency กับ error rate ของแต่ละ operation ฝั่ง legacy
  • Hexagonal architecture ในภาษาของ ports-and-adapters ตัว domain เป็นเจ้าของ port ที่ใช้ภาษาของตัวเอง (CustomerDirectory) และ anti-corruption layer ก็คือ adapter ที่อยู่หลัง port นั้น ส่วน adapter ธรรมดาซ่อนเทคโนโลยี แต่ตัวนี้ซ่อน model แปลกหน้าไว้ด้วย
  • Strangler fig ตัว strangler facade ตัดสินว่า ระบบไหน จะรับ request ส่วน anti-corruption layer ตัดสินว่า ข้อมูลหมายถึงอะไร ตอนที่ service ใหม่ยังต้องพึ่งตัวเก่า งาน migration ปกติต้องใช้ทั้งคู่ และ layer ก็หายไปเมื่อ capability ที่อยู่ข้างหลังมันย้ายไปแล้ว
  • เป็นตัวอย่าง ไม่ใช่ข้อบังคับ: ตัวอย่างของ Azure วาง Azure API Management ไว้ข้างหน้าเพื่อดูแลเรื่องการเปิดให้เรียกและเรื่อง protocol แล้วทำ mapping ใน Azure Functions ส่วนตัวอย่างของ AWS เก็บ layer ไว้เป็น class ใน ASP.NET monolith ที่เรียก service ที่แยกออกมา (Lambda function) ผ่าน Amazon API Gateway ส่วนฝั่ง feed การแปลแบบเดียวกันก็รันใน consumer ของ queue หรือ stream