背景与问题界定
一个逐步成长的后端项目在两年内经历了三次"推倒重来"。第一次是从main包下堆积所有代码到按功能模块分包(users/、orders/、products/);第二次是从功能分包到MVC三层(controller/、service/、repository/);第三次是从MVC到Clean Architecture风格。每一次重构都耗费了数周,团队成员对这三次架构的体验评价各不相同——有的认为MVC够用了,有的认为DDD才是一劳永逸的方案。
Go语言本身的哲学是"less is more",但这不代表项目结构可以随意。在微服务架构中,每个服务的代码可能只有几千到几万行,过于复杂的架构分层反而增加了认知负担。但另一种极端——完全没有分层——当业务逻辑增长到一定程度后,循环依赖、职责模糊、变化隔离失败等问题会集中爆发。问题的关键不是"选MVC还是DDD",而是"在项目的哪个阶段应该用哪种组织方式"。
目标拆解与工程约束
项目结构必须随业务复杂度演进,而非一步到位:初创阶段的服务可能只有5-6个接口,强行DDD只会增加不必要的开销。需要在代码组织上预留演进路径——从面向过程风格起步,随着业务增长逐步引入更严谨的分层和领域模型。
分层必须隔离变化,而非隔离团队:DDD的核心理念是"领域隔离"——支付领域的变更不应影响订单领域。在微服务架构中,这种隔离已经按服务边界实现了。在服务内部,更核心的是"技术隔离"——HTTP路由变更不应影响业务逻辑,DB迁移不应影响API响应格式。
包组织必须消除循环依赖:Go编译器不允许循环导入,这迫使开发者认真思考依赖方向。好的项目结构应该让依赖流向从外层(HTTP handler)到内层(domain logic),禁止反向依赖。如果出现了a import b, b import a的冲动,说明抽象边界没划对。
项目骨架必须支持代码生成:无论是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项目都不是"选对了架构"而成功的,而是"在合适的时机做了合适的架构演进"。结构演进应该像代码重构一样,是增量的、可回退的、有验证指标的,而不是一次性推到重来。