重构API这件事,我干了快十年,见过太多服务从两三个接口膨胀到一两百个,最后变成谁都不敢改、改了必出事故的雷区。去年帮一个创业团队做技术评审,他们有一个下单接口,参数是一坨三百多行的JSON,里面既有用户信息、商品列表,又有优惠券、配送方式和发票抬头,调用方为了凑齐这个对象,得先连续请求五个查询接口。项目上线两年,接口一百多个,但没有一个接口能独立说清楚"它到底代表一次什么业务动作"。这种局面不是靠多写文档、多点监控就能救回来的。我当时给的建议是:暂停新增功能,用领域驱动设计的视角,把API按业务能力重新拆一遍、重新塑形一次。这篇文章就是把这套"基于DDD的API接口优雅重构实践"完整复盘出来,适合那些正在被复杂单体接口或散乱微服务折磨的后端团队参考。
1. 重构之前:先给现有API做一次"痛感体检"
很多团队一谈重构就想直接动手改,这是最大的误区。API重构本质上是对业务认知的一次重构,如果连"现在的接口到底痛在哪、为什么痛"都没盘清楚,改完大概率是换一种方式继续乱。所以我每次进场的第一步,都是给现有API做一次系统性体检,把病态接口一个一个揪出来。
1.1 我见过的五个"病态接口"典型症状
第一个症状是贫血模型。实体类基本退化成数据库表的投影,只有getter和setter,业务逻辑全堆在Service层里。表现出来就是接口返回的全是数据结构,没有任何业务规则的味道。前端拿到的不是一个"订单",而是一堆字段拼起来的对象,业务规则散落在Controller、Service甚至前端JS里,改一个校验逻辑要动好几个地方。
第二个症状是上帝接口。一个查询接口返回一百多个字段,调用方每次只取其中五个,剩下的数据白白浪费带宽和序列化时间。这种接口往往是因为最初图省事,把多个查询场景的需求合并到一个大接口里,后续所有新需求都往里塞字段。
第三个症状是CRUD打天下。整个系统的接口看起来只有save、update、delete、list四种形状,业务动作被压扁成数据操作。"下单"是POST /order/save,"提交退款申请"也是POST /order/save,只是内部用type字段区分,加上一个type=refund_apply。这种接口短期开发快,但业务语义被完全抹掉了。
第四个症状是状态码失语。明明HTTP返回的是200,响应体里code=5001代表业务失败,调用方要解析两层才能判断成败。更麻烦的是,不同的接口团队对code的语义理解还不一致,这个服务里code=2003是库存不足,另一个服务里code=2003变成参数错误。
第五个症状是服务间的"数据库式调用"。服务A需要订单数据,不去调用服务A的定义好的查询接口,而是直接批量拉取订单接口的原始数据,在本地做过滤和join。这种调用模式会把接口性能拖垮,还会让接口签名被调用方的临时需求绑架。
1.2 用调用关系图圈出高优先级重构点
诊断不能只靠感觉,要有数据支撑。我的做法是从网关日志或者链路追踪系统里把近一个月的API调用记录拉出来,统计出每个接口的调用方、调用频率、平均耗时、失败率,以及响应体大小。然后重点标出几类异常:调用量极高但每次都只用一两个字段的接口,失败率波动明显且错误码集中在业务层的接口,还有那些明显是"为了支持另一个服务的内部逻辑"而设计的接口。
画调用关系图这步非常关键。把服务名画成节点,接口调用画成连接边,你会很快发现一些奇怪结构:比如一个底层服务被十几个上层服务直接调用,形成典型的"上帝服务"星型结构;或者两个服务之间存在循环调用,A调B的接口,B又调A的接口,这种结构基本可以断定业务边界切错了。把这些问题点汇总成"痛感Top 10"清单,这就是重构的切入点。注意,不是痛感最严重的优先,而是"改动范围可控、业务价值高"的优先,否则第一刀容易切到自己。
1.3 越改越乱的根源:业务语言没有统一
体检做到最后,你会发现一个更根本的问题:大家的业务词汇表根本没有对齐。同一个"客户",在订单团队眼里是下单人,在账户团队眼里是账号主体,在风控团队眼里是风险实体。同一份"订单数据",交易上下文关注的是金额和状态流转,物流上下文关注的是收货地址和包裹拆分,财务上下文关注的是发票和结算周期。
领域驱动设计里有个概念叫统一语言,意思是团队内部、团队之间、系统之间对业务术语要有唯一、无歧义的定义。API层的混乱,本质上是业务语言失焦的投影。接口命名一会叫createOrder,一会叫submitOrder,一会叫placeOrder,调用方根本分不清区别。所以在重构任何代码之前,先花时间把名词表、动词表理清楚,这是DDD重构的第一块基石。
2. 限界上下文:把按功能分组的API重切成按业务能力划分
体检完,接下来是DDD重构中最核心也最容易被做错的一步:确定限界上下文。你去看很多失败的DDD项目,问题几乎都出在这——上下文切成了一堆"听起来很高大上但没有实际业务边界"的领域,接口重切了却没有本质变化。真正的边界应该是从业务事件和业务语言里长出来的,它不是拍脑袋切出来的。
2.1 事件风暴与术语分析:边界不是拍脑袋切出来的
我通常用事件风暴来启动边界识别。准备一面足够大的墙,把业务相关方都叫上——产品、运营、技术、客服最好都来。然后按时间顺序贴便签:橙色表示命令(用户发起的行为),黄色表示事件(系统内已经发生的事实),蓝色表示读模型(界面要展示的数据),粉色表示外部依赖,绿色表示业务规则。
当整个业务过程被贴出来之后,有一个现象一定会出现:同一个名词在不同位置的定义开始打架。比如"订单已创建"这个事件,订单团队认为订单创建后库存就锁定,物流团队认为订单创建只是生成了一条记录,拍照的时候物流还没开始。这种分歧点就是天然的业务边界。两边对"订单"这个词的语义、生命周期理解不一致,它们就应该属于不同的限界上下文,各自维护各自的"订单"模型。
术语分析法也能辅助验证。把系统里所有业务词汇列出来,逐个问三个问题:这个词在所有场景里的含义是否一致?不同场景对同一个词的操作是否冲突?不同的词是否有可能是同一个概念?只要有一个问题答案是否定的,你就找到了至少两个上下文。
2.2 康威定律对API分组的意义
伊恩·康威在1968年有一个经典论断:设计系统的组织,其沟通结构会镜像地反映在系统架构上。放到API设计里,就是说API的边界划分应该跟着组织结构和团队协作关系走。如果你的团队已经按业务线拆成了交易组、支付组、履约组,那API就应该按这些业务能力来分组,而不是强行按技术分层(controller、service、dao)来组织。
我见过一个反例:公司组织上已经有独立的支付团队,但支付API还是挂在订单服务的路径下,支付团队要改接口必须和订单团队排期,结果每次改支付回调都要跨团队扯皮。后来把支付上下文独立出来,API路径改成/payment/,团队边界和接口边界对齐,效率立刻上来了。这里想强调一点:康威定律不是玄学,它是在提醒你,API结构设计如果不尊重组织沟通结构,最后一定会被组织沟通结构反噬。
2.3 一个订单服务切成五个上下文的完整过程
拿最常见的订单场景举例。假设现在有一个"万能的订单服务",接口包括:创建订单、修改订单、购物车、库存预占、支付回调、物流单生成、发票申请。第一步,拉出这个系统里的主要业务事件:购物车已更新、订单已提交、库存已锁定、支付成功、订单已发货、发票已开具。第二步,看这些事件分别围绕什么业务对象在转,哪些事件之间的关系是高内聚的,哪些是低频的。
以我的经验,这种场景通常能切出这几个上下文:
- 购物车上下文:关心选品、数量、价格试算,不关心库存能不能扣掉。
- 交易上下文:关心订单的创建、状态流转、优惠计算,是订单这个业务对象的"法定拥有者"。
- 支付上下文:关心支付单、支付回调、对账,它的"订单"只是支付单上的关联信息。
- 库存上下文:关心库存的预占、释放、扣减,根本不关心订单的详细商品信息。
- 履约上下文:关心出库、物流单、签收,它需要的只是订单的履约信息。
你会发现,同一个订单数据,被五个团队各自维护了一份带不同属性的版本。这不是数据冗余,而是不同上下文对订单的不同关注点。交易上下文里的订单有订单项、金额、优惠,履约上下文里的订单可能只需要订单号和收货地址。它们是不同的模型,不应该共享同一个数据库表,更不应该共享同一个大而全的API资源。
2.4 重切后的接口清单长什么样
边界切完之后,接口清单要按业务命令和业务事件重新设计。这里有一个很实用的判断标准:接口名应该是"动词+业务对象",而且动词要来自业务语言,不是技术语言。
举个例子。重构前是这种:
POST /order/save POST /order/update POST /order/delete POST /order/list重构后变成:
POST /trading/orders 提交订单 GET /trading/orders/{id} 查询订单详情(交易视角) DELETE /trading/orders/{id} 取消订单(未支付状态) POST /payments 创建支付单 POST /payments/{id}/callback 支付回调 POST /inventory/reservations 创建库存预占单 POST /fulfillments/orders 创建履约单注意,同样是DELETE /trading/orders/{id},它不是简单删除一条数据,而是执行"取消订单"这个业务动作,内部要校验订单状态是否允许取消、计算退款金额、触发取消通知事件。这才是API表达业务意图的正确方式。当你看到接口名把业务动作讲清楚了,调用方的代码就不再是一堆数据搬运,而是一段看得懂的业务故事。
3. 战术设计落地:聚合、防腐层与API资源模型对齐
边界划好了,接下来是战术设计,也就是在代码层面把DDD的聚合、仓库、应用服务、领域服务这些元件真正落地。很多团队折在这一步,因为他们把DDD做成了"一堆Service加一个Repository就叫DDD",实际上战术设计对API设计的影响远不止分层,它直接决定了你的API资源模型长什么样。
3.1 聚合根不是数据库表,API资源模型跟着聚合走
聚合是DDD中最容易被误读的概念。简单来说,聚合是一组必须保持数据一致性的业务对象集合,聚合根是外部访问这个集合的唯一入口。聚合边界就是事务边界,聚合内部要么全部成功要么全部回滚。
这给API设计的启示是:接口暴露的资源应该对应聚合根,而不是数据库表。还是拿订单举例,一个订单聚合通常包含订单主记录、订单项、收货地址。如果按照数据库表设计API,你会做出订单项的独立增删改查接口,调用方为了改一个订单要调五六个接口,中途任何一个失败,数据就处于残缺状态,这就是碎片化API的来源。
重构之后,API只暴露订单聚合根级别的操作:
@RestController @RequestMapping("/trading/orders") public class TradingOrderController { private final OrderApplicationService orderAppService; @PostMapping public PlaceOrderResponse placeOrder(@RequestBody PlaceOrderCommand command) { return orderAppService.placeOrder(command); } @PostMapping("/{orderId}/line-items") public void addLineItem(@PathVariable String orderId, @RequestBody AddLineItemCommand command) { orderAppService.addLineItem(orderId, command); } }调用方要么通过下单一次完成,要么通过向聚合添加商品项的方式操作,但每一步都经过聚合根校验,保证订单的不变式。判断子资源该不该有独立接口,有个简单的标准:这个东西离开聚合根还有没有独立业务含义?订单项单独存在没有任何意义,它永远属于某个订单,所以不该有独立的POST /order-items接口。
3.2 应用服务、领域服务、基础设施服务的三层边界
战术设计的经典分层是:接口层、应用层、领域层、基础设施层。这也是我见过被破坏得最多的部分。一个典型的重构前贫血Service长这样:
public class OrderService { public void saveOrder(OrderDTO dto) { if (dto.getStockCount() > 100) { throw new BizException("商品限购"); } // 五十行业务规则 // 手动写SQL,直接操作数据库 // 再调支付接口 } }重构后正确的做法是:应用服务只做用例编排,领域服务表达跨聚合的业务规则,聚合内的方法保证业务不变式,基础设施层负责真正的持久化和技术细节。应用服务这部分是API的直接入口,它接收Command对象,不接收领域实体,更不直接操作数据库。
public class OrderApplicationService { private final OrderRepository orderRepository; private final InventoryClient inventoryClient; private final PaymentClient paymentClient; @Transactional public PlaceOrderResponse placeOrder(PlaceOrderCommand command) { OrderId orderId = orderRepository.nextId(); Order order = Order.create(orderId, command.getCustomerId(), command.getLines(), command.getShippingAddress()); orderRepository.save(order); inventoryClient.reserve(orderId, command.getInventoryReservations()); return PlaceOrderResponse.from(order); } }领域层里,Order聚合有自己的create方法,里面执行限购校验、金额计算、状态初始化,这些规则封装在聚合内部,不泄漏到Service里。你可能会发现,应用服务还是有点"薄",但它不需要再写业务规则了,这就是充血模型和贫血模型的本质区别。接口层看到的东西始终是Command和Response DTO,领域对象永远不直接暴露给外部。
3.3 防腐层:隔离第三方API对领域模型的语义污染
业务系统天天要对接外部API:第三方支付、短信服务商、大模型开放平台、设备管理服务商、行情数据服务,这些五花八门的外部系统都有自己的数据模型和命名习惯。如果让外部模型直接穿透进你的领域层,领域模型很快会被外部语义污染,这也是很多系统重构后仍然别扭的原因之一。
DDD给出的方案是防腐层。防腐层位于领域层和外部系统之间,外部API的调用被封装在一个Adapter里,领域层只依赖自己定义的端口(Port),不依赖任何外部SDK。
// 定义在领域层 public interface PaymentGatewayPort { PaymentResult capture(PaymentIntent intent); RefundResult refund(RefundRequest request); } // 实现在基础设施层 @Service public class PaymentGatewayAdapter implements PaymentGatewayPort { private final ExternalPaymentClient client; @Override public PaymentResult capture(PaymentIntent intent) { ExternalPaymentRequest req = toExternalRequest(intent); ExternalPaymentResponse resp = client.capture(req); return toDomainResult(resp); } private ExternalPaymentRequest toExternalRequest(PaymentIntent intent) { // 领域对象转外部DTO,做名称、字段、单位的转换 } }这样做的价值体现在几处:第一,领域层对外部系统的存在一无所知,业务代码里不会出现第三方SDK的类型和方法调用;第二,外部系统升级、换供应商时,只需要改Adapter里的一小段代码;第三,领域模型的语义保持纯净,外部API里那些跟业务无关的字段、奇怪的命名习惯、不一致的单位换算,全被挡在防腐层外面。如果你的API要被大量下游系统消费,也把你自己理解成开放主机服务,对外提供一份稳定的发布语言,也就是公开DTO的无歧义契约,而不是直接暴露内部领域对象。
3.4 写命令与读查询分离,DTO放在哪里才不出圈
我特别建议在API重构时引入命令查询分离的思路。读操作和写操作在业务复杂度、性能优化方向上有本质差异:写操作关心数据一致性、状态流转,读操作关心响应速度、字段裁剪。把两者混在同一个接口模型里,往往导致谁都满足不好。
重构后,写接口接收的是命令对象。命令对象表达"用户意图",字段一般是不可变的,配有业务语义的校验逻辑。读接口返回的是查询Result,按视图需求裁剪字段,不直接返回领域实体。这里有一个重要的规范:Command 和 QueryResult之间的DTO转换只能在应用服务层和接口层进行,不允许把领域实体、领域服务方法暴露给Controller层。
我还遇到过一种情况,团队把QueryResult设计得跟数据库表字段一样,命名直接叫order_entity,这等于把领域模型泄漏出去了。正确做法是按调用方的使用场景来定义响应结构,比如订单详情接口返回OrderDetailView,里面包含订单基础信息和订单项列表,但不会出现status_code这种技术字段。DTO的命名也要像业务语言一样说话,不是一个笼统的ResponseBean。
4. 真正让重构"优雅"的脏活:兼容、版本、事务与验证
说句实话,DDD的建模和分层很多书上都写了,但真正决定一个重构项目生死的是另外一堆脏活:老接口怎么退场、新老版本怎么共存、跨服务的数据一致性怎么保证、重构后怎么证明没把业务改坏。这些步骤不做,再漂亮的模型都只是PPT。
4.1 老接口的退场仪式:适配映射与灰度迁移
不要追求"大爆炸式重构",也就是不要在某个凌晨把所有接口一次性切到新系统。业界成熟的模式是绞杀者模式:让新API和旧API在一段时间内共存,旧API的流量逐步被新API蚕食,最后旧系统像被绞杀藤缠住的树一样自然枯萎。
具体做法:在网关层或者Controller适配层,把旧请求映射到新的应用服务方法。比如旧接口POST /order/save,入参结构很乱,可以在适配器里把它转换成PlaceOrderCommand,再调用新链路。这个适配器就是临时的,等所有调用方切到新接口后,再整体删除。注意,删接口要定一个明确的退场日期,不能无限期拖下去。我见过很多团队说"先留着适配层,看看情况",结果适配层一留就是三年,成了新的历史包袱。
灰度迁移可以按调用方划分:先把对业务影响最小的内部调用方切过去,跑一两个迭代没有问题,再切外部重要客户,最后切剩余的零散流量。有条件的话,可以在核心接口上做金丝雀发布,让少量线上流量先走新链路,比较错误率和耗时,稳定后再放全量。这些操作分开看都不难,难的是坚持执行。
4.2 版本策略,别在一开始就搞成贵族式设计
新老接口共存期,版本策略一定会被摆上台面。常见三种:URL版本、Header版本、媒体类型版本,各有各的适用场景。
URL版本最简单。POST /v2/trading/orders和POST /v1/order/save,一眼就能看出来调的是哪个版本,日志排查、网关路由、客户端升级都很直接。缺点是版本的语义混进了资源路径里,严格来说不够RESTful,但对绝大多数业务团队来说,这是性价比最高的选择。
Header版本的做法是:URL保持不变,在请求Header里传Accept: application/vnd.trading.api+json;version=2。这样URL干净,但问题也明显,调试工具里要额外设置Header,网关日志要打印才能定位问题,调用方也很容易忘记传版本。我一般不推荐团队在第一版重构时使用。
媒体类型版本是RESTful规范的推荐方式,本质上和Header版本一脉相承,但需要客户端和服务端对媒体类型解析有非常严格的约定。如果你的API只面向自己团队的几个内部服务,搞这套属于过度设计。我的结论很简单:内部系统,用URL版本;公共服务、大量外部SaaS客户,优先考虑媒体类型版本。版本号从2开始,不要从1.1、1.2这种小版本开始,否则会陷入无穷无尽的版本泥潭。
4.3 聚合边界给分布式事务出的减法题
微服务化之后,原来单体应用里的一个事务往往被拆到多个服务里,分布式事务成为重构路上绕不开的坎。很多团队第一反应是引入分布式事务框架,但这往往是灾难的开始。
DDD分析出的聚合边界,其实就已经告诉你事务一致性边界应该怎么画了。聚合内部需要强一致,用本地事务就够了;跨聚合的协作,默认应该走最终一致性。比如下单流程,订单聚合和库存聚合如果不在同一个服务里,不要试图用一个分布式事务锁住两边。正确的做法是:订单服务在本地事务里创建订单并发一个"订单已提交"事件,库存服务订阅这个事件后执行库存预占,预占失败再通过补偿动作取消订单。整个过程有短暂的不一致窗口,但通过事件和补偿机制最终达到一致。
我这里想讲一次真实的教训。之前带团队重构一个下单链路,因为"感觉"分布式事务很高级,就引入了全局事务框架,结果每次下单都要锁库存表,高并发下单时大量请求等待锁,P99从80毫秒涨到2秒多,还时不时出现悬空事务导致数据对不上。后来我们重新看业务,发现下单和预占库存这两个动作其实是强关联的,就把它们放进了同一个服务、同一个本地事务里,支付走完全最终一致。复杂度骤降,性能问题也消失了。所以,能用本地事务解决的不要引入分布式事务,能靠事件+Saga补偿解决的不要硬上强一致方案。重构不是炫技,是在满足业务正确性的前提下找最简单的路径。
4.4 契约测试与影子流量:把重构风险关进笼子
重构最大的不确定性不是代码写不出来,而是改了之后有没有悄悄破坏调用方的假设。比如订单详情接口原来返回的total字段是商品总额,重构后语义变成了含税总额,调用方没发现,财务对账就全错了。为了锁住这类风险,推荐两个很实用的手段。
第一是消费者驱动契约测试。让每个下游消费者把自己对接口的期望写成契约,每次构建时,服务提供方都要运行一遍这些契约测试,一旦破坏了任何一个消费者的约定,CI直接报红。相当于把"不能破坏调用方"从口头约定变成了自动化的质量红线。契约测试的粒度要比接口联调测试细很多,它关注的不是业务流程跑不跑得通,而是具体的字段、类型、枚举值、状态码这些细节。
第二是影子流量回放。把线上真实请求记录下来,同时打到旧接口和新接口上,比对两者的响应。这里要注意,不是所有接口都能简单做响应body的字符串比对,更有效的是做字段级的语义比对。比如订单状态字段,旧接口返回1表示已支付,新接口返回paid,如果业务含义等价,也算通过。影子流量能在你正式上线之前就发现大量隐藏的不兼容问题。
再加上常规的新旧接口并存期对账:每天跑定时任务,用新老两条链路处理同一批数据,比对结果是否一致。这些验证手段本质上是把重构的"拍脑袋"变成"可证伪",没有这些手段,我对重构项目是否成功始终是存疑的。
我个人的体会是,所谓"发散创新"的优雅重构,从来不是靠某个灵光一现的天才设计,而是靠一套能把整个业务语言、边界、一致性、兼容性都梳理清楚的方法论。领域驱动设计给你的不是一堆时髦概念,而是一个逼着你把话说清楚、把边界划清楚、把后果想清楚的思维框架。重构完之后再去回看那些老接口,你会明白,真正让API变得优雅的,不是框架多新、命名多酷,而是每一个接口都开始像一句能被人读懂的业务语言。