Clean Architecture & Hexagonal (Ports & Adapters)
Giới thiệu
Clean Architecture (Robert Martin) và Hexagonal Architecture (Alistair Cockburn) là layering philosophy: cách organize code theo layer, từ business logic đến infrastructure.
Core idea: Business logic tập trung ở center, không depend on framework, database, hoặc UI. Everything else (web, cache, queue, API client) là plugin — có thể swap out.
Hình dung: 🎯 Bull's eye
┌─────────────────────────────────────┐
│ 👤 UI / HTTP Handler / Scheduler │ ← Interface Layer
├─────────────────────────────────────┤
│ 📦 Use Cases / Services │ ← Application Layer
├─────────────────────────────────────┤
│ 🎯 Domain / Business Rules │ ← Domain Layer (THE CENTER)
├─────────────────────────────────────┤
│ 💾 Repository / Cache / Queue │ ← Infrastructure Layer
├─────────────────────────────────────┤
│ 🔧 Frameworks (Gin, GORM, Redis) │ ← Framework Layer
└─────────────────────────────────────┘
Benefit:
- Domain logic không care HTTP, database, framework
- Easy to test: mock dependencies
- Easy to replace: swap Postgres for MongoDB, Gin for Echo
- Independent deployment: domain changes ≠ framework update
The Dependency Rule ⚠️
Law: Inner layers (domain, application) NEVER import outer layers (infrastructure, framework).
// ✓ OK: Framework depends on domain
import "myapp/domain/order"
// ❌ NEVER: Domain depends on framework
import "github.com/gin-gonic/gin"
Why? Nếu domain import gin:
- Test domain = test gin (slow, complicated)
- Change web framework = must refactor domain
- Reuse domain in CLI, scheduled job = duplicate code
Layer Details
Layer 1: Domain (Innermost)
Responsibility: Business rules mà không change khi infrastructure đổi.
// domain/order/order.go
package order
import "errors"
type Order struct {
id string
items []OrderItem
status OrderStatus
createdAt time.Time
}
type OrderStatus string
const (
StatusCreated OrderStatus = "created"
StatusConfirmed OrderStatus = "confirmed"
StatusShipped OrderStatus = "shipped"
)
// Business rule: order phải có items
func (o *Order) Confirm() error {
if len(o.items) == 0 {
return errors.New("order must have items")
}
o.status = StatusConfirmed
return nil
}
// Pure function: no DB, no HTTP
func (o *Order) Total() float64 {
sum := 0.0
for _, item := range o.items {
sum += item.Price() * float64(item.Quantity())
}
return sum
}
// Value Object
type Money struct {
amount float64
currency string
}
func NewMoney(amount float64, currency string) (Money, error) {
if amount < 0 {
return Money{}, errors.New("amount cannot be negative")
}
return Money{amount, currency}, nil
}
Characteristics:
- No external imports (except std lib)
- Pure functions / predictable
- 100% testable without mock
Layer 2: Application
Responsibility: Orchestrate domain objects, handle use cases, transaction boundary.
// application/service/confirm_order.go
package service
import (
"context"
"myapp/domain/order"
)
// UseCase Input (DTO)
type ConfirmOrderInput struct {
OrderID string
}
// UseCase Output
type ConfirmOrderOutput struct {
OrderID string
Total float64
}
type ConfirmOrderUseCase struct {
orderRepo order.Repository // ← Interface (defined in domain)
eventBus order.EventPublisher // ← Interface
}
// Execute use case
func (uc *ConfirmOrderUseCase) Execute(
ctx context.Context,
input ConfirmOrderInput,
) (*ConfirmOrderOutput, error) {
// Load aggregate
ord, err := uc.orderRepo.FindByID(ctx, input.OrderID)
if err != nil {
return nil, err
}
// Invoke business rule
if err := ord.Confirm(); err != nil {
return nil, err
}
// Persist
if err := uc.orderRepo.Save(ctx, ord); err != nil {
return nil, err
}
// Publish events
for _, event := range ord.PendingEvents() {
_ = uc.eventBus.Publish(ctx, event)
}
ord.ClearEvents()
return &ConfirmOrderOutput{
OrderID: ord.ID(),
Total: ord.Total(),
}, nil
}
Characteristics:
- Import domain + interfaces (ports)
- No framework code
- Orchestrator pattern (compose domain objects)
- Transaction boundary (one DB operation = one execute call)
Layer 3: Interface Adapters (Hexagon's Ports & Adapters)
Responsibility: Convert external data format → domain format, and vice versa.
// interface/http/handler/order.go
package handler
import (
"github.com/gin-gonic/gin"
"myapp/application/service"
)
type OrderHandler struct {
confirmOrderUseCase *service.ConfirmOrderUseCase
}
// HTTP request DTO
type ConfirmOrderRequest struct {
OrderID string `json:"order_id"`
}
// HTTP response DTO
type ConfirmOrderResponse struct {
OrderID string `json:"order_id"`
Total float64 `json:"total"`
}
// Adapter: HTTP → UseCase
func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
var req ConfirmOrderRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Convert HTTP request → UseCase input
useCaseInput := service.ConfirmOrderInput{
OrderID: req.OrderID,
}
// Execute use case
output, err := h.confirmOrderUseCase.Execute(c.Request.Context(), useCaseInput)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Convert UseCase output → HTTP response
c.JSON(200, ConfirmOrderResponse{
OrderID: output.OrderID,
Total: output.Total,
})
}
Another adapter: Repository
// infrastructure/postgres/order_repository.go
package postgres
import (
"context"
"database/sql"
"myapp/domain/order"
)
type OrderRepository struct {
db *sql.DB
}
// Adapter: SQL row → Order domain object
func (r *OrderRepository) FindByID(ctx context.Context, id string) (*order.Order, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, status FROM orders WHERE id = $1
`, id)
var ordID, status string
if err := row.Scan(&ordID, &status); err != nil {
return nil, err
}
// Reconstruct domain object from DB
ord := order.NewOrder(ordID)
ord.SetStatus(order.OrderStatus(status))
return ord, nil
}
// Adapter: Order domain object → SQL insert
func (r *OrderRepository) Save(ctx context.Context, ord *order.Order) error {
_, err := r.db.ExecContext(ctx, `
UPDATE orders SET status = $1 WHERE id = $2
`, ord.Status(), ord.ID())
return err
}
Key insight: Handler receives JSON → calls repository (which does SQL) → returns JSON. Domain object never sees JSON or SQL.
Layer 4: Infrastructure
Responsibility: Implementation details (database driver, HTTP client, cache library).
// infrastructure/cache/redis_cache.go
package cache
import "github.com/redis/go-redis/v9"
type RedisCache struct {
client *redis.Client
}
func (c *RedisCache) Get(key string) (string, error) {
return c.client.Get(ctx, key).Result()
}
Characteristics:
- Framework / library code
- Can be deleted & rewritten without affecting domain
- Multiple implementations can coexist (Redis cache vs In-Memory cache)
Layer 5: Framework
Responsibility: Wire everything together, start the app.
// main.go
package main
import (
"database/sql"
"github.com/gin-gonic/gin"
_ "github.com/lib/pq"
"myapp/application/service"
"myapp/infrastructure/postgres"
"myapp/interface/http/handler"
)
func main() {
// Wire infrastructure
db, _ := sql.Open("postgres", "...")
orderRepo := postgres.NewOrderRepository(db)
eventBus := inmemory.NewEventBus()
// Wire application
confirmOrderUseCase := &service.ConfirmOrderUseCase{
orderRepo: orderRepo,
eventBus: eventBus,
}
// Wire interface
orderHandler := &handler.OrderHandler{
confirmOrderUseCase: confirmOrderUseCase,
}
// Setup web framework
r := gin.Default()
r.POST("/orders/:id/confirm", orderHandler.ConfirmOrder)
r.Run(":8080")
}
Hexagonal Architecture (Ports & Adapters)
Hexagonal is Clean Architecture từ một góc nhìn khác: ports (interface) và adapters (implementation).
🔶 Web (HTTP Adapter)
↓
┌─────────────────┐
│ │
│ 🎯 Application │ ← Ports (interfaces)
│ │
└─────────────────┘
↓
🔶 Database (SQL Adapter)
🔶 Cache (Redis Adapter)
🔶 Queue (RabbitMQ Adapter)
Port: Interface định nghĩa behavior
// domain/order/port.go
package order
import "context"
// Output Port: what the domain needs from outside
type Repository interface {
FindByID(ctx context.Context, id string) (*Order, error)
Save(ctx context.Context, order *Order) error
}
// Another output port
type EventPublisher interface {
Publish(ctx context.Context, event interface{}) error
}
// Input Port: the use case interface
type ConfirmOrderPort interface {
Execute(ctx context.Context, orderID string) error
}
Adapter: Implements port
// infrastructure/postgres/order_repository.go
type OrderRepository struct { db *sql.DB }
// Implement domain.Repository port
func (r *OrderRepository) FindByID(...) (*order.Order, error) { ... }
func (r *OrderRepository) Save(...) error { ... }
// ✓ Any implementation of domain.Repository can be used:
// - PostgreSQL adapter
// - In-memory adapter (for testing)
// - MongoDB adapter (future)
Benefits of Hexagonal naming:
- Explicit: "Port" = interface, "Adapter" = implementation
- Plugin architecture: Add new adapter without changing core
- Testing: Adapter is easily mockable
Project Structure
myapp/
├── cmd/
│ └── server/
│ └── main.go ← Wiring
├── domain/
│ └── order/
│ ├── order.go ← Domain model
│ ├── port.go ← Interfaces (output ports)
│ └── status.go ← Domain constants
├── application/
│ └── service/
│ └── confirm_order.go ← Use case (input port impl)
├── interface/
│ ├── http/
│ │ └── handler/
│ │ └── order.go ← HTTP adapter
│ └── cli/
│ └── command.go ← CLI adapter
└── infrastructure/
├── postgres/
│ └── order_repository.go ← PostgreSQL adapter
├── cache/
│ └── redis_cache.go ← Redis adapter
└── event/
└── inmemory_bus.go ← In-memory event bus
Package naming:
domain/: No external importsapplication/: Imports domain + port interfacesinterface/: Imports application + framework (Gin, CLI lib, ...)infrastructure/: Imports domain port interfaces + external libs
Advanced: Dependency Injection
Clean architecture + DI = powerful combination.
// Wire dependencies in main.go
type Container struct {
OrderRepo order.Repository
EventBus order.EventPublisher
ConfirmUseCase *service.ConfirmOrderUseCase
OrderHandler *handler.OrderHandler
}
func NewContainer() *Container {
repo := postgres.NewOrderRepository(db)
bus := inmemory.NewEventBus()
useCase := service.NewConfirmOrderUseCase(repo, bus)
handler := handler.NewOrderHandler(useCase)
return &Container{
OrderRepo: repo,
EventBus: bus,
ConfirmUseCase: useCase,
OrderHandler: handler,
}
}
// Testing: swap implementations
func TestConfirmOrder(t *testing.T) {
mockRepo := &MockOrderRepository{}
mockBus := &MockEventBus{}
useCase := service.NewConfirmOrderUseCase(mockRepo, mockBus)
// Test pure use case logic, no DB/framework
output, err := useCase.Execute(context.Background(), input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
DI Frameworks (Go):
uber/fx— annotation-basedgoogle/wire— code generation- Manual (most explicit, recommended for small projects)
Common Pitfalls ⚠️
1. Layers Are Not Folders
// ❌ WRONG: Treating layer = folder
myapp/
├── layer1/
├── layer2/
└── layer3/
// ✓ RIGHT: Layers are logical, folders are by domain
myapp/
├── domain/
│ ├── order/
│ ├── payment/
│ └── customer/
├── application/
├── interface/
└── infrastructure/
2. Handler Too Smart
// ❌ WRONG: Business logic in handler
func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
order, _ := h.orderRepo.FindByID(c.Param("id"))
// ← Business logic here!
if len(order.Items) == 0 {
return error
}
order.Status = "confirmed"
h.orderRepo.Save(order)
}
// ✓ RIGHT: Business logic in domain, handler is thin
func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
input := service.ConfirmOrderInput{OrderID: c.Param("id")}
output, err := h.confirmOrderUseCase.Execute(c.Request.Context(), input)
if err != nil {
c.JSON(400, err)
return
}
c.JSON(200, output)
}
3. Leaky Abstraction
// ❌ WRONG: Repository exposes SQL error
func (r *OrderRepository) FindByID(id string) (*Order, error) {
// User sees: pq: column "status" does not exist (SQL error)
return nil, fmt.Errorf("pq: %w", err)
}
// ✓ RIGHT: Repository translates error
func (r *OrderRepository) FindByID(id string) (*Order, error) {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("order not found") // ← Domain error
}
return nil, errors.New("database error")
}
4. Circular Dependencies
application → domain ← infrastructure
↓
❌ PROBLEM: infrastructure imports application
Solution: Repository interface dalam domain, implement dalam infrastructure.
5. Too Many Layers
// ❌ OVERKILL: 6+ layers untuk simple CRUD
Request
↓ Handler
↓ Router
↓ Validator
↓ Transformer
↓ UseCase
↓ Service
↓ Helper
↓ Repository
↓ Database
// ✓ PRAGMATIC: 3-4 layers + clear responsibilities
Request
↓ Handler (HTTP ↔ Domain)
↓ UseCase (Orchestration)
↓ Domain (Business logic)
↓ Repository (Domain ↔ DB)
Tóm tắt
| Layer | Input | Process | Output | Import |
|---|---|---|---|---|
| Domain | - | Pure business logic | Objects | Nothing |
| Application | UseCase input | Orchestrate domain | UseCase output | Domain |
| Interface | External format | Convert ↔ domain | Response | Application |
| Infrastructure | Nothing | Implement port | Side effect | Nothing (or external libs) |
The Dependency Rule: Inner layers ≠ depend on outer layers. Always depends inward.
Bước tiếp theo
- Design Principles: SOLID principles để keep layers thin
- Testing: Unit test domain, integration test infrastructure
- System Design: When to split layers into services