← BACK TO BLOG

前后端分离:用外层仓库 + Git 子模块组织多服务

前后端分离后,各服务、各端各自成仓,外层再用 Git 子模块组装。这样既能独立演进、钉死产品快照,也能给 Vibe Coding 一份完整且边界清晰的工作区。

次阅读

背景

前后端分离几乎是默认选项:后端拆成若干服务,前端再分成用户端、管理端、小程序、App。仓库怎么切,却经常卡在两头。

一头是巨型单体仓:所有服务挤在一个 Git 仓库里。权限不好分,CI 动辄全量跑,提交记录互相污染,前端同学 clone 下来还要背一整份 Java 工程。

另一头是完全散落的独立仓:每个服务一个仓库,彼此没有任何约束。新人入职要问十个地址;本地联调靠口头约定端口;上线那天才发现管理端打的是旧接口、网关配的是新路由。

更常见的中间态是:仓库是拆开了,但"这一套东西怎么拼起来"只活在某个人的电脑和一份过期的文档里。

这篇文章要介绍的做法是第三种:

每个服务、每个前端端各自一个 Git 仓库;再在外层建一个仓库,用 Git 子模块把它们组装成一份产品。

外层仓不放业务代码,只放"怎么把这些仓拼成可运行系统"的东西:子模块指针、compose、网关配置、环境模板、一键脚本、架构说明。

长什么样

假设一个典型的业务系统:用户服务、订单服务、网关,加上 Web 和管理后台。目录可以是这样:

code
shop-workspace/                 # 外层仓库
├── .gitmodules
├── README.md
├── docker-compose.yml
├── .env.example
├── scripts/
│   ├── clone-all.sh
│   └── up-local.sh
├── gateway/                    # 子模块:API 网关
├── service-user/               # 子模块:用户服务
├── service-order/              # 子模块:订单服务
├── web/                        # 子模块:用户端
└── admin/                      # 子模块:管理端

.gitmodules 记录每个子模块从哪来、挂在哪:

ini
[submodule "gateway"]
    path = gateway
    url = git@example.com:shop/gateway.git
[submodule "service-user"]
    path = service-user
    url = git@example.com:shop/service-user.git
[submodule "service-order"]
    path = service-order
    url = git@example.com:shop/service-order.git
[submodule "web"]
    path = web
    url = git@example.com:shop/web.git
[submodule "admin"]
    path = admin
    url = git@example.com:shop/admin.git

新人拿到外层仓,一条命令就能把整套工作区拉齐:

bash
git clone --recurse-submodules git@example.com:shop/workspace.git

已经 clone 过、漏了子模块,补这一下:

bash
git submodule update --init --recursive

外层仓里的每一次提交,记录的不是业务代码本身,而是当时各个子模块分别停在哪个 commit。这个指针,就是整套系统的版本快照。

真正要解决的,不是"一个仓还是多个仓"

拆仓还是合仓,争论了很多年。子模块这套结构的价值,不在于站哪一边,而在于同时满足两件互相矛盾的事:

每个服务按自己的节奏演进;整套产品又能被钉死成一个可复现的组合。

用户服务可以一天发三次;管理端可以两周一个迭代;网关可能一个月才动一次。它们不该共用一条提交历史,也不该被绑在同一次 CI 上。但到了联调、预发、生产,你必须能回答:"当时跑起来的那一套,前端是哪一版、订单服务是哪一版、网关配的是哪份配置。"

独立仓解决了前半句,丢了后半句。单体仓保住了后半句,牺牲了前半句。外层仓 + 子模块,把"生命周期"和"产品快照"拆开:生命周期归各仓,快照归外层。

下面分开看,这套结构具体带来什么。

各仓独立,权限和发布都干净

前后端分离之后,人、技术栈、发布节奏本来就不一样。仓库边界应该跟这个现实对齐。

