CodeGym /课程 /Go SELF /DTO vs domain — 什么时候“原样”保存

DTO vs domain — 什么时候“原样”保存

Go SELF
第 45 级, 课程 4
可用

1. 为什么会出现 DTO vs domain 这个话题

如果你刚开始学习,会觉得很自然:“不是已经有 struct 了吗?我把 JSON 解析进去,在程序里直接用,再写回 JSON 就好了。” 而且前两天这么做完全可以,甚至是必要的——你的大脑已经忙着适应 Go 语法了,根本没空管架构。

但只要应用稍微复杂一点,就会出现一种不舒服的感觉:你一改 JSON 格式(比如把字段 created_at 改成 createdAt),结果 15 个地方的逻辑突然坏掉了。 或者你想在领域中把时间存成 time.Time,而外部契约传进来的是“没有时间的日期” (字符串 2026-01-16)——于是就开始了“所有地方都用字符串”,因为“这样更简单”。

所以,DTO vs domain 说的就是职责边界。 DTO 负责“外面长什么样”,领域模型负责“里面代表什么”。

术语:DTO、领域模型和映射

为了避免那种“我们在卖 enterprise for enterprise”的感觉,我们用最朴素的话来解释。

DTO(Data Transfer Object) 是一种为了数据交换而存在的结构:JSON、文件、网络、API、导入/导出。 它通常带有 json:"..." 标签,经常允许 omitempty, 有时还会把字段存成“更适合格式”的样子(字符串、*time.Time、可选字段)。

Domain model(领域模型) 是一种为了应用逻辑而存在的结构。 它倾向于更严格、更清晰:正确的类型(time.Time、int、自定义类型), 不变量(“任务必须有标题”),以及可预测的值。

映射 是 DTO ↔ 领域之间的显式转换。它不是“多余的一步”,而是一个控制点, 你可以在这里:规范化字符串、解析时间、检查必填字段、隐藏内部字段、添加计算值。

最后就形成了一个很有人味的流水线:

flowchart TD
    A["JSON bytes"] --> B["DTO (as received)"]
    B --> C["Validate() (check meaning)"]
    C --> D["Domain (convenient form)"]
    D --> E["Application logic"]
    E --> F["DTO (to send out)"]
    F --> G["JSON bytes"]

2. 一种结构还是 DTO+domain:怎么选

什么时候用一个结构就足够

这里先说一个重要点:把 DTO 和领域拆开,不是宗教。 如果你写的是一个小工具、一个 1–2 个文件的练习项目,或者 JSON 格式完全由你控制,并且和你想在内部保存数据的方式完全一致, 那一个结构可能就是很好的选择。

一个典型的“可以只用一个结构”的例子是:你把任务保存在 JSON 文件里,格式固定,字段简单 (id、title、done), 而且你愿意接受结构里包含 JSON 标签。这是正常的,尤其是在学习阶段。

但理解为什么在真实代码里这种方案经常“失控”很有帮助:结构里突然出现了“为了 JSON 的字段”“为了 UI 的字段” “为了内部逻辑的字段”“为了兼容性的字段”,最后它变成了一把“瑞士军刀”——什么都能做,但已经不敢拿来切面包了。

什么时候最好拆分 DTO 和领域模型

当项目开始增长时,拆分就不再是“美观问题”,而是一个保持控制的方法。 下面这些情况通常都值得拆分;我们把它们整理成表格,方便看。

症状 如果“一个结构包办一切”会发生什么 为什么 DTO+domain 更好
外部字段名和代码里想要的不一样 你读的是 Task.CreatedAt, 但 JSON 里是 created_at,标签会到处散开 DTO 负责标签,领域模型保持干净的名字
JSON 里的类型“不方便” 日期是字符串,数字也是字符串,null 出现在意想不到的位置 DTO 保留“原样”,领域模型负责“如何使用”
存在必须遵守的规则(不变量) json.Unmarshal 可以填充结构,但不能保证语义(比如“空 title”) 领域构造器/校验保证规则
需要隐藏内部字段 你不小心把 SecretToken 返回给外部,因为忘了 json:"-" 响应 DTO 里根本不包含这个字段
不同操作需要不同表示 创建需要一套,响应需要另一套,存储还需要第三套 DTO 按具体操作来设计: CreateTaskDTO、TaskDTO
格式可能会变化 任何 JSON 改动都会牵连业务逻辑 只改 DTO 和映射,领域几乎不用动

于是就有了一个核心原则:领域类型应该依赖语义,而不是依赖序列化。

4. 示例:任务——同一个应用里的 DTO 和领域模型

