Go 项目结构之争:DDD 还是简洁分层?

Go 项目一开始通常很简单:几个接口、几张表、几个服务函数,按 handlerservicerepository 分开就能跑起来。项目变大之后,团队又开始讨论 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。目录看起来分层,依赖关系却是网状的。

因此,问题不是“简洁分层一定不好”,而是它需要配合模块边界使用。项目越大,越不应该只有全局的 servicerepository

三、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”。真正值得比较的是:哪种结构能用更低的成本,守住当前最重要的业务边界。

如果项目主要是数据读写,就从按模块组织的简洁分层开始;如果业务规则已经成为系统的核心竞争力,就认真做领域建模;如果只有一个模块变复杂,就只改这个模块,不要让全项目承担复杂度。

好的架构不是把所有可能性提前设计好,而是让系统在需求变化时,有地方可以自然地长出来。

给读者的启示

  1. 目录结构不是架构本身。 真正的架构要看依赖方向、业务边界和变化成本。
  2. 简洁分层适合从零开始,DDD 适合应对复杂规则。 两者可以在同一个项目中共存。
  3. 优先按业务模块组织代码。 比起全局的 handlerservicerepository,模块化结构更能控制耦合范围。
  4. 让真实痛点推动抽象。 只有当规则散落、测试困难或边界混乱时,才引入更强的领域建模。