⚙️ Software✍️ Khoa📅 20/04/2026☕ 9 phút đọc

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:

  1. Test domain = test gin (slow, complicated)
  2. Change web framework = must refactor domain
  3. 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 imports
  • application/: Imports domain + port interfaces
  • interface/: 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-based
  • google/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