Nguyen Le Phong

软件架构基础第 4 篇,共 6 篇

代码库结构:按层、按功能,还是按领域?

顶层文件夹应该叫 controllers/services/models,还是 orders/billing/auth?这个选择悄悄地影响代码库的成长方式。一篇关于按层、按功能和尖叫式架构的实用指南——附上各公司规模下的权衡取舍。

你打开一个新项目,第一件事——在写一行逻辑之前——是创建一个文件夹。也许它叫 controllers/,也许叫 orders/。不管怎样,那个安静的选择已经是一个架构决策,它将悄悄地塑造后续的一切:新队友多快能找到方向,一个功能变更需要触碰多少文件,以及六个月后你的代码库读起来像你的业务还是你的框架

本文是对三种主要方式——按层按功能尖叫式(按领域)——的实用导览,附上各公司规模下诚实的权衡取舍。没有一个答案在所有情况下都是赢家,但读完之后你会有一个清晰的思维模型,能够有意识地选择,而不是随波逐流。

为什么文件夹结构是架构决策,而不是无聊的细枝末节

人们把文件夹结构当作“只是组织方式”来忽视。它远不止于此。你对代码的分组方式控制着三件事,而这三件事事后证明极为重要:

  • 耦合。当一起变化的代码相距遥远,每次小的更新都会变成一场跨文件夹的远征。当它们住在一起,相关的改动就聚集在一处。
  • 可发现性。不熟悉代码库的开发者会问:“billing 的代码在哪里?”这个问题的答案——一个文件夹,还是五个散落的文件夹——决定了入职所需的时间。
  • 影响范围。你需要打开多少文件才能发布一个新功能,或者安全地删除一个旧功能?结构要么约束这个影响范围,要么让它无边蔓延。
思维模型

把你的文件夹结构想象成一张地图。好的地图按照事物在现实世界中的关联方式分组——而不是按照构成它们的材料。按类型(controllers、models)分组文件,就像按形状对地图图标排序。按功能分组,就像按街区排序。其中一种帮你导航;另一种技术上正确,实际毫无用处。

康威定律——软件系统反映构建它的团队的沟通结构——在这里同样适用。如果你的团队围绕业务能力组织(支付小队、订单小队),按功能的结构会感觉自然,并强化正确的边界。按层的结构会与你对抗。

按层:熟悉的默认方式

按层组织按照技术角色对文件进行分组。一个典型的 Node 或 Spring Boot 项目最终看起来大致是这样:

src/
├─ controllers/
│  ├─ OrderController.ts
│  ├─ UserController.ts
│  └─ BillingController.ts
├─ services/
│  ├─ OrderService.ts
│  ├─ UserService.ts
│  └─ BillingService.ts
├─ repositories/
│  ├─ OrderRepository.ts
│  ├─ UserRepository.ts
│  └─ BillingRepository.ts
└─ models/
   ├─ Order.ts
   ├─ User.ts
   └─ Invoice.ts
按层结构:每个技术层都有自己的顶层文件夹。添加一个功能意味着在全部四个文件夹中添加文件。

每个 Web 框架教程都会落到这里。Rails 这样做,Spring Boot 的脚手架这样做,Express 生成器也这样做。这种熟悉感是它的主要资产。

按层的优势:

  • 只有两三个功能的小型应用——结构如此扁平,几乎没什么影响。
  • 所有人都在所有功能上工作的团队;层是专业化的单元。
  • 速度比长期清晰度更重要的原型和内部工具。

按层的痛点:

  • 一个功能散落在多个文件夹里。添加一个“退款”功能意味着要触碰 controllers/services/repositories/models/。这是四个文件夹,对应概念上的一件事。
  • 删除一个功能是一项考古工作。你怎么知道 services/ 里哪些文件属于 billing,哪些属于 orders?文件夹名称告诉不了你。
  • 合并冲突成倍增加。每次功能变更都会触碰同一层级的文件,当两名开发者并行工作时就会发生碰撞。
规模化悬崖

按层在约 10–15 个功能以内运转良好。超过这个数量,每个顶层文件夹就变成了一个杂物抽屉:40 个文件的 services/ 文件夹,没有清晰的归属,拉取请求触碰了审查者从未见过的文件。

按功能:一次变更只在一个文件夹里

按功能组织按照文件服务的产品切片对其进行分组。技术层(controller、service、model)仍然存在——它们只是住在各自的功能文件夹内部,而不是在顶层:

src/
├─ orders/
│  ├─ OrderController.ts
│  ├─ OrderService.ts
│  ├─ OrderRepository.ts
│  └─ Order.ts
├─ billing/
│  ├─ BillingController.ts
│  ├─ BillingService.ts
│  ├─ BillingRepository.ts
│  └─ Invoice.ts
├─ auth/
│  ├─ AuthController.ts
│  ├─ AuthService.ts
│  └─ User.ts
└─ shared/
   ├─ database.ts
   ├─ logger.ts
   └─ Money.ts
按功能结构:每个产品概念拥有自己的文件夹。添加一个退款功能只需添加一个文件夹——其他地方不需要改动。

