微服务契约共享的 Tradeoff:从 Monorepo 到 Polyrepo,该共享到哪一步
目录
📦 本文基于的完整项目源码:meirongdev/shop
先给结论#
如果你只想先看我的当前结论,再决定要不要继续往下看,可以先看这一版:
- 我更倾向于共享 contracts,而不是共享 client 接口。
- 即使在 monorepo 里,我也会尽早把 shared client 下沉到各 caller 自己的
client/包。 - 如果未来要拆成 polyrepo,我目前更偏向“共享 contracts artifact + caller 自己写 client + 契约测试兜底”。
- 只有当你真的面临多语言 SDK、外部开放接口,或者已经有成熟的平台治理能力时,我才会认真考虑 spec-first + 代码生成。
换句话说,这篇文章真正要回答的不是“Client 接口写在哪”,而是:跨服务的边界,到底该共享“数据契约”,还是连“调用方式”也一起共享。
起点:一个具体的问题#
先用一个具体例子,把问题落到地上。
在早期的 shop 项目里,BFF 调下游 domain service 的链路是这样的:
shared/shop-contracts/shop-contracts-*:按 domain 拆分的契约模块,只有路径常量(OrderApi.CART_LIST)和 DTO(record),不依赖 Spring,也不含任何业务逻辑。shared/shop-clients:集中放所有@HttpExchange接口(OrderServiceClient、MarketplaceServiceClient…),被buyer-bff、seller-bff一起依赖。shared/shop-common/shop-starter-http-client:统一的ShopHttpExchangeSupport,负责用RestClient构造代理、挂拦截器、传 trusted headers、做错误映射。
BFF 侧的写法大致是:
@Bean
OrderServiceClient orderServiceClient(ShopHttpExchangeSupport support,
BuyerClientProperties properties) {
return support.createClient(properties.orderServiceUrl(), OrderServiceClient.class);
}
问题就从这里展开:微服务(或 BFF)之间的交互,是不是只要共享 contracts 就够了?Client 交给每个 caller 自己写就行?如果将来这些 domain 还要各自拆成独立的 repo,tradeoff 又该怎么做?
📌 更新(2026-04-22):本文讨论之后,
shop项目已经完成重构——shared/shop-clients模块被删除,@HttpExchange接口下沉到各 caller 自己的client/包,并用 ArchUnit 规则SPRING-03 http_exchange_clients_must_live_in_caller_module钉住边界。本文保留了讨论脉络,下文"当前形态"一节给出了 demo 现在的分布。
为了避免讨论停留在抽象层面,下文会先解释为什么当前的 shared client 已经开始变成负担,再对比分仓后常见的三种契约流通模型,最后给出 demo 已经落地的最终形态。
Monorepo 现状:共享 Client 接口是不是必要的#
先从当前的 monorepo 情况说起。shared/shop-clients 这个模块的存在听起来合理——多个 BFF 都要调同一个 domain 服务,接口写一次就够了。但实际上它已经出现"胖接口"的腐化信号。
看一段 OrderServiceClient 的注释:
/**
* Shared {@code @HttpExchange} client for order-service.
* Used by both buyer-bff and seller-bff.
*/
@HttpExchange
public interface OrderServiceClient {
// ── Buyer-facing endpoints ──
@PostExchange(OrderApi.CART_LIST) ApiResponse<OrderApi.CartView> listCart(...);
@PostExchange(OrderApi.CART_ADD) ApiResponse<OrderApi.CartItemResponse> addToCart(...);
// ── Shared endpoints (buyer + seller) ──
@PostExchange(OrderApi.ORDER_LIST) ApiResponse<List<OrderApi.OrderResponse>> listOrders(...);
// ── Seller-facing endpoints ──
@PostExchange(OrderApi.ORDER_SHIP) ApiResponse<OrderApi.OrderResponse> shipOrder(...);
}
一个接口里混进了 buyer、seller、internal 三类方法。任何一个 caller 都能在 IDE 里"看到"它不该调的接口——接口隔离原则已经被打破。这正是 shared client library 反模式最典型的腐化路径:先是"写一次复用多次",再是"为第 N 个 caller 加个参数",最后变成一个什么 caller 都在依赖的上帝接口。
再看几个技术细节:
- 服务端并没有
implements这些接口。@RestController和@HttpExchange是两套注解系统,契约安全来自共享的 DTO + 路径常量,而不是来自"共享 client 接口"。也就是说,shop-clients提供的编译期安全,shop-contracts已经提供了。 - Starter 已经吃掉了几乎所有样板。Caller 自己写一个
@PostExchange(OrderApi.XXX)接口只是几行代码,成本极低。 - 共享 client 会把编译依赖传染整条链。
shop-clients聚合了所有contracts-*,任何一个 domain 的契约改动都会让shop-clients重编译、让所有 caller 重编译。
我现在的判断是:**在 monorepo 里也应该让 shared/shop-clients 逐步解散,下沉到各 caller 自己的 client/ 包;契约(路径 + DTO)继续走 shop-contracts-*;starter 保持现状。**这是本次讨论的第一个落点。
分仓之后,真正要决定的是什么#
假设接下来要把每个 domain 都拆成独立 repo,那么"共享到哪一步"这个问题就会被放大。分仓的核心矛盾是:
- Provider 希望按自己的节奏发布和演化
- Consumer 希望拿到稳定、可验证的接口定义
- 谁都不想要一个"改了不知道会炸谁"的隐式契约
于是真正要决定的不是"client 写在哪",而是契约以什么形式跨仓流通,以及由谁拥有它。通常就这三种主流模型。
模型 A — 每个 domain 发布契约 artifact(contracts-only)#
Domain 仓里同时发布一个 *-contracts artifact(Maven jar / npm 包),里面只放:
- 路径常量
- 请求 / 响应 DTO
- 必要的枚举 / 错误码
Caller 在自己仓里声明版本号依赖,自己写 @HttpExchange 接口。
👍 优势
- 版本解耦:caller 按自己的节奏升级,provider 按自己的节奏发布。
- 编译期发现大多数破坏性变更(字段改名、删除、类型变化)。
- 契约 artifact 里没有可执行代码,不会把 provider 的实现细节泄露出去。
- 对下游是一个显式的依赖边界,而不是隐式的"都在一个 repo 里随便 import"。
👎 代价
- 仍然是 artifact 依赖,breaking change 要走 semver 严格约束 + 下游迁移计划。
- 对非 JVM 的 caller(比如 KMP / Node / Python)不够友好,每种语言要各自产包。
这基本上就是现在 shop-contracts-* 的形态,分仓后可以考虑维持。
模型 B — Spec 为中心(OpenAPI / Protobuf / AsyncAPI)#
Domain 仓里真正的"源文件"是 api.yaml 或 .proto。服务端用 spec 生成 server stub,客户端用 spec 生成 SDK;或者干脆把 spec 集中到一个独立的 api-specs 仓,按域分目录,在 CI 里做向后兼容 linting(Buf / Redocly / oasdiff)。
👍 优势
- 多语言友好:同一份 spec 可以生成 Java / Kotlin / TS / Go / Python。
- Spec 本身可以作为"可执行契约",用 Specmatic / Prism 跑 mock 和校验。
- 生成器可以统一鉴权、错误处理、分页等风格。
👎 代价
- 生成器模板的治理成本不低:一旦模板要改,所有 caller 都要重生成、重回归。
- 生成的代码可读性和可调试性不如手写。
- Spring Cloud Contract、Pact、纯 OpenAPI 三个世界的技术选型要早定,否则后期切换代价很大。
模型 B 的优势很明显,但前提也很苛刻:**你得真的有能力长期维护生成器模板、兼容性校验、SDK 发布流程和回归链路。**如果团队规模有限、语言又比较单一,那么这条路线在工程上未必划算。
模型 C — 不共享 artifact,靠契约测试把关#
Caller 完全自己写 client,provider 不发任何包,也不提供生成的 SDK。契约漂移通过契约测试(Pact 的 consumer-driven、Specmatic 的 spec-driven)在 CI 里被捕获。
👍 优势
- 相对最解耦:caller 基本不依赖 provider 的发布节奏。
- 对语言和技术栈最宽容。
👎 代价
- 没有编译期保障,所有错误都推到契约测试或集成阶段。
- Pact 风格的 CDC 会引入"dependency drag":consumer 的测试反过来成为 provider 发布的阻塞条件。Specmatic 有一篇 Pact’s Dependency Drag 对这个问题的分析相当尖锐。
如果把这三种模型压缩成一张表,它们的差异会更直观:
| 模型 | 跨仓共享什么 | 适合什么场景 | 主要代价 |
|---|---|---|---|
| A. contracts-only artifact | DTO、路径常量、错误码 | JVM 为主、调用方不多、想保留编译期保障 | 需要做版本治理和 breaking change 管控 |
| B. spec-first | OpenAPI / Protobuf / AsyncAPI spec,以及生成 SDK | 多语言调用方、对外开放 API、平台能力较强 | 生成器和治理规则的长期维护成本高 |
| C. contract testing only | 不共享 artifact,只共享测试契约 | 技术栈异构、希望把依赖降到最低 | 编译期保障最弱,更多问题后移到测试阶段 |
怎么选:一个偏保守的建议#
在当前这个项目(JVM 为主、团队规模有限、迭代节奏以业务为导向)的前提下,我会选 模型 A + 契约测试兜底。理由有四点:
- 语言单一。JVM 生态里
record+@HttpExchange已经很接近声明式契约的体验,上 Spec 生成器的额外收益未必高。 - 边界已经清晰。
shop-contracts-*严格只放常量和 DTO,不引 Spring、不引 JPA,这和“共享数据而不是行为”的思路比较一致。 - 反模式信号明确。
shared/shop-clients正在重演 “I Was Taught to Share” 的老剧情,我会倾向尽早收掉。保留它等于为 polyrepo 留一个大概率要还的技术债。 - 少数场景再加 Spec。未来如果 KMP app 要直调、或者要对接外部合作伙伴,再针对那几个 endpoint 额外维护一份 OpenAPI,生成对应语言的 SDK,不必全量上。
分仓之后具体的落地动作:
- 每个 domain 仓里加一个
contracts/子模块,CI 里单独mvn deploy到内部 Nexus / GitHub Packages。 - Caller 的
pom.xml显式写版本号,禁止LATEST/RELEASE。 - 契约模块加 PR gate:
major变更(字段删除、类型缩窄)最好过 review + 通知下游;minor更适合只允许加字段;patch更适合只改注释 / 文档。 - 用 Spring Cloud Contract 或 Specmatic 在 provider 端跑契约测试,避免"常量改了但运行时 path 没改"的漂移。
- Starter 保持共享,它不属于契约,它只是 HTTP 基础设施。
当前形态:demo 已经落地的最终布局#
shop 作为一个练习和分享的 demo,没有历史包袱,所以直接按目标形态重构到位了。具体动作如下:
-
删除
shared/shop-clients模块。根 pom 移除对应<module>,删除整个目录。 -
@HttpExchange接口下沉到各 caller:services/buyer-bff/src/main/java/dev/meirong/shop/buyerbff/client/放 9 个买家侧需要的接口。services/seller-bff/src/main/java/dev/meirong/shop/sellerbff/client/放 6 个卖家侧需要的接口。- 每个接口只声明当前 caller 真正会调的方法——不再有"buyer 能看到 seller 端点"的现象。
-
PageResponse迁回shop-common-core。它是传输层分页元数据,不属于业务契约,放在dev.meirong.shop.common.api.PageResponse。 -
补一条 ArchUnit 规则
SPRING-03:@ArchTest static final ArchRule http_exchange_clients_must_live_in_caller_module = classes() .that().areAnnotatedWith(HttpExchange.class) .should().resideInAPackage("..client..") .andShould().resideOutsideOfPackages( "dev.meirong.shop.common..", "dev.meirong.shop.contracts..");这条规则保证未来谁往
shop-common或shop-contracts里塞@HttpExchange接口时会在 CI 里被拦下来。 -
文档同步:
CLAUDE.md、docs/ARCHUNIT-RULES.md更新边界说明;早期的2026-04-13-common-client-and-tracing-design.md方案加了"已被超越"的标注,保留历史上下文。
这一整套修改之后,demo 的共享分布变成这样:
shop-contracts-* → 共享的"数据契约":路径常量 + DTO + 错误码
shop-starter-http-client → 共享的 HTTP 基础设施:RestClient 构造、拦截器、错误映射
<caller>/.../client/ → caller 独占的 @HttpExchange 接口,按需而写
这正是前一节结论的 monorepo 形态:共享数据,不共享行为。如果未来真要分仓,我的理解是把 shop-contracts-* 的 publish 流程独立化就已经能覆盖大部分迁移成本,其他层级未必需要一起大改。
这种"用可执行规则锁住边界"的思路,也呼应了《Building Evolutionary Architectures》里 fitness function 的主张——架构演化可以依靠持续可验证的约束,而不仅仅是一次性的大重构。
参考资料:核心参考与延伸阅读#
原稿里的参考资料大多是有价值的,但它们和本文主线的距离并不完全一样。为了让读者更容易判断“哪些资料是直接支撑本文结论的”,这里拆成两组:
一类是核心参考:直接回答“微服务之间该不该共享 client / contract / spec”这个问题。
- Microservice Antipatterns: The Shared Client Library — mmainz.dev (2024):系统拆解了共享 client 带来的隐式耦合。
- Don’t Share Libraries among Microservices — phauer.com:虽然是老文,但它把“共享库为什么会伤害服务独立性”讲得非常透。
- Martin Fowler: Consumer-Driven Contracts:适合理解 consumer-driven contract 的基本语境。
- Pact Docs:如果你在比较 consumer-driven contract 的工具链实现,这是更直接的官方资料。
- AWS Well-Architected: Contract Testing:把 contract testing 放回工程治理语境里看,会更实用一些。
- Pact’s Dependency Drag — Specmatic (2024):很直接地指出 consumer-driven contract 可能反向制造耦合。
- Pact vs OpenAPI — Speakeasy (2024):更偏选型对比,带一点厂商立场,但对理解两条路线的成本差异有帮助。
另一类是延伸阅读:不直接决定本文结论,但有助于把“为什么不要一下子大改”这个思路补完整。
- Building Evolutionary Architectures:帮助理解为什么这类架构问题更适合“先收紧边界,再逐步演化”,而不是一上来就做仓库级大迁移。
小结#
回到最初的那个问题——BFF 调 domain service、或者 domain 之间互相调用,到底该共享到哪一步?
基于当前项目的实践,我的想法是:
- 更适合共享的:契约,也就是路径常量和 DTO。
- 可以共享的:HTTP 基础设施(拦截器、错误映射、trusted header 传递),放在 starter 里。
- 尽量不要共享的:
@HttpExchangeclient 接口本身。让 caller 自己写,几行代码的成本换一个清晰的边界。 - 我不建议共享的:跨 domain 的业务类、resilience 策略、鉴权实现。
shop 这个 demo 已经按这套原则落地并用 ArchUnit 规则把边界钉死;分仓之后,“共享契约 artifact + 自写 client + 契约测试兜底"在我看来是一条代价相对可控、演化空间也比较大的路线。Spec-first 的方案很漂亮,但它通常更需要平台团队级的投入——先把边界守好,再根据未来的多语言 / 外部合作诉求决定要不要往上加一层。