
一.技术和业务混在一起带来的问题在大多数开发场景中都会遇到过这样的场景需求本身很简单但打开对应的代码文件你会发现这个方法里同时出现了数据库查询、HTTP 调用、硬件 SDK 调用、框架异常等技术实现真正的业务规则只有两三行如以下关于“银行业务中转账时需要校验余额和限额”的伪代码业务逻辑例子class TransferService: def transfer(self, from_card, to_account, amount): # 读卡——硬件 SDK card_data sdk.read_card(from_card) # 查余额——数据库 SQL row db.execute(SELECT balance FROM accounts WHERE card_no%s, (card_data[card_no],)) if row[balance] amount: raise HTTPException(余额不足) # 业务规则却用了框架的异常 # 扣款——又一条 SQL db.execute(UPDATE accounts SET balancebalance-%s WHERE ...) # 通知核心系统——HTTP 调用 requests.post(http://core/transfer, {...}) # 打印凭条——又是硬件 SDK printer.print_receipt(...)这段方法全程都能跑通但是后续如果需要进行技术迭代/技术替换则需要进行以下步骤数据库从 PostgreSQL 换成 MongoDB这个方法要改。硬件 SDK 升级这个方法要改。核心系统接口调整这个方法要改。Web 框架从 FastAPI 换成 Flask连异常类型都要改。这段业务逻辑写个单元测试先得把数据库起起来、把后端服务搭起来、把硬件模拟器挂上——测试还没开始写部署环境已经配了半天。而这段方法里业务规则全程没变——还是“余额够、没超限额就能转”。但因为它和技术细节焊在一起导致任何一处技术变动都会波及业务代码。这不是某一段代码写得差也不是某个开发者水平不够。这是一个结构性问题业务逻辑和技术细节缠在一起导致技术的任何改动都会直接波及业务代码称为“业务逻辑被技术细节污染”。业务逻辑被技术细节污染的根源在于依赖方向。传统三层架构中表现层依赖业务层、业务层依赖数据访问层依赖方向从业务指向技术只要这个方向不变技术细节就会顺着依赖链一路渗透进核心如上图所示依赖方向是从业务指向技术的。业务层要调用数据访问层就必须知道具体用的是哪种 ORM、哪个 HTTP 客户端、哪款硬件 SDK。一旦底下任何一项发生变化依赖链上游的业务代码就被迫跟着改动——这就是技术细节污染业务逻辑的传导路径。而六边形架构则把依赖方向反过来让技术依赖业务而不是业务依赖技术。二.什么是六边形架构六边形架构Hexagonal Architecture又称端口与适配器架构Ports and Adapters由 Alistair Cockburn 于 2005 年提出核心理念是“让用户、程序、自动化测试和批处理脚本可以平等地驱动应用让应用的开发和测试可以独立于其最终运行的设备和数据库。”该架构的产生目标只有一个让应用的核心业务逻辑独立于外部技术实现可以被平等地驱动和测试。架构主要遵循三个基本原则领域层处于中心。业务规则/逻辑全部位于系统内核不引用任何技术实现。核心只定义“我需要什么”比如“我需要一个能按账号读取和保存账户的能力”它会声明一个接口来表达这个需求但不会关心这个能力是由 MySQL、PostgreSQL 还是内存提供的。依赖倒置。依赖关系严格单向朝内。核心定义端口接口适配器实现端口。是技术依赖业务而不是业务依赖技术。对称性。输入端与输出端本质相同都是应用与外部世界的交互通道。UI 层和 DB 层没有高低之分都是数据的出入口。正如 Cockburn 指出的传统分层把 UI 画在最上面、DB 画在最下面造成了“上层调用下层”的错误心理暗示。需要注意的是 “六边形”不是数学意义上的六条边而是一种视觉隐喻——表示内部核心与外部世界之间有多条交互通道每条通道都通过端口连接。三.六边形架构的架构设计架构设计图示如下如上图所示架构可以分为核心层、适配层两层其中核心层中又包含了四个组件它们各有职责和约束领域层承载实体、值对象、领域服务、业务规则。约束是零外部依赖——不 import 任何框架、数据库驱动、HTTP 库。应用服务层负责用例编排、事务管理、上下文。约束是不含业务规则——业务规则归领域层这里只做流程调度。入站端口定义核心对外暴露的能力即 UseCase 接口。仅接口由应用服务层实现。出站端口定义核心对外部世界的需求即 Repository / Gateway 接口。仅接口由核心区外部的适配器实现。而适配器位于核心层之外是六边形架构中唯一允许出现“技术细节”的地方其作用是实现核心定义的端口——入站适配器实现入站端口出站适配器实现出站端口核心层对此一无所知。入站适配器Driving Adapters把外部请求转换为端口调用。REST 控制器、CLI 命令、消息监听器、定时任务、硬件 SDK 回调都属于此类。出站适配器Driven Adapters把端口调用转换为具体技术操作。数据库实现、HTTP 客户端、消息发送器都属于此类。整个架构的特征是领域层几乎不随技术演进而变端口层随业务能力扩展而增加适配器层则随着每一次技术选型的变化而变化。四.重构实操例子下面将用六边形架构来重构第一章中出现的使用传统分层架构的例子先来回顾之前的代码class TransferService: def transfer(self, from_card, to_account, amount): card_data sdk.read_card(from_card) # 硬件 SDK row db.execute(SELECT balance FROM accounts WHERE card_no%s, (card_data[card_no],)) # 数据库 SQL if row[balance] amount: raise HTTPException(余额不足) # 业务规则 框架异常 db.execute(UPDATE accounts SET balancebalance-%s WHERE ...) # 数据库 SQL requests.post(http://core/transfer, {...}) # HTTP 调用 printer.print_receipt(...) # 硬件 SDK这段代码的主要问题是业务指向技术业务方法主动调用了 SDK、数据库、HTTP 客户端。只要这个方向不变技术细节就会不断渗入。4.1.领域层-剥离业务规则将SDK 调用、SQL 语句、HTTP 请求等全部剥离后剩下的业务规则其实只有一句话余额足够、状态正常、没超当日剩余额度把这部分抽出来放到一个不依赖任何技术的地方形成核心区内的“领域层 Domain”# domain/money.py 与 domain/account.py属于核心区内的领域层 from dataclasses import dataclass from decimal import Decimal class DomainError(Exception): 领域异常——由领域层自己定义不依赖任何框架 pass dataclass(frozenTrue) class Money: 值对象金额。没有身份标识由属性值定义。 amount: Decimal # 用 Decimal 而非 float避免精度误差 currency: str CNY def __post_init__(self): if self.amount 0: raise ValueError(金额不能为负数) class Account: 实体账户。由账号标识有生命周期状态会变化。 数据与规则绑定在一起——谁拥有数据谁负责规则。 def __init__(self, account_no: str, balance: Money, remaining_daily_limit: Money, status: str active): self.account_no account_no self.balance balance self.remaining_daily_limit remaining_daily_limit self.status status def can_transfer(self, amount: Money) - bool: 业务规则账户能否转出这笔金额。 return (self.status active and amount.amount self.balance.amount and amount.amount self.remaining_daily_limit.amount) def debit(self, amount: Money) - None: 业务行为扣款。判断与操作都封装在实体内部。 if not self.can_transfer(amount): raise DomainError(转账条件不满足) self.balance Money(self.balance.amount - amount.amount) def credit(self, amount: Money) - None: 业务行为入账。 self.balance Money(self.balance.amount amount.amount)注意1这段代码的 import 只有dataclasses和decimal都是标准库没有任何数据库驱动、HTTP 库、框架异常等技术代码业务规则和业务行为都从技术细节中脱离出来了。2debit和credit这两个方法同样属于业务行为它们也应该封装在实体内部。因为判断“能不能扣款”所需要的全部数据余额、状态、剩余额度都在Account内部所以判断和操作理应绑定在一起这就是领域驱动设计中常说的“谁拥有数据谁负责规则”4.2.出站/入站端口-定义“需求”抽离业务规则之后接下来就是考虑端口定义问题“转账需要从数据库读写账户、需要通知核心系统记账”这两个业务功能不是业务规则但业务又必须依赖它们。传统做法是直接在业务代码里import具体实现就是开头的做法。而六边形架构的做法是由核心定义需求把需求声明成接口# ports/account_repository.py属于核心区内的出站端口层 from typing import Protocol, Optional class AccountRepository(Protocol): 出站端口核心需要“能按账号存取账户”的能力 但不关心是数据库还是内存。 def find_by_no(self, account_no: str) - Optional[Account]: ... def save(self, account: Account) - None: ... class CoreSystemGateway(Protocol): 出站端口核心需要“通知核心系统记账”的能力 但不关心是 REST 还是 MQ。 def transfer(self, from_account_no: str, to_account_no: str, amount: Decimal) - dict: ...这两个接口都定义在核心区内部。它们的语义是“我需要什么”而不是“谁会提供”AccountRepository声明的是“按账号存取账户”这一能力需求至于这个能力最终由PostgreSQL、MongoDB 还是内存提供核心层不关心。CoreSystemGateway声明的是“通知核心系统记账”这一能力需求至于底层走 REST、gRPC 还是 MQ核心层同样不关心。4.3.应用服务层-业务编排有了领域对象和端口接下来写应用服务层。在展开阐述之前先说明两个容易混淆的概念什么是“编排”编排指的是协调多个组件、按正确的顺序完成一个完整的用例流程。它关心的是“先做什么、后做什么、谁和谁交互”而不是“这件事在业务上应该怎样判断”。编排与领域层的分工领域层负责决策回答“这笔转账能不能做”——账户状态是否正常、余额是否够、是否超过当日剩余额度。应用服务层负责编排回答“做这笔转账需要经过哪些步骤”——先加载账户、再调用领域判断、然后扣款入账、再保存、最后通知核心系统。用一句话概括领域层回答“能不能”应用服务层回答“怎么做”。领域层是老板负责拍板应用服务层是秘书负责流程。了解两者之间的区别之后我们再来看代码# application/transfer_use_case.py属于核心区内的应用服务层 class TransferUseCaseImpl: def __init__(self, account_repo: AccountRepository, core_gateway: CoreSystemGateway): # 通过构造注入端口而非具体实现 self.account_repo account_repo self.core_gateway core_gateway def execute(self, request: TransferRequest) - TransferResponse: # 编排加载两个账户 from_acc self.account_repo.find_by_no(request.from_account_no) to_acc self.account_repo.find_by_no(request.to_account_no) if not from_acc or not to_acc: return TransferResponse(successFalse, message账户不存在) # 编排把业务判断与业务行为委托给领域层 amount Money(request.amount) try: from_acc.debit(amount) # 领域层负责判断并扣款 to_acc.credit(amount) # 领域层负责入账 except DomainError as e: return TransferResponse(successFalse, messagestr(e)) # 编排通过端口持久化 self.account_repo.save(from_acc) self.account_repo.save(to_acc) # 编排通过端口通知外部系统 self.core_gateway.transfer( request.from_account_no, request.to_account_no, request.amount) # 编排返回结果 return TransferResponse(successTrue, message转账成功)这段代码里没有一行 SQL、没有一行 HTTP、没有任何 SDK 调用它只依赖AccountRepository和CoreSystemGateway这两个端口具体是谁实现了这两个端口它不知道也不需要知道。同时应用服务层没有出现if from_acc.balance amount这样的业务判断也没有出现from_acc.balance ...这样的直接操作而是将业务规则和业务行为全部交给领域层也就是“应用服务层不含业务规则”的含义。为了进一步看明白两者之间的关系可以查看应用服务层和核心领域层的交互逻辑时序图如下4.4.出站/入栈适配器-技术实现端在这个层级里才是真正的技术实现端# adapters/postgres_account_repository.py属于出站适配器层 class PostgresAccountRepository: 实现核心定义的 AccountRepository 端口 def __init__(self, db_session): self.db db_session def find_by_no(self, account_no: str) - Optional[Account]: row self.db.execute( SELECT * FROM accounts WHERE account_no%s, (account_no,) ).fetchone() if not row: return None return Account( account_norow[account_no], balanceMoney(Decimal(str(row[balance]))), remaining_daily_limitMoney(Decimal(str(row[remaining_daily_limit]))), statusrow[status]) def save(self, account: Account) - None: self.db.execute( UPDATE accounts SET balance%s WHERE account_no%s, (str(account.balance.amount), account.account_no)) # adapters/core_system_client.py属于出站适配器层 class CoreSystemRestClient: 实现核心定义的 CoreSystemGateway 端口 def __init__(self, http_client): self.http http_client def transfer(self, from_account_no, to_account_no, amount) - dict: return self.http.post(http://core/transfer, json{ from: from_account_no, to: to_account_no, amount: str(amount)})所有 SQL 语句集中在PostgresAccountRepository里所有 HTTP 调用集中在CoreSystemRestClient里如果换数据库只改前者如果换通信协议只改后者其余层级的代码不受影响。4.5.重构前后对比最后我们再来看一下重构前后的对比上图为重构前业务逻辑主动依赖具体技术下图为重构后应用服务层只依赖核心定义的端口适配器反过来实现该端口依赖方向从业务指向技术反转为技术指向业务。五.技术选型参考各层级的技术选型如下层推荐技术选型依据领域层纯 Python 3.10零框架依赖金额用Decimal应用层FastAPI / Flask轻量便于依赖注入入站适配器FastAPI、paho-mqtt、Celery覆盖 HTTP/MQ/定时任务出站适配器SQLAlchemy、asyncpg、httpx技术细节封装持久化PostgreSQL Redis事务 缓存加速金额字段用NUMERIC消息Kafka / RocketMQ异步解耦、事件分发测试pytest unittest.mock领域层独立测试