跳转到内容

DTO Leakage:当 API 一路渗进 Template

DTO Leakage 指的是:API Layer 的数据结构未经转换、未经语义翻译、也没有任何保护层,就一路传进 Component,甚至进入 Template。

听起来很务实。

而且在最初两三个 Ticket 里,它确实会让人觉得很高效。

API 提供数据,Component 需要数据,于是直接把 DTO 拿来用。

能有什么问题?

问题其实很多。

DTO Leakage。

DTO 是 Data Transfer Object。

它描述的是数据如何跨越一个技术边界,例如从 Backend 传到 Frontend。

因此,DTO 不是 UI Model,不是 Domain Model,也不是 ViewModel。

它是 Transport Model。

这个区别非常重要。

DTO 回答的问题是:

信息如何通过技术边界传输?

而 ViewModel 回答的是:

UI 为了正确、清晰地表达当前状态,到底需要什么?

如果这两个问题都由同一个数据结构来回答,耦合就产生了。

一个典型例子:

// API gibt zurück:
interface TaskDto {
task_id: string;
created_at: string; // ISO-String
assigned_to_user_id: string | null;
}

然后这个 DTO 被直接传进 Component:

@Component(...)
export class TaskCardComponent {
@Input() task: TaskDto; // ← DTO im UI-Layer
}

甚至继续进入 Template:

<article>
<h3>{{ task.task_id }}</h3>
<p>Erstellt am: {{ task.created_at | date }}</p>
<p>Zugewiesen an: {{ task.assigned_to_user_id ?? 'Unzugewiesen' }}</p>
</article>

看起来很普通。

但此时已经有多条边界被穿透:

  • Component 知道 API 字段名
  • Template 知道技术性的 null 语义
  • 日期格式化依赖 Transport Format
  • UI 文本由 API 结构推导出来
  • Backend 的字段变化可能直接破坏 UI 代码

这就是 DTO Leakage。

DTO 出现在 UI Layer。

直接使用 DTO 很容易让人产生“效率很高”的感觉。

不用写 Mapping,不用多定义一个 Interface,看起来也少了代码。

有时候这甚至完全可以接受。

例如一个小型原型、内部一次性工具,或者非常薄、API 又极其稳定的界面,直接使用 DTO 未必有什么问题。

真正的问题是:当这种做法从临时选择变成了架构。

到了那一步,DTO 就不再只是 Transport Object,而会悄悄变成 UI 的事实来源。

从此以后,API 的结构开始参与决定 Template 的结构。

这就是 Pragmatism 开始变成 Coupling 的地方。

DTO Leakage 通常不会立刻制造一个巨大的错误。

它更喜欢制造许多小问题。

API 改了字段名?

task_id -> id

Component、Template、Test,甚至 Sort Logic 都可能一起坏掉。

Backend 从 snake_case 改成 camelCase

created_at -> createdAt

于是开始全项目搜索 Component 和 Template。

ISO String 现在要当成 Date 使用?

created_at: string;

Parsing 或 Format Logic 就会慢慢落进 Component。

null 在业务上表示“未分配”?

assigned_to_user_id: null;

那 Business Logic 就会出现在 Template。

最后代码可能变成:

@if (task.assigned_to_user_id) {
<span>{{ getUserName(task.assigned_to_user_id) }}</span>
} @else {
<span>Unzugewiesen</span>
}

问题不在这一行本身。

问题在于 UI 已经开始解释 API Semantics。

DTO Leakage 本质上意味着:UI 开始围着 API 的形状转。

乍听之下这似乎很正常,毕竟数据就是从 API 来的。

但从架构角度看,这很危险。

UI 不应该关心 Backend 返回的是 task_idtaskIdid 还是 identifier。UI 需要的只是“一项 Task 的 ID”。

UI 也不应该知道日期是 ISO String、Timestamp 还是嵌套对象。UI 只需要一个可以正确展示的日期。

UI 更不应该知道 null 在 Backend 里代表“未分配”。它需要的是明确的展示含义。

API 属于 Infrastructure。 Component 属于 Presentation。

两者之间需要翻译。

错误的依赖方向与正确的依赖方向。

Component 不应该知道 TaskDto

它应该拿到一个专门为展示设计的模型。

interface TaskViewModel {
id: string;
createdAt: Date;
assignee: string;
}

ViewModel 不描述数据如何被传输。

它描述 UI 需要什么。

转换发生在一个明确的边界:

function toTaskViewModel(dto: TaskDto): TaskViewModel {
return {
id: dto.task_id,
createdAt: new Date(dto.created_at),
assignee: dto.assigned_to_user_id ?? 'Unzugewiesen',
};
}

之后 Component 只认识 ViewModel:

@Component(...)
export class TaskCardComponent {
@Input() task!: TaskViewModel;
}

Template 也变得明显无聊:

<article>
<h3>{{ task.id }}</h3>
<p>Erstellt am: {{ task.createdAt | date }}</p>
<p>Zugewiesen an: {{ task.assignee }}</p>
</article>

无聊的代码往往是好代码。

不是因为它简单,而是因为它在这一层已经不需要再做决定。

Mapper 经常被当成无意义的 Boilerplate。

有时候这种感觉并不是完全没有道理。

如果一个 Mapper 只是 1:1 复制字段,看起来确实像一种仪式:

