土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】六边形、洋葱与整洁架构:端口与适配器的统一视角

文章导航

分类入口
architecture
标签入口
#hexagonal-architecture#ports-adapters#clean-architecture#onion-architecture#dependency-rule

目录

一个支付系统上线两年后,产品经理提出需求:接入一家新的支付渠道。技术负责人评估后给出排期——六周。原因不是业务逻辑复杂,而是支付调用的代码散落在三个 Controller、两个 Service 和五个 DAO 里,改动任何一处都可能影响现有渠道的正常运行。系统没有集成测试,没人敢保证改完之后老渠道还能正常工作。

这类问题在业务系统中反复出现:更换数据库需要改业务逻辑,切换消息队列需要改领域模型,引入新的 UI 框架需要改应用服务。根源在于——业务逻辑和技术细节混在一起,没有清晰的边界。

2005 年,Alistair Cockburn 提出六边形架构(Hexagonal Architecture)。三年后 Jeffrey Palermo 提出洋葱架构(Onion Architecture),2012 年 Robert C. Martin 提出整洁架构(Clean Architecture)。三种架构名称不同、图示不同,但核心思想高度一致:用端口和适配器把业务逻辑与技术细节隔离开,通过依赖规则确保内层代码永远不依赖外层。

本文从三种架构的起源出发,提取它们共享的统一规则,用 Go 和 Java 两种语言给出完整的落地实现。


导航:上一篇:CQRS | 下一篇:管道-过滤器


一、三种架构的起源与动机

1.1 六边形架构:Alistair Cockburn,2005

Alistair Cockburn 在 2005 年发表了《Hexagonal Architecture》一文,提出了端口与适配器(Ports and Adapters)模式。他后来多次强调,“六边形”只是为了画图方便——用六边形代替矩形分层图,让人不再纠结”上下”关系。真正重要的概念是端口(Port)和适配器(Adapter)。

Cockburn 的动机非常明确:让应用程序不依赖于任何外部技术细节。无论外部是 Web 界面、测试脚本、批处理程序,还是另一个系统的 API 调用——核心逻辑应该完全一样。

他定义了两个核心概念:

Cockburn 强调的关键点是对称性:应用的左侧(驱动侧)和右侧(被驱动侧)在结构上对称,都通过端口与外部交互。这打破了传统分层架构中”上层调用下层”的单向思维。

1.2 洋葱架构:Jeffrey Palermo,2008

Jeffrey Palermo 在 2008 年提出洋葱架构(Onion Architecture),可以看作六边形架构的进一步细化。Cockburn 的模型没有对应用内部层次做详细划分——他只关心”内部”和”外部”的边界。Palermo 则明确定义了内部层次:

核心规则:依赖只能向内指。外层可以依赖内层,内层绝对不能依赖外层。领域模型不知道数据库的存在,应用服务不知道 HTTP 框架的存在。

1.3 整洁架构:Robert C. Martin,2012

Robert C. Martin(Uncle Bob)在 2012 年的博客文章中综合了六边形架构、洋葱架构等模式,提出整洁架构(Clean Architecture),2017 年出版同名专著做了详细阐述。

整洁架构用四层同心圆表示:

Martin 的核心贡献是形式化的依赖规则(Dependency Rule):

源代码依赖只能指向内层。内层的任何东西都不能知道外层的任何东西——包括函数、类、变量,以及任何软件实体。

1.4 演进时间线

三种架构一脉相承:2005 年 Cockburn 解决”应用与外部技术解耦”;2008 年 Palermo 解决”应用内部如何分层”;2012 年 Martin 解决”如何形式化并统一这些规则”。它们不是竞争关系,而是演进关系。理解这一点,就不会在项目中纠结”该用哪种”——因为本质上是同一个东西的不同表述。


二、统一视角:依赖规则

2.1 共同核心

剥去名称和图示差异,三种架构共享一条核心规则:外层依赖内层,内层不知道外层的存在。

这条规则可以概括所有三种架构的本质。无论画的是六边形、同心圆还是洋葱,只要遵守这条规则,就具备了三种架构要提供的所有好处。

2.2 依赖反转原则的架构级体现

依赖反转原则(Dependency Inversion Principle,DIP)是 SOLID 中的第五条。在类级别,DIP 指导使用接口和依赖注入;在架构级别,DIP 指导定义端口(抽象)并让外层提供适配器(细节)。

具体来说:应用核心定义接口 OrderRepository(端口),声明”我需要存储订单”这个能力。PostgresOrderRepository(适配器)实现这个接口并依赖它,而不是反过来。应用核心永远不需要 import "database/sql"import javax.persistence

2.3 统一依赖结构

下面这张图展示了三种架构的统一依赖结构,箭头方向代表依赖指向:

