成为全栈·Go 后端篇·用 Go 重写分层架构:领域包、装配根与最小抽象
语言迁移最容易复制的是语法,最值得重新审视的是边界。一个清晰的 Go 工程要让请求入口、业务规则、数据模型和基础设施各自承担有限责任。

本文代码快照:提交 ceac4e0(Go 1.26.6)。Node 对照来自同一仓库的 node-backend 实现;以源码和契约为准。
前言:重写不是把文件名逐个翻译
重写第一版,我照着 Node 的目录在 Go 里建了四个包,handler 一路往下转,最底下一行只有 return repo.List(ctx)。编译过了、请求也通了,只是每加一个接口就要在四个文件之间跳一遍。
Node 项目里可能有 routes、controllers、services、repositories;把它们翻译成 Go 的 handler、service、repository,目录看起来会很熟悉。但如果每个请求仍要经过三层只有一行的转发,或者为了"架构完整"创建一个通用 RepositoryT,文件多了,业务却未必更清楚。
M6 的目标是复刻既有 Node 后端对外可见的行为,守住冻结 OpenAPI 契约以及数据库不变量。它不是原有 TypeScript 文件的逐字翻译。Go 工程重新组织为传输层、领域包、平台设施和 bootstrap 装配根。每个部分围绕责任边界划分,而不是为了对应原语言里的某种类名。
本文会讨论四个设计问题:包边界怎么定;bootstrap 为什么单独存在;持久化模型为什么不直接作为 API DTO;为什么服务直接依赖 GORM,却没有再套一层通用 Repository。关键不是某种目录树"唯一正确",而是如何判断一个抽象是否真的替我们隔离变化。
一、先从一条请求的责任链出发
用"创建文章"作为例子,请求大致经过如下路径:
text
HTTP 请求
↓
transport/httpapi:路由、身份、请求校验、错误信封
↓
article.Service:文章规则、分类标签、事务
↓
platform/database + platform/model:数据库驱动与持久化行
↓
统一响应 DTO:映射为契约字段
这一条链把协议责任和业务责任拆开。httpapi 知道 HTTP 状态码、请求体、认证主体和统一响应结构;article 知道草稿、投稿、发布、归属校验以及标签同步;platform/database 知道 GORM 连接、数据库方言、连接池和行锁;platform/model 描述数据库中的行。
如果把数据库 SQL 放进 HTTP handler,协议代码会同时承担业务规则和数据持久化,测试也需要跨越太多边界。反过来,如果让领域服务直接操作 http.ResponseWriter,它就无法从 HTTP 传输中独立出来。边界不是为了"纯洁",而是为每部分保留可观察的输入、输出与失败方式。
这里也要留意,图里的层次不是单向依赖必须绝对纯净的教条。领域服务会调用数据库,这是它完成业务用例所需的基础设施;关键是领域规则不依赖具体 HTTP 响应格式,也不把一条 ORM 数据库行直接当成公开 API 模型。

