- 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.
8.7 KiB
8.7 KiB
02. 分层规范(后端)
决策
每个 domains/* 模块内部采用简化分层,domain 层可选,判断标准与 Flutter 端 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) |
| 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 里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,换票——多步骤状态校验)
// 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,门店列表——简单查询)
// 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——短期能跑,但会导致:
- 业务规则没法脱离容器单独测试:想验证"换票是否要判断过期时间",得连 Spring 容器、连数据库一起跑测试。
- 换底层实现要动到业务代码:比如把 JPA 换成 jOOQ,或者把 F6 调用从
RestTemplate换成WebClient,如果业务代码直接依赖具体实现类,改动会散落得到处都是。
分层的关键不是"分了几层",而是依赖方向单向流动,domain 只定义接口("我需要一个能查到 WebviewTicket 的东西"),不关心 infrastructure 具体怎么实现——这是依赖倒置原则。我们只取这套思想里最实用的一层隔离,不套用完整的 DDD 战术模式(聚合根、值对象、领域事件那一整套),避免简单模块也被迫按重量级模板写代码。