跳转到内容

Infrastructure——技术外部世界

Infrastructure 的名声并不算好。

一提到这个词,很多人就会想到 Port、Adapter、Repository Interface、Factory,以及一层又一层的 Integration Architecture。图里充满箭头,矩形框比箭头还多。

本系列的导言 把 Infrastructure 描述为数据从技术外部世界走向用户时经过的第一站。但这一站真正要做的事情,其实远没有这个名字听起来那么宏大。

对这篇文章来说,先采用一个简单视角就够了:

Infrastructure 是应用与那些无法被自己完全控制的东西发生接触的地方。

Backend。REST 或 GraphQL API。WebSocket 或 SSE。Local Storage。IndexedDB。Browser API。Authentication Provider。外部 SDK。另一个 Bounded Context。

这里的“无法完全控制”不一定意味着“属于另一家公司”。即使是同一家公司的 Backend 团队,也可以修改一个 Contract,而 Frontend 无法阻止。真正关键的不是组织边界,而是谁拥有这个结构、谁决定它如何继续演进。

具体技术并不是重点。Angular HttpClientfetch、Axios 或其他 Client,都不会改变这一层的架构职责。

外部世界
Infrastructure
自己的业务模型

Infrastructure 接收外部技术结构,并以受控方式把它们翻译到自己的世界。

一开始,它不需要做更多事情。

技术外部世界首先经过 Infrastructure Layer 的校验、标准化与转换,然后数据才进入自己的业务模型。

而这正是重点:Infrastructure 完全可以很无聊。

Backend 的 Contract 首先属于 Backend。外部 SDK 属于它的供应商。Browser API 的 Contract 属于 Browser。

这些模型不应该仅仅因为“它们最先到达前端”,就自动成为我们的 Domain Model。

一个很小的例子就足够说明问题。Backend 返回一个客户:

type CustomerDto = {
id: string;
first_name: string;
last_name: string;
};

内部则使用:

type Customer = {
id: string;
firstName: string;
lastName: string;
};

这个转换一点都不复杂。恰恰因为它如此普通,这个例子才有价值——现实中的很多边界就是这么不起眼。

CustomerDto 描述 Backend 如何传递一个 Customer。Customer 描述我们的应用如何理解和使用一个 Customer。只要中间有明确转换,这两个模型就可以独立演进。

这里很容易出现一个反问:

function toCustomer(dto: CustomerDto): Customer {
return {
id: dto.id,
firstName: dto.first_name,
lastName: dto.last_name,
};
}

如果两个结构几乎一样,为什么还要多写这些代码?

今天,这个 Mapper 的确看起来几乎多余。明天 Backend 也许会修改字段名:

first_name → givenName
last_name → familyName

理想情况下,只需要修改这里的转换。Store、Business Rule、Facade 和 Component 都不应该感知到这次变化。

Backend Contract 的变化在 Mapper 处被吸收,而内部 Customer Model 保持不变。

一个只有五行的 Mapper 并不是 Overengineering——前提是它阻止一个外部 Contract 在五十个文件里悄悄变成事实上的 Domain Model。

如果 DTO 不受控制地穿过这条边界,继续进入 State、Application、Component 和 Template,会发生什么,DTO Leakage 一文已经详细讨论。

这篇文章只需要记住一点:一条很小的转换边界,就足以防止外部系统的结构逐渐变成我们自己的应用结构。

前端开发里还有一个很容易被忽视的问题。

HTTP Response 来自外部来源。JSON Parsing 之后,我们拿到的只是 JavaScript Value:String、Number、Object、Array、null

像下面这样的 Type Assertion:

const customer = response as CustomerDto;

或者带泛型的 HTTP 调用:

http.get<CustomerDto>('/api/customers/42');

描述的只是 Frontend 期待得到什么结构。它并不能证明 Backend 运行时真的返回了这个结构。

TypeScript 类型不会校验外部数据。 它只在 Compile Time 描述我们希望按什么结构工作。运行时数据是否符合这个结构,如果有必要,就必须在系统边界单独验证。

这项验证属于 Infrastructure。

这条边界的工作可以归纳为几件事:

unknown / 外部数据
Infrastructure
├── 校验
├── 标准化
└── 转换
自己的模型

典型职责包括:

  • Runtime Validation
  • 校验必填字段
  • 标准化外部格式
  • 转换日期值
  • 受控处理 nullundefined
  • 转换技术 Status Code
  • 把 DTO 映射为自己的模型
  • 把外部系统错误转换成自己的技术错误

Runtime Validation 可以使用 Zod 之类的 Library。具体选择哪一个 Library,对架构决策并不重要。重要的是:验证发生在哪个边界。

标准化也应该保持简单和明确。

已知格式的日期可以转成 Date。可选字段可以按约定技术规则统一。外部 Status Code 可以映射成自己的技术或业务表示。

一旦某个差异已经无法通过明确的技术规则解释,Infrastructure 就不应该擅自发明业务含义。

无法解释的数据,首先应该被视为技术错误。

应用最终如何把这个错误呈现给用户,已经属于另一项职责。

Customer 示例使用 HTTP,只是因为最容易说明问题。同样的边界存在于所有外部数据进入应用的地方。

