背景与问题界定

一个逐步成长的后端项目在两年内经历了三次"推倒重来"。第一次是从main包下堆积所有代码到按功能模块分包(users/、orders/、products/);第二次是从功能分包到MVC三层(controller/、service/、repository/);第三次是从MVC到Clean Architecture风格。每一次重构都耗费了数周,团队成员对这三次架构的体验评价各不相同——有的认为MVC够用了,有的认为DDD才是一劳永逸的方案。

Go语言本身的哲学是"less is more",但这不代表项目结构可以随意。在微服务架构中,每个服务的代码可能只有几千到几万行,过于复杂的架构分层反而增加了认知负担。但另一种极端——完全没有分层——当业务逻辑增长到一定程度后,循环依赖、职责模糊、变化隔离失败等问题会集中爆发。问题的关键不是"选MVC还是DDD",而是"在项目的哪个阶段应该用哪种组织方式"。

目标拆解与工程约束

  1. 项目结构必须随业务复杂度演进,而非一步到位:初创阶段的服务可能只有5-6个接口,强行DDD只会增加不必要的开销。需要在代码组织上预留演进路径——从面向过程风格起步,随着业务增长逐步引入更严谨的分层和领域模型。

  2. 分层必须隔离变化,而非隔离团队:DDD的核心理念是"领域隔离"——支付领域的变更不应影响订单领域。在微服务架构中,这种隔离已经按服务边界实现了。在服务内部,更核心的是"技术隔离"——HTTP路由变更不应影响业务逻辑,DB迁移不应影响API响应格式。

  3. 包组织必须消除循环依赖:Go编译器不允许循环导入,这迫使开发者认真思考依赖方向。好的项目结构应该让依赖流向从外层(HTTP handler)到内层(domain logic),禁止反向依赖。如果出现了a import b, b import a的冲动,说明抽象边界没划对。

  4. 项目骨架必须支持代码生成:无论是protobuf生成的pb.go、sqlc生成的sqlc.go还是wire生成的wire_gen.go,生成的代码需要与手写代码有清晰的放置位置约定,且不能被手工修改。

方案设计

我们推荐的演进路线是"三阶段结构演进"。

Phase 1:扁平功能包(0-5000行代码)

internal/
├── handler/       # HTTP handler,接收请求,调用service
├── service/       # 业务逻辑
├── repository/    # 数据访问
├── model/         # 数据结构和DTO
├── middleware/    # 中间件
└── config/        # 配置定义

这个阶段使用简单的MVC三层,包之间单向依赖:handler → service → repository。适合CRUD风格的服务,业务逻辑简单。所有领域概念都放在model包中,虽然从DDD角度看这是贫血模型,但足够用。

Phase 2:领域分包(5000-20000行代码)

internal/
├── order/
│   ├── handler.go        # 订单相关的HTTP handler
│   ├── service.go        # 订单业务逻辑
│   ├── repository.go     # 订单数据访问
│   ├── model.go          # 订单领域对象
│   └── event.go          # 订单领域事件
├── user/
│   ├── handler.go
│   ├── service.go
│   ├── repository.go
│   ├── model.go
│   └── event.go
├── billing/
│   ├── ...
├── pkg/
│   ├── middleware/
│   ├── config/
│   └── errors/
└── cmd/
    └── server/main.go

每个领域一个独立包,内部自行组织自己的handler/service/repository。领域之间通过接口调用,而非直接依赖struct。这种结构要求每个领域都必须有清晰的边界,如果发现order调用了user的handler,说明边界划分有问题——应该通过service层接口单向调用。

Phase 3:DDD分层(20000+行代码)

internal/
├── order/
│   ├── domain/
│   │   ├── entity.go         # Order实体,带业务方法
│   │   ├── value_object.go   # 值对象:Money、Address
│   │   ├── repository.go     # 仓储接口(interface)
│   │   └── service.go        # 领域服务
│   ├── application/
│   │   ├── dto.go            # 应用层DTO
│   │   ├── create_order.go   # 用例:创建订单
│   │   └── cancel_order.go   # 用例:取消订单
│   ├── infrastructure/
│   │   ├── repository.go     # 仓储实现
│   │   ├── eventbus.go       # 事件发布实现
│   │   └── payment.go        # 外部服务适配
│   └── interfaces/
│       ├── http.go           # HTTP适配器
│       └── grpc.go           # gRPC适配器
├── pkg/...
└── cmd/...

domain层是纯Go代码,不依赖任何框架和基础设施(零依赖entity)。application层编排用例,依赖domain接口。infrastructure层实现domain接口。interfaces层处理入站适配。

演进中间层的经验是:定义清晰的域间接口契约:

// order/service.go
type OrderService struct {
    repo   order.Repository
    usrSvc UserService  // 通过接口依赖user领域
    bus    EventBus
}

type UserService interface {
    GetUser(ctx context.Context, id int64) (*UserInfo, error)
    HasPermission(ctx context.Context, userID int64, perm string) bool
}

这种设计使得user领域的变更不会直接传播到order领域,两者通过接口通信,可以独立演进。

实施路径与关键决策

  • 第一阶段:从Phase 1起步。新项目开始时使用最简单的扁平功能包结构。决策:严禁在项目初期引入DDD的分层框架,避免过度工程化。

  • 第二阶段:在Phase 1中识别领域边界。当单个service包超过500行时,开始识别领域边界。常见的划分依据是"变化频率"和"功能内聚性"。决策:领域拆分以"独立部署/独立迭代"的能力为标准,而非功能分类。

  • 第三阶段:逐步引入DDD概念。当领域之间的依赖关系变得复杂、循环依赖出现时,引入domain层和infrastructure层的分离。决策:不追求所有领域同时升级到DDD,可以order领域先做、user领域保持Phase 2。

  • 第四阶段:统一代码风格和lint规则。无论项目处于哪个阶段,统一使用golangci-lint的goimports、govet、staticcheck。新增包必须遵守依赖方向规则。决策:循环依赖检测作为CI阻断项。

验证指标与可持续迭代

验证指标:新增一个业务功能的平均代码变更量稳定在3-5个文件以内;一个领域的变更不需要修改其他领域的代码(通过git diff统计);领域间的接口变更频率低于领域内部的实现变更;同一个服务内不存在a import b,b import a的循环依赖(编译期保证)。可持续迭代方面,每季度评审一次包结构,判断是否需要向下一阶段演进。建立架构决策记录(ADR),在每次架构演进时记录决策理由和预期收益。

工程落地思考

“一开始就上DDD"和"永远不重构结构"是两个同样危险的极端。DDD的核心价值在于"领域模型驱动设计”,这意味着你需要先理解领域,再抽象模型——在没有理解领域之前强行划分子域,划出来的边界一定是错的。而MVC的价值在于简单直接,它在大多数CRUD场景下完全够用。我见过的所有成功的Go项目都不是"选对了架构"而成功的,而是"在合适的时机做了合适的架构演进"。结构演进应该像代码重构一样,是增量的、可回退的、有验证指标的,而不是一次性推到重来。