Go 项目结构之争:DDD 还是简洁分层?
Go 项目一开始通常很简单:几个接口、几张表、几个服务函数,按 handler、service、repository 分开就能跑起来。项目变大之后,团队又开始讨论 DDD、领域模型、聚合、应用层和基础设施层。
于是问题变成了:Go 项目到底应该采用 DDD,还是坚持简洁分层?
我的答案是:不要先选择目录结构,要先识别业务复杂度和变化边界。 简洁分层不是低级方案,DDD 也不是高级方案。它们只是解决不同问题的工具。
可以把项目想成一家餐馆。简洁分层像一家小店:前台接单,后厨做菜,仓库取料,路线短,出了问题也容易找到人。DDD 更像一家菜品、库存、会员和结算规则都很复杂的连锁餐厅:重点不是把岗位拆得越多越好,而是让“什么菜能做、什么时候能退单、库存怎么扣”这些规则有明确的负责人。
小店一开始就照搬连锁餐厅的总部制度,会被流程拖慢;连锁餐厅却不能永远把所有事情都交给一个大厨。Go 项目的结构选择,也是同一个道理。
一、争论表面是目录,本质是边界
很多团队讨论架构时,第一步就是贴目录树:
internal/
├── handler/
├── service/
├── repository/
└── model/
或者:
internal/
├── domain/
├── application/
├── interfaces/
└── infrastructure/
但目录本身不会自动带来好的架构。真正重要的是几个问题:
- 哪些代码属于同一个业务能力?
- 哪些规则必须始终成立?
- 哪些变化应该被隔离?
- 数据库、消息队列等技术细节,是否侵入了业务决策?
- 一个需求发生变化时,需要修改多少个包?
如果这些问题没有答案,换一套目录只是换了一种命名方式。
架构的价值不在于让目录看起来专业,而在于让变化停留在应该停留的地方。
先用一张图看两种结构的关注点:简洁分层按“技术职责”排队,DDD 更关心“业务规则”应该被谁拥有。
flowchart LR
subgraph simple["简洁分层:按技术分组"]
S1["请求"] --> S2["handler"] --> S3["service"] --> S4["repository"] --> S5[("数据库")]
S3 -. "规则容易集中" .-> S6["变胖的 service"]
end
subgraph ddd["DDD:按业务边界组织"]
D1["请求"] --> D2["应用用例"] --> D3["领域规则"]
D2 --> D4["仓储接口"]
D4 -. "具体实现" .-> D5["基础设施"]
D5 --> D6[("数据库")]
end
图中没有“谁更高级”的答案。左边像小餐馆的传菜路线,胜在短;右边像连锁餐厅的岗位边界,胜在业务规则变复杂时,能把变化关在领域边界里。
二、简洁分层为什么如此流行
最常见的 Go 项目结构大致如下:
internal/
├── handler/ # HTTP、RPC 等入口
├── service/ # 业务流程
├── repository/ # 数据访问
├── model/ # 数据结构
└── config/ # 配置
一次请求从 handler 进入 service,再由 repository 访问数据库。每一层职责清楚,开发者也容易找到代码。
这种结构很适合以下项目:
- 以增删改查为主的后台服务;
- 业务规则少,主要工作是数据转换;
- 团队规模小,成员需要快速理解全局;
- 项目生命周期较短,未来变化有限;
- 领域专家和开发团队之间没有复杂的业务协作。
它的优势很实际:代码少、跳转少、调试路径短。对于一个只有几个模块的内部工具,直接上完整 DDD,往往是在解决尚不存在的问题。
不过,简洁分层也有典型的失控方式。
1. service 变成万能垃圾桶
所有业务逻辑都塞进 service,最后出现一个几千行的 OrderService。它既负责校验订单,又计算价格,还处理库存、支付和通知。
这时所谓的分层仍然存在,但业务边界已经消失了。
2. model 只剩下数据库字段
模型只有一堆公开字段,任何地方都可以修改。订单能否取消、账户能否扣款等规则散落在不同服务函数里,代码能运行,却无法看出业务约束在哪里。
3. 层之间互相穿透
handler 直接调用数据库,repository 返回数据库专用对象,service 到处拼接 SQL。目录看起来分层,依赖关系却是网状的。
因此,问题不是“简洁分层一定不好”,而是它需要配合模块边界使用。项目越大,越不应该只有全局的 service 和 repository。
三、DDD 真正解决的是什么
DDD,即领域驱动设计,重点不是把代码拆成更多目录,而是让软件结构围绕业务领域组织。
它尤其适合这样的系统:
- 业务规则多,而且规则之间相互影响;
- 业务对象有明确的生命周期和状态变化;
- 同一个词在不同业务场景下含义不同;
- 需求经常变化,且变化集中在某些业务能力上;
- 系统需要长期维护,错误的业务决策会带来较大损失。
以订单为例,“取消订单”可能不是简单地把状态改成 cancelled:
- 已发货的订单不能取消;
- 使用优惠券后取消,优惠券是否退回;
- 部分退款和全额退款的规则不同;
- 取消后要不要释放库存;
- 某些订单只能由特定角色操作。
当规则达到这个复杂度,继续把所有判断写在一个 service 里,维护成本会快速上升。DDD 的价值在于把这些规则重新放回业务对象和业务边界中,让代码表达“为什么这样做”,而不只是表达“怎么更新数据库”。
一个偏 DDD 的 Go 模块可能长这样:
internal/order/
├── domain/
│ ├── order.go
│ ├── policy.go
│ └── errors.go
├── application/
│ ├── create_order.go
│ └── cancel_order.go
├── adapter/
│ ├── http.go
│ └── consumer.go
└── infrastructure/
└── postgres_repository.go
这里的核心不是目录数量,而是依赖方向:领域规则尽量不依赖数据库和 HTTP;应用层负责组织用例;适配层负责接收外部请求;基础设施层负责具体技术实现。
四、DDD 不等于“目录越多越专业”
DDD 最容易被误用的地方,是把概念数量当成架构质量。
一个只有用户注册、列表查询和后台导出的系统,却被拆成实体、值对象、聚合根、领域服务、仓储、工厂、应用服务、DTO 和转换器,最终开发者花更多时间维护架构仪式,而不是解决业务问题。
这通常会带来四类成本。
1. 概念成本
新成员需要先理解一套完整术语,才能定位一段普通代码。概念本身没有错,但如果业务并不需要,学习成本就变成了额外负担。
2. 重复成本
同一份数据在请求对象、领域对象、持久化对象和响应对象之间反复转换。边界清晰有价值,但每一次转换都应该有明确理由。
3. 依赖成本
为了“面向接口编程”,每个结构都配一个接口,结果接口数量远超实现数量。Go 的接口更适合由使用方在需要时定义,而不是在每个目录里提前铺开。
4. 认知成本
开发者需要在多个目录之间来回跳转,才能看懂一个简单用例。架构如果让普通修改变得困难,就说明抽象已经超过了业务的承受能力。
所以,DDD 的核心是保护领域规则,而不是制造更多文件。
五、比“全局分层”更实用的 Go 结构
对于大多数 Go 项目,我更推荐“按业务模块组织,模块内部适度分层”,而不是把整个项目切成全局的四五层。
例如:
internal/
├── user/
│ ├── handler.go
│ ├── service.go
│ ├── repository.go
│ └── model.go
├── order/
│ ├── handler.go
│ ├── service.go
│ ├── repository.go
│ └── model.go
├── payment/
│ ├── handler.go
│ ├── service.go
│ └── gateway.go
└── platform/
├── database.go
└── logger.go
它保留了简洁分层的易懂性,又把用户、订单、支付的代码隔离在各自模块中。订单规则复杂了,可以在 order 内部进一步演化:
internal/order/
├── domain/
├── application/
├── adapter/
└── infrastructure/
其他简单模块不需要被迫同步升级。架构应该允许局部复杂,而不是要求全项目整齐地复杂。
可以把这种演化理解成“给复杂模块加隔离舱”,而不是把整艘船都改造成复杂战舰:
flowchart TD
A["按业务模块组织的简洁结构"] --> B{"规则是否开始散落?"}
B -- "否" --> C["继续保持简单"]
B -- "是" --> D["提取领域对象与不变量"]
D --> E{"是否被数据库、网络绑住?"}
E -- "否" --> F["模块内保持轻量领域模型"]
E -- "是" --> G["隔离应用层、领域层、基础设施"]
G --> H["单体内形成清晰业务边界"]
H --> I{"边界稳定且拆分收益明确?"}
I -- "否" --> J["继续作为模块化单体演进"]
I -- "是" --> K["再评估是否拆成独立服务"]
这条路线的关键是:每一次升级都由具体问题触发。没有规则散落,就不必提取领域对象;没有外部依赖耦合,就不必提前拆应用层和基础设施层;没有稳定边界,也不必急着拆微服务。
这种结构还有一个好处:它让未来的拆分有迹可循。一个业务模块如果真的需要独立部署,可以先拥有清晰的包边界,再考虑是否拆成独立服务,而不是一开始就用微服务为未来下注。
六、到底该怎么选
可以用下面几组问题做判断。
| 判断问题 | 更适合简洁分层 | 更需要领域建模 |
|---|---|---|
| 业务规则 | 主要是字段校验和数据读写 | 有大量状态、约束和例外 |
| 变化方式 | 需求变化分散且简单 | 变化集中在明确的业务模块 |
| 数据模型 | 数据库表基本就是业务对象 | 数据库结构不能直接表达业务 |
| 项目周期 | 短期项目或内部工具 | 长期演进的核心系统 |
| 团队情况 | 小团队、角色重叠 | 多团队协作,需要统一业务语言 |
| 错误代价 | 出错容易修复 | 出错会造成资金、合规或履约问题 |
这不是一次性选择。更合理的方式是观察代码的痛点:
- 如果只是文件越来越多,先按业务模块重新组织;
- 如果规则散落在多个服务中,提取领域对象或策略;
- 如果数据库模型侵入了业务逻辑,隔离持久化模型;
- 如果跨模块调用越来越多,重新审视模块边界;
- 如果测试必须启动数据库才能验证规则,把纯业务逻辑移出基础设施。
换句话说,先解决真实的耦合,再引入对应的抽象。
七、一条适合 Go 的渐进式路线
我比较推荐下面这条路线。
第一步:从模块化的简洁结构开始
先按业务能力划分包。每个包内部可以有处理入口、业务服务和数据访问,不要一开始就为所有场景设计完整架构。
第二步:把不变量放到靠近业务的地方
当某条规则必须始终成立,就不要让所有调用方自行记住它。可以通过构造函数、方法或明确的策略对象,把规则集中起来。
例如,订单是否允许取消,应由订单自身或订单领域策略判断,而不是由三个接口各写一遍。
第三步:隔离外部依赖
当业务规则已经稳定,就让它不再直接依赖数据库、网络和具体消息组件。Go 中不必为了形式创建大量接口,只需要在真正需要替换或测试隔离的边界定义接口。
第四步:让测试推动结构演化
如果一段业务逻辑很难单独测试,通常说明它和外部依赖耦合过深。测试不是架构的附属品,它会帮助我们发现哪些代码应该进入领域层,哪些代码应该留在适配层。
第五步:只在边界稳定后考虑服务拆分
DDD 可以帮助识别边界,但不等于必须拆成微服务。很多系统在单体内部保持清晰模块,已经能获得大部分收益,同时避免网络调用、部署和运维复杂度。
八、几个常见误区
误区一:分层越多,架构越好
层数只能说明代码被分组了,不能说明依赖方向正确。一个五层架构也可能让数据库对象一路渗透到接口层。
误区二:用了 DDD,就不能直接写 SQL
DDD 不是禁止技术细节,而是要求技术细节不要主导业务模型。查询报表、批量导出等场景,直接使用更合适的数据访问方式,往往比强行套聚合更诚实。
误区三:所有对象都必须是领域对象
请求参数、数据库记录、消息载荷和领域对象承担的职责不同。是否需要转换,取决于它们是否会独立变化,而不是取决于某条架构教条。
误区四:简单项目永远不需要 DDD
简单项目也可能在某个模块突然变复杂。正确做法不是提前把整个项目 DDD 化,而是让结构能够在复杂度出现时局部演化。
结论:选择能承受变化的最小结构
Go 项目结构之争,最后不应该落在“DDD 更高级”还是“简洁分层更 Go”。真正值得比较的是:哪种结构能用更低的成本,守住当前最重要的业务边界。
如果项目主要是数据读写,就从按模块组织的简洁分层开始;如果业务规则已经成为系统的核心竞争力,就认真做领域建模;如果只有一个模块变复杂,就只改这个模块,不要让全项目承担复杂度。
好的架构不是把所有可能性提前设计好,而是让系统在需求变化时,有地方可以自然地长出来。
给读者的启示
- 目录结构不是架构本身。 真正的架构要看依赖方向、业务边界和变化成本。
- 简洁分层适合从零开始,DDD 适合应对复杂规则。 两者可以在同一个项目中共存。
- 优先按业务模块组织代码。 比起全局的
handler、service、repository,模块化结构更能控制耦合范围。 - 让真实痛点推动抽象。 只有当规则散落、测试困难或边界混乱时,才引入更强的领域建模。