包结构
我们的后端服务遵循按功能分包原则,组织在四个同级模块中。本文档是新代码放置位置的权威参考。
语言说明: 下面的示例是用 Java/Spring Boot 编写的,因为这是我们的主要后端技术栈,但该结构本身是语言无关的,并且在我们使用的每一种后端语言中都是强制性的(Kotlin、Go、Python、Node 等)。请转换文件扩展名和框架注解;保持文件夹布局、命名后缀(
*Db、*Repo、*RepoImpl、*Dao、*Controller、*ControllerImpl)以及接口/实现分离不变。
核心原则
按功能分组,而非按技术层分组。 对“订单”功能的更改应触及彼此相邻的文件,而不是分散在 controllers/、services/、repositories/ 包中的文件。技术分层发生在模块边界(db、client、capabilities、starter),而按功能分包发生在每个模块内部。
四个模块
每个服务由以下四个同级模块组成:
<service>/
├── capabilities/ # 功能/业务逻辑 — 核心
├── db/ # 持久化:实体、仓库、迁移
├── client/ # 与外部系统的出站集成
└── starter/ # Spring Boot 启动器 — 组装所有组件并运行
capabilities/ — 业务逻辑
每个功能都有自己的包,在每个功能内部,每个 API 端点都有一个子包(getById、create、updateById、getByQuery……)。每个子功能拥有三个同级文件夹:
service/— 该 API 的单个服务类dto/—request/和response/(适用者)mapper/— 将*Db实体 ↔ DTO(双向转换)仅用于此子功能
该功能的控制器位于功能包的根目录,像仓库一样拆分为接口和实现:
capabilities/<feature>/<Feature>Controller.java— 接口;声明路由、HTTP 动词、请求/响应类型、API 文档注解capabilities/<feature>/<Feature>ControllerImpl.java— 实现;携带框架注解(@RestController)并将每个路由委托给匹配的子功能服务
共享于该功能的所有子功能之间。请参阅控制器。
- 依赖于:
db(仅通过*Repo接口 — 请参阅 DB 使用规则)、client - 从不依赖于:
starter - 不包含
main类,不包含@SpringBootApplication - 拥有:HTTP 控制器、API 请求/响应 DTO、映射器、服务
db/ — 持久化
按每个表一个文件夹组织。每个表文件夹正好有三个子文件夹:
db/<tableName>/
├── model/
│ ├── <EntityName>Db.java # 主要 JPA 实体
│ └── <Other>Db.java # 子模型(嵌入类型、子实体、投影)
├── dao/
│ └── <EntityName>Dao.java # 低级数据访问(Criteria/JPQL/原生查询)
└── repository/
├── <EntityName>Repo.java # 公共接口 — capabilities 看到的唯一类型
└── impl/
└── <EntityName>RepoImpl.java # 在接口背后组合 Spring Data + Dao
命名约定
| 层级 | 后缀 | 示例 |
|---|---|---|
| JPA 实体 | Db | TestcaseDb |
| 仓库接口 | Repo | TestcaseRepo |
| 仓库实现 | RepoImpl | TestcaseRepoImpl |
| 数据访问辅助类 | Dao | TestcaseDao |
DB 使用规则
Capabilities 必须仅依赖于 *Repo 接口。 *RepoImpl 和 *Dao 是内部的 — 从不在 db/<tableName>/ 包外部注入或引用。这保持了持久化层的可替换性,并使能力测试专注于契约。
- 依赖于:此服务内无依赖
- 暴露:
*Repo接口和*Db模型类型 - 从不包含:业务逻辑、到 API DTO 的转换
client/ — 出站集成
包含调用此服务所依赖的其他服务的代码。主要为每个依赖的微服务提供一个子文件夹(paymentService、userService、executionService……)。第三方 SDK 包装器或消息生产者也可以放在这里,但主导模式(以及此模块存在的原因)是依赖的微服务客户端。
client/<microserviceName>/
├── <Microservice>Client.java # 接口 — capabilities 依赖的内容
├── impl/
│ └── <Microservice>ClientImpl.java # 实现:HTTP/gRPC/SDK 调用、重试、超时
├── dto/ # 此微服务 API 的请求/响应形状
│ ├── request/
│ └── response/
└── mapper/ # 可选:在其形状与我们的形状之间转换
规则
-
每个依赖的微服务一个子文件夹,以服务命名。不要将多个微服务合并到一个包中,即使它们“相关”。
-
Capabilities 仅依赖于
*Client接口。*ClientImpl是内部的 — 与*Repo/*RepoImpl规则相同。 -
客户端接口以其自身的 DTO 暴露微服务的 API;capabilities 可以根据需要进一步适配。
-
横切关注点(重试、熔断、请求/响应日志记录、认证头)属于
impl/— capabilities 永远不应看到它们。 -
依赖于:此服务内无依赖
-
暴露:
*Client接口及其请求/响应 DTO -
从不包含:业务逻辑、能力编排
starter/ — 组合根
唯一运行的模块。包含 @SpringBootApplication、全局 REST 异常处理器、安全配置、过滤器和 application.yml。没有控制器 — 那些在 capabilities/ 中。
- 依赖于:
capabilities(以及传递依赖db、client) - 拥有:可部署制品、横切 web 配置(安全、CORS、错误处理)
- 从不拥有:功能控制器或功能 DTO
依赖规则
starter → capabilities → { db, client }
箭头单向。db 和 client 从不导入 capabilities 或 starter。capabilities 从不导入 starter。
控制器
每个功能一个控制器,而不是每个子功能,并且它位于 capabilities/ 内部功能包的根目录。它像 *Repo / *RepoImpl 拆分一样拆分为两个文件:
capabilities/<feature>/<Feature>Controller.java— 接口。声明路由、HTTP 动词、路径/查询参数、请求/响应 DTO 类型以及 API 文档注解(OpenAPI/Swagger)。阅读接口的人应看到完整的 HTTP 契约。capabilities/<feature>/<Feature>ControllerImpl.java— 实现。携带框架注解(Spring 中的@RestController),是子功能服务的注入目标,并包含每个路由的一个简短方法,该方法调用匹配的service/并返回结果。没有超出委派的逻辑。
为何拆分?接口是 API 契约 — 稳定、有文档、可隔离测试。实现是 wiring — 它可以在不更改契约的情况下更改。与 *Repo vs *RepoImpl 相同的原因。
控制器将每个 HTTP 路由委托给 capabilities/<feature>/<sub-feature>/service/ 中匹配的子功能服务。请求/响应 DTO 由子功能拥有(capabilities/<feature>/<sub-feature>/dto/);控制器仅是一个薄薄的 HTTP 适配器。
为何在 capabilities/ 内部而不是 starter/?当 API 更改时,控制器、其服务和其 DTO 都会一起更改 — 将它们保留在一个模块中保留了“一个功能,一个位置”的按功能分包承诺。starter/ 保留用于横切 wiring(安全、错误处理器、应用入口点),而不是每个功能的 web 代码。
工作示例:testcase 功能
下面的树使用 .java 扩展名进行说明。替换您语言的文件扩展名和框架习惯用法 — Kotlin .kt、Go 包、Python 模块等 — 但保持文件夹布局和命名后缀完全相同。
testcase-service/
├── capabilities/
│ └── com/example/testcase/capabilities/
│ └── testcase/ # 主要功能
│ ├── TestcaseController.java # 接口 — HTTP 契约、路由声明、API 文档
│ ├── TestcaseControllerImpl.java # 实现 — @RestController,委托给子功能服务
│ ├── getById/ # 子功能 = 一个 API
│ │ ├── service/
│ │ │ └── GetTestcaseByIdService.java
│ │ ├── dto/
│ │ │ └── response/
│ │ │ └── GetTestcaseByIdResponse.java
│ │ └── mapper/
│ │ └── GetTestcaseByIdMapper.java # TestcaseDb → 响应
│ ├── create/
│ │ ├── service/
│ │ │ └── CreateTestcaseService.java
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── CreateTestcaseRequest.java
│ │ │ └── response/
│ │ │ └── CreateTestcaseResponse.java
│ │ └── mapper/
│ │ └── CreateTestcaseMapper.java # 请求 → TestcaseDb,TestcaseDb → 响应
│ ├── updateById/
│ │ ├── service/
│ │ │ └── UpdateTestcaseByIdService.java
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── UpdateTestcaseByIdRequest.java
│ │ │ └── response/
│ │ │ └── UpdateTestcaseByIdResponse.java
│ │ └── mapper/
│ │ └── UpdateTestcaseByIdMapper.java
│ └── getByQuery/
│ ├── service/
│ │ └── GetTestcaseByQueryService.java
│ ├── dto/
│ │ ├── request/
│ │ │ └── GetTestcaseByQueryRequest.java
│ │ └── response/
│ │ └── GetTestcaseByQueryResponse.java
│ └── mapper/
│ └── GetTestcaseByQueryMapper.java
│
├── db/ # 每个表一个文件夹
│ └── com/example/testcase/db/
│ └── testcase/ # 表名
│ ├── model/
│ │ ├── TestcaseDb.java # JPA 实体 — 主要
│ │ └── TestcaseStepDb.java # 子模型(子/嵌入)
│ ├── dao/
│ │ └── TestcaseDao.java # 查询/criteria 辅助类,包私有使用
│ └── repository/
│ ├── TestcaseRepo.java # 接口 — capabilities 依赖于 THIS
│ └── impl/
│ └── TestcaseRepoImpl.java # 组合 Spring Data + TestcaseDao
│
├── client/ # 每个依赖的微服务一个文件夹
│ └── com/example/testcase/client/
│ ├── executionService/ # 我们调用的依赖微服务
│ │ ├── ExecutionServiceClient.java # 接口
│ │ ├── impl/
│ │ │ └── ExecutionServiceClientImpl.java # HTTP/gRPC 调用、重试、超时
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── RunTestcaseRequest.java
│ │ │ └── response/
│ │ │ └── RunTestcaseResponse.java
│ │ └── mapper/
│ │ └── ExecutionServiceMapper.java
│ └── userService/ # 另一个依赖的微服务
│ ├── UserServiceClient.java
│ └── impl/
│ └── UserServiceClientImpl.java
│
└── starter/ # 仅限横切 wiring — 没有控制器
└── com/example/testcase/
├── TestcaseServiceApplication.java
├── config/
│ ├── SecurityConfig.java
│ └── WebConfig.java
├── error/
│ └── GlobalExceptionHandler.java # @ControllerAdvice — 横切,非每个功能
└── resources/application.yml
经验法则
- 为现有功能添加新 API? 在
capabilities/<feature>/内部创建一个新的子功能包,包含其自身的service/、dto/{request,response}/和mapper/,然后将路由声明添加到<Feature>Controller.java(接口)并将委派方法添加到<Feature>ControllerImpl.java。不要创建第二个控制器。 - 添加一个全新的功能? 在
capabilities/中创建功能包(控制器在根目录,每个 API 的子功能),并在db/<tableName>/中创建匹配的表文件夹,包含model/、dao/、repository/{impl/}。仅当调用外部系统时才添加客户端。不要触及starter/。 - 始终注入
*Repo,绝不注入*RepoImpl或*Dao。 Capabilities 仅依赖于接口。*RepoImpl和*Dao是db/<tableName>/内部的 — 它们的存在是为了让您无需更改能力代码即可切换查询策略或后端存储。 - 始终注入
*Client,绝不注入*ClientImpl。 对于依赖微服务调用,规则相同。Capabilities 依赖于接口;传输层(HTTP、gRPC、SDK)位于impl/之后。 client/内部每个依赖微服务一个文件夹。 以微服务命名文件夹。不要将所有内部服务分组到一个包中。- 子功能粒度止于
capabilities/。db/按表组织;client/按外部系统组织。按每个 API 拆分两者会分散凝聚的代码。 - 请求/响应 DTO 及其映射器是每个子功能私有的,绝不共享。 如果今天
create和updateById看起来相似,请保留两个 DTO 和两个映射器。共享会将两个将独立漂移的 API 耦合在一起。 - 映射器直接进行实体 ↔ DTO 转换。 没有中间领域模型层。
<SubFeature>Mapper将*Db↔ 该子功能的请求/响应 DTO。 - 三个子功能有相似代码? 在四个或更多位置出现相同模式之前,抵制提取共享服务或映射器的冲动。
- 跨功能重用? 将共享实用程序放入
capabilities/内部的common/包中。
…
[为翻译截断]