按功能的优势:

  • 一次变更住在一个文件夹里。添加退款流程意味着在 billing/ 里添加文件——不会触碰其他文件夹。影响范围小且可控。
  • 删除一个功能很简单。删掉文件夹。如果外部还有什么 import 了它,你的 IDE 会立刻告诉你。
  • 归属关系一目了然。支付团队拥有 billing/;身份团队拥有 auth/。Monorepo 工具和代码审查规则可以机械地执行这一点。
  • 入职更快。一个新人负责 orders,只需阅读 orders/。他们不需要先理解整个代码库。

按功能需要注意的地方:

  • 共享代码的放置很尴尬。Money 类型被 orders/billing/ 同时使用。它应该放在 orders/ 吗?那感觉不对。它最终放到了 shared/ 里——而 shared/ 本身可能因为没有人真正负责它,而逐渐变成第二个杂物抽屉。
  • 跨功能依赖需要规律。如果 orders/OrderService.ts 直接 import billing/BillingService.ts,你创建了一个文件夹结构看不见的耦合。团队用 lint 规则(eslint-plugin-import/no-restricted-paths)或模块边界工具(Nx、NestJS modules)来让这些显式化。

尖叫式架构:你的顶层说了什么?

Uncle Bob(Robert C. Martin)在一篇著名的博文中创造了尖叫式架构这个说法:“你应用的架构在尖叫什么?当你看顶层目录结构和最高层包里的源文件时;它们在尖叫:医疗系统?还是会计系统?还是库存管理系统?还是在尖叫:Rails?或 Spring/Hibernate?或 ASP.Net?”

这个洞见很优雅:如果你的顶层文件夹是 controllers/models/views/,你的代码库在尖叫 MVC 框架。框架是工具——它不应该是读者首先遇到的东西。

尖叫式架构(也叫按领域)把按功能推进了一步。不仅功能在顶层——那些功能的名字直接来自业务语言(领域驱动设计的通用语言),而不是技术角色:

// 尖叫“电商平台”
src/
├─ catalog/          // 领域概念:商品目录
├─ ordering/         // 领域概念:下单与跟踪订单
├─ fulfillment/      // 领域概念:拣货、打包、发货
├─ payments/         // 领域概念:收费、退款、发票
└─ identity/         // 领域概念:账户、认证、权限

// 与“框架优先”的反例相比:
src/
├─ controllers/      // 尖叫“MVC”
├─ models/           // 尖叫“ORM”
├─ views/            // 尖叫“模板引擎”
└─ services/         // 尖叫“service 层模式”

每个领域文件夹内部的布局由团队决定——这完全没问题。关键的承诺是顶层传达业务,而不是基础设施。一个新开发者(或者你的 CTO,或者业务侧的领域专家)可以读一下顶层文件夹名,就理解系统做什么,而不需要知道它是怎么构建的。

并排比较

以下是五个实用维度的诚实评估:

维度 按层 按功能 按领域(尖叫式)
可发现性 规模化后很低——“billing 代码在哪里?”没有快速答案 高——每个概念一个文件夹 最高——文件夹名来自业务本身
变更局部性 差——一个功能变更散布在所有层中 好——变更聚集在一个文件夹里 好——与按功能相同,加上明确的业务语言
入职速度 慢——新人必须理解整个层才能动任何东西 快——学习一个文件夹就能处理一个功能 最快——领域名称不需要先验知识就能引导理解
耦合风险 高——共享层成为隐式耦合面 中——跨功能 import 是可能的,但可见 低——明确的限界上下文和强制边界
“大泥球”风险 高——功能增多时层变成杂物抽屉 中——shared/ 文件夹可能无限增长 低——领域边界让混乱早早可见

大多数优秀团队最终落脚的务实混合方案

实践中,最好的团队不会选择一种方式然后在所有地方宗教般地执行。他们使用务实的混合方案:顶层是功能或领域文件夹,每个文件夹内部是小的技术层

混合结构:顶层是领域/功能文件夹,每个文件夹内部是技术层。 务实混合方案——顶层是领域,内部是技术层 src/ orders/ billing/ shared/ controller.ts service.ts repository.ts Order.ts controller.ts service.ts repository.ts Invoice.ts Money.ts logger.ts database.ts
大多数团队最终收敛的混合方案:顶层是领域/功能,然后在每个文件夹内部是小的技术层。技术层仍然存在——它们只是不再是你首先看到的东西。

这种混合方案兼得两全:

  • 顶层在尖叫你的业务——新来的人立刻知道这是一个电商应用,而不是一个通用的 MVC 骨架。
  • 每个功能自成一体——对 billing 流程的变更只触碰 billing/
  • 技术层仍然存在于每个文件夹内部,所以用 MVC 或 Clean Architecture 层来思考的开发者不会迷失。他们只需看功能文件夹的内部。
  • shared/ 文件夹存放真正横切关注点(Money 值对象、logger、数据库连接)。关键规则:添加到 shared/ 的东西应该是有意识地提取并拥有的——不是当逃生舱口扔进去的。
给 shared 文件夹命名