graph TB
    subgraph 最外层 - 框架与驱动
        A[HTTP 框架]
        B[数据库驱动]
        C[消息队列客户端]
    end

    subgraph 适配器层
        E[HTTP Controller<br>驱动适配器]
        F[PostgreSQL Repository<br>被驱动适配器]
        G[Kafka Producer<br>被驱动适配器]
    end

    subgraph 应用服务层
        I[用例 / 应用服务]
    end

    subgraph 领域核心
        J[实体 / 值对象]
        K[领域服务]
        L[端口接口]
    end

    A --> E
    B --> F
    C --> G
    E --> I
    F --> L
    G --> L
    I --> J
    I --> K
    I --> L
    K --> J

领域核心位于最内层,包含实体、值对象、领域服务和端口接口;应用服务通过端口接口与外部交互,只知接口不知实现;适配器实现端口接口,将技术细节转换为端口要求的格式。

2.4 依赖规则比层次划分更重要

实际项目中,到底分三层还是四层、领域服务和应用服务是否需要分开——重要性远低于依赖规则本身。一个分了四层但依赖方向混乱的项目,不会比只分两层但严格遵守依赖规则的项目更好。层次划分是组织手段,依赖规则才是约束本质。

小项目中,把领域服务和应用服务合并为一层完全合理。大项目中,可能需要在适配器层内部再做细分。关键是:无论怎么分,依赖方向不能错。


三、端口与适配器详解

3.1 驱动端口与被驱动端口

驱动端口(Driving Port / Primary Port)是外部世界调用应用的接口,定义应用对外提供的能力——“我能做什么”。典型的驱动端口如 CreateOrderCancelOrderGetOrderStatus。驱动端口通常就是应用服务的接口。

被驱动端口(Driven Port / Secondary Port)是应用调用外部世界的接口,定义应用需要的外部能力——“我需要什么”。典型如 SaveOrderSendNotificationChargePayment。被驱动端口由领域核心定义,由外层适配器实现——这正是依赖反转的关键。

3.2 驱动适配器与被驱动适配器

驱动适配器将外部请求转换为对驱动端口的调用:HTTP Controller、gRPC Handler、CLI 命令、消息消费者、定时任务。其职责是单一的协议转换,不应包含业务逻辑。如果 Controller 里有 if-else 分支处理业务规则,就是业务逻辑泄漏到了适配器层。

被驱动适配器实现被驱动端口:PostgreSQL Repository、Redis Cache、Kafka Producer、HTTP Client、In-Memory Repository(测试用)。

3.3 端口粒度设计原则

端口粒度太粗——一个 Repository 接口包含 30 个方法——违反接口隔离原则(Interface Segregation Principle,ISP),mock 测试困难。端口粒度太细——每个调用一个端口——导致接口数量爆炸,增加理解成本。

合理的粒度原则:

Go 语言的接口天然倾向小接口,与端口设计理念高度契合。Java 接口可通过继承组合小接口来实现灵活的粒度控制。


四、Go 语言实现

以简化的订单管理系统为例,展示六边形架构在 Go 中的完整落地。

4.1 项目结构

order-service/
  cmd/server/main.go            # 入口,依赖注入
  internal/
    domain/
      order.go                  # 领域模型
      errors.go                 # 领域错误
    port/
      driving.go                # 驱动端口
      driven.go                 # 被驱动端口
    app/
      order_service.go          # 应用服务
    adapter/
      driving/http/
        handler.go              # HTTP 驱动适配器
      driven/
        postgres/order_repo.go  # PostgreSQL 适配器
        memory/order_repo.go    # 内存适配器(测试用)

关键设计:internal 包利用 Go 的编译级访问控制;domain 包不 import 外部依赖;port 包只含接口定义。

4.2 领域模型

// internal/domain/order.go
package domain

import (
    "errors"
    "time"
)

type OrderStatus string

const (
    OrderStatusPending   OrderStatus = "pending"
    OrderStatusConfirmed OrderStatus = "confirmed"
    OrderStatusPaid      OrderStatus = "paid"
    OrderStatusCancelled OrderStatus = "cancelled"
)

type OrderItem struct {
    ProductID string
    Name      string
    Price     int64
    Quantity  int
}

type Order struct {
    ID         string
    CustomerID string
    Items      []OrderItem
    Status     OrderStatus
    TotalPrice int64
    CreatedAt  time.Time
    UpdatedAt  time.Time
}

