一个支付系统上线两年后,产品经理提出需求:接入一家新的支付渠道。技术负责人评估后给出排期——六周。原因不是业务逻辑复杂,而是支付调用的代码散落在三个 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 调用——核心逻辑应该完全一样。
他定义了两个核心概念:
- 端口(Port):应用与外界交互的接口契约。不是具体技术实现,而是一组操作的声明。“存储订单”是一个端口,“通过 HTTP 接收请求”也是一个端口。
- 适配器(Adapter):将外部技术细节转换为端口所要求的接口。PostgreSQL 的订单存储是一个适配器,MySQL 的订单存储是另一个;Spring MVC Controller 是一个适配器,gRPC Handler 也是一个。
Cockburn 强调的关键点是对称性:应用的左侧(驱动侧)和右侧(被驱动侧)在结构上对称,都通过端口与外部交互。这打破了传统分层架构中”上层调用下层”的单向思维。
1.2 洋葱架构:Jeffrey Palermo,2008
Jeffrey Palermo 在 2008 年提出洋葱架构(Onion Architecture),可以看作六边形架构的进一步细化。Cockburn 的模型没有对应用内部层次做详细划分——他只关心”内部”和”外部”的边界。Palermo 则明确定义了内部层次:
- 领域模型(Domain Model):核心业务实体和值对象,位于最内层
- 领域服务(Domain Services):跨实体的业务规则
- 应用服务(Application Services):编排用例,协调领域对象
- 基础设施(Infrastructure):数据库、消息队列、外部 API,位于最外层
核心规则:依赖只能向内指。外层可以依赖内层,内层绝对不能依赖外层。领域模型不知道数据库的存在,应用服务不知道 HTTP 框架的存在。
1.3 整洁架构:Robert C. Martin,2012
Robert C. Martin(Uncle Bob)在 2012 年的博客文章中综合了六边形架构、洋葱架构等模式,提出整洁架构(Clean Architecture),2017 年出版同名专著做了详细阐述。
整洁架构用四层同心圆表示:
- 实体(Entities):企业级业务规则
- 用例(Use Cases):应用级业务规则
- 接口适配器(Interface Adapters):数据格式转换
- 框架与驱动(Frameworks & Drivers):Web 框架、数据库、UI
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)是外部世界调用应用的接口,定义应用对外提供的能力——“我能做什么”。典型的驱动端口如
CreateOrder、CancelOrder、GetOrderStatus。驱动端口通常就是应用服务的接口。
被驱动端口(Driven Port / Secondary
Port)是应用调用外部世界的接口,定义应用需要的外部能力——“我需要什么”。典型如
SaveOrder、SendNotification、ChargePayment。被驱动端口由领域核心定义,由外层适配器实现——这正是依赖反转的关键。
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
测试困难。端口粒度太细——每个调用一个端口——导致接口数量爆炸,增加理解成本。
合理的粒度原则:
- 按聚合根(Aggregate Root)划分 Repository
端口:
OrderRepository、UserRepository - 按外部系统划分集成端口:
PaymentGateway、NotificationService - 一个端口的方法数控制在 3 到 7 个
- 从不被同时使用的方法组,考虑拆分为独立端口
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 对比
- Go 接口隐式实现(structural
typing),减少样板代码但降低可发现性;Java 显式
implements,IDE 可直接跳转所有实现 - Go 依赖注入在
main中手动完成;Java 借助 Spring 自动装配 - Go 的
internal包提供编译级访问控制;Java 需借助 ArchUnit 做测试期检查 - Go 社区倾向小接口(1-3 方法),Java 倾向稍大接口(3-7 方法)
六、依赖规则的严格执行
架构规则只靠口头约定和代码评审维护,长期一定会被打破。新成员加入、工期紧张、“临时” 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 重构效果
- 新增渠道成本大幅降低。 接入银联只需新增
UnionPayChannelAdapter,不修改PaymentService。排期从六周缩短到一周半。 - 渠道可独立测试。 每个适配器独立编写集成测试;应用服务用 mock 做单元测试,不依赖真实支付渠道。
- SDK 升级影响可控。 支付宝 SDK 升级只改
AlipayChannelAdapter,其他代码不受影响。 - 拆分支付可行。 应用服务可遍历多个渠道适配器完成拆分支付,各渠道调用逻辑互不干扰。
八、三种架构的细微差异与选择
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 选择建议
大多数项目中三种架构差异不大:
- 团队刚接触时从六边形入手——概念最少,最易落地
- .NET 技术栈优先看洋葱架构的社区资源
- 需要向管理层汇报时,整洁架构有完整专著和学术引用
- 不需要”非此即彼”——理解依赖规则,选最适合团队沟通的术语即可
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 直接调用领域模型,或让领域模型处理编排逻辑。前者导致编排逻辑(查询 → 检查 → 支付 → 更新 → 通知)无法被其他驱动适配器复用。后者让领域模型为编排引入对端口的依赖,破坏领域层纯净性。应用服务层是连接驱动端口和被驱动端口的桥梁,是用例逻辑的唯一归属地。
十、总结
六边形架构、洋葱架构和整洁架构是同一核心思想的三种表述:用依赖规则隔离业务逻辑与技术细节。
核心可压缩为三条规则:
- 业务逻辑定义接口(端口),外部技术实现接口(适配器)
- 依赖方向只能从外向内,内层不知道外层的存在
- 应用入口负责组装所有依赖
实践要点:用自动化工具(ArchUnit、depguard、CI 流水线)强制执行依赖规则;避免过度抽象,只为可能替换或需要测试隔离的外部依赖创建端口;让领域模型承载行为,避免贫血模型;保持应用服务层作为编排的唯一位置。
这三种架构不是银弹。简单 CRUD 用传统分层即可;业务复杂、外部集成多、测试要求高的系统,端口-适配器能显著降低变更成本。选择时关注依赖规则本身,不纠结叫”六边形”还是”洋葱”还是”整洁”——三个名字,一条规则。
参考资料
- Alistair Cockburn, “Hexagonal Architecture”, 2005. https://alistair.cockburn.us/hexagonal-architecture/
- Jeffrey Palermo, “The Onion Architecture: Part 1”, 2008. https://jeffreypalermo.com/2008/07/the-onion-architecture-part-1/
- Robert C. Martin, “The Clean Architecture”, 2012. https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
- Robert C. Martin, “Clean Architecture: A Craftsman’s Guide to Software Structure and Design”, Prentice Hall, 2017.
- Martin Fowler, “AnemicDomainModel”, 2003. https://martinfowler.com/bliki/AnemicDomainModel.html
- Mark Richards, Neal Ford, “Fundamentals of Software Architecture”, O’Reilly, 2020.
- ArchUnit Documentation. https://www.archunit.org/userguide/html/000_Index.html
- Vaughn Vernon, “Implementing Domain-Driven Design”, Addison-Wesley, 2013.
- Eric Evans, “Domain-Driven Design: Tackling Complexity in the Heart of Software”, Addison-Wesley, 2003.
- Go Internal Packages. https://go.dev/doc/go1.4#internalpackages
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】架构质量属性:不只是"高可用高性能"
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
【系统架构设计百科】告警策略:如何避免"狼来了"
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。
【系统架构设计百科】复杂性管理:架构的核心战场
系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略
【系统架构设计百科】微服务架构深度审视:优势、代价与适用边界
微服务不是免费的午餐。本文从分布式系统八大谬误出发,拆解微服务真正解决的问题与引入的代价,梳理服务边界划分的工程方法论,还原 Amazon 和 Netflix 从单体到微服务的真实演进时间线,给出微服务适用与不适用的判断框架。