跳转到内容

Remote 不是组件库

两个 Remote 需要相同或相似的功能,并不能自动证明它们之间应该建立横向依赖。

Remote 是能够独立运行的应用,也是一个有明确业务职责的区域。它不是 Runtime SDK,不是组件库,也不应该成为其他 Remote 的内部 Service 提供者。

当同一类功能第二次出现时,这条边界就会开始让人不舒服。因为从实现角度看,答案往往显得太明显:

这个功能不是已经有了吗?为什么不直接复用?

问题本身合理。

但最直觉的答案并不总是合理。

在共享已有实现之前,首先要确认:这里真的属于同一个业务概念吗?两个界面今天看起来完全相同,明天却可能因为截然不同的业务原因朝不同方向变化。

确认了这一点之后,才有意义在三种方式之间做选择:

  • 有意识的多份实现;
  • Shared Library;
  • 独立负责的 Capability。

这三种方案没有哪一种天然“更成熟”。它们解决不同问题,也会产生不同形式的耦合。

假设 Calendar-Remote 已经实现了患者选择。用户可以通过搜索框找到患者,再从结果列表中选中一个。

现在 Billing-Remote 也需要患者选择。

看起来最简单的办法是:

Remote B
└── 使用 Remote A 中的患者选择

技术上,Remote B 可以直接从 Remote A 的项目 Import Source Code,可以调用它公开的 Runtime API,可以加载 Remote A 中的 Fragment,甚至可以把整个 Remote 嵌进来。具体形式会改变耦合发生在哪一层,但不会改变最根本的架构决策。

Source Import 会带来 Build 和 Version 耦合:Remote B 访问了另一个业务项目的内部实现。Runtime API 或嵌入式 Fragment 则进一步把 Availability、Activation 和 Lifecycle 也耦合起来。

无论哪种形式,Remote B 都开始横向依赖 Remote A。

此后,Remote B 的本地开发和隔离测试可能需要 Remote A 的代码、Contract,甚至需要它正在运行的实例。Rollback 和不同 Activation State 更难处理。Bug 也更难判断究竟属于 Consumer 还是 Provider。

更重要的是,Remote A 的职责已经改变。

Calendar Team 不再只是开发 Calendar。它现在还需要为陌生 Consumer 提供稳定 Contract。内部 Component、Service 或 Data Model 事实上变成 Public API。原本只在 Calendar 内部安全的修改,现在可能破坏其他 Remote。

一个业务区域在不知不觉间变成了其他业务区域的平台。

这种变化经常不是架构决策,而只是历史偶然:因为代码最先在这里出现,于是这里就被默认当成 Provider。技术上“已经存在”取代了对 Ownership 的讨论。

但一个应用不会因为它的某个功能在别处也需要,就自动变成 Library。

“两边需要完全相同的业务功能”首先应该被当成一个待验证的假设。

来看三个不同区域里的患者选择。

Calendar 选择患者,是为了安排预约。Billing 选择患者,是为了分配 Invoice 或 Payer。Documentation 选择患者,是为了创建或归档文档。

这三个 UI 一开始可能几乎完全一样:

  • 一个搜索框;
  • 一份结果列表;
  • 姓名和出生日期;
  • 也许还有保险状态。

但视觉相似,对业务含义几乎说明不了什么。

在预约场景中,重要的可能是患者是否可以在当前机构接受治疗、是否存在预约警告,或者是否与某位医生存在关联。

在 Billing 中,更重要的可能是 Payer、Billing Lock、Invoice Status 或特殊保险信息。

在 Documentation 中,决定“能不能选、怎么选”的可能是访问权限、归档规则、法律上正确的姓名格式或具体 Document Context。

所以,差异不仅出现在额外展示字段上。真正不同的是这些功能为什么会发生变化。

新的 Billing Rule 可能只应该影响 Billing 中的患者选择。新的 Document Archive Rule 只影响 Documentation。预约限制也不应该自动改变其他领域中的选择逻辑。

相同外观并不能证明业务含义相同。

如果明天两个 Remote 对这个“共享功能”提出了彼此冲突的需求,会发生什么?

如果 Shared Component 必须同时支持两种版本,它的 API 很快就会长出 Optional Parameter、业务 Flag 和 Consumer-specific Callback。不同模型被挤进一个 Shared Type,Switch Logic 决定当前到底在哪个业务 Context 中运行。

