📦 本文基于的完整项目源码: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 接口(OrderServiceClientMarketplaceServiceClient …),被 buyer-bffseller-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 都在依赖的上帝接口。

再看几个技术细节:

  1. 服务端并没有 implements 这些接口@RestController@HttpExchange 是两套注解系统,契约安全来自共享的 DTO + 路径常量,而不是来自"共享 client 接口"。也就是说,shop-clients 提供的编译期安全,shop-contracts 已经提供了。
  2. Starter 已经吃掉了几乎所有样板。Caller 自己写一个 @PostExchange(OrderApi.XXX) 接口只是几行代码,成本极低。
  3. 共享 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 + 契约测试兜底。理由有四点:

  1. 语言单一。JVM 生态里 record + @HttpExchange 已经很接近声明式契约的体验,上 Spec 生成器的额外收益未必高。
  2. 边界已经清晰shop-contracts-* 严格只放常量和 DTO,不引 Spring、不引 JPA,这和“共享数据而不是行为”的思路比较一致。
  3. 反模式信号明确shared/shop-clients 正在重演 “I Was Taught to Share” 的老剧情,我会倾向尽早收掉。保留它等于为 polyrepo 留一个大概率要还的技术债。
  4. 少数场景再加 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,没有历史包袱,所以直接按目标形态重构到位了。具体动作如下:

  1. 删除 shared/shop-clients 模块。根 pom 移除对应 <module>,删除整个目录。

  2. @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 端点"的现象。
  3. PageResponse 迁回 shop-common-core。它是传输层分页元数据,不属于业务契约,放在 dev.meirong.shop.common.api.PageResponse

  4. 补一条 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-commonshop-contracts 里塞 @HttpExchange 接口时会在 CI 里被拦下来。

  5. 文档同步CLAUDE.mddocs/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”这个问题。

另一类是延伸阅读:不直接决定本文结论,但有助于把“为什么不要一下子大改”这个思路补完整。

  • Building Evolutionary Architectures:帮助理解为什么这类架构问题更适合“先收紧边界,再逐步演化”,而不是一上来就做仓库级大迁移。

小结#

回到最初的那个问题——BFF 调 domain service、或者 domain 之间互相调用,到底该共享到哪一步?

基于当前项目的实践,我的想法是:

  • 更适合共享的:契约,也就是路径常量和 DTO。
  • 可以共享的:HTTP 基础设施(拦截器、错误映射、trusted header 传递),放在 starter 里。
  • 尽量不要共享的@HttpExchange client 接口本身。让 caller 自己写,几行代码的成本换一个清晰的边界。
  • 我不建议共享的:跨 domain 的业务类、resilience 策略、鉴权实现。

shop 这个 demo 已经按这套原则落地并用 ArchUnit 规则把边界钉死;分仓之后,“共享契约 artifact + 自写 client + 契约测试兜底"在我看来是一条代价相对可控、演化空间也比较大的路线。Spec-first 的方案很漂亮,但它通常更需要平台团队级的投入——先把边界守好,再根据未来的多语言 / 外部合作诉求决定要不要往上加一层。