Files
conti-docs/backend/02-layering.md
T
Guangfei.Zhao 8c0fcd84e8 feat: Enhance documentation on layering, object naming conventions, and API design
- Added object naming conventions (PO/DAO/BO/DTO/VO) in 02-layering.md to clarify terminology and usage within the team.
- Updated 06-api-design.md to include MapStruct for DTO and entity conversion, providing examples and configuration details.
- Expanded 07-config-governance.md with local development instructions and strategies for running without K8s, including two recommended approaches.
- Included K8s probe configuration details in 08-observability.md for liveness and readiness checks.
- Clarified CI/CD processes in 09-build-deploy.md, detailing environment distinctions and deployment strategies for local, Dev, UAT, and Prod.
- Introduced ArchUnit for architectural testing in 10-testing.md, ensuring adherence to defined layering rules and coverage verification with Jacoco.
2026-08-13 15:19:05 +08:00

166 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 02. 分层规范(后端)
## 决策
每个 `domains/*` 模块内部采用简化分层,`domain` 层**可选**,判断标准与 Flutter 端 [02-layering.md](../02-layering.md) 保持一致的思路:
```
domains/xxx/
src/main/kotlin/com/continental/retailapp/xxx/
api/ # Controller、请求/响应 DTO
application/ # Service,编排用例、跨 repository 协调
domain/ # 可选:领域模型、repository/client 接口、状态机/复杂业务规则
infrastructure/ # JPA repository 实现、外部 client 实现
```
## 各层职责
- **api**`Controller`(只做参数校验 + 调用 `application`)、请求/响应 DTO。不写业务逻辑,不直接依赖 `infrastructure` 的具体实现类。
- **application**`Service`,编排用例、事务边界(`@Transactional` 一般加在这一层)。依赖 `domain` 定义的接口(有 domain 层时),或直接依赖 `infrastructure` 暴露的接口(跳过 domain 层时)。
- **domain**(可选):领域模型(可以是纯 Kotlin data class,不一定是 JPA entity)、repository/client 接口、封装多步骤业务规则或状态机的领域服务。不依赖 Spring Web/JPA 相关类型,可以脱离容器单独做单元测试。
- **infrastructure**`domain`(或 `application`,跳过 domain 层时)里接口的具体实现——JPA repository 实现、`WebClient`/Feign 外部调用实现。
## 对象命名约定(PO / DAO / BO / DTO / VO
Java 生态里这几个缩写来源不一、经常被混用,这里把我们实际用的名字和这些通用叫法对应清楚,避免团队内部各叫各的:
| 通用叫法 | 全称 | 所在层 | 我们的命名 |
| --- | --- | --- | --- |
| PO | Persistent Object | infrastructure | `XxxEntity`JPA entity,见 [03-persistence.md](./03-persistence.md) |
| DAO | Data Access Object | infrastructure | `XxxJpaRepository`Spring Data JPA repository 接口) |
| BO | Business Object | domain(可选) | `domain` 层的领域模型,如本文 `WebviewTicket` |
| DTO | Data Transfer Object | api | **统称**,不是单独的类;`Request`/`Response` 都是 DTO 的具体形态 |
| VO | View Object | api | `Xxx*Response`,即返回给前端的对象 |
落地规则:
- **类名统一用 `Request`/`Response` 后缀**,不额外起 `XxxDTO`/`XxxVO` 这样的名字——`Request`/`Response` 已经把方向(输入/输出)表达清楚了,`DTO`/`VO` 只是这两者的统称,没必要在类名上重复。
- **`domain` 层模型(BO)不是必须的**,规则见下一节;没有 `domain` 层时,`Entity`PO)直接由 `application`/`api` 层转换成 `Response`,不会凭空多出一个 BO。
- **`Entity`PO)永远不跨出 `infrastructure` 层**`api`/`application` 看到的最多是 `domain` 层模型或 `Response`,见 [06-api-design.md](./06-api-design.md) 里 `Entity → Response` 的 MapStruct 转换约定。
## 何时可以跳过 domain 层
- **可以跳过**:简单 CRUD、没有跨 repository 协调、没有状态机——`application` 直接依赖 `infrastructure` 里定义的 repository/client 接口即可(接口和实现放在同一层)。
- **必须要有**:多步骤业务规则(如 WebView 换票的状态校验)、需要协调多个数据源(如 `workbench` 聚合多个 Mini 域)、包含状态机或需要独立于容器做单元测试的核心业务逻辑——接口定义在 `domain``infrastructure` 反向实现。
## 依赖方向
```
api → application → domain(或直接 → infrastructure 的接口,若跳过 domain
domain → 不依赖 api / infrastructure
infrastructure → 依赖 domain 的接口(若有),依赖 platform-persistence / platform-integration
```
`domain` 层的类不 import `org.springframework.web.*` / `jakarta.persistence.*`,保证这一层的单元测试不需要起 Spring 容器、不需要真实数据库。
## 示例一:有 domain 层(`webview-ticket`,换票——多步骤状态校验)
```kotlin
// domain/model/WebviewTicket.kt
data class WebviewTicket(
val ticketId: String,
val storeId: Long,
val userId: Long,
val status: TicketStatus,
val expiresAt: Instant,
)
enum class TicketStatus { ISSUED, CONSUMED, EXPIRED, INVALIDATED }
// domain/repository/WebviewTicketRepository.kt
interface WebviewTicketRepository {
fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket?
fun save(ticket: WebviewTicket)
}
// domain/service/IssueWebviewTicketService.kt
class IssueWebviewTicketService(
private val ticketRepository: WebviewTicketRepository,
private val clock: Clock,
) {
fun issue(userId: Long, storeId: Long): WebviewTicket {
val existing = ticketRepository.findActiveTicket(userId, storeId)
if (existing != null && existing.status == TicketStatus.ISSUED && existing.expiresAt.isAfter(clock.instant())) {
return existing // 已有有效票据,直接复用,不重复签发
}
val ticket = WebviewTicket(
ticketId = UUID.randomUUID().toString(),
storeId = storeId,
userId = userId,
status = TicketStatus.ISSUED,
expiresAt = clock.instant().plus(Duration.ofMinutes(5)),
)
ticketRepository.save(ticket)
return ticket
}
}
// application/WebviewTicketAppService.kt
@Service
class WebviewTicketAppService(
private val issueWebviewTicketService: IssueWebviewTicketService,
) {
@Transactional
fun issueTicket(userId: Long, storeId: Long): WebviewTicketResponse {
val ticket = issueWebviewTicketService.issue(userId, storeId)
return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt)
}
}
// infrastructure/persistence/WebviewTicketRepositoryImpl.kt
@Repository
class WebviewTicketRepositoryImpl(
private val jpaRepository: WebviewTicketJpaRepository,
) : WebviewTicketRepository {
override fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket? =
jpaRepository.findByUserIdAndStoreIdAndStatus(userId, storeId, TicketStatus.ISSUED)?.toDomain()
override fun save(ticket: WebviewTicket) {
jpaRepository.save(ticket.toEntity())
}
}
```
`IssueWebviewTicketService` 的复用/过期判断规则可以直接用一个假的 `WebviewTicketRepository` 实现做单元测试,不需要起 Spring 容器或真实数据库,也不需要 mock HTTP。
## 示例二:跳过 domain 层(`identity-store`,门店列表——简单查询)
```kotlin
// infrastructure/persistence/StoreRepository.kt
interface StoreRepository {
fun findStoresByUserId(userId: Long): List<StoreEntity>
}
@Repository
interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository {
@Query("select s from StoreEntity s join UserStoreEntity us on us.storeId = s.id where us.userId = :userId")
override fun findStoresByUserId(userId: Long): List<StoreEntity>
}
// application/StoreAppService.kt
@Service
class StoreAppService(
private val storeRepository: StoreRepository,
) {
fun listStores(userId: Long): List<StoreResponse> =
storeRepository.findStoresByUserId(userId).map { StoreResponse(it.id, it.name) }
}
```
没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `infrastructure``application` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录。
## 附录:为什么要分层,以及依赖倒置在这里怎么体现
如果 `Controller` 里直接写 `EntityManager` 查询、直接 `new WebClient.create(...)` 调用 F6——短期能跑,但会导致:
1. **业务规则没法脱离容器单独测试**:想验证"换票是否要判断过期时间",得连 Spring 容器、连数据库一起跑测试。
2. **换底层实现要动到业务代码**:比如把 JPA 换成 jOOQ,或者把 F6 调用从 `RestTemplate` 换成 `WebClient`,如果业务代码直接依赖具体实现类,改动会散落得到处都是。
分层的关键不是"分了几层",而是**依赖方向单向流动**,`domain` 只定义接口("我需要一个能查到 `WebviewTicket` 的东西"),不关心 `infrastructure` 具体怎么实现——这是[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)。我们只取这套思想里最实用的一层隔离,不套用完整的 DDD 战术模式(聚合根、值对象、领域事件那一整套),避免简单模块也被迫按重量级模板写代码。
## 参考链接
- [依赖倒置原则(Dependency Inversion Principle](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
- [The Clean ArchitectureUncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [Spring 官方分层架构指南](https://docs.spring.io/spring-framework/reference/core/beans/introduction.html)