套件結構
我們的後端服務遵循基於功能的套件(Package by Feature),並組織成四個同級模組。此文件是決定新程式碼放置位置的唯一真理來源。
語言說明。 以下範例以 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/native queries)
└── repository/
├── <EntityName>Repo.java # PUBLIC 介面 — 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 可以進一步調整這些 DTO。
-
跨橫切關注點(重試、斷路器、請求/回應記錄、驗證標頭)屬於
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 合約 — 穩定、有文件記錄、可獨立測試。實作是接線 — 它可以在不改變合約的情況下變更。原因與 *Repo 和 *RepoImpl 相同。
控制器將每個 HTTP 路由委派給 capabilities/<feature>/<sub-feature>/service/ 中對應的子功能服務。請求/回應 DTO 由子功能擁有(capabilities/<feature>/<sub-feature>/dto/);控制器僅是薄薄的 HTTP 轉接器。
為什麼放在 capabilities/ 而不是 starter/ 中?當 API 變更時,控制器、其服務和其 DTO 會一起變更 — 將它們保留在同一個模組中可維護「一個功能,一個位置」的基於功能套件承諾。starter/ 保留給跨橫切接線(安全性、錯誤處理器、應用程式進入點),而非每個功能的 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 → response
│ ├── create/
│ │ ├── service/
│ │ │ └── CreateTestcaseService.java
│ │ ├── dto/
│ │ │ ├── request/
│ │ │ │ └── CreateTestcaseRequest.java
│ │ │ └── response/
│ │ │ └── CreateTestcaseResponse.java
│ │ └── mapper/
│ │ └── CreateTestcaseMapper.java # request → TestcaseDb, TestcaseDb → response
│ ├── 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 # 查詢/條件輔助,package-private 使用
│ └── repository/
│ ├── TestcaseRepo.java # 介面 — capabilities 依賴於此
│ └── 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/ # 僅跨橫切接線 — 沒有控制器
└── 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>/的內部元件 — 它們的存在是為了讓您可以切換查詢策略或後端儲存,而無需變更 capability 程式碼。 - 始終注入
*Client,絕不注入*ClientImpl。 對依賴微服務的呼叫也適用相同規則。Capabilities 依賴介面;傳輸層(HTTP、gRPC、SDK)位於impl/後方。 client/內每個依賴的微服務一個資料夾。 以微服務名稱命名資料夾。不要將「所有內部服務」分組到一個套件中。- 子功能粒度止於
capabilities/。db/按資料表組織;client/按外部系統組織。將任一者按 API 拆分會分散內聚的程式碼。 - 請求/回應 DTO 及其映射器是每個子功能專屬的,絕不共享。 如果
create和updateById今天看起來相似,請保留兩個 DTO 和兩個映射器。共享會將兩個會獨立改變的 API 耦合在一起。 - 映射器直接進行實體 ↔ DTO 轉換。 不需要中間領域模型層。
<SubFeature>Mapper將*Db轉換為該子功能的請求/回應 DTO。 - 三個子功能有相似的程式碼? 在您在四個或更多位置看到相同模式之前,請抵制提取共享服務或映射器的衝動。
- 跨功能重用? 將共享工具放在
capabilities/內的common/套件中。