func NewOrder(id, customerID string, items []OrderItem) (*Order, error) {
    if id == "" {
        return nil, errors.New("order id is required")
    }
    if customerID == "" {
        return nil, errors.New("customer id is required")
    }
    if len(items) == 0 {
        return nil, errors.New("order must have at least one item")
    }
    var total int64
    for _, item := range items {
        if item.Price <= 0 || item.Quantity <= 0 {
            return nil, errors.New("item price and quantity must be positive")
        }
        total += item.Price * int64(item.Quantity)
    }
    now := time.Now()
    return &Order{
        ID: id, CustomerID: customerID, Items: items,
        Status: OrderStatusPending, TotalPrice: total,
        CreatedAt: now, UpdatedAt: now,
    }, nil
}

func (o *Order) Confirm() error {
    if o.Status != OrderStatusPending {
        return ErrInvalidStatusTransition
    }
    o.Status = OrderStatusConfirmed
    o.UpdatedAt = time.Now()
    return nil
}

func (o *Order) MarkPaid() error {
    if o.Status != OrderStatusConfirmed {
        return ErrInvalidStatusTransition
    }
    o.Status = OrderStatusPaid
    o.UpdatedAt = time.Now()
    return nil
}

func (o *Order) Cancel() error {
    if o.Status == OrderStatusPaid {
        return ErrCannotCancelPaidOrder
    }
    if o.Status == OrderStatusCancelled {
        return ErrOrderAlreadyCancelled
    }
    o.Status = OrderStatusCancelled
    o.UpdatedAt = time.Now()
    return nil
}
// internal/domain/errors.go
package domain

import "errors"

var (
    ErrOrderNotFound           = errors.New("order not found")
    ErrInvalidStatusTransition = errors.New("invalid order status transition")
    ErrCannotCancelPaidOrder   = errors.New("cannot cancel a paid order")
    ErrOrderAlreadyCancelled   = errors.New("order is already cancelled")
)

领域模型不依赖任何框架包,业务规则封装在实体方法中,状态转换合法性由领域模型自身校验。

4.3 端口定义

// internal/port/driving.go
package port

import (
    "context"
    "order-service/internal/domain"
)

type OrderService interface {
    CreateOrder(ctx context.Context, customerID string, items []domain.OrderItem) (*domain.Order, error)
    GetOrder(ctx context.Context, orderID string) (*domain.Order, error)
    ConfirmOrder(ctx context.Context, orderID string) error
    CancelOrder(ctx context.Context, orderID string) error
}
// internal/port/driven.go
package port

import (
    "context"
    "order-service/internal/domain"
)

type OrderRepository interface {
    Save(ctx context.Context, order *domain.Order) error
    FindByID(ctx context.Context, orderID string) (*domain.Order, error)
    Update(ctx context.Context, order *domain.Order) error
}

type EventPublisher interface {
    PublishOrderCreated(ctx context.Context, order *domain.Order) error
    PublishOrderCancelled(ctx context.Context, orderID string) error
}

接口只使用领域类型和标准库类型,不引入外部技术依赖。

4.4 应用服务

// internal/app/order_service.go
package app

import (
    "context"
    "fmt"
    "order-service/internal/domain"
    "order-service/internal/port"

    "github.com/google/uuid"
)

type orderService struct {
    repo      port.OrderRepository
    publisher port.EventPublisher
}

func NewOrderService(repo port.OrderRepository, pub port.EventPublisher) port.OrderService {
    return &orderService{repo: repo, publisher: pub}
}

func (s *orderService) CreateOrder(ctx context.Context, customerID string, items []domain.OrderItem) (*domain.Order, error) {
    order, err := domain.NewOrder(uuid.New().String(), customerID, items)
    if err != nil {
        return nil, fmt.Errorf("create order: %w", err)
    }
    if err := s.repo.Save(ctx, order); err != nil {
        return nil, fmt.Errorf("save order: %w", err)
    }
    _ = s.publisher.PublishOrderCreated(ctx, order)
    return order, nil
}

func (s *orderService) GetOrder(ctx context.Context, orderID string) (*domain.Order, error) {
    return s.repo.FindByID(ctx, orderID)
}

func (s *orderService) ConfirmOrder(ctx context.Context, orderID string) error {
    order, err := s.repo.FindByID(ctx, orderID)
    if err != nil {
        return fmt.Errorf("find order: %w", err)
    }
    if err := order.Confirm(); err != nil {
        return fmt.Errorf("confirm order: %w", err)
    }
    return s.repo.Update(ctx, order)
}

func (s *orderService) CancelOrder(ctx context.Context, orderID string) error {
    order, err := s.repo.FindByID(ctx, orderID)
    if err != nil {
        return fmt.Errorf("find order: %w", err)
    }
    if err := order.Cancel(); err != nil {
        return fmt.Errorf("cancel order: %w", err)
    }
    if err := s.repo.Update(ctx, order); err != nil {
        return fmt.Errorf("update order: %w", err)
    }
    _ = s.publisher.PublishOrderCancelled(ctx, orderID)
    return nil
}

应用服务编排领域对象和端口调用,不含业务规则,不含技术细节。

