Go+DDD 对象全家桶:DO/VO/DTO 等概念 + 实践
# 前言
本文面向在 Go 中实践领域驱动设计(DDD)的工程师,旨在清晰定义常见 "对象" 概念(DO/VO/DTO/PO/BO/AO/Form),并明确其目录位置、转换时机、最佳实践及反模式,同时提供查询条件容器的设计建议。
# 核心概念定义
| 名称 | 全称 | 核心特征 | 建议存放位置 |
|---|---|---|---|
| DO | Domain Object / Entity | 有唯一标识(ID)、生命周期与业务行为,包含领域规则 | domain 包 / 模块下 |
| VO | Value Object | 不可变、按值比较(无独立 ID),代表具体值 / 概念(如 Money、Address) | domain/vo 或领域实体同包 |
| DTO | Data Transfer Object | 用于层间数据传输(如 API↔Service),携带序列化标签(JSON/protobuf 等) | api/dto、application/dto 或 transport 层 |
| PO | Persistence Object | 与数据库映射(含 ORM/SQL 标签),属于仓储实现细节 | internal/repo/mysql/model、infra/persistence等 |
| BO | Business Object | 命名模糊(业务组合对象 / 服务层 "富 DTO"),建议避免;若使用需明确语义 | application/bo(限定为 "业务应用层只读聚合视图") |
| AO | Application Object | 应用层内部传递对象,易与 DTO 混淆,建议复用 DTO 或明确命名 | application/* 或直接使用 DTO |
| Form | - | HTTP 层表单 / JSON 绑定结构体,带校验标签(binding/validate) | api/handler/form 或 transport/http/form |
# 设计总原则
- 领域模型纯净性:
domain包内的 DO/VO 不得包含数据库 / JSON 标签,不得依赖基础设施(infra)包,仅保留业务逻辑与验证逻辑。 - 转换责任边界:DB 与 Domain、HTTP 与 Domain 的转换必须放在适配器层(repository/handler/mapper),领域层不处理格式转换。
- 依赖倒置:领域层定义抽象接口(如
domain/repository),具体实现(如 ORM/PO)放在基础设施层(infra)。 - 命名唯一性:避免 BO/AO/DTO 混用,若需多用途,通过模块名明确区分(如
api/dto、application/vo)。
# 推荐项目目录结构(Go)
/cmd/... # 程序入口
/internal/
app/ # 应用层(用例/服务)
user_service.go # 业务服务实现
dto/ # 应用层DTO
user_dto.go
domain/ # 领域层
user/
user.go # DO(实体)
address_vo.go # VO(值对象)
repository.go # 仓储接口(领域层抽象)
repo/ # 基础设施层(仓储实现)
mysql/
model/
user_po.go # PO(数据库模型)
user_repo.go # 仓储接口实现
mapper.go # PO ↔ DO 映射函数
api/ # 接口层
http/
handler/
user_handler.go # HTTP处理器
form/
user_form.go # Form(HTTP绑定结构体)
pkg/
query/ # 通用查询条件容器
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 典型代码示例
# 1. 领域层:DO(实体)+ VO(值对象)
DO(实体):封装业务行为与生命周期internal/domain/user/user.go
package user
import "time"
var (
ErrInvalidName = errors.New("invalid name")
ErrInvalidEmail = errors.New("invalid email")
)
// User 领域实体(DO)——不含任何DB/JSON标签
type User struct {
ID string
Name string
Email Email // 嵌入VO
CreatedAt time.Time
}
// NewUser 领域工厂:确保实体创建符合业务规则
func NewUser(id, name string, email Email) (*User, error) {
if name == "" {
return nil, ErrInvalidName
}
if err := email.Validate(); err != nil {
return nil, err
}
return &User{
ID: id,
Name: name,
Email: email,
CreatedAt: time.Now(),
}, nil
}
// ChangeEmail 领域行为:封装邮箱修改规则
func (u *User) ChangeEmail(e Email) error {
if err := e.Validate(); err != nil {
return err
}
u.Email = e
return nil
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
VO(值对象):不可变,按值比较,封装验证逻辑internal/domain/user/email.go
package user
// Email 值对象:代表邮箱概念,不可变
type Email struct {
Addr string // 实际邮箱地址
}
// NewEmail 工厂方法:确保创建时验证合法性
func NewEmail(addr string) (Email, error) {
e := Email{Addr: addr}
if err := e.Validate(); err != nil {
return Email{}, err
}
return e, nil
}
// Validate 验证逻辑:封装邮箱格式规则
func (e Email) Validate() error {
if e.Addr == "" {
return ErrInvalidEmail
}
// 实际场景可添加正则校验(如`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
return nil
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 2. 基础设施层:PO(持久化对象)与映射
PO(数据库模型):与数据库字段映射,含 ORM 标签internal/repo/mysql/model/user_po.go
package model
import "time"
// UserPO 数据库映射对象(PO)——含GORM标签
type UserPO struct {
ID string `gorm:"primaryKey;column:id"`
Name string `gorm:"column:name;not null"`
Email string `gorm:"column:email;uniqueIndex"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
}
// TableName 显式指定数据库表名
func (UserPO) TableName() string {
return "users"
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
映射函数:负责 PO 与 DO 的转换(放在仓储实现层,避免领域层依赖 infra)internal/repo/mysql/mapper.go
package mysql
import (
"myapp/internal/domain/user"
"myapp/internal/repo/mysql/model"
)
// poToDomain 将PO转换为领域对象(DO)
func poToDomain(po *model.UserPO) (*user.User, error) {
email, err := user.NewEmail(po.Email) // 依赖领域VO的工厂方法,确保验证
if err != nil {
return nil, err
}
return &user.User{
ID: po.ID,
Name: po.Name,
Email: email,
CreatedAt: po.CreatedAt,
}, nil
}
// domainToPO 将领域对象(DO)转换为PO
func domainToPO(do *user.User) *model.UserPO {
return &model.UserPO{
ID: do.ID,
Name: do.Name,
Email: do.Email.Addr, // 提取VO的原始值
CreatedAt: do.CreatedAt,
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 3. 接口层:Form(HTTP 绑定)与 DTO(数据传输)
Form(HTTP 请求绑定):带校验标签,负责接收用户输入internal/api/http/form/user_form.go
package form
// CreateUserForm HTTP请求绑定结构体(Form)
type CreateUserForm struct {
Name string `json:"name" binding:"required,min=2,max=30"` // 表单校验规则
Email string `json:"email" binding:"required,email"` // 使用gin的binding标签
}
2
3
4
5
6
7
DTO(应用层传输):定义服务间数据结构,带序列化标签internal/app/dto/user_dto.go
package dto
// UserDTO 应用层数据传输对象
type UserDTO struct {
ID string `json:"id"` // 序列化标签(供API返回)
Name string `json:"name"`
Email string `json:"email"`
}
// CreateUserDTO 用于创建用户的DTO(输入参数)
type CreateUserDTO struct {
Name string `json:"name"`
Email string `json:"email"`
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 4. 转换流程示例(Handler → Service → Repository)
Handler 层:Form → DTO 转换,调用服务internal/api/http/handler/user_handler.go
package handler
import (
"myapp/internal/api/http/form"
"myapp/internal/app"
"myapp/internal/app/dto"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
userService *app.UserService
}
func (h *UserHandler) Create(c *gin.Context) {
// 1. 绑定HTTP请求到Form并校验
var f form.CreateUserForm
if err := c.ShouldBindJSON(&f); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 2. Form转换为应用层DTO
reqDTO := dto.CreateUserDTO{
Name: f.Name,
Email: f.Email,
}
// 3. 调用应用服务
resDTO, err := h.userService.Create(c.Request.Context(), reqDTO)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
// 4. 返回DTO
c.JSON(200, resDTO)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Service 层:DTO → 领域对象 转换,调用领域逻辑internal/app/user_service.go
package app
import (
"context"
"myapp/internal/app/dto"
"myapp/internal/domain/user"
)
type UserService struct {
userRepo user.Repository // 依赖领域层仓储接口
}
func (s *UserService) Create(ctx context.Context, req dto.CreateUserDTO) (*dto.UserDTO, error) {
// 1. DTO转换为领域VO(通过领域工厂确保验证)
email, err := user.NewEmail(req.Email)
if err != nil {
return nil, err
}
// 2. 创建领域对象(DO)
u, err := user.NewUser(generateID(), req.Name, email)
if err != nil {
return nil, err
}
// 3. 调用仓储保存领域对象
if err := s.userRepo.Save(ctx, u); err != nil {
return nil, err
}
// 4. 领域对象转换为返回DTO
return &dto.UserDTO{
ID: u.ID,
Name: u.Name,
Email: u.Email.Addr,
}, nil
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 转换时机与位置规范
| 转换方向 | 负责层 / 组件 | 核心目的 |
|---|---|---|
| HTTP Form → DTO | Handler(transport 层) | 解析用户输入,做初步格式校验,隔离 HTTP 框架细节 |
| DTO → 领域对象(DO/VO) | Application(服务层) | 将外部数据转换为领域可识别的模型,通过领域工厂确保业务规则被遵守 |
| 领域对象 → DTO | Application(服务层) | 隐藏领域内部细节,仅暴露外部需知的字段,适配输出协议(如 JSON/protobuf) |
| PO ↔ DO | Repository 实现(infra 层) | 隔离数据库模型与领域模型,确保领域层不依赖 ORM / 数据库细节 |
# 查询条件容器(Query/Criteria)设计
查询条件容器用于封装查询参数(如分页、过滤、排序),其设计需避免耦合外部协议或数据库细节。
# 推荐方案
简单场景(与仓储强耦合)在领域仓储接口层定义查询容器,确保上层(应用层)可直接使用:
// domain/user/repository.go(领域仓储接口) type UserQuery struct { Name string // 按名称过滤 Email string // 按邮箱过滤 Page int // 分页参数 PageSize int SortBy string // 排序字段 } type UserRepository interface { Find(ctx context.Context, q UserQuery) ([]*User, int64, error) // 返回数据+总数 }1
2
3
4
5
6
7
8
9
10
11
12HTTP 驱动的查询在
api/form中绑定 HTTP 参数,再转换为仓储层的查询容器(避免 HTTP 标签污染仓储接口):// api/http/form/user_query_form.go type UserQueryForm struct { Name string `form:"name"` Email string `form:"email"` Page int `form:"page" binding:"min=1"` PageSize int `form:"page_size" binding:"min=1,max=100"` } // Handler中转换 func (h *UserHandler) List(c *gin.Context) { var f form.UserQueryForm c.ShouldBindQuery(&f) // 转换为领域查询容器 q := user.UserQuery{ Name: f.Name, Email: f.Email, Page: f.Page, PageSize: f.PageSize, } users, total, _ := h.userService.List(c, q) }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21复杂过滤(规格模式)若过滤逻辑涉及领域语义(如 "已支付且金额≥1000"),在领域层定义
Specification接口:// domain/specification.go type Specification[T any] interface { IsSatisfiedBy(T) bool // 内存中验证 ToSQL() (string, []interface{}) // 转换为SQL条件(可选,由仓储实现) }1
2
3
4
5
# 最佳实践总结
- 领域层纯净:
domain包只放 DO/VO/ 接口和业务逻辑,不依赖任何外部包(如 ORM/HTTP 框架)。 - 单向依赖:上层(应用 / 接口)依赖领域层接口,领域层不依赖下层(infra)。
- 映射集中管理:转换函数集中在适配器层(handler/repo),避免散落在业务逻辑中。
- DTO 专用化:为不同场景定义专用 DTO(如
CreateUserDTO/GetUserDTO),避免 "万能 DTO"。 - VO 不可变:VO 使用值类型,通过工厂方法创建,禁止直接修改字段(确保值语义)。
- 错误边界清晰:转换失败(如无效邮箱字符串→Email VO)需在应用层捕获并返回友好信息。
# 常见反模式(禁止做法)
- 领域对象带 ORM/JSON 标签:污染领域模型,导致领域层依赖 infra,违反依赖倒置。
- Handler 直接使用 PO:暴露数据库实现细节(如字段名、索引),增加 API 与 DB 的耦合。
- DTO 替代领域模型:在 DTO 中实现业务逻辑,导致领域规则分散,失去 DDD 核心价值。
- 结构体多职责:同一结构体同时作为 PO/DTO/Form(如既带
gorm标签又带json标签)。 - SQL 逻辑侵入领域层:在
domain包中写 SQL 片段或 ORM 查询,混淆业务与存储逻辑。 - 命名混乱:BO/AO/DTO 随意混用,无明确注释,增加团队沟通成本。
# 进阶建议
- 映射工具:小型项目建议手写映射(可读性高、易调试);大型项目可使用代码生成工具(如
go generate+ 自定义模板),避免反射工具(如mapstruct)带来的隐式错误。 - 测试:为所有映射函数编写单元测试,覆盖字段映射、格式转换(如时间字符串→
time.Time)、验证失败场景。
# 结语(自查清单)
domain包是否只包含 DO/VO/ 接口和纯业务逻辑?- PO 是否放在
infra/repo下,且领域层不依赖 PO? - DTO/Form 是否带专属标签(JSON/binding),且与领域对象严格分离?
- 转换逻辑是否集中在 handler 或 repo 实现层,未侵入领域层?
- 查询容器是否避免了 HTTP/DB 细节的直接耦合?
- 所有映射函数是否有单元测试?
遵循以上规范,可在 Go+DDD 实践中实现清晰的分层边界、低耦合的代码结构,提升系统可维护性。