为了不飘到抽象层面,我们继续沿用“任务”这个教学应用(一个假想的“迷你任务追踪器”)。我们希望能够:

  1. 从 JSON 读取任务,
  2. 检查数据是否有意义,
  3. 在程序内部处理任务,
  4. 再把任务输出回 JSON。

领域模型:让逻辑使用起来顺手

领域结构最好不要带标签。让它们表达意义即可。

package domain

import "time"

type Task struct {
	ID        int
	Title     string
	Done      bool
	CreatedAt time.Time
	DoneAt    *time.Time
}

这里已经能看出一个规则:DoneAt 是可选的,所以用 *time.Time。 如果任务还没完成,DoneAt 就是 nil。 这不是 Go 的“任性”,而是对现实的一种相当诚实的表达。

现在我们再加一个方法,让任务“标记为完成”,同时不破坏不变量。

package domain

import "time"

func (t *Task) MarkDone(now time.Time) {
	t.Done = true
	t.DoneAt = &now
}

注意这里的风格:领域逻辑和领域结构放在一起。更妙的是,它完全不在乎这个任务之后会被序列化成 JSON、YAML,还是刻在石板上。

DTO:JSON 消息长什么样

现在假设我们的外部契约是这样的:

{
  "id": 10,
  "title": "Buy milk",
  "done": false,
  "created_at": "2026-01-16T10:00:00Z",
  "done_at": null
}

我们可以做一个与这个格式相对应的 DTO。这里 time.Time 完全可以用, 因为 encoding/json 能把 time.Time 编码/解码成字符串 (通常是 RFC3339)。

package dto

import "time"

type TaskDTO struct {
	ID        int        `json:"id"`
	Title     string     `json:"title"`
	Done      bool       `json:"done"`
	CreatedAt time.Time  `json:"created_at"`
	DoneAt    *time.Time `json:"done_at,omitempty"`
}

这里有个重要细节:omitempty 表示在编码时如果字段为空, 就不要把它包含进去。如果 DoneAt == nil, 那么在 json.Marshal 时,done_at 这个字段就会从 JSON 中消失。

为什么“创建”和“响应”经常要用不同的 DTO

在实践中,输入数据(“创建任务”)和输出数据(“这是任务”)很少完全一致。 如果你收到的是创建用 JSON,通常不会有 id 和 created_at: 它们是由应用生成的。

所以输入我们会单独定义一个 DTO:

package dto

type CreateTaskDTO struct {
	Title string `json:"title"`
}

看起来很无聊,但无聊是好事:无聊的代码比“通用大杂烩结构”更容易维护。

DTO 的校验:在映射前先检查意义

在上一讲里,我们已经把 decode 和 validate 分开了。这里我们只是把它当成一种习惯来用。 DTO 可以自己做校验。

package dto

import (
	"errors"
	"strings"
)

func (d CreateTaskDTO) Validate() error {
	if strings.TrimSpace(d.Title) == "" {
		return errors.New("title is required")
	}
	return nil
}

这里没有什么“架构感”。这只是普通的人类检查:标题不能为空,也不能只由空格组成 (空格也很想成为一个任务,但不是今天)。

映射:DTO → domain

现在我们来做显式转换。创建任务时,领域需要的是: Title、CreatedAt,而 ID 先保持为“0” (或者之后再赋值)。假设在我们的应用里,ID 由存储层生成,而这里我们只是创建对象。

package dto

import (
	"strings"
	"time"

	"example.com/taskapp/domain"
)

func (d CreateTaskDTO) ToDomain(now time.Time) domain.Task {
	return domain.Task{
		Title:     strings.TrimSpace(d.Title),
		CreatedAt: now,
	}
}

为什么这样做很好:

  1. 规范化(TrimSpace)集中在一个地方。
  2. 领域模型不需要知道 JSON。
  3. 我们可以改变外部格式,而几乎不用碰领域逻辑。

映射:domain → DTO,用于响应

当我们需要把任务输出成 JSON 时,最好显式构造 DTO。

package dto

import "example.com/taskapp/domain"

func FromDomainTask(t domain.Task) TaskDTO {
	return TaskDTO{
		ID:        t.ID,
		Title:     t.Title,
		Done:      t.Done,
		CreatedAt: t.CreatedAt,
		DoneAt:    t.DoneAt,
	}
}

还是那句话:无聊、透明、可读。完美。

把一切串成流程:Unmarshal → Validate → ToDomain → Marshal

我们把一个小例子放到 main 里,这样就更清楚它在真实代码里是怎么工作的。

package main

import (
	"encoding/json"
	"fmt"
	"time"

	"example.com/taskapp/dto"
)

