บทที่ 10 · Part 2 — Choosing Code Architecture
Ports & Adapters, Onion and Clean
แยก lineage และ vocabulary ของ 3 แนวทาง แล้วออกแบบ payment ports จากฝั่งผู้ใช้จริง
Ports & Adapters, Onion and Clean
repository จำนวนมากติดป้าย “Clean Architecture” เพราะมี entities/usecases/interfaces
แต่ business code import Stripe SDK และ SQL driver อยู่ข้างใน ชื่อ directory ไม่ได้ทำให้
dependency ชี้ถูก และคำว่า Hexagonal, Onion, Clean แม้มี family resemblance ก็ไม่ใช่
คำพ้องหรือสิ่งที่ DDD บังคับ
จบบทนี้คุณจะ
แยกต้นทางและ vocabulary ของ Ports & Adapters, Onion และ Clean ใช้ inside/outside, driving/driven adapter กับ dependency direction ออกแบบ consumer-owned payment port และตรวจ architecture จาก imports/runtime boundary แทนชื่อ folder
[!IMPORTANT] วิธีอ่านหลักฐานในบทนี้ คำที่เป็น Definition/Pattern มีแหล่งต้นทางอยู่ใกล้ claim ส่วน decision table, starting structure, checklist และ lab เป็น Heuristic/Trade-off หรือ Course Convention สำหรับฝึกตัดสินใจ ไม่ใช่มาตรฐานสากล เว้นแต่บทจะระบุแหล่งและขอบเขตไว้ชัดเจน
ที่มาของ 3 แนวทางที่เกี่ยวข้องกัน
Hexagonal Architecture / Ports & Adapters จาก Alistair Cockburn เน้นให้ application ทำงานได้โดยไม่ผูกกับ UI/database และคุยกับโลกภายนอกผ่าน purposeful ports กับ adapters
Onion Architecture จาก Jeffrey Palermo เน้น dependency ชี้เข้า inner model และ infrastructure อยู่ชั้นนอก
Clean Architecture จาก Robert C. Martin สังเคราะห์แนวคิดหลายสาย เน้น separation และ dependency rule ข้ามวงชั้น
ทั้ง 3ต้องการปกป้อง policy จาก technology แต่ใช้ภาพและ vocabulary ต่างกัน คอร์สนี้ใช้ Ports & Adapters เป็นหลักเพราะอธิบาย integration conversation ได้ตรง โดยไม่บังคับวงชั้น หรือชื่อ package ตาม template
ฝั่ง Driving และ Driven
- Driving adapter เริ่ม conversation กับ application เช่น HTTP handler, CLI, message consumer
- Driving port คือ use case ที่ application เปิดให้เรียก เช่น
CreatePayment - Driven port คือ capability ที่ application ต้องใช้ เช่น
PaymentGateway,ChargeStore - Driven adapter implement capability ด้วย Stripe, MySQL, Redis หรือ clock
ภาพ Mermaid แสดง relationship เชิงแนวคิด แต่ใน Go implementation adapter เป็นฝ่าย import package ที่ประกาศ interface และ satisfy แบบ implicit ไม่ใช่ port import adapter
Consumer เป็นเจ้าของ Port
Port ควรพูดภาษาของ use case:
package payment
type PaymentGateway interface {
Charge(ctx context.Context, cmd ChargeCommand) (ChargeOutcome, error)
}
type ChargeCommand struct {
OperationID string
Amount Money
PaymentRef string
}
type ChargeOutcome struct {
Status OutcomeStatus
ProviderRef string
}
อย่าประกาศ CreatePaymentIntent(params stripe.PaymentIntentParams) เพราะนั่นเป็น vendor
API facade ไม่ใช่ application port Adapter มีหน้าที่ map ChargeCommand ไป provider DTO
และแปลผลกลับเป็น Succeeded, Declined, Pending, Unknown หรือ error ที่ application
contract กำหนด
Go interface guidance แนะนำให้ interface อยู่ใน package ผู้ใช้และไม่สร้างก่อนมี use case จริง Interface เล็กไม่ได้แปลว่าทุก struct ต้องมี interface คู่กัน
ทิศทาง Dependency และ Data Ownership
Allowed dependencies:
http/worker adapters → application/domain contracts
mysql/stripe adapters → consumed ports and domain/application types
application → domain
domain → standard library and deliberately selected pure dependencies
Composition root เป็นจุดที่รู้ concrete implementations:
gateway := stripeadapter.New(stripeClient, cfg.StripeAPIVersion)
store := mysqlcharge.New(db)
service := payment.NewService(store, gateway, clock)
handler := paymenthttp.New(service)
การ instantiate ตรง ๆ ไม่ผิด dependency injection ไม่จำเป็นต้องมี container framework เป้าหมายคือ business code ไม่เลือก vendor และ tests เปลี่ยน boundary dependency ได้
Port ไม่ได้แก้ distributed correctness
Interface ช่วย isolate dependency แต่ idempotency, timeout ambiguity, transaction, reconciliation และ authentication ยังต้องออกแบบตาม protocol จริง อย่าคิดว่าห่อ Stripe ด้วย interface แล้ว payment ปลอดภัย
หลีกเลี่ยง Architecture Theater
กลิ่นที่พบบ่อย:
domainimport ORM annotation หรือ vendor SDK- port มี method ทุก method ของ provider client
- use case ส่ง
map[string]anyเพื่อหลีกเลี่ยง type dependency แต่เสีย contract - adapter ตัดสิน refund eligibility ซึ่งเป็น domain rule
common/interfacesเป็น registry กลางที่ไม่มี consumer owner- mock-based tests ตรวจ internal call order แทน observable outcome
ตรวจด้วย go list -deps, static import rule และ code review scenario อย่าตรวจแค่ path
บาง module เรียบง่ายอาจมีทุกไฟล์ใน package เดียวแต่ dependency ถูก ขณะที่ directory สวย
อาจมี runtime callback ข้าม boundary แบบซ่อนอยู่
แบบฝึกปฏิบัติ: Payment Boundary
วาด ports/adapters รอบ CreatePayment แล้วส่งมอบ 4 artifact:
- driving command/result ที่ไม่มี HTTP type
PaymentGatewayinterface ที่ไม่มี Stripe type- translation table ระหว่าง provider outcomes กับ application outcomes
- allowed import list และ composition-root sketch
ทดสอบคำถามเปลี่ยน provider: file ใดต้องเปลี่ยน หาก domain/service test ต้อง import vendor SDK แสดงว่า boundary รั่ว แต่ไม่ต้องเขียน complete Stripe adapter ใน lab นี้
รายการตรวจสอบ
- เรียก pattern ด้วย vocabulary ต้นทางอย่างถูกต้อง
- Driving กับ driven ports ตอบ purposeful conversation
- Interface อยู่ฝั่ง consumer และเล็กตาม use case
- Vendor DTO/error ไม่รั่วเข้า application/domain
- Composition root เป็นจุดรู้ concrete implementation
- Import/runtime dependency ถูกตรวจจริง ไม่อาศัย folder name
- Reliability/security controls ถูกออกแบบนอกเหนือจาก interface
สรุปบทนี้
Ports & Adapters, Onion และ Clean มีเป้าหมายร่วมเรื่อง dependency แต่ไม่ใช่ pattern เดียว คอร์สใช้ ports เพื่อบอก conversation ระหว่าง application กับโลกภายนอก Consumer-owned contract และ translation boundary มีค่ามากกว่าการเลียน directory template
อ่านเพิ่มเติม
- Hexagonal Architecture — original Ports & Adapters article
- The Onion Architecture, Part 1 และ Part 3 — original series
- The Clean Architecture — original synthesis article
- Go Code Review Comments: Interfaces — official Go guidance