外部模型的侵入不是一次架构决定,而是一行一行长出来的:某个需求"顺手"把第三方的 PaymentResponse 引进了 Service,某个字段改名三个月后才暴露影响面,某个上游把 500 换成带业务码的 200,核心逻辑里就多了个 code == 20000。等要换供应商,才发现领域层已经说不了自己的语言。
防腐层(Anti-Corruption Layer, ACL)做的事就是划一条线:线内是领域语义,线外是别人的语义,翻译只发生在线上的适配器里。
1. 三类污染
| 污染类型 | 表现 | 不隔离的后果 |
|---|---|---|
| 外部模型侵入核心 | 领域实体带上游的 @JsonProperty,字段名跟着上游改 |
上游改名 → 领域层跟着改 → 全链路回归 |
| 外部变更传导 | 上游新增必填字段、改枚举,编译期不报错 | 影响面无法估计 |
| 异常语义混淆 | 超时、限流、业务拒绝混在同一个 RuntimeException |
判断不出该重试还是该失败 |
第三类最容易被忽略:429 是"稍后重试",INSUFFICIENT_BALANCE 是"业务拒绝、重试无用",SIGN_INVALID 是"我方配错了、必须告警"。它们该是三个概念,而不是同一个 catch。先把结果定义成封闭类型:
public interface PaymentGateway { // 端口:只有领域语言
PaymentResult charge(PaymentOrder order);
}
public sealed interface PaymentResult {
record Success(String providerTxId) implements PaymentResult {}
record Rejected(String reasonCode, String msg) implements PaymentResult {} // 不重试
record TransientFailure(String msg, Duration retryAfter) implements PaymentResult {} // 可重试
}
这段里没有 HttpClient、没有上游 DTO 和错误码。
2. 三种边界
| 边界 | 方向 | 内容 | 漏做会怎样 |
|---|---|---|---|
| 数据模型 | 外→内 / 内→外 | 字段名、类型、单位、枚举、空值语义、嵌套 | 上游改名直接改到领域实体 |
| 错误语义 | 外→内 | 上游错误码/HTTP 码 → 内部异常族 | 重试策略写在调用点,四处不一致 |
| 协议 | 内→外 | 签名、加密、超时、分页、批量、幂等 | 协议细节渗进业务代码 |
单位和枚举最容易漏:金额上游用"分"、内部用 BigDecimal 元;上游状态有 SUCCESS/FINISHED/DONE 三个同义词。这类转换在适配器里是一行 switch,散在业务代码里就是几十处 if。
转换落在三个动作:orderNo → 上游 outTradeNo;BigDecimal 元 → "分"(movePointRight(2),是两个世界的单位约定,不是业务规则);SUCCESS/FINISHED/DONE 归一到一个内部枚举。异常就地翻译:超时与限流都返回 TransientFailure 并带 retryAfter,其余落进 Rejected。
3. 适配器的放置:端口在内,实现在外
order-domain order-infrastructure
├── domain/PaymentGateway.java ←── ├── gateway/AcmePaymentAdapter.java
├── domain/PaymentOrder.java ├── gateway/AcmeClient.java
└── domain/PaymentResult.java └── gateway/AcmeChargeRequest.java
▲
└── order-application 依赖 domain,不依赖 infrastructure
实现侧靠 @Bean 装配,领域侧不知道 AcmePaymentAdapter 存在。规则靠编译期强制:order-domain 的 pom.xml 里不能出现 spring-boot-starter-web、okhttp、jackson。这比靠 Code Review 可靠。外部系统多时可再分一层 PaymentProviderPort(这家供应商怎么调);只有一家供应商、短期不会换,合成一层就够。
4. DTO 转换的成本与控制
| 方式 | 优点 | 代价 | 适用 |
|---|---|---|---|
| 手工 setter 映射 | 明确、可调试、可重构 | 字段多时样板代码多 | 字段 < 15,或含条件判断 |
| MapStruct 编译期生成 | 零反射、编译期报错 | 需注解处理器配置 | 字段多、结构规整 |
| BeanUtils 反射拷贝 | 一行搞定 | 运行期反射、改名静默失败 | 临时脚本,不进生产 |
| JSON 序列化往返 | 少写代码 | 类型错误推到运行期 | 不推荐 |
可维护性问题不在工具,在双向转换:30 个字段的 DTO 既外→内又内→外,就是 60 个字段要维护,且两边不对称。办法是单向化——领域侧只声明"我需要的",适配器把外部结构压成最小形状。
@Mapper(componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR) // 漏映射直接编译失败
public interface OrderViewMapper {
@Mapping(source = "channelCode", target = "channel")
OrderView toView(ExternalOrderDTO dto); // 枚举归一放在 default 方法里
}
ReportingPolicy.ERROR 是关键:默认的 WARN 只在日志里刷过,等于没有约束。
5. 失败语义:四类,不是两类
| 失败类型 | 例子 | 该做什么 | 不该做什么 |
|---|---|---|---|
| 超时 | 连接超时、读超时 | 幂等前提下可重试;两者分开 | 无限重试;重试非幂等写 |
| 可重试 | 429、502/503/504、连接被拒 |
退避重试,尊重 Retry-After |
立即重试形成风暴 |
| 不可重试 | 参数错、签名错、业务拒绝 | 快速失败,映射成明确异常 | 重试 |
| 部分成功 | 批量中一半成功 | 记录逐条结果并暴露 | 压成整体成功或失败 |
连接超时和读超时是两回事:前者说明请求没发出去,重试安全;后者说明请求很可能已被处理,非幂等写重试就重复提交。
ClientHttpRequestFactorySettings s = ClientHttpRequestFactorySettings.defaults()
.withConnectTimeout(Duration.ofMillis(500)) // 连不上就是连不上
.withReadTimeout(Duration.ofMillis(3000)); // 给上游留处理时间
读超时后要重试写操作,必须依赖上游幂等键或本地幂等表——写进注释,否则下一个人加个 @Retryable 就把重复扣款加上了。
6. 版本变化如何被隔离
正确做法:兼容分支只出现在一个文件里。
@Component
class AcmePaymentAdapter implements PaymentGateway {
private final boolean useV2; // 由配置注入,不写死
@Override
public PaymentResult charge(PaymentOrder order) {
return useV2 ? chargeV2(order) : chargeV1(order); // /payments vs /pay/apply
}
private PaymentResult chargeV2(PaymentOrder order) { ... }
@SuppressWarnings("unused")
private PaymentResult chargeV1(PaymentOrder order) { ... }
}
同时写契约测试:同一批输入下两套实现的 PaymentResult 语义必须一致,切换时立刻能发现漂移。差异大到无法共存(如认证从签名改 OAuth)就做两个实现,由配置决定装配哪个,业务不感知。
7. 与被调方共享库的反模式
| 反模式 | 表面好处 | 实际代价 |
|---|---|---|
| 上游 SDK 模型类直接进领域层 | 省掉 DTO | 上游升级 = 强制升级;带来一堆上游注解 |
| SDK 与本地 Jackson/okhttp 版本冲突 | 开发省事 | 仲裁结果不可预期,运行期 NoSuchMethodError |
| 共享"公共 DTO 包" | 版本一致 | 谁都改,改一次全体重编 |
| 复用 SDK 内部线程池 | 不用配 | 上游默认参数不可控,成隐藏瓶颈 |
原则:SDK 只能出现在基础设施模块的适配器里,且必须被端口包住,升级影响面收敛到"改一个适配器 + 跑契约测试"。
8. 什么时候不需要防腐层
判据是变更速率差:两边模型的变更原因和节奏不同就该切;总是一起改,切了只增加同步成本。四类不必上:内部同构服务(同团队同模型,字段名即领域语言)、一次性脚本、只取一个 boolean 的探针调用、同进程内的模块调用(那是模块边界问题)。
9. 测试策略
重点不是覆盖率,是契约:
| 类型 | 打什么 | 断言什么 |
|---|---|---|
| 桩单测 | 端口接口 | 业务逻辑对三种 PaymentResult 的处理 |
| 契约测试 | 真实适配器 + 录制的上游响应 | 上游结构不变时转换正确 |
| 抓包回放 | 样本报文存入 src/test/resources |
上游新版报文也能解析 |
| 集成测试 | 上游沙箱 | 端到端连通性,只在改适配器时跑 |
@Test
void should_treat_read_timeout_as_transient() {
AcmeClient client = mock(AcmeClient.class);
when(client.charge(any(), any())).thenThrow(new AcmeTimeoutException("read"));
assertInstanceOf(PaymentResult.TransientFailure.class,
new AcmePaymentAdapter(client, false).charge(order()));
}
这条比"字段映射对不对"更重要:错误分类错一次,就是一次重试风暴或一次静默丢单。每加一个上游错误码,都要补一条映射测试。
10. 常见坑
| 坑 | 现象 | 改法 |
|---|---|---|
| 外部 DTO 直接进领域层 | 领域实体带上游注解,上游加字段就序列化异常 | 领域模型与上游 DTO 分离 |
| 异常映射只做在 Controller | Service 互调时拿到原始上游异常 | 在适配器里就映射成领域异常 |
| 适配层塞业务逻辑 | "顺便算个优惠",换供应商要重写 | 适配层只翻译,不决策 |
| 外部字段改名引发全链路改动 | 一处改名十几个文件 | 转换只在一处 |
| 重试写在调用点 | 各业务里次数不同 | 收敛到 RestClient 配置 |
11. 排错
| 现象 | 排查方向 |
|---|---|
| 上游说收到、本地说失败 | 读超时;按 outTradeNo 查上游结果并对账 |
| 换上游要改一大片业务代码 | 端口太薄,或领域层直接用了上游类型 |
上游报错但日志只有 NullPointerException |
响应体解析失败被吞;先记原始报文再解析 |
启动后随机 NoSuchMethodError |
SDK 与本地依赖冲突;mvn dependency:tree 找重复 class |
| 契约测试全过但线上解析错 | 线上返回了网关 HTML;断言 Content-Type |
12. 边界
| 场景 | 更适合的做法 |
|---|---|
| 内部同构服务、同团队同模型 | 直接共享模型或走 gRPC |
| 上游本身就是你的领域概念 | 客户端库加薄封装即可 |
| 上游 API 每周改 | 防腐层治标;优先找稳定版本或本地镜像 |
| 需要全量动态 schema | JsonNode 直通并标注"未建模" |
13. 自测
- 上游返
429与返BALANCE_NOT_ENOUGH,在领域层该是同一个异常吗? - 端口在领域层、实现在基础设施层,靠什么机制保证依赖方向不被打破?
- 读超时之后为什么不能无条件重试创建订单的请求?
- 上游字段改名,改动该落在几个文件里?
- 什么样的两个系统之间不需要防腐层?
- 适配器测试里,为什么"错误分类"比"字段映射"更关键?