Hexagonal architecture หรืออีกชื่อคือ ports and adapters คือการวาง business logic ไว้ตรงกลางของแอป แล้วดันทุกอย่างที่เหลือ ไม่ว่าจะเป็น HTTP, database, message queue หรือผู้ให้บริการอีเมล ออกไปไว้ที่ขอบ แกนกลาง (core) จะนิยาม interface เล็ก ๆ ที่เรียกว่า port ไว้บอกว่าตัวเองให้บริการอะไรและต้องการอะไรจากภายนอก ส่วน adapter คือตัวที่เสียบเข้ากับ port เพื่อต่อ core เข้ากับเทคโนโลยีจริง core จะไม่ import web framework หรือ database driver เลยแม้แต่ตัวเดียว
ผลที่ได้จับต้องได้จริง เราทดสอบ business rule ได้โดยไม่ต้องมี database เปลี่ยน Postgres เป็นอย่างอื่นได้โดยไม่แตะ domain และเพิ่มทางเข้าใหม่ เช่น CLI หรือ queue consumer ได้โดยไม่ต้องเขียน logic ซ้ำ บทความนี้อธิบาย pattern นี้ สร้างตัวอย่างเล็ก ๆ ด้วย Go และบอกว่าเมื่อไรที่โครงสร้างเพิ่มเติมนี้คุ้มค่า
โครงสร้างของ hexagonal architecture
Alistair Cockburn เป็นคนอธิบาย pattern นี้ไว้ในปี 2005 รูปหกเหลี่ยมไม่ได้มีความหมายพิเศษอะไร แค่เผื่อพื้นที่ให้วาด port ได้หลายด้าน และหนีจากภาพบนลงล่างแบบ layered architecture
มีสามส่วน:
- Core (domain และ application logic) มี entity, business rule และ use case เป็นโค้ดธรรมดาที่ไม่พึ่ง framework
- Port interface ที่ core เป็นเจ้าของ
- Adapter โค้ดที่อยู่นอก core ทำหน้าที่ implement หรือเรียกใช้ port ด้วยเทคโนโลยีเฉพาะ
port มีสองทิศทาง:
| ฝั่ง Driving (primary) | ฝั่ง Driven (secondary) | |
|---|---|---|
| ชื่อเรียกอื่น | API, inbound | SPI, outbound |
| ใครเป็นฝ่ายเริ่ม | โลกภายนอกเรียกเข้ามาที่ core | core เรียกออกไปหาโลกภายนอก |
| port นิยามอะไร | แอปทำอะไรได้บ้าง (use case) | แอปต้องการอะไร (storage, messaging, payment) |
| ตัวอย่าง adapter | HTTP handler, gRPC server, CLI, queue consumer, test | Postgres repository, SMTP client, payment API client, in-memory fake |
dependency ทุกตัวชี้เข้าข้างใน adapter พึ่ง core ส่วน core พึ่งแค่ port interface ของตัวเอง
ลงมือเขียนด้วย Go: service สมัครสมาชิก
ตัวอย่างนี้เป็น service เล็ก ๆ ที่ลงทะเบียนบัญชีผู้ใช้ บันทึกลง database และส่งอีเมลต้อนรับ
internal/
account/ # the hexagon: domain, use cases, ports
account.go
ports.go
service.go
adapter/
httpapi/ # driving adapter
postgres/ # driven adapter
email/ # driven adapter
cmd/api/main.go # wiringDomain
// internal/account/account.go
package account
import (
"errors"
"strings"
"time"
)
var (
ErrInvalidEmail = errors.New("invalid email")
ErrInvalidName = errors.New("name is required")
ErrEmailTaken = errors.New("email already registered")
ErrNotFound = errors.New("account not found")
)
type Account struct {
ID string
Email string
Name string
CreatedAt time.Time
}
func New(id, email, name string, now time.Time) (Account, error) {
email = strings.ToLower(strings.TrimSpace(email))
name = strings.TrimSpace(name)
if !strings.Contains(email, "@") {
return Account{}, ErrInvalidEmail
}
if name == "" {
return Account{}, ErrInvalidName
}
return Account{ID: id, Email: email, Name: name, CreatedAt: now}, nil
}ไม่มี JSON tag ไม่มี SQL ไม่มี HTTP status code business rule อยู่ที่นี่ที่เดียว ไม่กระจายไปที่อื่น
Port
// internal/account/ports.go
package account
import "context"
// API: the driving port. What the outside world can ask the core to do.
type API interface {
Register(ctx context.Context, email, name string) (Account, error)
Get(ctx context.Context, id string) (Account, error)
}
// SPI: driven ports. What the core needs from the outside world.
type Repository interface {
// Create returns ErrEmailTaken if the email already exists.
Create(ctx context.Context, a Account) error
FindByID(ctx context.Context, id string) (Account, error)
}
type Notifier interface {
SendWelcome(ctx context.Context, a Account) error
}port พูดภาษาของ domain Repository ไม่มี Exec หรือ Query และ Notifier บอกว่า "ส่งอีเมลต้อนรับ" ไม่ได้บอกว่า "ส่ง SMTP message" สัญญาเรื่อง error (ErrEmailTaken, ErrNotFound) ก็เป็นส่วนหนึ่งของ port ด้วย adapter ทุกตัวจึงต้องแปล error ของตัวเองให้เป็นค่าเหล่านี้
ตรงนี้เข้ากับ Go ได้ดีมาก interface ใน Go ถูก implement โดยปริยาย และธรรมเนียมของ Go คือนิยาม interface ไว้ฝั่งที่ใช้งาน ซึ่งก็คือนิยามของ driven port พอดี Postgres adapter จึง satisfy Repository ได้โดยที่ core ไม่ต้อง import มันเลย (อ่านเพิ่มได้ที่ struct, method และ interface ใน Go)
Use case
// internal/account/service.go
package account
import (
"context"
"fmt"
"log/slog"
"time"
)
type Service struct {
repo Repository
notify Notifier
newID func() string
now func() time.Time
}
var _ API = (*Service)(nil) // compile-time check
func NewService(repo Repository, notify Notifier, newID func() string, now func() time.Time) *Service {
return &Service{repo: repo, notify: notify, newID: newID, now: now}
}
func (s *Service) Register(ctx context.Context, email, name string) (Account, error) {
a, err := New(s.newID(), email, name, s.now())
if err != nil {
return Account{}, err
}
if err := s.repo.Create(ctx, a); err != nil {
return Account{}, fmt.Errorf("create account: %w", err)
}
if err := s.notify.SendWelcome(ctx, a); err != nil {
// The account exists. A failed email shouldn't fail the signup.
slog.WarnContext(ctx, "welcome email failed", "account", a.ID, "err", err)
}
return a, nil
}
func (s *Service) Get(ctx context.Context, id string) (Account, error) {
return s.repo.FindByID(ctx, id)
}แม้แต่นาฬิกาและตัวสร้าง ID ก็ inject เข้ามา test จึงให้ผลเหมือนเดิมทุกครั้ง
Driving adapter: HTTP
// internal/adapter/httpapi/handler.go
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/acme/signup/internal/account"
)
type Handler struct{ accounts account.API }
func NewHandler(accounts account.API) *Handler { return &Handler{accounts: accounts} }
func (h *Handler) Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("POST /accounts", h.register)
mux.HandleFunc("GET /accounts/{id}", h.get)
return mux
}
type accountResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
a, err := h.accounts.Register(r.Context(), req.Email, req.Name)
switch {
case errors.Is(err, account.ErrInvalidEmail), errors.Is(err, account.ErrInvalidName):
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
case errors.Is(err, account.ErrEmailTaken):
http.Error(w, err.Error(), http.StatusConflict)
case err != nil:
http.Error(w, "internal error", http.StatusInternalServerError)
default:
writeJSON(w, http.StatusCreated, accountResponse{a.ID, a.Email, a.Name})
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}handler get ใช้รูปแบบเดียวกัน โดยแปลง ErrNotFound เป็น 404 หน้าที่ของ adapter มีแค่การแปล คือรับ JSON เข้ามา เรียก domain แปลง error ของ domain เป็น HTTP status code แล้วส่ง DTO ออกไป ถ้าจะเปลี่ยนจาก net/http เป็น Gin ก็แก้แค่ไฟล์นี้ไฟล์เดียว ดูหน้าตาของ adapter แบบนั้นได้ใน คู่มือทำ REST API ด้วย Gin และ GORM
Driven adapter: Postgres
// internal/adapter/postgres/accounts.go
package postgres
import (
"context"
"database/sql"
"errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/acme/signup/internal/account"
)
type AccountRepo struct{ db *sql.DB }
func NewAccountRepo(db *sql.DB) *AccountRepo { return &AccountRepo{db: db} }
func (r *AccountRepo) Create(ctx context.Context, a account.Account) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO accounts (id, email, name, created_at) VALUES ($1, $2, $3, $4)`,
a.ID, a.Email, a.Name, a.CreatedAt)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" && pgErr.ConstraintName == "accounts_email_key" {
return account.ErrEmailTaken
}
return err
}unique constraint ใน database เป็นตัวรับประกันว่า "อีเมลต้องไม่ซ้ำ" ได้อย่างปลอดภัยแม้มี request พร้อมกัน แล้ว adapter ก็แปล error 23505 ของ PostgreSQL ให้เป็น ErrEmailTaken ของ domain ส่วน FindByID (ไม่ได้แสดงไว้) ก็แปลง sql.ErrNoRows เป็น ErrNotFound ด้วยวิธีเดียวกัน core จึงไม่เคยเห็น type ของ driver เลย
ประกอบทุกส่วนเข้าด้วยกัน
// cmd/api/main.go (imports omitted, including the
// blank import of github.com/jackc/pgx/v5/stdlib)
func main() {
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
svc := account.NewService(
postgres.NewAccountRepo(db),
email.NewSMTPNotifier(os.Getenv("SMTP_ADDR")),
uuid.NewString,
time.Now,
)
log.Fatal(http.ListenAndServe(":8080", httpapi.NewHandler(svc).Routes()))
}main เป็นที่เดียวที่รู้จัก type จริงทุกตัว มันเลือก adapter แล้วเสียบเข้ากับ core
การทดสอบคือจุดที่ hexagonal architecture คุ้มค่าที่สุด
เพราะ driven port เป็น interface เราจึงทดสอบทุก use case ด้วย in-memory adapter ได้ ไม่ต้องใช้ container หรือ network
// internal/account/service_test.go
package account_test
type memRepo struct{ byID map[string]account.Account }
func (m *memRepo) Create(_ context.Context, a account.Account) error {
for _, existing := range m.byID {
if existing.Email == a.Email {
return account.ErrEmailTaken
}
}
m.byID[a.ID] = a
return nil
}
func (m *memRepo) FindByID(_ context.Context, id string) (account.Account, error) {
a, ok := m.byID[id]
if !ok {
return account.Account{}, account.ErrNotFound
}
return a, nil
}
type spyNotifier struct{ sent []string }
func (s *spyNotifier) SendWelcome(_ context.Context, a account.Account) error {
s.sent = append(s.sent, a.Email)
return nil
}
func TestRegisterRejectsDuplicateEmail(t *testing.T) {
ctx := context.Background()
notifier := &spyNotifier{}
n := 0
svc := account.NewService(
&memRepo{byID: map[string]account.Account{}},
notifier,
func() string { n++; return fmt.Sprintf("acc-%d", n) },
func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) },
)
if _, err := svc.Register(ctx, "[email protected]", "Ana"); err != nil {
t.Fatalf("first register: %v", err)
}
_, err := svc.Register(ctx, " [email protected] ", "Ana again")
if !errors.Is(err, account.ErrEmailTaken) {
t.Fatalf("want ErrEmailTaken, got %v", err)
}
if len(notifier.sent) != 1 {
t.Fatalf("want 1 welcome email, got %d", len(notifier.sent))
}
}test นี้ครอบคลุมทั้งการ normalize อีเมล กฎห้ามซ้ำ และ side effect เรื่องการส่งอีเมล เรายังต้องมี integration test สำหรับ Postgres adapter อยู่บ้าง แต่ test พวกนั้นทดสอบแค่การแปลข้อมูล ไม่ได้ทดสอบ business rule
Hexagonal เทียบกับ layered และ clean architecture
| Layered (N-tier) | Hexagonal | Clean / Onion | |
|---|---|---|---|
| ทิศทาง dependency | บนลงล่าง business layer มักพึ่ง data layer | ชี้เข้าข้างใน core ไม่พึ่งอะไร | ชี้เข้าข้างใน กฎเดียวกัน |
| ตำแหน่งของ database | อยู่ล่างสุด เป็นฐานราก | เป็น adapter ที่ขอบ | เป็นวงนอก |
| แนวคิดหลัก | แยกตามหน้าที่ทางเทคนิค | แยกข้างในออกจากข้างนอก | เหมือน hexagonal แต่ตั้งชื่อ layer มากกว่า |
clean และ onion architecture ต่อยอดจากแนวคิดเดียวกัน ถ้าเข้าใจ ports and adapters ก็ใช้เป็นภาพในหัวสำหรับทั้งสามแบบได้
ข้อผิดพลาดที่พบบ่อย
- ปล่อยให้ type ของ adapter รั่วเข้ามาใน core เช่น embed
gorm.Modelไว้ใน entity หรือใส่jsontag ให้ struct ของ domain ซึ่งผูก core ไว้กับ library ควรเก็บ DTO และ type ของแถวใน database ไว้ใน adapter - ออกแบบ port ให้หน้าตาเหมือน database port แบบ
Query(sql string)ไม่ใช่ port ที่ดี ควรตั้งชื่อ port ตามสิ่งที่ domain ต้องการ - สร้าง interface ให้ทุกอย่าง abstract เฉพาะตรงขอบระบบ ไม่ต้องทำกับ helper ภายใน core
- ลืมเรื่อง transaction ถ้า use case ต้องอัปเดตสอง repository แบบ atomic ต้องออกแบบรองรับ เช่น ทำเป็น method เดียวของ repository สำหรับ aggregate นั้น หรือมี port แบบ unit of work ที่ adapter implement ด้วย database transaction
เมื่อไรที่ hexagonal architecture เกินจำเป็น
- CRUD service บาง ๆ ที่ "business logic" มีแค่ validate แล้ว insert ครั้งเดียว โค้ดสำหรับ map ข้อมูลไปมาอาจมากกว่าตัว logic เสียอีก
- prototype และ script ที่รู้อยู่แล้วว่าจะทิ้ง
ไม่จำเป็นต้องใช้ทั้งหมดในครั้งเดียว เริ่มจากแยก dependency ที่ทำให้เขียน test ยาก ซึ่งส่วนใหญ่คือ database และ API ของบุคคลที่สาม แนวทางนี้เข้ากันได้ดีกับการกำหนด ขอบเขตของ service ด้วย DDD ที่แต่ละ bounded context มี hexagon เล็ก ๆ ของตัวเอง
คำถามที่พบบ่อย
port กับ adapter ต่างกันอย่างไร
port คือ interface ที่ core เป็นเจ้าของ ใช้อธิบายการโต้ตอบหนึ่งอย่าง เช่น Repository หรือ Notifier ส่วน adapter คือ implementation จริงที่ต่อ port นั้นเข้ากับเทคโนโลยี เช่น Postgres repository หรือ SMTP client
hexagonal architecture กับ clean architecture เหมือนกันไหม
ทั้งสองมีกฎหลักเดียวกันคือ dependency ต้องชี้เข้าหา business logic แต่ clean architecture เพิ่ม layer ซ้อนกันเป็นวงพร้อมชื่อเรียกภายใน core ส่วน hexagonal แยกแค่ข้างในกับข้างนอก
ในโปรเจกต์ Go แบบ hexagonal ควรวาง interface ไว้ตรงไหน
ไว้ใน package ของ core ที่เป็นผู้ใช้งาน core เป็นคนประกาศ port แล้ว adapter ใน package อื่นก็ satisfy มันโดยปริยาย ดูว่าจัดวางร่วมกับ internal/ และ cmd/ อย่างไรได้ใน การจัดโครงสร้างโปรเจกต์ Go
สรุปสิ่งที่ควรจำ
- วาง business rule ไว้ใน core ที่ไม่ import framework หรือ driver ใด ๆ
- นิยาม driving port สำหรับสิ่งที่แอปทำได้ และ driven port สำหรับสิ่งที่แอปต้องการ โดยใช้ภาษาของ domain
- ทำ adapter ให้บาง หน้าที่มีแค่แปลข้อมูลและ error
- ประกอบทุกอย่างใน
mainและทดสอบ use case ด้วย in-memory adapter - ไม่ต้องใช้ pattern เต็มรูปแบบกับ CRUD บาง ๆ ใช้ในจุดที่การทดสอบและการเปลี่ยน implementation ได้มีความสำคัญ
ถ้ากำลังชั่งใจเรื่องสถาปัตยกรรมแบบนี้สำหรับระบบใหม่ ทีม Vectorkub ช่วยออกแบบและพัฒนา ให้ได้
