我们如何将 Go 服务从单体平滑拆分到 DDD 分层
当单体成为痛点,我们先别急着“大爆炸”
无论是在传统的业务团队还是快速迭代的创业公司,Go 服务通常从一个简单的 `main.go` 加几个 handler 开始。一开始这很香:部署简单、开发顺手、逻辑一梭子下来就能跑。但当业务从“活动页”变成“订单中心”,当接口里开始直接操作数据库表,当你在一个函数里同时处理和支付、库存、用户积分相关的逻辑时,代码的“腐化”就成了必然的趋势。
面对这种混乱,很多人第一反应是“搞微服务”。但盲目拆分服务往往会引入分布式事务、网络延迟、链路追踪等一堆比代码混乱更麻烦的问题。我们当时的思路是:不拆服务,先拆代码结构。 目标很明确——在保留单体部署便捷的前提下,把逻辑理清楚,给未来可能的服务拆分预留好边界。
第一步:从“按技术分层”走向“按业务分包”
大多数 Go 单体项目都会有三个文件夹:`models`、`api`(或者 handler)、`utils`。这类目录结构本质上与技术分层类似,但它有一个致命弱点:没有边界。比如用户相关的逻辑和订单相关的逻辑,可能同时操作 `models.User` 和 `models.Order`,久而久之形成一团乱麻。
我们做的第一件事,是先把包名“业务化”。重新混排目录,让每个包就是一个业务域:
├── cmd/ // main 入口,只做启动和依赖组装
├── internal/ // 私有业务代码
│ ├── order/ // 订单域
│ │ ├── application/ // 用例编排、DTO
│ │ ├── domain/ // 实体、值对象、domain service
│ │ └── infra/ // 对 repository 接口的公网/数据库实现
│ ├── user/
│ └── payment/
└── pkg/
这个阶段不要苛求代码全能,优先确保依赖方向一致:`application` 依赖 `domain`,`domain` 不依赖任何东西,`infra` 负责把数据库操作实现并绑定到接口上。
第二步:让 Domain 真正“裸”起来
很多人把 DDD 理解为多建一堆文件夹,但没有重构业务的本质。我们当时最大的一个坑是,`domain` 层里写满了 `gorm` 的 tag。订单实体里有 `gorm:"column:create_time"` 这种标签,导致 domain 层不仅无法脱离数据库做测试,也难以建模复杂的业务规则。
后来我们做了两个关键决定:
1. 让 domain 层干干净净,只包含业务字段和行为,不引入任何 ORM 依赖。用接口定义仓储(Repository),交给 infra 层去用 GORM 实现。这样 domain 层就可以做纯单元测试,业务逻辑也不容易被数据库细节污染。
2. 用行为去封装状态。原来代码也习惯直接操作结构体的字段,比如 `order.Status = "paid"`。现在我们会在 Order 上定义方法,比如 `MarkPaid()`、`CalculateTotal()`。所有状态的流转都走方法,防止业务规则散落。
比如订单域的核心逻辑是“取消超时未支付订单”,它不需要知道这笔数据存在哪个 MySQL 表里,只需要调 `repository.GetPendingOrder`,再判断 `order.IsExpired()`。每一条规则都能跟踪和落点。
第三步:由内而外迁移,不搞大爆炸
有了目标结构,别拿着 IDE 一把梭全改。最好的方式是进行业务切换。我们当时的迁移节奏大致是:
- 挑一个相对独立的业务,比如说“钱包/余额”域。
- 先写完整的 domain 结构代码(entity/repo interface),再把现有 SQL 逻辑移动到 infra。
- 在 application 里实现 use case,做一个防腐层适配老的 handler。
- 灰度验证逻辑无差异后,清理掉老的 handler 遗留。
渐进的好处是:功能始终可运行、可回滚。每迁移完一个模块,团队基本都能看到结构改善带来的好处——比如新增需求无需再被动修改一堆 handler 里杂七杂八的代码,而是在自己的业务包里轻松完成,几乎不碰别人的文件。
第四步:用防腐层隔离外部影响
考虑到很多单体服务周边有大量第三方依赖(比如消息队列、其他内部 RPC),DDD 做得好不好,很大程度上取决于防腐层是否清晰。在移动端支付回调这种高频变动场景里,我们让回调接口只负责“翻译”外部报文,转化成 domain 方法调用,例如 `paymentService.HandlePaid(orderID, txnID)`。这样即使合作方的回调协议版本变了,也只改防腐层,核心逻辑稳定不变。
一段并不轻松的旅程
整体来说是围绕着“结构演进”在做,微服务一个没拆,但重构效果却非常明显:代码的显性耦合减小,开发时经常会发现,自己处理某个 bug 时不必再打开其他业务域的文件。
DDD 并非银弹,在简单的 CRUD 项目里强行上这套反而会导致繁琐。但如果你正面临单体内部逻辑纠缠、接口难以扩展、需求变更总是不敢碰的情况,这也许是一条值得考虑的路。从单体到 DDD,不一定是推倒重来,而是顺着业务的脉络,先把边界修好。 架构可以继续保持单个 Go 服务,但代码心智上已经完成了“微服务化”。
转载请注明出处,版权归原作者所有。
管理员
黑卡会员