前端仓库可以只有 Node、Vite、ESLint;后端仓库只有 Maven / Gradle、Dockerfile、接口契约。CI 只跑自己那一层:前端改样式不会触发 Java 单测,用户服务改字段也不会让管理端重新构建。权限同样可以按仓切:外包只做管理端,就只给 admin 的写权限,外层仓和核心服务保持只读或不可见。

提交历史也不会搅在一起。回滚管理端,不会在同一条 git log 里翻到订单服务的数据库迁移;查用户服务上周谁改了鉴权,不用从一堆 CSS 提交里筛。

对只负责某一端的人来说,日常甚至不必打开外层仓:照常 clone 自己的服务仓库,照常提 PR、发版。外层仓是给"需要看整套系统的人"用的,不是给每个人加的额外负担。

外层仓给出可复现的产品快照

散落的独立仓最容易丢的,是组合信息

"Web 的 feat/pay 要配订单服务的 v2.3.1,网关要开新路由,管理端暂时还停留在上周的 main"——这种知识如果只存在于群聊和某个人的记忆里,换人、换机器、过两周,就会失效。

子模块把这件事变成外层仓里的一次普通提交:每个子目录钉死一个 commit hash。外层仓的 tag v1.4.0 的含义不再是"大概是那一阵的代码",而是:

网关 @ a1b2c3 + 用户服务 @ d4e5f6 + 订单服务 @ 789abc + Web @ def012 + 管理端 @ 345678

预发出了问题,把外层仓切到同一个 tag,子模块一起回到当时的指针,本地或测试环境就能按同一组合拉起来。这比"每个仓自己找一下大概哪个分支"可靠得多。

跨服务的改动也有了落点。一次需求同时改了接口和两个前端,各仓各自合入之后,外层仓再提交一次"指针更新"。产品层面的发布记录在外层,服务层面的提交记录在各仓,两条线都清楚。

跨切关注点有地方放

多仓拆开之后,有一类文件会无家可归:docker-compose.yml、本地 Nginx / 网关路由、.env.example、一键启动脚本、接口联调说明、架构图。

塞进某一个业务仓都不合适——用户服务不该托管管理端的端口约定,Web 仓也不该变成全公司的 compose 中心。单独再开一个"文档仓"又容易和真实代码脱节。

外层仓正好是这类东西的位置:它描述的是组合,不是某一个服务的实现。compose 在这里声明各服务如何组网;脚本在这里负责拉子模块、起依赖、灌入测试数据;README 在这里写"从零到本地可跑"的唯一入口。

于是 onboarding 从"拉五个仓库、问五个端口、改五份配置"收成:"clone 外层仓,按 README 执行。"本地联调的真实来源,也从口头约定变成了仓库里可审查、可演进的文件。

工作区完整,又不强迫所有人背全量历史

对全栈、架构、排障来说,把相关服务放在同一个工作区里很重要:跳转接口定义、对照前后端字段、让 AI 编码工具看见完整上下文,都比只打开一个仓要准。Vibe Coding 把这件事从"偶尔方便"变成了日常刚需,下一节单独说。

子模块给的是目录在一起、历史仍分开。IDE 打开外层仓,看到的是完整树;每个子目录仍然是独立 Git 仓库,提交、分支、远程互不影响。不需要把所有服务的全部 commit 揉进同一条历史上,也能得到"整仓在眼前"的开发体验。

只做某一端时,仍然可以只 clone 那一个仓。需要联调或排跨服务问题时,再打开外层仓。两种工作方式共存,而不是用单体仓强迫所有人下载全量历史。

对当下 Vibe Coding 有什么好处

Vibe Coding 指的是:你用自然语言描述意图,Cursor、Claude Code 这类 Agent 在工作区里读代码、改代码、跑命令。模型不会凭空知道你们公司有几个仓,它看见的几乎只有一件事——当前工作区里有什么

散落的独立仓对 Agent 不友好。你打开 Web 仓让它"把支付状态透到列表页",它看不到订单服务的字段和接口,只能猜 URL、猜 DTO,生成"看起来对、对不上后端"的代码。你再开一个后端仓另起一轮对话,上下文从零开始,前后端对不齐是常态。