4.5 适配器实现

HTTP 驱动适配器

// internal/adapter/driving/http/handler.go
package http

import (
    "encoding/json"
    "errors"
    "net/http"
    "order-service/internal/domain"
    "order-service/internal/port"
)

type OrderHandler struct {
    service port.OrderService
}

func NewOrderHandler(svc port.OrderService) *OrderHandler {
    return &OrderHandler{service: svc}
}

func (h *OrderHandler) RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("POST /orders", h.CreateOrder)
    mux.HandleFunc("GET /orders/{id}", h.GetOrder)
    mux.HandleFunc("POST /orders/{id}/confirm", h.ConfirmOrder)
    mux.HandleFunc("POST /orders/{id}/cancel", h.CancelOrder)
}

func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    var req struct {
        CustomerID string             `json:"customer_id"`
        Items      []domain.OrderItem `json:"items"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid request body")
        return
    }
    order, err := h.service.CreateOrder(r.Context(), req.CustomerID, req.Items)
    if err != nil {
        writeError(w, http.StatusInternalServerError, err.Error())
        return
    }
    writeJSON(w, http.StatusCreated, order)
}

func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    order, err := h.service.GetOrder(r.Context(), r.PathValue("id"))
    if err != nil {
        if errors.Is(err, domain.ErrOrderNotFound) {
            writeError(w, http.StatusNotFound, "order not found")
            return
        }
        writeError(w, http.StatusInternalServerError, err.Error())
        return
    }
    writeJSON(w, http.StatusOK, order)
}

func (h *OrderHandler) ConfirmOrder(w http.ResponseWriter, r *http.Request) {
    if err := h.service.ConfirmOrder(r.Context(), r.PathValue("id")); err != nil {
        writeError(w, http.StatusInternalServerError, err.Error())
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func (h *OrderHandler) CancelOrder(w http.ResponseWriter, r *http.Request) {
    if err := h.service.CancelOrder(r.Context(), r.PathValue("id")); err != nil {
        writeError(w, http.StatusInternalServerError, err.Error())
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, map[string]string{"error": msg})
}

PostgreSQL 被驱动适配器

// internal/adapter/driven/postgres/order_repo.go
package postgres

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "order-service/internal/domain"
)

type OrderRepository struct {
    db *sql.DB
}

func NewOrderRepository(db *sql.DB) *OrderRepository {
    return &OrderRepository{db: db}
}

func (r *OrderRepository) Save(ctx context.Context, order *domain.Order) error {
    itemsJSON, err := json.Marshal(order.Items)
    if err != nil {
        return fmt.Errorf("marshal items: %w", err)
    }
    _, err = r.db.ExecContext(ctx,
        "INSERT INTO orders (id, customer_id, items, status, total_price, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7)",
        order.ID, order.CustomerID, itemsJSON,
        string(order.Status), order.TotalPrice, order.CreatedAt, order.UpdatedAt)
    return err
}

func (r *OrderRepository) FindByID(ctx context.Context, orderID string) (*domain.Order, error) {
    var o domain.Order
    var status string
    var itemsJSON []byte
    err := r.db.QueryRowContext(ctx,
        "SELECT id, customer_id, items, status, total_price, created_at, updated_at FROM orders WHERE id = $1",
        orderID).Scan(&o.ID, &o.CustomerID, &itemsJSON, &status, &o.TotalPrice, &o.CreatedAt, &o.UpdatedAt)
    if err == sql.ErrNoRows {
        return nil, domain.ErrOrderNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("query order: %w", err)
    }
    o.Status = domain.OrderStatus(status)
    if err := json.Unmarshal(itemsJSON, &o.Items); err != nil {
        return nil, fmt.Errorf("unmarshal items: %w", err)
    }
    return &o, nil
}

func (r *OrderRepository) Update(ctx context.Context, order *domain.Order) error {
    result, err := r.db.ExecContext(ctx,
        "UPDATE orders SET status = $1, updated_at = $2 WHERE id = $3",
        string(order.Status), order.UpdatedAt, order.ID)
    if err != nil {
        return fmt.Errorf("update order: %w", err)
    }
    rows, _ := result.RowsAffected()
    if rows == 0 {
        return domain.ErrOrderNotFound
    }
    return nil
}

4.6 依赖注入

// cmd/server/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"

    "order-service/internal/adapter/driven/postgres"
    httpAdapter "order-service/internal/adapter/driving/http"
    "order-service/internal/app"

    _ "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("postgres",
        "postgres://user:pass@localhost:5432/orders?sslmode=disable")
    if err != nil {
        log.Fatalf("connect db: %v", err)
    }
    defer db.Close()

    repo := postgres.NewOrderRepository(db)
    svc := app.NewOrderService(repo, &noopPublisher{})

    handler := httpAdapter.NewOrderHandler(svc)
    mux := http.NewServeMux()
    handler.RegisterRoutes(mux)
    log.Fatal(http.ListenAndServe(":8080", mux))
}

main 函数是唯一知道所有具体类型的地方。应用核心完全不知道 PostgreSQL 和 HTTP 的存在。


五、Java/Spring 实现

5.1 项目结构

com.example.order/
  domain/
    model/         Order.java, OrderItem.java, OrderStatus.java
    exception/     OrderNotFoundException.java
  port/
    driving/       OrderService.java
    driven/        OrderRepository.java, EventPublisher.java
  application/     OrderServiceImpl.java
  adapter/
    driving/web/   OrderController.java
    driven/
      persistence/ JdbcOrderRepository.java
      messaging/   KafkaEventPublisher.java

5.2 领域模型

// domain/model/Order.java
package com.example.order.domain.model;

import com.example.order.domain.exception.InvalidStatusTransitionException;
import java.time.Instant;
import java.util.List;

public class Order {
    private final String id;
    private final String customerId;
    private final List<OrderItem> items;
    private OrderStatus status;
    private final long totalPrice;
    private final Instant createdAt;
    private Instant updatedAt;

    private Order(String id, String customerId, List<OrderItem> items,
                  long totalPrice, Instant createdAt) {
        this.id = id;
        this.customerId = customerId;
        this.items = List.copyOf(items);
        this.status = OrderStatus.PENDING;
        this.totalPrice = totalPrice;
        this.createdAt = createdAt;
        this.updatedAt = createdAt;
    }

    public static Order create(String id, String customerId, List<OrderItem> items) {
        if (id == null || id.isBlank())
            throw new IllegalArgumentException("Order ID is required");
        if (items == null || items.isEmpty())
            throw new IllegalArgumentException("Order must have at least one item");
        long total = items.stream()
                .mapToLong(i -> i.price() * i.quantity()).sum();
        return new Order(id, customerId, items, total, Instant.now());
    }

    public void confirm() {
        if (this.status != OrderStatus.PENDING)
            throw new InvalidStatusTransitionException(this.status, OrderStatus.CONFIRMED);
        this.status = OrderStatus.CONFIRMED;
        this.updatedAt = Instant.now();
    }

    public void cancel() {
        if (this.status == OrderStatus.PAID)
            throw new InvalidStatusTransitionException(this.status, OrderStatus.CANCELLED);
        if (this.status == OrderStatus.CANCELLED)
            throw new InvalidStatusTransitionException(this.status, OrderStatus.CANCELLED);
        this.status = OrderStatus.CANCELLED;
        this.updatedAt = Instant.now();
    }

    public String getId() { return id; }
    public String getCustomerId() { return customerId; }
    public List<OrderItem> getItems() { return items; }
    public OrderStatus getStatus() { return status; }
    public long getTotalPrice() { return totalPrice; }
    public Instant getCreatedAt() { return createdAt; }
    public Instant getUpdatedAt() { return updatedAt; }
}
// domain/model/OrderItem.java
package com.example.order.domain.model;

public record OrderItem(String productId, String name, long price, int quantity) {
    public OrderItem {
        if (price <= 0) throw new IllegalArgumentException("Price must be positive");
        if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
    }
}

5.3 端口与应用服务

// port/driving/OrderService.java
public interface OrderService {
    Order createOrder(String customerId, List<OrderItem> items);
    Order getOrder(String orderId);
    void confirmOrder(String orderId);
    void cancelOrder(String orderId);
}
// port/driven/OrderRepository.java
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(String orderId);
    void update(Order order);
}
// application/OrderServiceImpl.java
package com.example.order.application;

import com.example.order.domain.exception.OrderNotFoundException;
import com.example.order.domain.model.*;
import com.example.order.port.driven.*;
import com.example.order.port.driving.OrderService;
import java.util.List;
import java.util.UUID;

public class OrderServiceImpl implements OrderService {
    private final OrderRepository repository;
    private final EventPublisher publisher;

    public OrderServiceImpl(OrderRepository repository, EventPublisher publisher) {
        this.repository = repository;
        this.publisher = publisher;
    }

    @Override
    public Order createOrder(String customerId, List<OrderItem> items) {
        Order order = Order.create(UUID.randomUUID().toString(), customerId, items);
        repository.save(order);
        publisher.publishOrderCreated(order);
        return order;
    }

    @Override
    public Order getOrder(String orderId) {
        return repository.findById(orderId)
                .orElseThrow(() -> new OrderNotFoundException(orderId));
    }

    @Override
    public void confirmOrder(String orderId) {
        Order order = getOrder(orderId);
        order.confirm();
        repository.update(order);
    }

    @Override
    public void cancelOrder(String orderId) {
        Order order = getOrder(orderId);
        order.cancel();
        repository.update(order);
        publisher.publishOrderCancelled(orderId);
    }
}

5.4 适配器

// adapter/driving/web/OrderController.java
@RestController
@RequestMapping("/orders")
public class OrderController {
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody CreateOrderRequest req) {
        Order order = orderService.createOrder(req.customerId(), req.toItems());
        return ResponseEntity.status(HttpStatus.CREATED).body(order);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Order> get(@PathVariable String id) {
        return ResponseEntity.ok(orderService.getOrder(id));
    }

    @PostMapping("/{id}/confirm")
    public ResponseEntity<Void> confirm(@PathVariable String id) {
        orderService.confirmOrder(id);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/{id}/cancel")
    public ResponseEntity<Void> cancel(@PathVariable String id) {
        orderService.cancelOrder(id);
        return ResponseEntity.noContent().build();
    }
}

5.5 Spring 依赖注入与端口-适配器

Spring 的依赖注入天然支持端口-适配器。容器扫描到实现了端口接口的 @Repository@Component 类,自动注入到需要该接口的地方。也可以显式配置:

// 显式 wire-up
@Configuration
public class OrderConfig {
    @Bean
    public OrderService orderService(OrderRepository repo, EventPublisher pub) {
        return new OrderServiceImpl(repo, pub);
    }
}

OrderServiceImpl 完全不依赖 Spring——构造函数只接收端口接口。Spring 只在最外层配置类出现。

5.6 Go 与 Java 对比


六、依赖规则的严格执行

架构规则只靠口头约定和代码评审维护,长期一定会被打破。新成员加入、工期紧张、“临时” workaround 变永久代码——依赖规则需要自动化检查来保障。

6.1 Java:ArchUnit 规则

// ArchitectureTest.java
class ArchitectureTest {
    private static JavaClasses classes;

    @BeforeAll
    static void setup() {
        classes = new ClassFileImporter().importPackages("com.example.order");
    }

    @Test
    void domain_should_not_depend_on_adapter() {
        noClasses().that().resideInAPackage("..domain..")
                .should().dependOnClassesThat().resideInAPackage("..adapter..")
                .check(classes);
    }

    @Test
    void domain_should_not_depend_on_spring() {
        noClasses().that().resideInAPackage("..domain..")
                .should().dependOnClassesThat().resideInAPackage("org.springframework..")
                .check(classes);
    }

    @Test
    void application_should_not_depend_on_adapter() {
        noClasses().that().resideInAPackage("..application..")
                .should().dependOnClassesThat().resideInAPackage("..adapter..")
                .check(classes);
    }

    @Test
    void port_should_not_depend_on_adapter() {
        noClasses().that().resideInAPackage("..port..")
                .should().dependOnClassesThat().resideInAPackage("..adapter..")
                .check(classes);
    }
}

6.2 Go:depguard 配置

# .golangci.yml
linters:
  enable:
    - depguard
linters-settings:
  depguard:
    rules:
      domain-isolation:
        files:
          - "**/internal/domain/**"
        deny:
          - pkg: "order-service/internal/adapter"
            desc: "domain must not depend on adapter"
          - pkg: "database/sql"
            desc: "domain must not depend on database/sql"
      app-isolation:
        files:
          - "**/internal/app/**"
        deny:
          - pkg: "order-service/internal/adapter"
            desc: "app must not depend on adapter"

6.3 CI 集成

架构检查应作为 CI 步骤,与单元测试并行执行:

# .github/workflows/ci.yml
jobs:
  architecture-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: ArchUnit tests
        run: mvn test -Dtest=ArchitectureTest
      - name: Go architecture lint
        run: golangci-lint run --config .golangci.yml ./...

6.4 为什么约定不够

没有自动化检查的项目中,依赖规则违反遵循固定模式:项目初期严格遵守;三个月后新成员偶尔遗漏违规 import;六个月后有人”临时”在领域层引用数据库包;一年后”临时”代码变永久,更多人效仿;两年后内外层纠缠,回到出发前。自动化检查成本低(配置一次),收益持续。


七、案例分析:支付网关系统的架构演进

7.1 初始状态

某电商公司的支付网关上线于 2019 年,初始只支持支付宝和微信两个渠道。Spring Boot 开发,典型三层架构。支付逻辑直接写在 Service 层,与支付渠道 SDK 调用交织:

// 重构前的 PaymentService(简化示意)
@Service
public class PaymentService {
    @Autowired private AlipayClient alipayClient;
    @Autowired private WxPayClient wxPayClient;
    @Autowired private PaymentDao paymentDao;

    public PaymentResult pay(String orderId, BigDecimal amount, String channel) {
        PaymentRecord record = new PaymentRecord(orderId, amount, channel);
        paymentDao.insert(record);

        if ("ALIPAY".equals(channel)) {
            AlipayTradePayRequest req = new AlipayTradePayRequest();
            req.setBizContent("{\"out_trade_no\":\"" + orderId
                + "\",\"total_amount\":\"" + amount + "\"}");
            AlipayTradePayResponse resp = alipayClient.execute(req);
            record.setStatus(resp.isSuccess() ? "SUCCESS" : "FAILED");
            record.setTransactionId(resp.getTradeNo());
        } else if ("WXPAY".equals(channel)) {
            Map<String, String> data = Map.of(
                "out_trade_no", orderId,
                "total_fee", String.valueOf(amount.multiply(BigDecimal.valueOf(100)).intValue()));
            Map<String, String> result = wxPayClient.unifiedOrder(data);
            record.setStatus(
                "SUCCESS".equals(result.get("result_code")) ? "SUCCESS" : "FAILED");
            record.setTransactionId(result.get("transaction_id"));
        }
        paymentDao.update(record);
        return new PaymentResult(record.getStatus(), record.getTransactionId());
    }
}

7.2 问题爆发

2021 年需要接入银联和 Apple Pay,并支持同一订单拆分到不同渠道支付。每接入一个新渠道需要添加 if-else 分支;修改任何一行可能影响所有渠道;无法对单渠道独立做单元测试;SDK 升级需要改业务代码。

7.3 引入端口-适配器

第一步,定义支付端口:

public interface PaymentChannelPort {
    String channelName();
    PaymentChannelResult charge(String orderId, BigDecimal amount);
    PaymentChannelResult refund(String transactionId, BigDecimal amount);
}

第二步,迁移现有逻辑为适配器:

@Component
public class AlipayChannelAdapter implements PaymentChannelPort {
    private final AlipayClient alipayClient;

    public AlipayChannelAdapter(AlipayClient alipayClient) {
        this.alipayClient = alipayClient;
    }

    @Override
    public String channelName() { return "ALIPAY"; }

    @Override
    public PaymentChannelResult charge(String orderId, BigDecimal amount) {
        AlipayTradePayRequest req = new AlipayTradePayRequest();
        req.setBizContent("{\"out_trade_no\":\"" + orderId
            + "\",\"total_amount\":\"" + amount + "\"}");
        try {
            AlipayTradePayResponse resp = alipayClient.execute(req);
            return resp.isSuccess()
                ? PaymentChannelResult.success(resp.getTradeNo())
                : PaymentChannelResult.failure(resp.getSubMsg());
        } catch (AlipayApiException e) {
            return PaymentChannelResult.failure(e.getMessage());
        }
    }

    @Override
    public PaymentChannelResult refund(String txnId, BigDecimal amount) {
        // 退款逻辑类似,调用 alipayClient 的退款接口
        return PaymentChannelResult.success(txnId);
    }
}

第三步,重写应用服务:

@Service
public class PaymentService {
    private final Map<String, PaymentChannelPort> channels;
    private final PaymentRepository paymentRepository;

    public PaymentService(List<PaymentChannelPort> channelList,
                          PaymentRepository paymentRepository) {
        this.channels = channelList.stream()
                .collect(Collectors.toMap(PaymentChannelPort::channelName, c -> c));
        this.paymentRepository = paymentRepository;
    }

    public PaymentResult pay(String orderId, BigDecimal amount, String channelName) {
        PaymentChannelPort channel = channels.get(channelName);
        if (channel == null) throw new UnsupportedChannelException(channelName);

        Payment payment = Payment.create(orderId, amount, channelName);
        paymentRepository.save(payment);

        PaymentChannelResult result = channel.charge(orderId, amount);
        payment.applyResult(result);
        paymentRepository.update(payment);
        return payment.toResult();
    }
}

7.4 重构效果


八、三种架构的细微差异与选择

8.1 对比分析

维度 六边形架构 洋葱架构 整洁架构
提出时间 2005 2008 2012
核心概念 端口与适配器 同心圆分层 依赖规则 + 四层同心圆
内部层次 未明确,只区分内/外 四层:Domain Model、Domain Services、Application Services、Infrastructure 四层:Entities、Use Cases、Interface Adapters、Frameworks
端口概念 明确定义驱动/被驱动端口 隐含在层间接口中 用 Interface Adapters 层处理
对称性 强调左右对称 不强调 不强调
测试策略 替换适配器实现测试 从内向外逐层测试 通过 boundary crossing 测试
学习曲线 中等 中等 较高
社区资源 大量博客与示例 集中在 .NET 社区 最多,有专著和大量演讲

8.2 选择建议

大多数项目中三种架构差异不大:

8.3 与分层架构的关系

传统分层架构(Layered Architecture)是”从上到下”的依赖:Presentation → Business Logic → Data Access。业务逻辑层依赖数据访问层——业务逻辑知道数据库的存在。

六边形/洋葱/整洁架构的关键区别:反转内层对外层的依赖。业务逻辑定义接口(端口),数据访问层实现接口(适配器)。依赖方向从”业务 → 数据库”变成”数据库实现 → 业务接口”。

这个反转带来的最大好处是可测试性:业务逻辑可以在完全没有数据库的情况下测试。并非所有项目都需要这种反转——简单 CRUD 应用中传统分层完全够用。端口-适配器的价值在业务逻辑足够复杂、外部集成多、需要独立测试时才真正体现。


九、常见误区与反模式

9.1 误区一:把目录结构等同于架构

最常见的误区:认为”按 domain、port、adapter 分好目录就是六边形架构”。目录结构只是组织手段,架构本质是依赖规则。一个按六边形组织目录但 domain 包 import 了 database/sql 的项目,不是六边形架构。判断标准:删除所有适配器代码后,领域层和应用服务层是否还能编译通过。

9.2 误区二:过度抽象

另一个极端:为每个外部调用都创建端口。为读取配置文件创建 ConfigPort,为获取时间创建 ClockPort,为生成 UUID 创建 IDGeneratorPort。这大幅增加代码量和理解成本,收益微乎其微。端口的创建应基于务实判断:这个外部依赖在可预见的未来是否可能被替换,或需要在测试中被隔离?时钟和 ID 生成器只在需要确定性测试时才值得抽象。

9.3 误区三:领域模型贫血

贫血领域模型(Anemic Domain Model)是 Martin Fowler 在 2003 年描述的反模式:领域对象只有数据字段和 getter/setter,没有行为,所有业务逻辑都在 Service 层。在端口-适配器架构中这尤其有害——如果领域层没有值得保护的业务逻辑,“领域层不依赖外层”就失去意义。

正确做法:订单状态转换规则在 Order 实体中,价格计算在值对象中,业务校验在工厂方法中。应用服务负责编排,领域模型负责决策。“订单能否取消”是领域模型的责任,“取消后通知用户”是应用服务的责任。

9.4 误区四:忽略应用服务层

有些项目让 Controller 直接调用领域模型,或让领域模型处理编排逻辑。前者导致编排逻辑(查询 → 检查 → 支付 → 更新 → 通知)无法被其他驱动适配器复用。后者让领域模型为编排引入对端口的依赖,破坏领域层纯净性。应用服务层是连接驱动端口和被驱动端口的桥梁,是用例逻辑的唯一归属地。


十、总结

六边形架构、洋葱架构和整洁架构是同一核心思想的三种表述:用依赖规则隔离业务逻辑与技术细节

核心可压缩为三条规则:

  1. 业务逻辑定义接口(端口),外部技术实现接口(适配器)
  2. 依赖方向只能从外向内,内层不知道外层的存在
  3. 应用入口负责组装所有依赖

实践要点:用自动化工具(ArchUnit、depguard、CI 流水线)强制执行依赖规则;避免过度抽象,只为可能替换或需要测试隔离的外部依赖创建端口;让领域模型承载行为,避免贫血模型;保持应用服务层作为编排的唯一位置。

这三种架构不是银弹。简单 CRUD 用传统分层即可;业务复杂、外部集成多、测试要求高的系统,端口-适配器能显著降低变更成本。选择时关注依赖规则本身,不纠结叫”六边形”还是”洋葱”还是”整洁”——三个名字,一条规则。


参考资料

  1. Alistair Cockburn, “Hexagonal Architecture”, 2005. https://alistair.cockburn.us/hexagonal-architecture/
  2. Jeffrey Palermo, “The Onion Architecture: Part 1”, 2008. https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/
  3. Robert C. Martin, “The Clean Architecture”, 2012. https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
  4. Robert C. Martin, “Clean Architecture: A Craftsman’s Guide to Software Structure and Design”, Prentice Hall, 2017.
  5. Martin Fowler, “AnemicDomainModel”, 2003. https://martinfowler.com/bliki/AnemicDomainModel.html
  6. Mark Richards, Neal Ford, “Fundamentals of Software Architecture”, O’Reilly, 2020.
  7. ArchUnit Documentation. https://www.archunit.org/userguide/html/000_Index.html
  8. Vaughn Vernon, “Implementing Domain-Driven Design”, Addison-Wesley, 2013.
  9. Eric Evans, “Domain-Driven Design: Tackling Complexity in the Heart of Software”, Addison-Wesley, 2003.
  10. Go Internal Packages. https://go.dev/doc/go1.4#internalpackages

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。

2026-04-13 · architecture

【系统架构设计百科】复杂性管理:架构的核心战场

系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略


By .