function toViewModel(dto: TaskDto): TaskViewModel {
return {
id: dto.id,
title: dto.title,
};
}

但即使如此,它仍然承担一个很重要的职责:

它标记了一条边界。

今天它也许只是复制字段。 明天它会转换日期。 后天它会归一化 Optional Value。 下周它可能让 15 个 Component 对一次 API 变更毫无感觉。

Mapper 不只是代码。

它是一处架构上的预定断裂点。

如果 API 变化,最好就在这里破,而不是到处一起破。

Mapper 是一道防火墙。

好的 ViewModel 不是换了字段名的 DTO。

它应该面向展示需求。

常见职责包括:

  • 把技术字段名转换成 UI 语义
  • 把日期转换成适合使用的类型或 Label
  • 在明确的边界处理 nullundefined
  • 把 ID 转换成可读名称
  • 提前计算展示状态
  • 派生 UI Action 的权限
  • 一致地提供 Loading / Error State
  • 把复杂条件从 Template 中拿走

例如:

interface TaskViewModel {
id: string;
title: string;
createdAtLabel: string;
assigneeLabel: string;
canEdit: boolean;
isOverdue: boolean;
}

这样 Template 就不用自己计算“是否逾期”。

@if (task.isOverdue) {
<p class="warning">Überfällig</p>
}

Template 消费的是一个决定。

它不负责做这个决定。

有时候,是的。

但替代方案通常并不是“不做这份工作”。

只是把工作分散到系统各处而已。

一点 Mapping 在 Template。 一点 Null Handling 在 Component。 一点 Date Logic 在 Pipe。 一点 API Knowledge 在 Test。 再一点特殊规则散落到另一个 Component。

短期看起来更快,因为没有人专门写 Mapper。

长期反而更慢,因为没人知道转换到底发生在哪里。

显式 Mapping 是看得见的工作。

DTO Leakage 是隐藏起来的工作。

并不是所有 DTO 的直接使用都属于架构罪行。

以下场景可以接受:

  • 很小的 Prototype
  • Throwaway Code
  • 生命周期极短的内部 Admin Tool
  • 只停留在 Infrastructure 的 generated API client
  • 真正只展示 Raw API Data 的 View
  • API 与 UI Contract 有意识地保持一致,而且非常稳定

但一旦出现业务展示语义,就应该提高警惕。

一个实用的经验规则:

如果 Template 知道 API 字段名、技术性的 Null Value 或 Transport Format,这条边界大概率已经漏了。

不要放在 Component。

Component 是消费者,不应该负责理解 task_id 为什么要变成 id

Mapping 可以放在 Adapter、Domain 或 Application Layer 中。具体叫什么没有依赖方向重要。

一个可能的结构:

Infrastructure
API Client
DTOs
Application / Domain
Mapper
ViewModel
Facade / Store
Presentation
Component
Template

或者直接看数据流:

TaskDto
-> toTaskViewModel(dto)
-> TaskViewModel
-> Component
-> Template

Component 拿到的是翻译结果。

不是原材料。

Layer Flow:Infrastructure/API DTO → Mapper/Adapter → Feature Store/Facade → ViewModel → Component/Template。DTO 在进入 Presentation 之前结束。

DTO Leakage 往往会让 Test Scope 变得不必要地大。

如果 Component 知道 DTO,Component Test 就必须重建 API Structure:

const task: TaskDto = {
task_id: '123',
created_at: '2026-06-19T10:00:00Z',
assigned_to_user_id: null,
};

于是这个测试会间接同时覆盖 API Semantics、Mapping、Null Handling 和 UI Behavior。

使用 ViewModel 后,Component Test 会更小:

const task: TaskViewModel = {
id: '123',
createdAt: new Date('2026-06-19T10:00:00Z'),
assignee: 'Unzugewiesen',
};

Mapper 则得到自己的聚焦测试:

describe('toTaskViewModel', () => {
it('maps unassigned tasks', () => {
expect(
toTaskViewModel({
task_id: '123',
created_at: '2026-06-19T10:00:00Z',
assigned_to_user_id: null,
}),
).toEqual({
id: '123',
createdAt: new Date('2026-06-19T10:00:00Z'),
assignee: 'Unzugewiesen',
});
});
});

真正的收益在这里:

  • Component 测试展示
  • Mapper 测试翻译
  • Infrastructure Test 测试 API Integration

每一层只测试自己的职责。

如果 API 改变,修改 Mapper。

而不是修改 Component。 不是修改 Template。 不是修改每一个 Test。 也不是全项目寻找那些曾经随手读取过 created_at 的地方。

这不是追求学术纯洁性。

这是可维护性。

DTO 可以存在。

只是不能住得到处都是。

DTO Leakage 危险的地方,恰恰在于它看起来很务实。

少一个 Interface,少一个 Mapper,也少做一个显式决定。

但代价会在以后以耦合的形式回来。

好的前端架构不意味着尽可能多地创造模型,而是有意识地划分边界。

DTO 属于技术边界。

ViewModel 属于 UI。

两者之间必须有翻译。

不是为了让架构图更漂亮。

而是为了避免变化一路流到最昂贵的地方:Component、Template 和 Test。

简短的规则是:

DTO 进来,ViewModel 出去,Component 保持干净。