二、Go 里用包表达边界,不必把每个名词都变成接口
当前主要包分布如下:
| 包 | 责任 |
|---|---|
| cmd/server | 读取配置、打开资源、启动 HTTP Server、处理退出 |
| internal/bootstrap | 组装依赖,绑定操作处理器并校验完整性 |
| internal/transport/httpapi | HTTP 路由、身份与权限边界、校验、统一响应 |
| internal/article | 文章查询、状态动作、分类标签同步 |
| internal/auth | 注册、登录、刷新、微信身份与账号能力 |
| internal/comment | 评论可见性、审核和通知联动 |
| internal/member / administration | 会员能力与管理功能 |
| internal/platform/model | GORM 持久化行与表名 |
| internal/platform/database | 驱动、迁移、连接池及数据库专用能力 |
| internal/platform/storage | 本地文件与 R2 对象存储实现 |
| internal/notification | 通知记录及其事务写入逻辑 |
Go 的 package 是代码组织和可见性的基本单位。internal 目录限制外部 module 导入,包内小写标识符不导出。一个包应该围绕稳定的概念或变化原因聚合代码,而不是照搬 UML 图里的每个对象都建一个目录。
接口也不应该成为文件组织的默认产物。若某个服务只有一个实际实现,而且调用方并不需要在生产路径中替换它,先定义一堆接口可能只增加跳转成本。等存在真实替换点、测试隔离需要或明确的所有权边界,再定义最小接口通常更直接。Go 接口由使用方按需求声明,也不必先为每个实现写一份"全功能接口"。
M6 在少数外部替换边界使用小接口,例如 auth.WechatClient 表示微信 code 换取身份的外部能力,storage.Provider 表示存储目标,httpapi.Identity 表示身份解析。它们隔离的是运行环境或外部服务变化,不是抽象所有内部业务。
三、bootstrap 是唯一装配根
Go 的 cmd/server 做运行时入口,bootstrap/app.go 负责组合应用。它加载冻结契约,构建 Identity/Auth 服务和领域 service,建立 provider 映射,再把 68 个 OpenAPI 操作绑定到对应 handler。所有契约声明的操作都应有注册处理器;缺一个,应用在启动时就失败,而不是等流量首次访问时才暴露遗漏。
下面是它的装配过程。整段代码里没有一处业务规则,只有「谁需要谁」:
go
// internal/bootstrap/app.go(节选,省略 provider 映射与部分 Bind 调用)
// New 作为唯一装配根连接各领域,并检查全部契约操作已注册。
func New(db *gorm.DB, c config.Config, o Options) (*httpapi.App, error) {
catalog, err := contract.Load()
if err != nil {
return nil, err
}
identity := auth.New(db, c.JWTSecret)
identity.AppID = c.WechatAppID
identity.Wechat = o.Wechat
if identity.Wechat == nil {
identity.Wechat = auth.WechatHTTP{AppID: c.WechatAppID, Secret: c.WechatSecret}
}
// ...(此处按配置构造 local 与 r2 两个 storage.Provider)
content := article.New(db)
app := httpapi.New(catalog, identity, c.Origins)
app.TrustedProxies = c.TrustedProxies
app.BindAuth(identity)
app.BindContent(content, taxonomy.New(db))
comments := comment.New(db)
// ...(此处把评论拒绝率写入 comments 并绑定发现类操作)
app.BindMember(member.New(db), administration.New(db, content))
app.BindAttachments(attachment.New(db, c.Storage, p))
for id := range catalog.Operations {
if !app.Registered[id] {
return nil, fmt.Errorf("operation not implemented: %s", id)
}
}
return app, nil
}
最后那个 for id := range catalog.Operations 循环就是完整性检查:契约声明了多少操作,装配根就要求注册多少个,任一缺失 New 直接返回错误,进程起不来。Options 结构允许测试注入微信与存储替身,正式运行才落到真实适配器,把变化点收敛到一处。
这个设计把"对象怎样产生"集中起来,但不把业务放到 bootstrap。比如文章服务的规则仍然在 article 包;bootstrap 只知道它需要怎样的依赖,并把它提供给 HTTP 层。职责可以拆成:
- cmd/server 决定进程何时启动和关闭;
- bootstrap 决定应用由哪些实现组成;
- httpapi 决定一条 HTTP 操作通过哪些公共边界;
- domain service 决定业务允许什么状态变化。
这种显式装配也让不同命令有机会复用同一组基础组件。例如 migrate 可以加载配置和数据库,但不需要构造 HTTP handler;data 工具则具有独立的数据连接配置。若所有东西都藏在全局 init() 或隐式容器里,程序的真实依赖就更难从入口读出来。
装配根的另一个职责是完整性检查。契约里有多少 operation、当前注册了多少 handler,这两者的对应关系属于启动期结构约束。用 map 注册 provider 并在启动时验证,可以在发布前发现漏接,而不是只靠一批碰巧访问到的页面。

