Go项目结构演进:从MVC到DDD分层
背景与问题界定 一个逐步成长的后端项目在两年内经历了三次"推倒重来"。第一次是从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层接口单向调用。 ...