localStorage 里的值首先只是 String 或 null。它不会因为“昨天存进去的是一个对象”,今天就自动变回那个对象。

支付供应商的 SDK 会抛出自己的 Error Class 和 Code。

WebSocket 会收到 Message,而新 Server Version 可能改变它们的结构。

这些场景都在问同一个问题:我们是直接把 Raw Value 放进应用,还是先把它转换成 Feature 真正理解的形式?

Error 本身也可能需要转换:

try {
await paymentSdk.charge(order);
} catch (error) {
throw toPaymentInfrastructureError(error);
}

toPaymentInfrastructureError 可以了解这个 SDK 的 Error Code 和技术特性。应用其他部分则只需要处理自己定义、自己控制的一组技术错误。

即使以后更换支付供应商,这门“外语”也仍然停留在 Infrastructure Boundary。

这是这篇文章最重要的一条边界。

Infrastructure 可以消除外部技术不确定性,但不应该据此实现 Business Rule。

例如外部 Contract 返回:

"ACTIVE"
CustomerStatus.Active

这个转换可以属于 Infrastructure。

但一个 Active Customer 是否允许下单,是另一个问题:

CustomerStatus.Active
这个客户可以下单吗?

这里才开始进入业务逻辑。

两者在代码里可能看起来极其相似。一个 switch、一个 if,甚至只是一行赋值。但它们承担的职责完全不同。

Infrastructure 可以转换:

"2026-08-10T10:15:00Z" → Date
null → undefined
"C" → CreditHold
first_name → firstName

+State 回答的问题是:

这个订单可以取消吗?
这个客户可以下单吗?
这个 State Transition 合法吗?
当前业务状态下允许哪些操作?

Infrastructure 消除技术和结构层面的不确定性。业务决策从它之后开始。

Infrastructure 可以做什么,不该做什么

Section titled “Infrastructure 可以做什么,不该做什么”

Infrastructure 可以并且应该:

  • 封装外部 Contract
  • 把 DTO 转换为自己的模型
  • 标准化技术格式
  • 校验外部输入
  • 封装技术 API
  • 把外部 Error 转换成自己的技术 Error
  • 阻止外部模型不受控制地渗透进应用

Infrastructure 不应该:

  • 实现 Business Rule
  • 做业务决策
  • 生成业务派生
  • 为具体 View 构建 ViewModel
  • 管理 UI State
  • 控制 Navigation
  • 打开 Dialog
  • 承担表现层职责
  • 仅仅因为“代码现在就在某个技术 Service 里”,就继续把其他逻辑塞进来

如果 Infrastructure Layer 里的 Business Logic 越来越“有意思”,就值得认真检查:这些逻辑是否真的属于这里。

Customer 例子中的简单 Mapper,并不是 Eric Evans 在 Domain-Driven Design 中所说的完整 Anti-Corruption Layer。

但两者共享同一个防御性原则:外部模型不应该不受控制地成为自己的模型。

如果只是把 first_name 转成 firstName,这条边界确实非常普通。

当两边开始使用不同术语、不同状态模型、甚至不同业务含义时,事情才真正复杂起来。也许外部系统的 "ACTIVE" 与我们 Feature 里的 Active 并不是同一个含义。也许 Legacy System 返回某些 Error Code,而这些 Code 只在那个系统里有意义。也许两个系统都在建模“Customer”,但从业务上讲的其实并不是同一个概念。

到了这里,机械字段映射就不再足够。

这种更深层的外部模型与内部模型之间的转换,会在 Anti-Corruption Layer 中详细讨论。

对 Infrastructure Layer 来说,目前只需要保留一个更简单的结论:即使是一条很小、显式的边界,也能防止外部模型悄悄穿过整个应用。

需要多少边界就做多少,但 Infrastructure 越少越好

Section titled “需要多少边界就做多少,但 Infrastructure 越少越好”

不是每个 Primitive Field 都需要独立 Value Object。也不是每个 API Object 都必须配一个 Interface、Repository、Port、Adapter、Factory 和几层抽象。

Layering 不是比赛谁的文件更多。

一个非常简单的 Contract 也不一定需要重复建模。真正重要的是:直接使用它会带来多大风险。

可以问这些问题:

  • 这个 Contract 属于谁?
  • 它能否在我们无法控制的情况下变化?
  • 它会深入应用多少层?
  • 外部与内部之间是否存在不同语义?
  • 数据是否需要标准化或验证?
  • 未来再解耦会有多贵?

一个只在局部使用的小型技术 Lookup Structure,也许完全不值得再加一层抽象。

但一个以后会影响业务决策的支付状态,即使最初转换只有几行,也值得有意识地设置边界。

区别不在代码行数。

而在于:如果没有这条边界,会产生多少耦合。

边界只做到必要程度,Infrastructure 只保留必要内容。

Infrastructure 完全可以继续很无聊。

走到 Infrastructure Layer 的末端时,我们已经接收、校验、标准化了外部数据,并把它们转换成 Feature 可以信任的形式。

但还没有做任何业务决策。

"ACTIVE" 已经变成 CustomerStatus.Active。可这个客户是否允许下单、订单能否取消、当前 State Transition 是否允许,都还没有答案。

当这些数据跨过技术边界之后会发生什么,就是 +State 开始回答的问题。

业务逻辑从那里开始。