Files
conti-docs/backend/02-layering.md
T
Guangfei.Zhao 74b02ed427 feat: add documentation for cross-domain collaboration and aggregation
- Introduced a new section on cross-domain collaboration and aggregation, detailing decision-making processes, contract module usage for cross-domain reads, and domain events for writes.
- Added guidelines for parallel aggregation using a dedicated thread pool and context propagation.
- Established rules for transaction boundaries, idempotency, optimistic locking, scheduled tasks, and caching strategies in a concurrent environment.
- Included examples and best practices for implementing these concepts in the application.
2026-08-14 16:03:47 +08:00

223 lines
13 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 协调;Entity/领域模型 → Response 的转换
domain/ # 可选:领域模型、repository/client 接口、状态机/复杂业务规则
infrastructure/ # JPA repository 实现、外部 client 实现
```
`integration/*` 模块(`f6-adapter``mini-clients`,见 [01-project-structure.md](./01-project-structure.md))不套这四层——它们本身就是"别人的 infrastructure",内部只有 client 实现 + 对外暴露的接口和传输模型,规则见 [05-integration-layer.md](./05-integration-layer.md)。
## 各层职责
- **api**`Controller`(只做参数校验 + 调用 `application`)、请求/响应 DTO。不写业务逻辑,**不 import `infrastructure` 包下的任何类型**(包括 Entity)。
- **application**`Service`,编排用例、事务边界(`@Transactional` 一般加在这一层)。依赖 `domain` 定义的接口(有 domain 层时),或直接依赖 `infrastructure` 暴露的接口(跳过 domain 层时)。**`Entity`/领域模型 → `Response` 的转换在这一层完成**`application/mapper/`,见 [06-api-design.md](./06-api-design.md))。
- **domain**(可选):领域模型(可以是纯 Kotlin data class,不一定是 JPA entity)、repository/client 接口、封装多步骤业务规则或状态机的领域服务。不依赖 Spring Web/JPA 相关类型,可以脱离容器单独做单元测试。
- **infrastructure**`domain`(或 `application`,跳过 domain 层时)里接口的具体实现——JPA repository 实现、基于 `RestClient` 的外部调用实现。
## 对象命名约定(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` 层转换成 `Response`,不会凭空多出一个 BO。
- **Entity 边界规则**(这一条在 [06-api-design.md](./06-api-design.md) 和 [10-testing.md](./10-testing.md) 的 ArchUnit 规则里用的是同一句话,三处必须一致):
> **`XxxEntity` 不出现在 `api` 层的任何签名或 import 里,也不跨出所在模块的边界。**
> **`Entity`/`Domain` 模型 → `Response` 的转换发生在 `application` 层。**
`domain` 层时更严一档:Entity 到 `infrastructure` 的 repository 实现为止,`application` 拿到的已经是领域模型。
这句话对应 ArchUnit 的 `noClasses().that().resideInAPackage("..api..").should().dependOnClassesThat().resideInAPackage("..infrastructure..")`,能被自动检查,不靠人盯。
### 为什么规则不是"Entity 永不跨出 infrastructure"
因为跳过 domain 层的简单 CRUD 场景下,那条更严的规则会强迫我们为每个查询凭空造一个和 Entity 字段一模一样的中间模型,只为了把它从 `infrastructure` 搬到 `application`——纯粹的样板代码,没有换来任何隔离收益(`application` 转手就把它变成 `Response` 了)。
真正需要防的是**两件事**:Entity 泄漏到 API 契约上(数据库字段一改,APP 就崩),以及 Entity 泄漏到别的模块(别的模块从此依赖上你的表结构)。上面那条规则精确地只挡这两件事,所以它既能自动检查,也不会逼出无意义的中间类。
## 何时可以跳过 domain 层
先明确一件事:**"跳过 domain 层"跳过的是领域模型和领域服务,不是跳过接口抽象**。`application` 依赖的仍然是一个接口(只不过接口定义挪到了 `infrastructure` 包内),而不是直接 `@Autowired` 一个 `JpaRepository``EntityManager` 到处用。
- **可以跳过**:简单 CRUD、没有跨 repository 协调、没有状态机——`application` 直接依赖 `infrastructure` 里定义的 repository/client 接口即可(接口和实现放在同一层)。
- **必须要有**:多步骤业务规则(如 WebView 换票的状态校验)、需要协调多个数据源(如 `workbench` 聚合多个 Mini 域)、包含状态机或需要独立于容器做单元测试的核心业务逻辑——接口定义在 `domain``infrastructure` 反向实现。
判断不确定时按"先跳过、需要时再补"处理:从"无 domain 层"补出一个 domain 层是局部重构(把规则从 `application` 提到 `domain`,加一层模型转换),成本可控;反过来为了对称给所有简单查询都套上 domain 层,则是持续付出的样板成本。
## 依赖方向
```
api → application → domain(或直接 → infrastructure 的接口,若跳过 domain
domain → 不依赖 api / infrastructure
infrastructure → 依赖 domain 的接口(若有),依赖 platform-persistence / platform-integration
```
`domain` 层的类不 import `org.springframework.web.*` / `jakarta.persistence.*`,保证这一层的单元测试不需要起 Spring 容器、不需要真实数据库。
模块之间的依赖方向(domain 之间不互相依赖、跨域只走 `-contract` 契约模块)见 [01-project-structure.md](./01-project-structure.md),两套规则一个管模块内、一个管模块间,都由 [10-testing.md](./10-testing.md) 里的 ArchUnit 测试检查。
## 示例一:有 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)
// 领域模型 → Response 的转换在 application 层
return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt)
}
}
// infrastructure/persistence/WebviewTicketRepositoryImpl.kt
@Repository
class WebviewTicketRepositoryImpl(
private val jpaRepository: WebviewTicketJpaRepository,
) : WebviewTicketRepository {
// Entity 到这里为止,不会出现在返回值里
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。
注意 `IssueWebviewTicketService` 上**没有 `@Service` 注解**——domain 层不依赖 Spring[10-testing.md](./10-testing.md) 有一条 ArchUnit 规则盯着这件事)。它成为 bean 的方式是在 `infrastructure/config/` 里显式声明:
```kotlin
// infrastructure/config/DomainServiceConfig.kt
@Configuration
class DomainServiceConfig {
@Bean
fun issueWebviewTicketService(repository: WebviewTicketRepository, clock: Clock) =
IssueWebviewTicketService(repository, clock)
}
```
多写这几行换来的是:领域规则这一层可以脱离 Spring 单独编译和测试。domain 层类不多,这个成本是可控的。
## 示例二:跳过 domain 层(`identity-store`,门店列表——简单查询)
```kotlin
// infrastructure/persistence/StoreView.kt
// Spring Data 接口投影:只声明这次查询需要的字段,Hibernate 只 select 这几列。
// 用它而不是直接返回 StoreEntity,是为了让 application 拿到的东西不带 Entity 的
// 生命周期(游离态/懒加载)和无关字段——不需要额外写一个类,接口本身就是契约。
interface StoreView {
val id: Long
val name: String
val code: String
}
// infrastructure/persistence/StoreRepository.kt
interface StoreRepository {
fun findStoresByUserId(userId: Long): List<StoreView>
}
@Repository
interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository {
@Query(
"""
select s.id as id, s.name as name, s.code as code
from StoreEntity s
join UserStoreEntity us on us.storeId = s.id
where us.userId = :userId
""",
)
override fun findStoresByUserId(userId: Long): List<StoreView>
}
// application/StoreAppService.kt
@Service
class StoreAppService(
private val storeRepository: StoreRepository,
private val storeMapper: StoreMapper, // application/mapper/,见 06-api-design.md
) {
fun listAccessibleStores(userId: Long): List<StoreResponse> =
storeMapper.toResponseList(storeRepository.findStoresByUserId(userId))
}
```
没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `infrastructure``application` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录。
写操作或者确实需要整个实体的场景,`StoreRepository` 也可以返回 `StoreEntity`——那时候 Entity 进到 `application` 是允许的(见上面的边界规则),只要它不出现在 `api` 层、也不跨出这个模块就行。投影是查询场景下的优选,不是硬性要求。
## 附录:为什么要分层,以及依赖倒置在这里怎么体现
如果 `Controller` 里直接写 `EntityManager` 查询、直接 `RestClient.create(...)` 调用 F6——短期能跑,但会导致:
1. **业务规则没法脱离容器单独测试**:想验证"换票是否要判断过期时间",得连 Spring 容器、连数据库一起跑测试。
2. **换底层实现要动到业务代码**:比如把 JPA 换成 jOOQ,或者把 F6 调用从 `RestTemplate` 换成 `RestClient`,如果业务代码直接依赖具体实现类,改动会散落得到处都是。
分层的关键不是"分了几层",而是**依赖方向单向流动**,`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 Data JPA: Projections](https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html)