Hexagonal Architecture (Ports and Adapters): แยกแกน application ออกจากโลกภายนอก
บทความนี้เป็น บทแปลภาษาไทยอย่างไม่เป็นทางการ ของ “Hexagonal architecture (Ports and Adapters)” โดย Alistair Cockburn หนึ่งในผู้ร่วมร่าง Agile Manifesto เผยแพร่ครั้งแรกเมื่อ 4 กันยายน 2005 (ในชื่อ HaT Technical Report 2005.02) และปรับปรุงเรื่อยมาบน alistair.cockburn.us
แปลและเรียบเรียงเพื่อการศึกษา — ไดอะแกรมเชิงแนวคิดวาดใหม่เป็นภาษาไทย (ในต้นฉบับ Cockburn วาดแกน application เป็นรูปหกเหลี่ยม (hexagon) ที่มี port อยู่รอบด้าน แต่ Mermaid วาดหกเหลี่ยมไม่สะดวก จึงคลี่ออกมาเป็นผังไหลซ้าย-ขวาแทน โดยยังรักษาความหมาย “ข้างในหนึ่งเดียว ล้อมรอบด้วย adapter หลายตัว” ไว้)
ศัพท์เทคนิคคงไว้เป็นภาษาอังกฤษตามต้นฉบับ ลิขสิทธิ์ของเนื้อหาต้นฉบับเป็นของผู้เขียน
เจตนา (Intent)
หัวข้อที่มีชื่อว่า “เจตนา (Intent)”ทำให้ application สามารถถูกขับเคลื่อนได้เท่า ๆ กันไม่ว่าจะโดยผู้ใช้ที่เป็นมนุษย์ โดยโปรแกรมอื่น โดย test อัตโนมัติ หรือโดย script แบบ batch และให้พัฒนา-ทดสอบมันได้แบบแยกเดี่ยว (in isolation) โดยไม่ต้องพึ่งอุปกรณ์และฐานข้อมูลจริงในเวลารัน (run-time)
เมื่อมีคำร้อง (request) วิ่งเข้ามาจากภายนอก มันจะถูกแปลงจาก protocol เฉพาะของอุปกรณ์นั้น ๆ ให้กลายเป็นการเรียกใช้งาน (procedure call) หรือข้อความ (message) ที่ application เข้าใจ แล้วส่งต่อเข้าไปยัง application กล่าวคือ application “ไม่รู้เรื่อง” เกี่ยวกับธรรมชาติของอุปกรณ์ input เลย
และเมื่อ application มี output จะส่งกลับออกไป มันก็ส่งออกผ่านกลไกเดียวกันนี้ในทิศทางกลับกัน จากภายในออกไปยังภายนอก
การมีจุดแยกแบบนี้ทำให้เราสามารถสร้าง adapter ตัวใหม่มาต่อได้ในภายหลัง โดยที่ตัว application ไม่รับรู้ด้วยซ้ำว่ามันคุยอยู่กับอะไร
แรงจูงใจ (Motivation)
หัวข้อที่มีชื่อว่า “แรงจูงใจ (Motivation)”ปัญหาที่ Ports and Adapters ตั้งใจแก้ คือปัญหาสุดคลาสสิกสองข้อที่กัดกินซอฟต์แวร์มานาน และทั้งสองข้อจริง ๆ แล้วเป็น “ความผิดพลาดเดียวกัน” ที่ปรากฏคนละฝั่ง
ฝั่งผู้ใช้ — business logic รั่วไปปนอยู่ใน code UI. ความผิดพลาดที่พบบ่อยที่สุดคือปล่อยให้ตรรกะทางธุรกิจซึมเข้าไปอยู่ใน code ของหน้าจอ ผลที่ตามมามีสามเรื่องที่ทบต้นกัน:
- ทดสอบอัตโนมัติไม่ได้ เพราะตรรกะที่อยากทดสอบผูกติดกับรายละเอียดของหน้าจอที่เปลี่ยนบ่อยและ test ยาก
- ยกจากระบบที่มนุษย์ขับมาเป็นระบบที่รันแบบ batch ไม่ได้ เพราะตัว logic เรียกใช้งานได้ก็ต่อเมื่อมีคนกดปุ่มบนหน้าจอเท่านั้น
- ให้โปรแกรมอื่นมาขับ application ของเราไม่ได้ ในเมื่อทางเข้าเดียวคือ GUI
ฝั่ง server — application พันกันแน่นกับฐานข้อมูล. ในอีกด้านหนึ่ง ตรรกะของ application ไปผูกกับการมีอยู่ของฐานข้อมูลภายนอกจนแยกไม่ออก นักพัฒนาต้องคอยและงานสะดุดหยุดชะงักทุกครั้งที่ฐานข้อมูลยังไม่พร้อม กำลังถูกเปลี่ยน หรือไม่มีให้ใช้ในเวลาที่ต้องการเขียนหรือทดสอบ code
องค์กรมักพยายามแก้ด้วยการเพิ่ม “layer” (layer) ใหม่ในสถาปัตยกรรม พร้อมสัญญาเสียงแข็งว่าคราวนี้จะไม่เอา business logic ไปปนอีกแล้ว แต่เนื่องจากไม่มีกลไกตรวจจับว่าเมื่อไหร่คำสัญญานั้นถูกละเมิด อีกไม่กี่ปีต่อมาก็จะพบว่า code ปนกันกลับมาเหมือนเดิม
ทางออกที่เราต้องการคือให้ ทุก logic ที่น่าสนใจของ application เปิดให้เรียกผ่าน API ได้เต็มรูปแบบ โดยไม่ต้องผ่าน UI หรือฐานข้อมูลจริง เมื่อทำได้เช่นนั้นแล้ว:
- ทำ automated regression test ได้เต็มระบบ
- ผู้เชี่ยวชาญด้านธุรกิจเขียนเคสทดสอบอัตโนมัติได้ตั้งแต่ก่อน GUI จะเสร็จ
- รัน application แบบ “headless” (ไม่มีหน้าจอ) เพื่อให้โปรแกรมอื่นมาเรียกใช้ function ได้
- และเมื่อ business logic เผลอรั่วออกไปนอกที่ทางของมัน ก็ตรวจจับและดึงกลับได้ง่ายขึ้น
ลักษณะของทางแก้ (Nature of the solution)
หัวข้อที่มีชื่อว่า “ลักษณะของทางแก้ (Nature of the solution)”กุญแจสำคัญคือมองให้ออกว่าปัญหาทั้งฝั่งผู้ใช้และฝั่ง server นั้น แท้จริงเป็น ความผิดพลาดเดียวกัน: การเอา business logic ไปพันกับ code ที่ทำหน้าที่คุยกับสิ่งภายนอก
ความไม่สมมาตร (asymmetry) ที่เราควรใช้ประโยชน์ ไม่ใช่ “ซ้าย-ขวา” หรือ “บน-ล่าง” แต่เป็น ข้างใน-ข้างนอก (inside vs outside) กฎมีข้อเดียวและเรียบง่าย: code ที่อยู่ส่วน “ข้างใน” ต้องไม่รั่วออกไปปนกับส่วน “ข้างนอก” เมื่อยึดกฎนี้ ปัญหาทั้งสองข้อข้างต้นก็หายไปพร้อมกัน
Port (ช่องทางการสนทนา). คำว่า port ชวนให้นึกถึง port ของระบบปฏิบัติการ หรือ port ของอุปกรณ์อิเล็กทรอนิกส์ protocol ของแต่ละ port ไม่ได้ถูกกำหนดด้วยเทคโนโลยี แต่ถูกกำหนดด้วย จุดประสงค์ของการสนทนา ที่เกิดขึ้นตรงนั้น และในทาง code มันปรากฏออกมาในรูปของ API หรือ interface กล่าวคือ port คือ “หน้าตาของการคุยกัน” หนึ่งชุด ที่ประกาศไว้ที่ขอบของ application
Adapter (ตัวแปลง). adapter คือชิ้นส่วนที่แปลงระหว่างนิยามของ API ที่ port ประกาศไว้ กับสัญญาณ/รูปแบบที่อุปกรณ์หรือเทคโนโลยีจริงต้องการ ตัวอย่างเช่น GUI ก็คือ adapter ตัวหนึ่งที่แปลงการเคลื่อนไหวของมนุษย์ (คลิก พิมพ์) ให้กลายเป็นการเรียก API ของ port ฝั่งขับ และจุดสำคัญคือ 1 port รองรับ adapter ได้หลายตัว: นอกจาก GUI แล้ว ยังต่อ test harness (ชุดเครื่องมือทดสอบ), ตัวขับแบบ batch, หรือช่องทางให้ application อื่นเข้ามาคุยได้ทั้งหมด โดยที่เนื้อในของ application ไม่ต้องเปลี่ยน
ฝั่งฐานข้อมูลก็เช่นกัน adapter อาจเป็น SQL adapter, flat-file adapter, หรือ mock (in-memory) database adapter การสนทนาที่ application ทำผ่าน API นั้นเหมือนเดิมทุกประการ ไม่ว่าเทคโนโลยีจัดเก็บข้อมูลจริงจะเปลี่ยนไปเป็นอะไร
Cockburn ย้ำว่าเลข “หก” ไม่มีความหมายพิเศษ ไม่ได้แปลว่าต้องมี6 port เขาเลือกวาดเป็น hexagon ด้วยเหตุผลทางภาพล้วน ๆ คือ
- (1) เพื่อเน้นความไม่สมมาตร ข้างใน-ข้างนอก และแสดงว่ามี port อยู่หลายด้าน
- (2) เพื่อกันไม่ให้คนคิดแบบ “layer ซ้อนกันมิติเดียว” (บน-กลาง-ล่าง) ที่ชวนให้ลาก business logic ไหลลงมาปนกับ persistence
- (3) เพื่อให้มีที่ว่างพอวาด port ราว 2 ถึง 4 ด้าน ซึ่งเป็นจำนวนที่พบบ่อยในทางปฏิบัติ
ด้วยเหตุที่ชื่อ “hexagonal” ทำให้คนติดกับเลขหกอยู่บ่อย ๆ ภายหลัง Cockburn จึงชอบเรียกรูปแบบนี้ด้วยชื่อที่ตรงความหมายกว่าว่า Ports and Adapters
โครงสร้าง (Structure)
หัวข้อที่มีชื่อว่า “โครงสร้าง (Structure)”ในรูปแบบที่ง่ายที่สุด application หนึ่งตัว (แกนที่มี business logic) จะสื่อสารกับโลกภายนอกผ่าน port เสมอ และทุกครั้งที่ผ่าน port จะมี adapter คั่นอยู่เพื่อแปลงเทคโนโลยี ด้านหนึ่งเป็นฝั่งที่ “ขับ” application อีกด้านเป็นฝั่งที่ถูก application “ขับ”
flowchart LR Human[Human user] --> UIAdapter[UI adapter] UIAdapter --> LeftPort[Driving port API] LeftPort --> App[Application core - business logic] App --> RightPort[Driven port API] RightPort --> DBAdapter[Database adapter] DBAdapter --> DB[Database]
ภาพ 1 — รูปแบบพื้นฐานของ Ports and Adapters แกน application อยู่ตรงกลาง ไม่รู้จักทั้งมนุษย์และฐานข้อมูลโดยตรง มันรู้จักแค่ port (คือ API) เท่านั้น ส่วน adapter คือชั้นแปลงระหว่าง port กับเทคโนโลยีจริง · ดูภาพต้นฉบับ ↗
พอเราเห็นแล้วว่า “1 port ต่อได้หลาย adapter” ภาพก็ขยายออกเป็นแบบต่อไปนี้
ฝั่งซ้าย (ฝั่งขับ) มี adapter ได้หลายแบบ เช่น test อัตโนมัติ, มนุษย์ผ่าน GUI, application ที่คุยผ่าน HTTP, หรือ application ในเครื่องเดียวกัน
ส่วนฝั่งขวา (ฝั่งถูกขับ) ก็สลับได้ระหว่างฐานข้อมูลจำลองในหน่วยความจำ (in-memory mock), ฐานข้อมูลสำหรับ test, และฐานข้อมูลจริงตอนรัน
flowchart LR Test[Automated test] --> DriveAd[Driving adapters] User[Human user] --> DriveAd Http[HTTP application] --> DriveAd Local[Local application] --> DriveAd DriveAd --> DrivePort[Driving port API] DrivePort --> App[Application] App --> DrivenPort[Driven port API] DrivenPort --> DrivenAd[Driven adapters] DrivenAd --> Mock[In-memory mock database] DrivenAd --> TestDB[Test database] DrivenAd --> RunDB[Runtime database]
ภาพ 2 — application ที่มี2 port ทำงานอยู่ และแต่ละ port มี adapter หลายตัวสลับกันได้ ข้อกำหนดเชิงหน้าที่ (functional specification) ของ application ถูกตรึงไว้ที่ระดับ interface ของ hexagon ชั้นใน ไม่ใช่ที่ตัว adapter · ดูภาพต้นฉบับ ↗
หากนำ hexagon ไปเทียบกับภาพ “สถาปัตยกรรมแบบ layer” ที่คุ้นเคย ก็จะได้ว่า adapter ฝั่งผู้ใช้อยู่ด้านบน แกน application อยู่ตรงกลาง และ adapter ฝั่งข้อมูลอยู่ด้านล่าง
flowchart TB A1[User-side adapters - FIT, GUI, batch] --> P1[Driving port] P1 --> APP[Application - business logic] APP --> P2[Driven port] P2 --> A2[Data-side adapters - mock, SQL, flat file]
ภาพ 3 — Ports and Adapters เทียบกับผัง3 layer ลำดับที่ adapter ถูกทยอยต่อเข้ามาระหว่างพัฒนามักเป็นดังนี้: (1) ต่อ FIT test harness คู่กับฐานข้อมูลจำลอง (2) เพิ่ม GUI แต่ยังใช้ฐานข้อมูลจำลองอยู่ (3) ทำ integration test ด้วย script อัตโนมัติ คู่กับฐานข้อมูลจริง (4) ใช้งานจริง มนุษย์ขับ คู่กับฐานข้อมูลจริง · ดูภาพต้นฉบับ ↗
code ตัวอย่าง: application คำนวณส่วนลด
หัวข้อที่มีชื่อว่า “code ตัวอย่าง: application คำนวณส่วนลด”ตัวอย่างในต้นฉบับใช้ตรรกะที่เล็กจนเห็นภาพได้ทั้งหมด นั่นคือส่วนลด:
discount(amount) = amount * rate(amount)โดย amount มาจากผู้ใช้ (เข้ามาทาง port ฝั่งขับ) ส่วน rate มาจากฐานข้อมูล (ออกไปทาง port ฝั่งถูกขับ) เราจะประกอบระบบทีละขั้น โดยที่ ตัว application แทบไม่เปลี่ยนเลย เปลี่ยนแค่ adapter รอบ ๆ
ขั้นที่ 1 — FIT + application + ฐานข้อมูลจำลองแบบค่าคงที่. เริ่มจากทดสอบก่อนมี UI ด้วยกรอบ test FIT โดยเขียนพฤติกรรมที่คาดหวังลงในตาราง HTML แล้วให้ ColumnFixture ทำหน้าที่เป็น adapter ฝั่งขับ แปลงแต่ละแถวของตารางให้กลายเป็นการเรียก application:
eg.Discounteramount discount()100 5200 10package eg;import fit.ColumnFixture;
// ColumnFixture คือ adapter ฝั่งขับ: มันอ่านค่าจาก column ในตาราง FIT// แล้วเรียก application จากนั้นเอาผลลัพธ์ไปเทียบกับ column ค่าคาดหวังpublic class Discounter extends ColumnFixture { private Application app = new Application(); public double amount; // input จากตาราง public double discount() { // output ที่ FIT จะตรวจสอบ return app.discount(amount); }}รันได้จากบรรทัดคำสั่งผ่าน FIT FileRunner จึงเสียบเข้ากับระบบ build อัตโนมัติ (เช่น CruiseControl) ได้ทันที
ในขั้นนี้ rate ยังถูกฝังเป็นค่าคงที่แบบแบนราบไว้ในตัว application คือ 5% คงที่ จึงได้ผลลัพธ์ 100 → 5 และ 200 → 10 เสมือนฐานข้อมูลจำลองที่ง่ายที่สุด
ทั้งนี้ค่า 5% คงที่ของขั้นที่ 1 เป็นคนละเรื่องกับอัตราแบบขั้นบันได 1%/2%/5% ที่จะเพิ่มเข้ามาในขั้นที่ 3
ขั้นที่ 2 — UI + application + ฐานข้อมูลจำลองแบบค่าคงที่. ต่อ GUI adapter เข้ามาแยกต่างหาก โดยไม่แตะ code application ผลคือตอนนี้ทดสอบได้ทั้งแบบอัตโนมัติ (ผ่าน FIT) และแบบมือ (ผ่านหน้าจอ) พร้อมกัน
ขั้นที่ 3 — (FIT หรือ UI) + application + ฐานข้อมูลจำลอง. ถึงคราวแทนที่ค่าคงที่ด้วย adapter ฐานข้อมูลที่สลับได้จริง เราแยก interface RateRepository ออกมาเป็น port ฝั่งถูกขับ แล้วให้ RepositoryFactory เป็นตัวเลือกว่าจะคืน mock หรือของจริง ส่วน application รับ repository เข้ามาทาง constructor ซึ่งก็คือ dependency injection:
// port ฝั่งถูกขับ: application ประกาศว่าต้องการอะไร โดยไม่พูดถึงเทคโนโลยีpublic interface RateRepository { double getRate(double amount);}
// adapter จำลองในหน่วยความจำ ใช้ตอน test ไม่ต้องมีฐานข้อมูลจริงpublic class MockRateRepository implements RateRepository { public double getRate(double amount) { if (amount <= 100) return 0.01; if (amount <= 1000) return 0.02; return 0.05; }}
// factory ตัดสินใจว่าจะให้ adapter ตัวไหน โดย application ไม่รับรู้public class RepositoryFactory { public static RateRepository getMockRateRepository() { return new MockRateRepository(); } // getDatabaseRateRepository() ... คืน adapter ที่คุย SQL จริง}
public class Application { private final RateRepository rateRepository;
// ฉีด adapter ผ่าน constructor: สลับ mock กับของจริงได้โดยไม่แก้ logic public Application(RateRepository repo) { this.rateRepository = repo; }
public double discount(double amount) { return amount * rateRepository.getRate(amount); }}พอโครงเป็นแบบนี้ การเปลี่ยน mock ให้เป็นฐานข้อมูลจริงจึงเป็นเพียงการเปลี่ยน adapter ที่ factory ส่งให้ ตัว Application ไม่เปลี่ยนแม้แต่บรรทัดเดียว นี่คือหัวใจของทั้งรูปแบบ
หมายเหตุการนำไปใช้ (Application Notes)
หัวข้อที่มีชื่อว่า “หมายเหตุการนำไปใช้ (Application Notes)”ความไม่สมมาตรซ้าย-ขวา (Left-right asymmetry)
หัวข้อที่มีชื่อว่า “ความไม่สมมาตรซ้าย-ขวา (Left-right asymmetry)”แม้ port ทุกด้านจะเท่าเทียมกันในเชิงแนวคิด แต่ในทางปฏิบัติมีความต่างที่เป็นประโยชน์ระหว่าง ฝั่งซ้าย/บน (primary) กับ ฝั่งขวา/ล่าง (secondary):
- Primary actor (ฝั่งขับ). คือ actor ที่เป็นฝ่ายเริ่มบทสนทนา กระตุ้นให้ application เริ่มทำงานจากสภาวะนิ่ง เราจึงวาดไว้ด้านซ้ายหรือด้านบนของ hexagon ฝั่งนี้เข้ากันได้ดีที่สุดกับ adapter ทดสอบอย่าง FIT เพราะ FIT ถูกออกแบบมาเพื่ออ่าน script แล้วขับ application
- Secondary actor (ฝั่งถูกขับ). คือ actor ที่ถูก application เรียกใช้ เพื่อตอบคำถาม (เช่น ฐานข้อมูล) หรือเพื่อรับการแจ้งเตือน (เช่น ระบบส่งอีเมล) เราวาดไว้ด้านขวาหรือด้านล่าง ฝั่งนี้เข้ากันได้ดีที่สุดกับ adapter ทดสอบอย่าง mock object เพราะมันถูกออกแบบมาเพื่อตอบคำถามและบันทึกเหตุการณ์ที่ถูกเรียก
การแยกนี้สอดคล้องกับแนวคิด primary/secondary actor ในการเขียน use case และกับ context diagram ของระบบพอดี ประโยชน์ที่แท้จริงเกิดจากการที่แต่ละฝั่งรันแบบแยกเดี่ยวได้ ไม่ใช่จากการเอาไปใช้เป็นทางลัดทางสถาปัตยกรรม
Use case กับขอบเขตของ application
หัวข้อที่มีชื่อว่า “Use case กับขอบเขตของ application”ความผิดพลาดที่พบบ่อยคือเขียน use case โดยยัดความรู้เรื่องเทคโนโลยีของ port ภายนอกเข้าไปด้วย (เช่น อ้างถึงหน้าจอ ปุ่ม หรือรูปแบบตารางในฐานข้อมูล) ผลคือ specification ยาว อ่านยาก เปราะ และแพงในการดูแล
แนวทางที่ถูกต้องคือ เขียน use case ที่ขอบของ application (คือที่ hexagon ชั้นใน) โดยระบุแต่ function และเหตุการณ์ในเชิงจุดประสงค์ ไม่พูดถึงเทคโนโลยีภายนอกเลย specification ที่ได้จะสั้นลง อ่านง่ายขึ้น ดูแลถูกลง และทนต่อการเปลี่ยนแปลงตามกาลเวลาได้ดีกว่ามาก
ควรมีกี่ port?
หัวข้อที่มีชื่อว่า “ควรมีกี่ port?”คำถามนี้ไม่มีสูตรตายตัว ลองนึกถึงสองสุดขั้ว: ปลายด้านหนึ่งคือให้ทุก use case มี port ของตัวเอง (ได้เป็นร้อย ๆ port) อีกปลายคือยุบ port ฝั่งขับทั้งหมดเป็นหนึ่ง และ port ฝั่งถูกขับทั้งหมดเป็นหนึ่ง (เหลือแค่สอง) ทั้งสองสุดขั้วดูจะไม่เหมาะ ตัวอย่างที่พบจริง เช่น:
- ระบบแจ้งเตือนสภาพอากาศ — 4 port: feed ข้อมูลอากาศ, ผู้ดูแลระบบ, สมาชิกที่รับการแจ้งเตือน, และฐานข้อมูลสมาชิก
- เครื่องชงกาแฟ — 4 port: ผู้ใช้, ฐานข้อมูลสูตร/ราคา, ชุดจ่าย (dispenser), และกล่องรับเหรียญ
- ระบบจ่ายยาในโรงพยาบาล — 3 port: พยาบาล, ฐานข้อมูลใบสั่งยา, และชุดจ่ายยา
การเลือกจำนวน port ที่ “ผิด” ไปบ้างไม่ได้สร้างความเสียหายอะไรมาก มันเป็นเรื่องของสัญชาตญาณ Cockburn เองมักเอนเอียงไปทางจำนวนน้อย คือราว 2 ถึง 4 port
การใช้งานที่พบจริง (Known Uses)
หัวข้อที่มีชื่อว่า “การใช้งานที่พบจริง (Known Uses)”flowchart LR WeatherFeed[National weather service feed] --> FeedAdapter[Feed adapter] FeedAdapter --> P1[Weather feed port] Admin[Administrator] --> AdminAdapter[Admin GUI adapter] AdminAdapter --> P2[Administration port] P1 --> APP[Weather alert application] P2 --> APP APP --> P3[Notification port] P3 --> NotifyAdapter[Telephone or email adapter] NotifyAdapter --> Subscriber[Subscriber] APP --> P4[Subscriber database port] P4 --> DBAdapter[Database adapter] DBAdapter --> SubDB[Subscriber database]
ภาพ 4 — application แจ้งเตือนสภาพอากาศ ที่เดิมใช้แจ้งเตือนแผ่นดินไหว พายุทอร์นาโด ไฟไหม้ และน้ำท่วมจากกรมอุตุนิยมวิทยาไปยังโทรศัพท์/เครื่องตอบรับของสมาชิก มี4 port ได้แก่ feed จากกรมอุตุนิยมวิทยา, การแจ้งเตือนสมาชิก (โทรศัพท์/เครื่องตอบรับ/อีเมล), หน้าจอผู้ดูแลระบบ, และฐานข้อมูลสมาชิก · ดูภาพต้นฉบับ ↗
ตอนแรกทีมพัฒนาระบบแจ้งเตือนสภาพอากาศพยายามนิยาม interface ตาม เทคโนโลยี ที่ใช้อยู่ (เช่น สายที่รับ feed มา, สายโทรศัพท์ที่ใช้แจ้งเตือน)
พอมีคำขอให้เพิ่ม feed แบบ HTTP, เพิ่มการแจ้งเตือนทางอีเมล, และให้จับกลุ่มบริการต่างกันตามความต้องการของลูกค้าแต่ละราย สถาปัตยกรรมเดิมก็เริ่มพัง
เมื่อทีมเปลี่ยนมุมมองมาจัดสถาปัตยกรรมตาม จุดประสงค์ แทนเทคโนโลยี โดยให้ adapter เป็นสิ่งที่สลับได้ ผลก็พลิก:
- เพิ่ม HTTP adapter และ email adapter ได้ง่าย
- รันแบบ headless ผ่าน API ได้ ทำให้ต่อ app-to-app adapter ให้ระบบอื่นมาใช้ได้
- ต่อ test/mock adapter เพื่อทำ regression test แบบแยกเดี่ยวได้
การใช้งานอื่น ๆ ที่คุ้นเคย: Mac, Windows, Web 2.0
หัวข้อที่มีชื่อว่า “การใช้งานอื่น ๆ ที่คุ้นเคย: Mac, Windows, Web 2.0”รูปแบบนี้ปรากฏในสิ่งที่เราใช้กันทุกวัน ระบบปฏิบัติการ Macintosh (ต้นยุค 1990) เรียกร้องให้ทุก interface ต้องขับผ่าน API ได้ Windows ก็วิวัฒน์ไปในทางเดียวกัน
และปรากฏการณ์ Web 2.0 (ราวปี 2005) ก็คือการที่บริการต่าง ๆ เปิด API ของ port ฝั่งขับ ออกสู่สาธารณะ เพื่อให้ application ของบุคคลที่สามเข้ามาเรียกใช้ได้ตรง ๆ เช่น การเอาข้อมูลอาชญากรรมในท้องถิ่นไปวางซ้อนบน Google Maps หรือการฝังความสามารถด้านภาพถ่ายของ Flickr เข้าไปใน application อื่น
ข้อสังเกตคือกระแสนี้พูดถึงแต่ port ฝั่งขับเป็นหลัก ส่วน port ฝั่งถูกขับแทบไม่ถูกกล่าวถึง
กรณี output ที่ต้องเก็บไว้ (Stored outputs)
หัวข้อที่มีชื่อว่า “กรณี output ที่ต้องเก็บไว้ (Stored outputs)”มีตัวอย่างหนึ่งที่ Willem Bogaerts เขียนไว้บน C2 wiki ว่าด้วย application ที่ต้อง “พัก” output ไว้เพื่อเลือกเก็บบางส่วน
สถาปัตยกรรมแบบ layer ดั้งเดิมจะเน้นแยก UI ออกจากที่จัดเก็บ (storage) แต่ในมุมของ Ports and Adapters “output” กลายเป็นเรื่องที่เป็นกลาง โดยให้ตัวควบคุมการนำเสนอ (presentation control) จัดการทั้งการพักข้อมูลไว้ชั่วคราว (buffer) และการเก็บ ผลคือสถาปัตยกรรมด้าน output เรียบง่ายลง
อีกอุปมาที่ Cockburn ชอบใช้คือ เครื่องเสียงแบบแยกชิ้น (component stereo) แต่ละชิ้นมี interface ที่นิยามไว้ชัดตามจุดประสงค์ และต่อเข้าด้วยกันได้ไม่จำกัดรูปแบบผ่านสายและ adapter นี่คือภาพของการประกอบระบบที่ยืดหยุ่นที่เราต้องการ
พัฒนาแบบทีมกระจายและทีมใหญ่ (สถานะ: ยังทดลอง)
หัวข้อที่มีชื่อว่า “พัฒนาแบบทีมกระจายและทีมใหญ่ (สถานะ: ยังทดลอง)”ทีมที่อยู่คนละที่ต่างสร้างชิ้นส่วนของตนให้เข้ากับ Ports and Adapters โดยใช้ FIT และ mock เพื่อทดสอบ application/คอมโพเนนต์ของตัวเองแบบแยกเดี่ยว ระบบ build (CruiseControl) รันทุกครึ่งชั่วโมงด้วยชุด FIT บวก mock พอ subsystem ไหนเสร็จ ก็ค่อยเปลี่ยน mock เป็นฐานข้อมูล test จริง
แยกการพัฒนา UI ออกจาก application logic (สถานะ: ยังทดลองระยะแรก)
หัวข้อที่มีชื่อว่า “แยกการพัฒนา UI ออกจาก application logic (สถานะ: ยังทดลองระยะแรก)”บริบทคือเทคโนโลยีและ metaphor ของ UI ยังไม่นิ่ง สถาปัตยกรรมของบริการเบื้องหลังก็ยังไม่ตัดสินใจและคาดว่าจะเปลี่ยนหลายรอบในหกเดือน แต่ project เริ่มแล้วและมีแรงกดดันเรื่องกำหนดเวลา
ทีม application จึงสร้าง FIT test และ mock เพื่อแยกตัวออกมา ทำให้มี function ที่ทดสอบได้และสาธิตให้ผู้ใช้ดูได้ก่อน โดยยังเลื่อนการตัดสินใจเรื่อง UI และบริการเบื้องหลังออกไป ตั้งใจให้การเติม UI และบริการเข้ามาภายหลัง “ทำได้อย่างตรงไปตรงมา” เมื่อตัดสินใจเสร็จ
รูปแบบที่เกี่ยวข้อง (Related Patterns)
หัวข้อที่มีชื่อว่า “รูปแบบที่เกี่ยวข้อง (Related Patterns)”- Adapter. หนังสือ Design Patterns นิยาม Adapter ว่า “แปลง interface ของ class หนึ่งให้เป็นอีก interface ที่ client คาดหวัง” — Ports and Adapters ก็คือการนำ Adapter pattern มาใช้อย่างเป็นระบบทั้งสองฝั่งของ application
- Model-View-Controller (MVC). ปรากฏตั้งแต่ Smalltalk ปี 1974 และมีสายพันธุ์อย่าง Model-Interactor และ Model-View-Presenter แต่ละแบบคือ Ports and Adapters ที่ทำเฉพาะ port ฝั่งขับ เท่านั้น ไม่ครอบคลุมฝั่งถูกขับ
- Mock Objects และ Loopback. mock object คือ “สายลับสองหน้า” ที่ใช้ทดสอบพฤติกรรมของ object อื่น มันเลียนแบบ interface ของ actor ภายนอก คอยสังเกตวิธีที่ถูกเรียก แล้วรายงานเมื่อผิดจากที่คาด Ports and Adapters ยืมคำว่า “mock” มาใช้เรียก actor ภายนอกจำลองในหน่วยความจำ ส่วน Loopback คือการวางตัวแทนภายในไว้แทนอุปกรณ์ภายนอกอย่างชัดแจ้ง
- Pedestal. รูปแบบของ Barry Rubel (จาก Patterns for Generating a Layered Architecture) สร้างแกนสมมาตรในซอฟต์แวร์ควบคุม โดยสร้าง object แทนอุปกรณ์ฮาร์ดแวร์แล้วเชื่อมผ่านชั้นควบคุม มันอธิบายฝั่งใดฝั่งหนึ่งของ hexagon ได้ แต่ไม่ได้เน้นว่า adapter ทั้งสองฝั่งมีความคล้ายกัน และบริบทของมันคือการควบคุมเชิงกลไก ซึ่งไม่ตรงกับงาน IT เสียทีเดียว
- Checks. ภาษาแห่งรูปแบบของ Ward Cunningham ว่าด้วยการตรวจจับและจัดการข้อผิดพลาดของ input จากผู้ใช้ ใช้ได้ตรงบริเวณขอบของ hexagon ชั้นใน
- Dependency Inversion และ Dependency Injection. หลักการของ Bob Martin ที่ว่า “module ระดับสูงไม่ควรพึ่ง module ระดับต่ำ ทั้งคู่ควรพึ่งสิ่งที่เป็นนามธรรม (abstraction) และรายละเอียดควรพึ่งนามธรรม ไม่ใช่กลับกัน” คือแกนทฤษฎีที่ทำให้ Ports and Adapters เป็นไปได้ ส่วน Dependency Injection (Martin Fowler) คือรูปแบบที่แสดงวิธีสลับ adapter ของ secondary actor ได้จริง จะเสียบผ่านการเขียน code โดยตรง หรือผ่าน file ตั้งค่า (เช่น framework Spring) ก็ได้
สำหรับงาน Domain-Driven Design Ports and Adapters คือ เปลือก (shell) มาตรฐาน ที่ห่อ domain model เอาไว้ตรงกลาง
hexagon ชั้นในคือ domain model บริสุทธิ์ (Entities, Value Objects, Aggregates) ที่ล้อมรอบด้วย Application Service ซึ่งประสาน use case
ส่วน Repository ก็คือ port ฝั่งถูกขับที่ทำให้ domain ยังคง persistence-ignorant คือไม่รู้ว่าข้อมูลถูกเก็บด้วยเทคโนโลยีอะไร
ฝั่ง UI/REST/CLI/test เป็น adapter ฝั่งขับ ทำให้ ทดสอบ domain แบบแยกเดี่ยว ได้โดยไม่ต้องมีฐานข้อมูลจริง
flowchart LR Client[UI, REST, CLI, tests] --> InAdapter[Driving adapters] InAdapter --> AppSvc[Application Service - use cases] AppSvc --> Domain[Domain model - Entities, Value Objects, Aggregates] AppSvc --> RepoPort[Repository port] RepoPort --> RepoAdapter[Repository adapter - SQL, in-memory] RepoAdapter --> Store[Data store]
อ่านเพิ่มเรื่องการจัดสถาปัตยกรรมรอบ domain ได้ที่บท Architecture & Advanced และ Application Architecture
กิตติกรรมประกาศ (Acknowledgements)
หัวข้อที่มีชื่อว่า “กิตติกรรมประกาศ (Acknowledgements)”Cockburn ขอบคุณ
- Gyan Sharma (Intermountain Health Care) สำหรับ code ตัวอย่าง
- Rebecca Wirfs-Brock สำหรับหนังสือ Object Design
- ผู้เขียนหนังสือ Design Patterns สำหรับรากฐานของ Adapter pattern
- ชุมชนบน wiki ของ Ward Cunningham สำหรับบทสนทนาเกี่ยวกับรูปแบบนี้ตลอดหลายปี โดยเฉพาะบทความ “hexagonal soup” ของ Kevin Rutherford (silkandspinach.net) ที่ให้มุมมองเพิ่มเติม
แหล่งอ้างอิงและอ่านต่อ (References)
หัวข้อที่มีชื่อว่า “แหล่งอ้างอิงและอ่านต่อ (References)”- FIT — Framework for Integrated Test (Ward Cunningham)
- Adapter pattern — Design Patterns (Gang of Four)
- Pedestal pattern (Barry Rubel)
- Checks — pattern language (Ward Cunningham)
- Dependency Inversion Principle (Robert C. Martin)
- Dependency Injection (Martin Fowler)
- Mock Objects (Freeman และคณะ)
- Loopback pattern และเอกสารเรื่อง use case (Alistair Cockburn)
📄 อ่านต้นฉบับภาษาอังกฤษ: Hexagonal architecture (Ports and Adapters) — Alistair Cockburn, alistair.cockburn.us (เผยแพร่ครั้งแรก 4 กันยายน 2005 ในชื่อ HaT Technical Report 2005.02)