Implementation 仍然只发布一份。

但意义已经不再共享。

多个业务变体只是被藏进了同一个技术抽象里。

Calendar、Billing 和 Documentation 中三个视觉相似的患者选择,其业务含义和规则并不相同。

有意识的重复实现,可能才是更干净的选择

Section titled “有意识的重复实现,可能才是更干净的选择”

软件开发常常把 Duplication 反射性地当作错误,于是团队很容易过早抽象所有看起来相似的东西。

在业务边界上,这恰恰可能是更昂贵的决定。

如果 Calendar 与 Billing 中的患者选择具有不同业务含义,两边完全可以各自实现:

Calendar-Remote
└── 用于预约的患者选择
Billing-Remote
└── 用于结算的患者选择

两个实现仍然可以复用相同的 Design Token、Input Component、Table Component 或技术搜索机制。它们并不因此必须共享同一个业务 Component。

这里应该把三个层次分开:

  1. 外观是否共享;
  2. 技术基础组件是否共享;
  3. 业务含义是否共享。

统一外观可以由 Design System 保证。重复技术机制可以放进技术 Library。但这并不意味着业务选择逻辑也必须共用一份实现。

当两个功能因为不同原因变化、由不同团队负责、使用不同 Backend Contract,或者很可能进一步业务分化时,有意识的多份实现尤其合理。

额外代码不一定是浪费。

它购买的是独立变化能力。

当然,这也不是随意 Copy-and-paste 的许可证。Duplication 应该是一项明确决策,并被记录下来。之后两个实现可以各自演进,也不应该再人为要求它们永久保持同步。

如果后来经过较长时间验证,发现语义、行为和变化原因确实始终一致,仍然可以再建立共同抽象。晚一点合并,通常比拆掉一个过早建立的共享责任更容易。

多写一份实现,在经济上完全可能比跨多个职责区域长期协调更便宜。

Shared Abstraction 应该表达稳定的共同点,而不是冻结两个实现此刻看起来相似的事实。

业务 UI Component 尤其容易在最初显得“很通用”。第一版只有一个 Consumer。第二个 Consumer 需要一个额外 Option。第三个 Consumer 又要求在原本内部流程的某一步加 Callback。

每多一个 Consumer,Configuration 就继续增长:

患者选择
├── Calendar 模式
├── Billing 模式
├── Documentation 模式
├── 可选 Warning
├── 可替换结果展示
├── 选择前 Callback
├── 选择后 Callback
└── 每个 Remote 的特殊情况

抽象没有消除差异,只是把差异搬进了更大的 Property API、Flag 和 Conditional Flow。

当 Central Type 开始混合多种业务语言,或者任何参与团队都无法拒绝一个新增需求时,所谓复用已经变成共同谈判点。

好的抽象应该表达共同的稳定性,而不是保存当前的相似性。

当多个 Consumer 确实需要同一份稳定实现时,Shared Library 是合理选择。

技术 Library 通常比较容易满足这个条件。Logging、Telemetry、Design Token、基础 Component、技术 Error Handling 或 Auth Client Abstraction,本来就可以拥有宽而稳定的 Contract,其含义也不会随着业务 Consumer 变化。

Formatting Function、I18n Mechanism 或边界明确的 Protocol Type 也经常适合共享。

业务 Shared Library 的门槛更高。

只有当它在所有 Consumer 中语义真的相同、变化来自同一种业务原因、所有 Consumer 都需要同一行为,并且谁拥有 Shared Contract 已经明确时,才值得共享。

例如,一个由法律明确规定、在所有使用场景中含义完全相同的计算算法,就可能适合共享。业务 Validator 也可以共享——前提是它无论出现在哪里都代表同一条规则,而不是因 Context 不同被不同解释。

业务 Shared Library 不是中立的储物间。

它是一个被多个区域共同使用、拥有自己 Contract 的产品。

这也意味着,它必须能够拒绝那些只服务于单个 Consumer 的需求。否则共同实现又会逐渐长成一堆业务变体。

Shared Library 可以消除对另一个 Remote 的直接 Runtime Dependency:

Remote A ─┐
Remote B ─┼── Shared Library
Remote C ─┘

没有 Remote 需要在 Runtime 等待另一个 Remote。本地开发与运维也仍然可以彼此独立。

但耦合并没有全部消失。

