148 lines
7.3 KiB
Markdown
148 lines
7.3 KiB
Markdown
# 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 外部调用实现。
|
|||
|
|
|
|||
|
|
## 何时可以跳过 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 Architecture(Uncle 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)
|