DTO Leakage:当 API 一路渗进 Template
DTO Leakage 指的是:API Layer 的数据结构未经转换、未经语义翻译、也没有任何保护层,就一路传进 Component,甚至进入 Template。
听起来很务实。
而且在最初两三个 Ticket 里,它确实会让人觉得很高效。
API 提供数据,Component 需要数据,于是直接把 DTO 拿来用。
能有什么问题?
问题其实很多。

DTO 到底是什么
Section titled “DTO 到底是什么”DTO 是 Data Transfer Object。
它描述的是数据如何跨越一个技术边界,例如从 Backend 传到 Frontend。
因此,DTO 不是 UI Model,不是 Domain Model,也不是 ViewModel。
它是 Transport Model。
这个区别非常重要。
DTO 回答的问题是:
信息如何通过技术边界传输?
而 ViewModel 回答的是:
UI 为了正确、清晰地表达当前状态,到底需要什么?
如果这两个问题都由同一个数据结构来回答,耦合就产生了。
DTO Leakage 长什么样
Section titled “DTO Leakage 长什么样”一个典型例子:
// 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。

为什么一开始那么诱人
Section titled “为什么一开始那么诱人”直接使用 DTO 很容易让人产生“效率很高”的感觉。
不用写 Mapping,不用多定义一个 Interface,看起来也少了代码。
有时候这甚至完全可以接受。
例如一个小型原型、内部一次性工具,或者非常薄、API 又极其稳定的界面,直接使用 DTO 未必有什么问题。
真正的问题是:当这种做法从临时选择变成了架构。
到了那一步,DTO 就不再只是 Transport Object,而会悄悄变成 UI 的事实来源。
从此以后,API 的结构开始参与决定 Template 的结构。
这就是 Pragmatism 开始变成 Coupling 的地方。
DTO 一路渗透之后会发生什么
Section titled “DTO 一路渗透之后会发生什么”DTO Leakage 通常不会立刻制造一个巨大的错误。
它更喜欢制造许多小问题。
API 改了字段名?
task_id -> idComponent、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。
真正的问题:依赖方向错了
Section titled “真正的问题:依赖方向错了”DTO Leakage 本质上意味着:UI 开始围着 API 的形状转。
乍听之下这似乎很正常,毕竟数据就是从 API 来的。
但从架构角度看,这很危险。
UI 不应该关心 Backend 返回的是 task_id、taskId、id 还是 identifier。UI 需要的只是“一项 Task 的 ID”。
UI 也不应该知道日期是 ISO String、Timestamp 还是嵌套对象。UI 只需要一个可以正确展示的日期。
UI 更不应该知道 null 在 Backend 里代表“未分配”。它需要的是明确的展示含义。
API 属于 Infrastructure。 Component 属于 Presentation。
两者之间需要翻译。

替代方案:ViewModel
Section titled “替代方案:ViewModel”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 不是烦人的机械劳动
Section titled “Mapper 不是烦人的机械劳动”Mapper 经常被当成无意义的 Boilerplate。
有时候这种感觉并不是完全没有道理。
如果一个 Mapper 只是 1:1 复制字段,看起来确实像一种仪式:
function toViewModel(dto: TaskDto): TaskViewModel { return { id: dto.id, title: dto.title, };}但即使如此,它仍然承担一个很重要的职责:
它标记了一条边界。
今天它也许只是复制字段。 明天它会转换日期。 后天它会归一化 Optional Value。 下周它可能让 15 个 Component 对一次 API 变更毫无感觉。
Mapper 不只是代码。
它是一处架构上的预定断裂点。
如果 API 变化,最好就在这里破,而不是到处一起破。

ViewModel 里应该放什么
Section titled “ViewModel 里应该放什么”好的 ViewModel 不是换了字段名的 DTO。
它应该面向展示需求。
常见职责包括:
- 把技术字段名转换成 UI 语义
- 把日期转换成适合使用的类型或 Label
- 在明确的边界处理
null和undefined - 把 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 消费的是一个决定。
它不负责做这个决定。
这不是重复劳动吗
Section titled “这不是重复劳动吗”有时候,是的。
但替代方案通常并不是“不做这份工作”。
只是把工作分散到系统各处而已。
一点 Mapping 在 Template。 一点 Null Handling 在 Component。 一点 Date Logic 在 Pipe。 一点 API Knowledge 在 Test。 再一点特殊规则散落到另一个 Component。
短期看起来更快,因为没有人专门写 Mapper。
长期反而更慢,因为没人知道转换到底发生在哪里。
显式 Mapping 是看得见的工作。
DTO Leakage 是隐藏起来的工作。
什么时候可以直接使用 DTO
Section titled “什么时候可以直接使用 DTO”并不是所有 DTO 的直接使用都属于架构罪行。
以下场景可以接受:
- 很小的 Prototype
- Throwaway Code
- 生命周期极短的内部 Admin Tool
- 只停留在 Infrastructure 的 generated API client
- 真正只展示 Raw API Data 的 View
- API 与 UI Contract 有意识地保持一致,而且非常稳定
但一旦出现业务展示语义,就应该提高警惕。
一个实用的经验规则:
如果 Template 知道 API 字段名、技术性的 Null Value 或 Transport Format,这条边界大概率已经漏了。
Mapping 应该在哪里发生
Section titled “Mapping 应该在哪里发生”不要放在 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 -> TemplateComponent 拿到的是翻译结果。
不是原材料。

测试也会变简单
Section titled “测试也会变简单”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 保持干净。