Consumer 仍然依赖 Library Version。修改必须保持兼容,或者被有意识地 Migration。新 Release 需要对多个 Consumer 进行测试。发生 Breaking Change 时,还要决定所有 Remote 是否同步升级,还是允许多 Version 并存。

Project Graph 也会变宽。如果 Shared Library 一个很小的修改就让大量 Remote 与 Host 都被标记为 Affected,那不仅仅是 Build System 问题,而是在展示共同依赖的影响半径。

Shared Library 可以消除 Runtime Coupling,却不会自动消除组织耦合或业务耦合。

Library 越居中,它对 Release Decision、Prioritization 和技术 Migration 的影响就越大。某个修改在代码上可以很小,在组织上却仍然需要大范围协调。

没有 Runtime Coupling,仍然不等于独立变化。

一旦多个 Remote 使用同一个 Library,就会出现一系列无法靠“放进 shared 文件夹”解决的问题:

  • 谁拥有这个 Library?
  • 谁决定修改?
  • 谁给 Consumer 的需求排优先级?
  • 谁评估 Breaking Change?
  • 谁发布新 Version?
  • 谁支持旧 Version?
  • Consumer 需求冲突时谁做决定?
  • Consumer 可以停留在旧 Version 吗?
  • 谁有权拒绝某个需求?

Shared Library 不会消灭 Ownership。

它只会把 Ownership 集中起来。

没有明确 Owner 时,Library 很容易变成共同仓库:

shared-domain
├── patient-picker
├── billing-rules
├── calendar-mapper
├── document-validator
└── 各种特殊情况

所有人都在用,却没人完整负责。修改需要广泛协调。Breaking Change 被不断避免,因为没人愿意承担所有 Consumer 的 Migration。特殊情况越来越多,因为“再加一个 Option”看起来比删除旧 Variant 更安全。

最终,这个 Library 可能比任何单个 Consumer 都更难修改。

“shared”这个词只说明很多地方在使用它。

它没有回答谁对它负责。

第一处实现,不会自动成为 Owner

Section titled “第一处实现,不会自动成为 Owner”

患者选择也许最早出现在 Calendar。后来 Billing 和 Documentation 也需要类似能力。

这并不意味着 Calendar Team 从此就应该永久拥有“共享患者选择”。

Calendar Team 可能根本不对 Billing 或 Documentation 负责,也没有被授权去运营一个供其他业务区域使用的平台。它的 Release Priority 围绕 Calendar 展开。外部 Consumer 的需求因此必然与它真正的产品目标竞争。

但组织很容易产生另一个期待:既然代码最早在 Calendar Team,这个团队就应该负责 Stabilization、Documentation、Versioning 和 Consumer Support。于是一次历史偶然变成永久组织结构。

偶然的历史起点,不是可持续的 Ownership 模型。

如果一项能力真的要成为共享能力,就必须重新明确决定 Ownership。它可以继续属于原团队——前提是团队的 Auftrag 和职责也相应扩展;也可以交给另一个团队、Platform Owner,或者形成一项独立 Capability。

已有代码本身回答不了这个问题。

什么时候它应该成为独立 Capability

Section titled “什么时候它应该成为独立 Capability”

有些功能确实具有独立业务身份,并在多个 Context 中被需要。

一套完整的患者搜索,可能远远不只是搜索框和结果列表。它可能需要检查权限、处理重复记录、支持不同搜索策略、执行法律上的可见性规则,并拥有自己的业务流程。

复杂 Document Editor、Payment Process、受法律约束的 Signature Flow 或独立 Reporting Area,也可能属于同一类情况。

这时,Library 可能已经不够。能力拥有自己的业务 Lifecycle,因此值得拥有独立 Ownership、自己的运行责任和显式 Integration Point。

共享 Capability
├── 自己的业务 Contract
├── 明确 Owner
├── 独立 Lifecycle
├── 明确定义的 Integration Point
└── 多个 Host 或使用 Context

这样的 Capability 并不是“Remote A 里有个 Fragment,Remote B 顺手 Import 过来”。

它拥有自己的业务身份,Contract 被有意识地公开,Error Ownership、Lifetime、Versioning 和 Activation 都被明确处理。Integration 更适合发生在上层 Composition Level,或者通过专门为此设计的 Contract。

这与 Nested Remote 完全不同。后者往往只是因为某个 UI 已经在别处存在,就把另一个 Remote 嵌入当前 Remote。

