บทที่ 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

กลิ่นที่พบบ่อย:

  • domain import 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:

  1. driving command/result ที่ไม่มี HTTP type
  2. PaymentGateway interface ที่ไม่มี Stripe type
  3. translation table ระหว่าง provider outcomes กับ application outcomes
  4. 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

อ่านเพิ่มเติม