四、HTTP handler 为什么不能成为"万能服务"
Handler 的输入是 HTTP Request,输出是 HTTP Response;它适合做边界转换,不适合持有全部业务工作流。当前 httpapi 把方法、路径、OpenAPI operation、身份要求、请求体解码和 schema 校验连接起来,再调用具体领域处理器。
如果把文章的分类校验、标签删除与重建、slug 约束、事务控制和响应映射都写进同一个 handler,测试一项业务规则就必须伪造完整请求上下文,重复逻辑也会散落在不同端点。业务服务接收经过边界确认的值,并返回明确结果或错误;HTTP 层再把领域错误翻译成契约要求的状态码和信封。
同样,不要把所有逻辑都塞进一个"万能 service"。文章、评论、身份、附件都有不同的不变量和协作对象。领域包按能力划分,让相关规则可以在一起审阅。跨领域工作流若涉及同一数据库原子提交,就由实际用例明确持有事务,并通过依赖参数或小范围协作实现;不要隐式地让任意 service 随时开启一个不可见事务。
M6 中的 notification 具有特别的协作意义:文章发布、评论审核等动作会产生通知。通知记录如果属于业务动作的一部分,就应共享该动作的事务;通知发送到外部系统则是另一种可靠性问题。保存通知数据和向第三方发送消息不能都被一句"调用通知服务"掩盖。
五、持久化行与 API DTO 不应共用一个 struct
GORM model 描述的是表行:数据库列名、索引或关联、nullable 状态与写入字段。API DTO 描述的是契约:字段是否存在、是否允许 null、客户端是否可见、日期要以什么格式表达。两者生命周期不一样。下面是文章持久化行的真实定义:
go
// internal/platform/model/articles.go
// Article 文章持久化行,毫秒时间和显式软删除字段与 Node 数据一致。
type Article struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
Title string `gorm:"column:title"`
Slug *string `gorm:"column:slug"`
Summary *string `gorm:"column:summary"`
Content string `gorm:"column:content"`
CoverImage *string `gorm:"column:cover_image"`
AuthorID int64 `gorm:"column:author_id"`
AuthorName *string `gorm:"column:author_name"`
CategoryID *int64 `gorm:"column:category_id"`
CategoryName *string `gorm:"column:category_name"`
CategorySlug *string `gorm:"column:category_slug"`
Status string `gorm:"column:status"`
Tags *string `gorm:"column:tags"`
ViewCount int64 `gorm:"column:view_count"`
LikeCount int64 `gorm:"column:like_count"`
PublishedAt *int64 `gorm:"column:published_at"`
CreatedAt int64 `gorm:"column:created_at"`
UpdatedAt int64 `gorm:"column:updated_at"`
DeletedAt *int64 `gorm:"column:deleted_at"`
}
func (Article) TableName() string { return "articles" }
光看类型就能读出契约线索:可空列一律是 *string 或 *int64,必填列才是值类型;DeletedAt 是显式软删除时间;时间列统一是 int64 毫秒,与 Node 数据保持一致。这些字段直接对应数据库列。如果把这张表行当成响应模型,Content 全文、DeletedAt 和内部关联都会被一起序列化出去。
例如,一个 Article 行可以包含内部审核字段、删除时间或数据库关联,而公开文章响应不应该因为使用同一个 struct 就把这些列序列化出去。管理端和前台还可能有不同的响应投影;列表摘要只需要标题、slug、摘要、封面和统计,不必把全文 Markdown 一并加载。
因此,服务层或 transport mapper 会明确从持久化行映射到契约输出。看起来多了一段赋值,但这一步就是隐私、字段与兼容边界。契约变更时,响应映射是需要审阅的位置;数据库迁移时,API 输出可以不变。这样两种变化就不必被 ORM 的自动序列化行为捆在一起。
输入也有同样的问题。Go 的零值让字段缺失与值为 false、0 或空字符串容易混淆。若一个更新端点需要区分"没有提交这个字段"和"明确把字段清空",输入层要保留字段存在性,再通过白名单映射到数据库更新。这个逻辑不能交给持久化 struct 的默认 JSON 解码猜测。
六、为什么没有通用 Repository
常见分层方案会创建 UserRepository、ArticleRepository、CommentRepository,甚至一个支持任意实体的 RepositoryT。当仓储需要提供有意义的领域查询、隔离特定存储系统,或被多个用例共享时,这样做可以成立。但只为了把 db.Where(...).First(...) 包成一个名字,抽象可能没有真正隔离变化。
M6 的业务服务直接依赖 *gorm.DB。这里的依据是当前服务以关系型数据库为目标,GORM 已经是明确的持久化工具;服务需要组合多次查询、更新和事务,而通用 repository 往往会把查询能力重新发明一遍。它也可能在 service 和 ORM 之间制造第二套不完整 DSL。
直接使用 GORM 不代表把数据库细节散落到所有地方。数据库初始化、迁移、方言差异、连接池和锁在 platform/database;领域查询与更新仍在相关领域包中;事务由用例掌握并明确传递。依赖具体 ORM 的成本是可见的:若将来真的换 ORM,领域 service 会有改动。我们接受这个成本,因为当前没有第二个关系型引擎接口需要被运行时替换,且三种数据库都通过 GORM 驱动和显式方言迁移支持。
一个判断抽象价值的问题是:删掉它之后,哪种变化会更难?如果答案只是"代码看起来更不分层",要继续追问它到底隔离了什么。如果抽象让调用方不再知道事务细节,却又不能跨多条写操作原子提交,那它隐藏的是正确性必需信息,而不是不必要噪音。
七、事务由业务用例明确持有
创建文章涉及文章行、分类关系、标签关系,某些操作还会写通知。如果只插入主表成功,而标签同步失败,数据库会留下不完整状态。事务边界应覆盖必须共同成功或失败的一组写入。
示意代码:
go
err := db.Transaction(func(tx *gorm.DB) error {
article, err := createArticleRow(tx, input)
if err != nil {
return err
}
if err := syncTags(tx, article.ID, input.TagIDs); err != nil {
return err
}
return nil
})
注意 tx 被显式传给 helper。它们不能在内部又随意从全局 db 取一个连接,否则部分写操作可能落到事务外。把事务作为参数透传,会让原子范围能被代码阅读者看见,也便于测试回滚行为。
GORM 默认会为单条写操作提供默认事务。项目选择 SkipDefaultTransaction 来减少不必要的单语句包装,但这意味着多步业务写入必须显式开启事务。此选项不是"所有写操作都无需事务",更不是性能优化的口号;它要求工程师准确识别事务不变量。文章、互动计数、刷新令牌旋转等路径都要结合业务和数据库约束判断原子范围。
事务失败后的可观察结果也要验证。只看返回 error 不够,还要重新读取数据确认文章、标签关系和通知全部回滚,或者检查失败响应是否按契约映射。事务是数据正确性的工具,其证据是失败路径留下的状态。
八、怎么判断分层是否真的帮到团队
一个实用检查方法是取一个具体变更,而不是点评目录树:
- 如果 OpenAPI 响应字段改变,需要改哪些包?是否能在明确 mapper 看到输出边界?
- 如果把 HTTP 入口从本机 server 换成另一个 listener,领域规则是否仍可用?
- 如果存储从本地磁盘切到 R2,变化是否只集中在 Provider 和配置适配?
- 如果文章写入增加一项关系数据,事务范围和回滚路径在哪里?
- 如果少注册一个 operation,系统是在启动时暴露,还是上线后由用户发现?
- 如果测试替换微信上游,是否只需注入 WechatClient?
如果答案能落到具体代码位置,分层就有实际作用。如果每个小改动都要穿过 Controller、UseCase、Port、Adapter、Repository、Gateway 六层才改变一条查询,那么抽象数量可能已超过变化数量。
这不是反对接口、仓储或用例层。复杂业务、多团队共享、多个存储引擎或长期插件化系统都可能需要更丰富边界。教学项目也不应为了少文件而牺牲事务正确性和清晰性。好的架构是符合当前变化轴并能在证据出现时演进的架构。
小结:包边界应该能解释责任和变化
M6 用 transport/httpapi 处理 HTTP 协议,用领域包表达业务,用 platform 放数据库与存储等设施,由 bootstrap 显式组装。持久化行与 API DTO 分开,操作注册在启动时核对完整性;service 直接用 GORM,事务由用例明确持有。少量接口只出现在真实外部替换边界。
读一套 Go 工程时,不妨追一条请求和一个失败路径。它们比"分了几层"更能说明架构是否合理:业务规则是否有归属,事务是否可见,字段是否按契约投影,依赖是否在运行入口明确装配,以及故障能否在适当边界被处理。
延伸阅读
- Go Web 框架怎么选:本项目为什么使用标准库
- Node 后端分层架构:Controller、Service、Repository 的边界
- 契约先行:设计一套被七个端复用的 API
- 后台骨架:布局、数据路由与分层守卫
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