有些团队叫它 shared/,有些叫 common/lib/core/。名字没有规律重要:把它当作一个迷你内部库来对待。如果一个文件总是和某个特定功能一起被修改,它不属于 shared——它属于那个功能内部。

团队规模下:强制边界与 monorepo

随着代码库增长,多个团队参与贡献,仅靠文件夹约定是不够的——开发者随时可以用一个快速的相对 import 跨越边界。下一步是让边界变得被强制执行

  • NestJS modules 让模块间依赖变得显式:你只能访问另一个模块 exports 的东西。跨越模块边界而没有通过公开 API 的 import 会是一个编译错误。
  • Nx 边界规则让你声明哪些功能或库允许依赖哪些,然后在 CI 中作为 lint 错误来强制执行。
  • Monorepo 走得最远:每个领域(orders、billing、auth)变成自己的包,有自己的 package.json、自己的测试,以及明确的公开导出。Turborepo 和 Nx 是 JavaScript 生态中管理这一切的主要工具。结构看起来不像文件树,更像一个包的图——但底层思想完全相同:按业务能力分组,让边界显式化,机械地执行它们。

跳到 monorepo 并不总是必要的。很多团队用单个包加上有规律的混合结构,幸福地运转多年。当跨团队耦合成为真实的痛点时再考虑 monorepo 工具——不要预防性地。

按公司规模的建议

正确的结构取决于你的组织今天处于哪个阶段。以下是各阶段的直接建议:

阶段 应该采用的结构 原因 现实提示
独立开发者 / 预发布创业公司 按层浅按功能 你有 3–5 个功能。任何结构都管用。以速度和简单性为优化目标。 大多数 Rails/Express 教程的默认方式在这里完全可以。不要过度设计。
成长中的创业公司(5–20 名工程师) 按功能,加一个 shared/ 文件夹 功能在增多。按层开始让人痛苦。支付模块的变更不应该涟漪到订单模块。 很多成功的 Series A 公司就在这里:一个 repo,一个服务,功能作为顶层文件夹。
规模化公司(20–100 名工程师) 混合方案(顶层领域,内部是层)加边界 lint 规则 团队拥有各自领域。意外的跨领域耦合是真实风险。在 CI 中执行边界。 这是 Nx/NestJS module 的甜蜜点。Shopify 的核心 Rails 应用在这个阶段以著名的方式采用了强域分离。
企业级(100+ 名工程师) 带领域包的 monorepo,或每个限界上下文一个独立服务 包级别的隔离、独立部署以及领域间版本化的公开 API 变得必要。 Google、Meta 和 Airbnb 运营着严格所有权的大型 monorepo。很多企业走另一条路:每个领域一个微服务,这是尖叫式架构推向逻辑终点的样子。
常见错误

最常见的结构错误不是选错了方式——而是在错误的方式上待太久。团队从按层开始,在 20 个功能时感到痛苦,却仍然不重构,因为“结构技术上还能用”。在 15 个功能时花一个下午重组文件夹,能避免在 50 个功能时数月的困惑。

看清差异:发布一个功能时哪些部分会亮起来

感受按层与按功能区别最具体的方式是问:当我添加一个新功能时,哪些盒子会亮起来?

按层:一个新功能触碰所有四个层级盒子。按功能:一个新功能只触碰一个盒子。 按层——添加“退款”功能 controllers/ ← 已触碰 services/ ← 已触碰 repositories/ ← 已触碰 models/ ← 已触碰 触碰了 4 个文件夹 影响范围大 按功能——添加“退款”功能 orders/ billing/ ← 已触碰 auth/ shared/ 触碰了 1 个文件夹 影响范围可控
添加一个“退款”功能。左侧(按层),所有四个技术层都亮了——每个层级文件夹都被触碰。右侧(按功能),只有 billing/ 亮了。代码库的其余部分在结构上与这次变更隔离。

这张图是按功能分组最有力的论据。“影响范围”不是一个抽象概念——它是你在一个拉取请求中打开的文件夹和文件的数量。审查者、CI 流水线和 git blame 在一次功能变更是一个紧密的整体而不是涂抹在每一层时,工作起来都更好。

核心要点

  • 结构即架构。你的文件夹布局控制着耦合、可发现性,以及每次未来变更的影响范围。
  • 按层很熟悉,对小型应用没问题,但超过 10–15 个功能后变成杂物抽屉。
  • 按功能让变更保持局部。一个新功能触碰一个文件夹。删除一个功能意味着删除一个文件夹。
  • 尖叫式架构说顶层应该描述你的业务,而不是你的框架。如果非技术利益相关方能读懂你的文件夹名并理解系统做什么,你就成功了。
  • 大多数优秀团队落脚的务实混合方案:顶层是领域/功能名称,每个文件夹内部是小的技术层。
  • 随着规模扩大强制执行边界。文件夹约定是建议;模块系统、lint 规则和 monorepo 包让它们变成保证。
  • 早早重组——在 15 个功能时花一个下午重组文件夹,避免在 50 个功能时数月的困惑。

现在你已经看到了如何在单个服务内部组织代码,自然的下一个问题是当一个服务不够用时该怎么办。继续阅读:单体 → 微服务