# 02. 分层规范(后端) ## 决策 每个 `domains/*` 模块内部采用简化分层,`domain` 层**可选**,判断标准与 Flutter 端 [02-layering.md](../../conti-retail-app/docs/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 } @Repository interface StoreJpaRepository : JpaRepository, 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 } // 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 = 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 Architecture(Uncle 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)