Nested Remote 会耦合 Availability 和 Lifecycle,Routing 与 Error Ownership 也更复杂。本地开发需要多个应用。对用户来说,谁真正拥有当前这项可见功能也可能变得模糊。

真正共享的 Capability 需要自己的 Owner。

它不应该因为最早出现在某个 Remote 中,就永久属于那个 Remote。

Remote A → 自己实现
Remote B → 自己实现

当业务含义或规则不同、预计未来会继续分化,或者独立 Release 比中央实现更重要时,这个方案合理。

它的价值是清晰 Ownership 和独立变化。没有共同 Versioning,也没有一个必须同时满足多个业务领域的 Central Contract。

代价是实现和测试会重复,Bug 可能要在多个地方修复,展示和行为也可能意外变得不一致。

Remote A ─┐
Remote B ─┼── versioned Shared Library

当业务含义与行为确实相同、实现已经足够稳定,并且明确 Owner 负责一个有意识公开的 Contract 时,这个方案合理。

它可以保证一致行为,并集中修复 Bug;Implementation 也只维护一份。

代价是 Version 和 Governance Coupling。修改影响更广,Migration 需要协调,Consumer 需要 Support,Ownership 被集中。

Host 或 Composition
├── Remote A
├── Remote B
└── 独立 Capability

当这项能力拥有自己的业务 Lifecycle,需要出现在多个 Host 或使用场景,而且明显大于一个简单 Library Building Block 时,这个方案合理。

它得到清晰业务 Contract、明确 Ownership 和独立演进能力。

代价则是额外 Runtime Integration、Deployment 和运维责任。Observability、Error Handling 与 Version Management 都要为这个新应用再解决一次。

三种同等合理的路径:有意识的多份实现、Shared Library 和独立 Capability。

这里不需要固定 Scoring Matrix,但一些问题可以帮助判断“共同点”究竟属于哪一类:

  • 业务含义真的相同,还是只是 UI 看起来相似?
  • 两处实现会因为同一种原因变化吗?
  • 它们必须永远保持相同行为吗?
  • 允许它们在业务上分化吗?
  • 它们使用同一个 Backend Contract 吗?
  • 这项能力离开原 Remote 后,仍然具有独立业务意义吗?
  • 谁拥有 Shared Contract?
  • Consumer 需求冲突时谁做决定?
  • Consumer 可以使用不同 Version 吗?
  • 同步 Migration 可以接受吗?
  • Duplication 的实际实现成本有多大?
  • Sharing 的长期协调成本有多高?
  • 这项能力是否足够完整,值得自己的 Lifecycle?
  • Shared Implementation 会不会隐藏真正的业务差异?

最重要的检查问题仍然是:

如果明天两个 Remote 对这个“看起来一样”的功能提出不同需求,会发生什么?

这个答案往往比任何纯技术分析都更能说明,它究竟是不是一项真正共享的 Capability。

Duplication 会带来额外实现、额外测试,也可能需要在多个位置修同一个 Bug。平行实现可能逐渐不一致,也需要独立维护。

Sharing 则会带来协调、Versioning、Governance、中央 Prioritization 和 Consumer Support。Breaking Change 必须计划,Build 与 Test 会影响更多 Project。省掉的重复代码,可能在多年里以持续协调成本偿还。

独立 Capability 也有自己的成本:Runtime Integration、Deployment、运维、Observability、Contract Maintenance 和明确 Error Ownership。

没有哪一种成本天然更低。

它们只是出现在不同位置、不同时间。

因此,真正重要的问题不是“怎样复用最多代码”,而是“哪些职责应该长期共同承担”。

复用不是免费收益。

它是在重复实现成本和共同责任之间进行交换。

“两个 Remote 需要完全相同的业务功能”不是结论,而是需要验证的假设。

如果只是界面相似,而含义、规则与变化原因不同,有意识地多份实现可能反而能保护更干净的业务边界。

如果语义和 Lifecycle 确实长期一致、稳定,那么 Shared Library 可以合理。但它必须有清晰 Contract、Versioning 和明确 Owner。

如果能力拥有自己的业务 Lifecycle,则可以把它作为独立 Capability 负责,并在合适的 Composition Level 集成。

无论哪一种,都不应该让 Remote B 仅仅因为“Remote A 里已经有代码”就横向依赖 Remote A。

只有当意义、Lifecycle 和责任都真的应该共享时,才共享实现。