巨型单体仓则是另一种噪声:Agent 索引到无关服务、生成物、好几份 lockfile,分不清该改哪个包。一次跨端需求,提交记录也容易揉成一团。

外层仓 + 子模块卡在中间,正好对上 Vibe Coding 要的两种上下文。

打开外层仓,Agent 拿到的是整套产品,而不是某一个端。 一次对话里可以同时改订单服务的 API、网关路由、Web 列表和管理端筛选。字段名、错误码、compose 端口都在磁盘上,不必靠你口头补课。外层的 README、.env.example、一键脚本也是 Agent 的说明书:从 clone 到本地可跑,少问人、少猜环境。

目录边界就是仓库边界,Agent 更容易改对地方、提交对仓。 web/service-order/ 各是独立 Git 仓库,也就可以各有一份 .cursor/rulesAGENTS.md:前端约定写在前端仓,Java 约定写在后端仓,外层只放"服务如何组网、如何联调"。人看目录能分清职责,Agent 按目录也能分清该动哪一层。改完之后,提交发生在对应子模块里,而不是一条巨无霸 commit 横跨五个技术栈。

需要收窄时,仍然可以只打开某一个子仓。 修一个纯样式问题,没必要把网关和三个后端塞进上下文。子模块允许你把工作区缩小到一个服务;排跨服务问题、做端到端需求时,再回到外层。上下文宽度可以按任务选,而不是被仓库策略锁死。

还有一层对排障很实用:外层仓钉死的是"当时那一套"。线上或预发的组合出了 bug,把外层切到同一个 tag,Agent 面对的就是当时的前端、当时的接口、当时的网关配置,而不是各仓 main 上已经分叉的最新代码。Vibe Coding 再快,也对不准一份不存在于工作区里的历史组合。

一句话:Vibe Coding 吃的是工作区,不是 Git 托管页面上的仓库列表。这套结构让工作区既能拼成完整系统,又能按服务切开,Agent 才有东西读、有边界守、有配方可复现。

使用时要注意什么

子模块不是免费午餐,几个习惯需要提前说清,否则团队会觉得它"难用"。

子模块检出后默认停在游离的某个 commit 上,不是某个分支名。要改某个服务,先进入该子目录,checkout 到功能分支再提交;改完把子模块推到自己的远程,再回到外层仓,把指针更新提交上去。顺序是:先推子仓,再提交外层。只提交外层、忘了推子仓,别人 update 时会拿到一个远端还不存在的 hash。

外层仓不要当业务代码的垃圾桶。业务逻辑进各服务仓;外层只保留组合与编排。一旦开始在外层写接口、改页面,边界会很快糊掉,最后退化成一个藏着子模块的单体仓。

也不是所有项目都值得上这一套。两个人、一个后端、一个前端,直接两个仓加一份简短 README 往往更轻。子模块的收益出现在:服务开始变多、角色开始分家、你已经说不清"线上那一套到底是哪些版本拼出来的"的时候。

和单体仓(monorepo)相比,这套结构换来的是独立权限、独立 CI、独立历史;付出的是子模块操作纪律,以及"改跨服务需求时要更新外层指针"的额外一步。和完全散落的多仓相比,几乎只多了一个很薄的外层仓,却把最容易丢的组合信息收了回来。

小结

前后端分离之后,仓库策略不该再在"全部揉在一起"和"全部撒开不管"里二选一。

各服务、各端单独建仓,对应的是它们本就不同的生命周期;外层仓用子模块把它们钉在一起,对应的是产品必须可复现、可交付、可回滚。对外,这是一份当时有效的系统配方;对 Vibe Coding,这是一份完整且边界清晰的工作区。

配方和食材分开存放,食材可以各自迭代,配方仍然能把某一刻的组合原样做出来。人靠这份配方交付,Agent 靠同一份配方读懂系统。这才是这套架构真正值钱的地方。