func main() {
	data := []byte(`{"title":"  Buy milk  "}`)

	var in dto.CreateTaskDTO
	if err := json.Unmarshal(data, &in); err != nil {
		fmt.Println("bad json:", err)
		return
	}

	if err := in.Validate(); err != nil {
		fmt.Println("bad data:", err)
		return
	}

	task := in.ToDomain(time.Now().UTC())
	out := dto.FromDomainTask(task)

	b, _ := json.MarshalIndent(out, "", "  ")
	fmt.Println(string(b))
}

这里要注意一个细节:json.Unmarshal 可能返回不同类型的错误, 有时区分“JSON 坏了”还是“数据坏了”会很有用。比如,encoding/json 在语法问题时会返回 *json.SyntaxError 类型的错误, 而你可以通过 errors.As 来识别它。

5. 边界:标签、原样存储以及项目结构

标签放哪儿,如何避免项目变成“标签统治世界”

新人很常见的问题是:“我把标签直接加到 domain.Task 上不就更简单了吗。” 然后你突然发现,领域模型被迫为了 JSON 去保留一些字段和命名,而这些对逻辑根本没用。

一个通常能带来平静生活的实践规则是: 标签放在 DTO 里,而领域结构保持无标签。 这样领域就可以在不同上下文中使用:不仅是 JSON,还包括测试、内存、其他格式。

如果你的项目已经开始有包结构了(而且你以前学过),那像下面这样的组织方式就很自然:

taskapp/
  domain/
    task.go
  dto/
    task_dto.go
  main.go

这已经有点像“成熟”的项目组织方式了,但又没有多余的复杂度。

什么时候“原样保存”依然有用

有时 DTO 的“原样”就是你的存储模型。比如你在做导入/导出,并且希望文件里保存的内容就是你对外输出的内容, 不做额外计算,也不引入额外不变量(或者只引入很少)。

这种情况下,DTO 可以作为“存储格式”,而领域作为“工作格式”。 你读取 DTO,做校验,映射到领域里处理,然后再映射回 DTO 用于保存。

关键在于,“原样”最好保留在边界上,因为那里最重要的是保持契约。 但在应用内部,几乎总是更划算使用那些能表达语义并能防止愚蠢状态的类型。

6. 常见错误

错误 1:一个结构包办一切,最后像雪球一样越滚越大。
一开始这看起来很方便:一个 Task,里面既有标签,又有 omitempty, 还有“将来可能会用到”的字段。但很快这个结构就变成了垃圾桶:你不敢删字段,不敢重命名, 不敢改类型,因为“万一 JSON 坏了呢”。把 DTO 和领域拆开,就能把控制权拿回来: DTO 跟着契约变,领域跟着逻辑变。

错误 2:领域模型为了跟 JSON 一致,硬把“格式类型”当成业务类型(时间用字符串、数字也用字符串)。
这种做法通常看起来像“以后我再处理”,但“以后”往往不会来。结果就是你把日期当字符串比较, 把数字当文本排序,然后遇到“100 比 9 小”这种级别的 bug。更好的做法是在入口把数据解析/转换好, 内部使用正常的类型(time.Time、int)。

错误 3:把 omitempty 当成校验。
omitempty 说的是编码(Marshal),不是语义。 一个字段可以从 JSON 中消失,但这并不代表从逻辑上它就“允许为空”。 如果 title 是必填的,那应该由 Validate 去检查,而不是 omitempty。

错误 4:映射逻辑散落在代码各处。
今天你在一个地方解析 CreatedAt,明天在另一个地方,后天忘了 TrimSpace,然后任务开始各过各的。映射应该是一个明确的控制点: ToDomain() / FromDomain(),或者单独的转换函数, 但一定要放在一个清晰的位置。

错误 5:试图“悄悄修正数据”,结果把诊断信息弄丢了。
比如传进来的是一个错误的日期格式,而你决定“那就用 time.Time{} 吧”。 然后你再去找为什么“1970-01-01”(或者零时间)突然进了报表。 如果输入数据不正确,最好在边界直接返回错误,而不是在应用内部静悄悄地制造奇怪状态。

1
任务
Go SELF, 第 45 级, 课程 4
已锁定
两个数据层
两个数据层
1
任务
Go SELF, 第 45 级, 课程 4
已锁定
图书草稿
图书草稿
1
任务
Go SELF, 第 45 级, 课程 4
已锁定
任务 API 的响应
任务 API 的响应
1
任务
Go SELF, 第 45 级, 课程 4
已锁定
更新状态
更新状态
1
调查/小测验
JSON(基础)第 45 级,课程 4
不可用
JSON(基础)
JSON(基础)
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION