六边形架构(端口与适配器)
大约 17 分钟约 4980 字
六边形架构(端口与适配器)
简介
六边形架构(Hexagonal Architecture),又称端口与适配器架构(Ports and Adapters),由 Alistair Cockburn 于 2005 年提出。其核心思想是将应用程序的核心业务逻辑与外部关注点(数据库、UI、第三方服务)完全隔离。应用程序通过"端口"定义与外界的交互契约,通过"适配器"实现具体的对接方式。这使得核心业务逻辑可以在不依赖任何外部技术的情况下进行开发和测试。
特点
架构概览
六边形架构结构图
外部系统(UI / API / 消息队列)
│
┌─────────▼─────────┐
│ Driving Adapters │ ← 主适配器(控制器、CLI)
│ (Primary/Inbound) │
└─────────┬─────────┘
│ 调用
┌─────────▼─────────┐
│ Driving Ports │ ← 入站端口(用例接口)
│ (Inbound/UseCase) │
└─────────┬─────────┘
│
┌───────────────┼───────────────┐
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Application Core │ │
│ │ (Domain Model) │ │
│ │ - Entities │ │
│ │ - Value Objects │ │
│ │ - Domain Services │ │
│ │ - Use Cases │ │
│ └─────────┬─────────┘ │
│ │ │
└───────────────┼───────────────┘
│
┌─────────▼─────────┐
│ Driven Ports │ ← 出站端口(仓储接口)
│ (Outbound) │
└─────────┬─────────┘
│ 实现
┌─────────▼─────────┐
│ Driven Adapters │ ← 次适配器(仓储、外部服务)
│ (Secondary/Out) │
└─────────┬─────────┘
│
外部系统(数据库 / 第三方 API / 文件系统)核心概念解析
/// <summary>
/// 六边形架构核心概念:
///
/// 1. Domain(领域层)— 纯 C# 类,不依赖任何框架
/// - Entity: 有唯一标识的业务实体
/// - Value Object: 无标识的不可变值对象
/// - Domain Service: 跨实体的业务逻辑
/// - Domain Event: 领域事件
///
/// 2. Port(端口)— 定义交互契约的接口
/// - Driving Port (Inbound): 应用对外暴露的能力(用例)
/// - Driven Port (Outbound): 应用依赖的外部能力(仓储)
///
/// 3. Adapter(适配器)— 端口的具体实现
/// - Driving Adapter: 调用 Driving Port(Controller、CLI、gRPC)
/// - Driven Adapter: 实现 Driven Port(Repository、HttpClient)
///
/// 依赖方向:
/// 外层 → 内层(所有依赖指向领域核心)
/// Domain 不引用任何外部项目/包
/// </summary>.NET 实现详解
项目结构
HexagonalApp/
├── src/
│ ├── HexApp.Domain/ # 领域核心(零外部依赖)
│ │ ├── Entities/
│ │ │ └── Order.cs
│ │ ├── ValueObjects/
│ │ │ ├── Money.cs
│ │ │ └── Address.cs
│ │ ├── Events/
│ │ │ └── OrderCreatedEvent.cs
│ │ └── Exceptions/
│ │ └── DomainException.cs
│ │
│ ├── HexApp.Application/ # 应用层(定义端口)
│ │ ├── Ports/
│ │ │ ├── Inbound/ # Driving Ports(用例接口)
│ │ │ │ ├── ICreateOrderUseCase.cs
│ │ │ │ └── IGetOrderUseCase.cs
│ │ │ └── Outbound/ # Driven Ports(外部依赖接口)
│ │ │ ├── IOrderRepository.cs
│ │ │ └── INotificationService.cs
│ │ ├── UseCases/ # 用例实现
│ │ │ └── CreateOrderUseCase.cs
│ │ └── DTOs/
│ │ └── OrderDto.cs
│ │
│ ├── HexApp.Adapters.Driving/ # Driving Adapters(入站适配器)
│ │ ├── Api/ # REST API 控制器
│ │ │ └── OrderController.cs
│ │ └── CLI/ # 命令行接口
│ │ └── OrderCli.cs
│ │
│ ├── HexApp.Adapters.Driven/ # Driven Adapters(出站适配器)
│ │ ├── Persistence/ # 数据库适配器
│ │ │ ├── SqlOrderRepository.cs
│ │ │ └── AppDbContext.cs
│ │ ├── Notification/ # 通知适配器
│ │ │ └── EmailNotificationService.cs
│ │ └── ExternalApi/ # 外部 API 适配器
│ │ └── PaymentGateway.cs
│ │
│ └── HexApp.CompositionRoot/ # 组合根(DI 配置)
│ └── ServiceCollectionExtensions.cs
│
└── tests/
├── HexApp.Domain.Tests/
├── HexApp.Application.Tests/
└── HexApp.Adapters.Driven.Tests/领域层实现
// ─── Domain/Entities/Order.cs ───
namespace HexApp.Domain.Entities;
/// <summary>
/// 订单实体 — 纯领域对象,不依赖任何外部框架
/// </summary>
public class Order
{
public Guid Id { get; private set; }
public string CustomerName { get; private set; }
public Address ShippingAddress { get; private set; }
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public Money TotalAmount => _lines.Sum(l => l.LineTotal);
public OrderStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
// 私有构造函数(强制通过工厂方法创建)
private Order() { }
// 工厂方法 — 确保创建的实体始终有效
public static Order Create(string customerName, Address shippingAddress)
{
if (string.IsNullOrWhiteSpace(customerName))
throw new DomainException("客户名称不能为空");
if (shippingAddress == null)
throw new DomainException("收货地址不能为空");
var order = new Order
{
Id = Guid.NewGuid(),
CustomerName = customerName,
ShippingAddress = shippingAddress,
Status = OrderStatus.Created,
CreatedAt = DateTime.UtcNow
};
// 发布领域事件
order.AddDomainEvent(new OrderCreatedEvent(order.Id, customerName));
return order;
}
public void AddLine(string productName, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Created)
throw new DomainException("只有创建状态的订单可以添加商品");
if (quantity <= 0)
throw new DomainException("数量必须大于0");
var line = new OrderLine(productName, quantity, unitPrice);
_lines.Add(line);
}
public void Confirm()
{
if (!_lines.Any())
throw new DomainException("订单至少需要一个商品项");
if (Status != OrderStatus.Created)
throw new DomainException("只有创建状态的订单可以确认");
Status = OrderStatus.Confirmed;
}
public void Cancel(string reason)
{
if (Status == OrderStatus.Shipped)
throw new DomainException("已发货的订单不能取消");
Status = OrderStatus.Cancelled;
}
// 领域事件支持
private readonly List<object> _domainEvents = new();
public IReadOnlyList<object> DomainEvents => _domainEvents.AsReadOnly();
public void AddDomainEvent(object eventItem) => _domainEvents.Add(eventItem);
public void ClearDomainEvents() => _domainEvents.Clear();
}
public enum OrderStatus
{
Created,
Confirmed,
Shipped,
Cancelled
}值对象
// ─── Domain/ValueObjects/Money.cs ───
namespace HexApp.Domain.ValueObjects;
/// <summary>
/// 金额值对象 — 不可变、按值比较
/// </summary>
public record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency = "CNY")
{
if (amount < 0)
throw new DomainException("金额不能为负数");
Amount = amount;
Currency = currency;
}
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new DomainException("不能对不同币种进行运算");
return new Money(a.Amount + b.Amount, a.Currency);
}
public static Money Zero(string currency = "CNY") => new(0, currency);
}
// ─── Domain/ValueObjects/Address.cs ───
namespace HexApp.Domain.ValueObjects;
/// <summary>
/// 地址值对象
/// </summary>
public record Address
{
public string Province { get; }
public string City { get; }
public string Detail { get; }
public string PostalCode { get; }
public Address(string province, string city, string detail, string postalCode)
{
if (string.IsNullOrWhiteSpace(province))
throw new DomainException("省份不能为空");
if (string.IsNullOrWhiteSpace(city))
throw new DomainException("城市不能为空");
if (string.IsNullOrWhiteSpace(detail))
throw new DomainException("详细地址不能为空");
Province = province;
City = city;
Detail = detail;
PostalCode = postalCode;
}
public string FullAddress => $"{Province}{City}{Detail}";
}领域事件与异常
// ─── Domain/Events/OrderCreatedEvent.cs ───
namespace HexApp.Domain.Events;
/// <summary>
/// 订单创建事件
/// </summary>
public record OrderCreatedEvent(Guid OrderId, string CustomerName);
// ─── Domain/Exceptions/DomainException.cs ───
namespace HexApp.Domain.Exceptions;
/// <summary>
/// 领域异常 — 表示业务规则违反
/// </summary>
public class DomainException : Exception
{
public DomainException(string message) : base(message) { }
public DomainException(string message, Exception inner) : base(message, inner) { }
}
// ─── Domain/Entities/OrderLine.cs ───
namespace HexApp.Domain.Entities;
/// <summary>
/// 订单行 — 实体
/// </summary>
public class OrderLine
{
public string ProductName { get; }
public int Quantity { get; }
public Money UnitPrice { get; }
public Money LineTotal => new(UnitPrice.Amount * Quantity, UnitPrice.Currency);
public OrderLine(string productName, int quantity, Money unitPrice)
{
ProductName = productName;
Quantity = quantity;
UnitPrice = unitPrice;
}
}端口定义
入站端口(Driving Ports)
// ─── Application/Ports/Inbound/ICreateOrderUseCase.cs ───
namespace HexApp.Application.Ports.Inbound;
/// <summary>
/// 创建订单用例 — 入站端口
/// 定义了外部调用方可以执行的操作
/// </summary>
public interface ICreateOrderUseCase
{
Task<OrderDto> ExecuteAsync(CreateOrderRequest request);
}
// ─── Application/Ports/Inbound/IGetOrderUseCase.cs ───
namespace HexApp.Application.Ports.Inbound;
/// <summary>
/// 查询订单用例
/// </summary>
public interface IGetOrderUseCase
{
Task<OrderDto?> GetByIdAsync(Guid orderId);
Task<PagedResult<OrderDto>> SearchAsync(OrderSearchCriteria criteria);
}
// ─── Application/DTOs/OrderDto.cs ───
namespace HexApp.Application.DTOs;
public record CreateOrderRequest(
string CustomerName,
string Province,
string City,
string Detail,
string PostalCode,
List<OrderLineRequest> Lines);
public record OrderLineRequest(string ProductName, int Quantity, decimal UnitPrice);
public record OrderDto(
Guid Id,
string CustomerName,
string FullAddress,
decimal TotalAmount,
string Status,
DateTime CreatedAt,
List<OrderLineDto> Lines);
public record OrderLineDto(string ProductName, int Quantity, decimal UnitPrice, decimal LineTotal);
public record OrderSearchCriteria(string? CustomerName = null, int Page = 1, int PageSize = 20);
public record PagedResult<T>(List<T> Items, int TotalCount, int Page, int PageSize);出站端口(Driven Ports)
// ─── Application/Ports/Outbound/IOrderRepository.cs ───
namespace HexApp.Application.Ports.Outbound;
/// <summary>
/// 订单仓储端口 — 出站端口
/// 定义了应用核心对外部存储的依赖
/// </summary>
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id);
Task SaveAsync(Order order);
Task<PagedResult<Order>> SearchAsync(string? customerName, int page, int pageSize);
}
// ─── Application/Ports/Outbound/INotificationService.cs ───
namespace HexApp.Application.Ports.Outbound;
/// <summary>
/// 通知服务端口 — 出站端口
/// </summary>
public interface INotificationService
{
Task NotifyOrderCreatedAsync(Guid orderId, string customerName);
Task NotifyOrderShippedAsync(Guid orderId, string trackingNumber);
}
// ─── Application/Ports/Outbound/IEventPublisher.cs ───
namespace HexApp.Application.Ports.Outbound;
/// <summary>
/// 事件发布端口
/// </summary>
public interface IEventPublisher
{
Task PublishAsync<T>(T domainEvent) where T : class;
}用例实现
应用服务层
// ─── Application/UseCases/CreateOrderUseCase.cs ───
namespace HexApp.Application.UseCases;
/// <summary>
/// 创建订单用例 — 实现 Driving Port
/// 编排领域对象和出站端口完成业务逻辑
/// </summary>
public class CreateOrderUseCase : ICreateOrderUseCase
{
private readonly IOrderRepository _orderRepository;
private readonly INotificationService _notificationService;
private readonly IEventPublisher _eventPublisher;
public CreateOrderUseCase(
IOrderRepository orderRepository,
INotificationService notificationService,
IEventPublisher eventPublisher)
{
_orderRepository = orderRepository;
_notificationService = notificationService;
_eventPublisher = eventPublisher;
}
public async Task<OrderDto> ExecuteAsync(CreateOrderRequest request)
{
// 1. 创建值对象
var address = new Address(
request.Province,
request.City,
request.Detail,
request.PostalCode);
// 2. 创建领域实体(封装业务规则)
var order = Order.Create(request.CustomerName, address);
// 3. 添加订单行
foreach (var line in request.Lines)
{
var unitPrice = new Money(line.UnitPrice);
order.AddLine(line.ProductName, line.Quantity, unitPrice);
}
// 4. 确认订单
order.Confirm();
// 5. 持久化
await _orderRepository.SaveAsync(order);
// 6. 发布领域事件
foreach (var domainEvent in order.DomainEvents)
{
await _eventPublisher.PublishAsync(domainEvent);
}
// 7. 发送通知
await _notificationService.NotifyOrderCreatedAsync(order.Id, order.CustomerName);
// 8. 清理领域事件
order.ClearDomainEvents();
// 9. 返回 DTO
return MapToDto(order);
}
private static OrderDto MapToDto(Order order)
{
return new OrderDto(
order.Id,
order.CustomerName,
order.ShippingAddress.FullAddress,
order.TotalAmount.Amount,
order.Status.ToString(),
order.CreatedAt,
order.Lines.Select(l => new OrderLineDto(
l.ProductName, l.Quantity,
l.UnitPrice.Amount, l.LineTotal.Amount)).ToList());
}
}适配器实现
Driving Adapter — REST API 控制器
// ─── Adapters.Driving/Api/OrderController.cs ───
using Microsoft.AspNetCore.Mvc;
using HexApp.Application.Ports.Inbound;
namespace HexApp.Adapters.Driving.Api;
/// <summary>
/// 订单 API 控制器 — Driving Adapter
/// 负责将 HTTP 请求转换为对 Driving Port 的调用
/// </summary>
[ApiController]
[Route("api/orders")]
public class OrderController : ControllerBase
{
private readonly ICreateOrderUseCase _createOrderUseCase;
private readonly IGetOrderUseCase _getOrderUseCase;
public OrderController(
ICreateOrderUseCase createOrderUseCase,
IGetOrderUseCase getOrderUseCase)
{
_createOrderUseCase = createOrderUseCase;
_getOrderUseCase = getOrderUseCase;
}
[HttpPost]
public async Task<ActionResult<OrderDto>> CreateOrder(
[FromBody] CreateOrderRequest request)
{
var order = await _createOrderUseCase.ExecuteAsync(request);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<OrderDto>> GetOrder(Guid id)
{
var order = await _getOrderUseCase.GetByIdAsync(id);
if (order == null) return NotFound();
return Ok(order);
}
[HttpGet]
public async Task<ActionResult<PagedResult<OrderDto>>> SearchOrders(
[FromQuery] string? customerName,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var criteria = new OrderSearchCriteria(customerName, page, pageSize);
var result = await _getOrderUseCase.SearchAsync(criteria);
return Ok(result);
}
}Driving Adapter — CLI 命令
// ─── Adapters.Driving/CLI/OrderCli.cs ───
using System.CommandLine;
using HexApp.Application.Ports.Inbound;
namespace HexApp.Adapters.Driving.CLI;
/// <summary>
/// CLI 适配器 — 另一种 Driving Adapter
/// 同一个用例可以有不同的调用方式
/// </summary>
public class OrderCli
{
private readonly ICreateOrderUseCase _createOrderUseCase;
public OrderCli(ICreateOrderUseCase createOrderUseCase)
{
_createOrderUseCase = createOrderUseCase;
}
public async Task<int> RunAsync(string[] args)
{
var nameOption = new Option<string>("--customer", "客户名称");
var rootCommand = new RootCommand("订单管理 CLI");
var createCommand = new Command("create", "创建订单");
createCommand.AddOption(nameOption);
createCommand.SetHandler(async (name) =>
{
var request = new CreateOrderRequest(
name, "上海", "上海市", "浦东新区xx路", "200000",
new List<OrderLineRequest>());
var order = await _createOrderUseCase.ExecuteAsync(request);
Console.WriteLine($"订单创建成功: {order.Id}");
}, nameOption);
rootCommand.AddCommand(createCommand);
return await rootCommand.InvokeAsync(args);
}
}Driven Adapter — EF Core 仓储
// ─── Adapters.Driven/Persistence/SqlOrderRepository.cs ───
using HexApp.Application.Ports.Outbound;
using HexApp.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace HexApp.Adapters.Driven.Persistence;
/// <summary>
/// SQL Server 仓储实现 — Driven Adapter
/// 实现出站端口,负责与数据库交互
/// </summary>
public class SqlOrderRepository : IOrderRepository
{
private readonly AppDbContext _dbContext;
public SqlOrderRepository(AppDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Order?> GetByIdAsync(Guid id)
{
return await _dbContext.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id);
}
public async Task SaveAsync(Order order)
{
var existing = await _dbContext.Orders.FindAsync(order.Id);
if (existing == null)
{
_dbContext.Orders.Add(order);
}
else
{
_dbContext.Entry(existing).CurrentValues.SetValues(order);
}
await _dbContext.SaveChangesAsync();
}
public async Task<PagedResult<Order>> SearchAsync(
string? customerName, int page, int pageSize)
{
var query = _dbContext.Orders.Include(o => o.Lines).AsQueryable();
if (!string.IsNullOrEmpty(customerName))
{
query = query.Where(o => o.CustomerName.Contains(customerName));
}
int total = await query.CountAsync();
var items = await query
.OrderByDescending(o => o.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<Order>(items, total, page, pageSize);
}
}Driven Adapter — 通知服务
// ─── Adapters.Driven/Notification/EmailNotificationService.cs ───
using HexApp.Application.Ports.Outbound;
namespace HexApp.Adapters.Driven.Notification;
/// <summary>
/// 邮件通知服务 — Driven Adapter
/// 实现出站端口,负责发送邮件通知
/// </summary>
public class EmailNotificationService : INotificationService
{
private readonly ILogger<EmailNotificationService> _logger;
private readonly SmtpConfig _config;
public EmailNotificationService(
ILogger<EmailNotificationService> logger,
SmtpConfig config)
{
_logger = logger;
_config = config;
}
public async Task NotifyOrderCreatedAsync(Guid orderId, string customerName)
{
_logger.LogInformation("发送订单创建通知: OrderId={OrderId}, Customer={Customer}",
orderId, customerName);
// 实际的邮件发送逻辑
using var client = new System.Net.Mail.SmtpClient(_config.Host, _config.Port);
var message = new System.Net.Mail.MailMessage(
_config.FromAddress,
$"customer@example.com",
$"订单 {orderId} 已创建",
$"尊敬的 {customerName},您的订单已创建成功。");
await client.SendMailAsync(message);
}
public async Task NotifyOrderShippedAsync(Guid orderId, string trackingNumber)
{
_logger.LogInformation("发送发货通知: OrderId={OrderId}, Tracking={Tracking}",
orderId, trackingNumber);
// 邮件发送逻辑
await Task.CompletedTask;
}
}
public class SmtpConfig
{
public string Host { get; set; } = "";
public int Port { get; set; }
public string FromAddress { get; set; } = "";
}Driven Adapter — 内存仓储(测试用)
// ─── Adapters.Driven.Tests/TestAdapters/InMemoryOrderRepository.cs ───
using HexApp.Application.Ports.Outbound;
using HexApp.Domain.Entities;
namespace HexApp.Adapters.Driven.Tests.TestAdapters;
/// <summary>
/// 内存仓储 — 用于测试的 Driven Adapter
/// 不依赖任何数据库,速度极快
/// </summary>
public class InMemoryOrderRepository : IOrderRepository
{
private readonly List<Order> _orders = new();
public Task<Order?> GetByIdAsync(Guid id)
{
var order = _orders.FirstOrDefault(o => o.Id == id);
return Task.FromResult(order);
}
public Task SaveAsync(Order order)
{
var existing = _orders.FirstOrDefault(o => o.Id == order.Id);
if (existing != null)
{
_orders.Remove(existing);
}
_orders.Add(order);
return Task.CompletedTask;
}
public Task<PagedResult<Order>> SearchAsync(
string? customerName, int page, int pageSize)
{
var query = _orders.AsEnumerable();
if (!string.IsNullOrEmpty(customerName))
{
query = query.Where(o => o.CustomerName.Contains(customerName));
}
var items = query
.OrderByDescending(o => o.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToList();
return Task.FromResult(new PagedResult<Order>(
items, _orders.Count, page, pageSize));
}
}测试策略
领域层单元测试
// ─── Domain.Tests/OrderTests.cs ───
using HexApp.Domain.Entities;
using HexApp.Domain.ValueObjects;
namespace HexApp.Domain.Tests;
[TestFixture]
public class OrderTests
{
[Test]
public void Create_WithValidData_ReturnsOrder()
{
// Arrange
var address = new Address("上海", "上海市", "浦东新区xx路", "200000");
// Act
var order = Order.Create("张三", address);
// Assert
Assert.That(order.CustomerName, Is.EqualTo("张三"));
Assert.That(order.Status, Is.EqualTo(OrderStatus.Created));
Assert.That(order.Id, Is.Not.EqualTo(Guid.Empty));
}
[Test]
public void Create_WithEmptyName_ThrowsDomainException()
{
var address = new Address("上海", "上海市", "浦东新区xx路", "200000");
var ex = Assert.Throws<DomainException>(() =>
Order.Create("", address));
Assert.That(ex!.Message, Is.EqualTo("客户名称不能为空"));
}
[Test]
public void AddLine_ToCreatedOrder_Success()
{
var order = CreateTestOrder();
var unitPrice = new Money(99.9m);
order.AddLine("商品A", 2, unitPrice);
Assert.That(order.Lines, Has.Count.EqualTo(1));
Assert.That(order.TotalAmount.Amount, Is.EqualTo(199.8m));
}
[Test]
public void Confirm_WithoutLines_ThrowsDomainException()
{
var order = CreateTestOrder();
var ex = Assert.Throws<DomainException>(() => order.Confirm());
Assert.That(ex!.Message, Does.Contain("至少需要一个商品项"));
}
[Test]
public void Create_PublishesOrderCreatedEvent()
{
var order = CreateTestOrder();
Assert.That(order.DomainEvents, Has.Count.EqualTo(1));
Assert.That(order.DomainEvents[0], Is.InstanceOf<OrderCreatedEvent>());
}
private static Order CreateTestOrder()
{
var address = new Address("上海", "上海市", "浦东新区xx路", "200000");
return Order.Create("张三", address);
}
}用例层集成测试
// ─── Application.Tests/CreateOrderUseCaseTests.cs ───
using HexApp.Application.Ports.Outbound;
using HexApp.Application.UseCases;
using HexApp.Adapters.Driven.Tests.TestAdapters;
using Moq;
namespace HexApp.Application.Tests;
[TestFixture]
public class CreateOrderUseCaseTests
{
private InMemoryOrderRepository _repository;
private Mock<INotificationService> _notificationMock;
private Mock<IEventPublisher> _eventPublisherMock;
private CreateOrderUseCase _useCase;
[SetUp]
public void SetUp()
{
_repository = new InMemoryOrderRepository();
_notificationMock = new Mock<INotificationService>();
_eventPublisherMock = new Mock<IEventPublisher>();
_useCase = new CreateOrderUseCase(
_repository,
_notificationMock.Object,
_eventPublisherMock.Object);
}
[Test]
public async Task ExecuteAsync_WithValidRequest_CreatesOrder()
{
var request = new CreateOrderRequest(
"李四", "北京", "北京市", "海淀区xx路", "100000",
new List<OrderLineRequest>
{
new("商品A", 2, 50.0m),
new("商品B", 1, 100.0m)
});
var result = await _useCase.ExecuteAsync(request);
Assert.That(result, Is.Not.Null);
Assert.That(result.CustomerName, Is.EqualTo("李四"));
Assert.That(result.TotalAmount, Is.EqualTo(200.0m));
Assert.That(result.Status, Is.EqualTo("Confirmed"));
}
[Test]
public async Task ExecuteAsync_PublishesEventAndNotifies()
{
var request = new CreateOrderRequest(
"王五", "广州", "广州市", "天河区xx路", "510000",
new List<OrderLineRequest> { new("商品C", 1, 30.0m) });
await _useCase.ExecuteAsync(request);
_eventPublisherMock.Verify(
p => p.PublishAsync(It.IsAny<object>()),
Times.AtLeastOnce);
_notificationMock.Verify(
n => n.NotifyOrderCreatedAsync(It.IsAny<Guid>(), "王五"),
Times.Once);
}
}依赖注入配置
组合根
// ─── CompositionRoot/ServiceCollectionExtensions.cs ───
using HexApp.Application.Ports.Inbound;
using HexApp.Application.Ports.Outbound;
using HexApp.Application.UseCases;
using HexApp.Adapters.Driven.Notification;
using HexApp.Adapters.Driven.Persistence;
using Microsoft.Extensions.DependencyInjection;
namespace HexApp.CompositionRoot;
/// <summary>
/// 组合根 — 在这里将端口和适配器绑定
/// 这是唯一知道具体实现的地方
/// </summary>
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddHexApp(this IServiceCollection services)
{
// 注册 Driving Ports (用例)
services.AddTransient<ICreateOrderUseCase, CreateOrderUseCase>();
services.AddTransient<IGetOrderUseCase, GetOrderUseCase>();
// 注册 Driven Ports (适配器)
services.AddScoped<IOrderRepository, SqlOrderRepository>();
services.AddScoped<INotificationService, EmailNotificationService>();
services.AddScoped<IEventPublisher, EventBusAdapter>();
// 注册基础设施
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer("ConnectionStrings"));
return services;
}
/// <summary>
/// 测试配置 — 使用内存适配器
/// </summary>
public static IServiceCollection AddHexAppForTesting(this IServiceCollection services)
{
services.AddTransient<ICreateOrderUseCase, CreateOrderUseCase>();
services.AddTransient<IGetOrderUseCase, GetOrderUseCase>();
// 使用内存仓储替代数据库
services.AddSingleton<IOrderRepository, InMemoryOrderRepository>();
services.AddSingleton<INotificationService, ConsoleNotificationService>();
services.AddSingleton<IEventPublisher, ConsoleEventPublisher>();
return services;
}
}与其他架构对比
六边形 vs 分层 vs 洋葱 vs Clean
/// <summary>
/// 架构对比分析:
///
/// ┌──────────────┬──────────────┬──────────────┬──────────────┐
/// │ 维度 │ 六边形 │ 分层架构 │ Clean │
/// ├──────────────┼──────────────┼──────────────┼──────────────┤
/// │ 核心思想 │ 端口与适配器 │ 按层分包 │ 依赖规则 │
/// │ 外部隔离 │ ★★★★★ │ ★★★ │ ★★★★ │
/// │ 可测试性 │ ★★★★★ │ ★★★ │ ★★★★ │
/// │ 复杂度 │ ★★★★ │ ★★ │ ★★★★★ │
/// │ 学习曲线 │ ★★★★ │ ★★ │ ★★★★ │
/// │ 适用规模 │ 中大型 │ 小型 │ 中大型 │
/// └──────────────┴──────────────┴──────────────┴──────────────┘
///
/// 六边形架构的关键优势:
/// 1. 明确区分"主动"和"被动"的外部交互
/// 2. 端口是纯接口,适配器可以独立替换
/// 3. 同一个应用可以同时有 API、CLI、消息队列等多种入口
/// 4. 测试时可以用内存适配器替代所有外部依赖
///
/// 选择建议:
/// - 小型项目(< 10 个实体):简单分层架构即可
/// - 中型项目(10-50 个实体):六边形架构
/// - 大型项目(> 50 个实体):六边形 + CQRS + 事件驱动
/// </summary>迁移指南
从分层架构迁移到六边形
/// <summary>
/// 迁移步骤:
///
/// 第一阶段:提取接口
/// 1. 为所有数据访问类提取接口(IOrderRepository)
/// 2. 为所有外部服务调用提取接口(IEmailService)
/// 3. 将接口移到 Application 层的 Ports/Outbound
///
/// 第二阶段:提取用例
/// 1. 识别每个 Controller Action 对应的业务逻辑
/// 2. 将业务逻辑提取到独立的 UseCase 类
/// 3. 为 UseCase 定义接口(Driving Port)
///
/// 第三阶段:重组项目结构
/// 1. 将领域模型移到 Domain 项目
/// 2. 将接口移到 Application/Ports
/// 3. 将 Controller 移到 Adapters.Driving
/// 4. 将 Repository 实现移到 Adapters.Driven
///
/// 第四阶段:验证
/// 1. 确保 Domain 项目没有外部依赖
/// 2. 确保所有测试通过
/// 3. 添加新的适配器(如 CLI)验证端口定义合理
/// </summary>优点
缺点
性能注意事项
总结
六边形架构通过端口与适配器的抽象,将业务核心与外部技术完全隔离。它的核心价值在于:让业务逻辑可以独立于数据库、UI、第三方服务进行开发和测试。在 .NET 生态中,结合 Clean Architecture 的依赖规则和 DI 容器的组合根模式,可以构建出高可维护性、高可测试性的应用程序。
关键知识点
- 端口是接口,适配器是接口的实现
- Driving Port(入站端口)定义应用对外暴露的能力
- Driven Port(出站端口)定义应用对外部系统的依赖
- 依赖方向永远指向领域核心(外层依赖内层)
- Domain 项目应该是零外部依赖的纯 C# 项目
- 组合根(Composition Root)是唯一知道具体实现的地方
- 测试时可以用内存适配器替代所有外部依赖
- 一个应用可以有多个 Driving Adapter(API + CLI + MQ)
常见误区
- 误区:六边形架构每个端口只能有一个适配器
纠正:一个端口可以有多个适配器实现(如 IRepository 有 SQL 和 InMemory 两个实现) - 误区:Domain 层不能有任何接口
纠正:Domain 可以定义 Domain Service 接口,但不应依赖外部技术 - 误区:六边形架构就是三层架构的换个说法
纠正:六边形的关键区别是依赖方向反转和明确的端口/适配器边界 - 误区:所有项目都应该使用六边形架构
纠正:小型项目和简单 CRUD 应用使用六边形架构是过度设计
进阶路线
- 初级:理解端口/适配器概念,用接口隔离数据访问
- 中级:完整实现六边形项目结构,编写隔离测试
- 高级:结合 CQRS 和事件驱动,实现复杂业务编排
- 专家级:多适配器切换策略,领域事件跨边界传播,微服务拆分
适用场景
- 业务逻辑复杂的中大型应用
- 需要支持多种入口(API + CLI + 消息队列)的应用
- 需要高度可测试性的金融/医疗等关键系统
- 需要切换数据库或外部服务的系统
- 长期维护的企业级应用
落地建议
- 新项目直接按六边形结构组织代码
- 老项目分阶段迁移:先提取接口,再重组结构
- Domain 项目强制禁止添加外部 NuGet 包(通过 CI 规则检查)
- 使用 ArchUnit 或自定义 Roslyn Analyzer 验证架构规则
- 每个用例编写独立的集成测试(使用内存适配器)
- 组合根集中配置 DI,避免分散注册
排错清单
复盘问题
- 如果需要在 UseCase 中调用三个不同的出站端口,如何保证事务一致性?
- 领域事件应该在哪里发布——用例层还是仓储层?
- 如何在六边形架构中实现 CQRS?
- 当业务逻辑跨越多个用例时,如何避免代码重复?
- 六边形架构与微服务架构如何结合?
- 如何处理跨聚合根的业务规则?
- 适配器中的异常应该如何传播到 Driving Adapter?
- 如何在不破坏端口接口的情况下扩展新功能?
