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

13 KiB
Raw Blame History

02. 分层规范(后端)

决策

每个 domains/* 模块内部采用简化分层,domain可选,判断标准与 Flutter 端 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-adaptermini-clients,见 01-project-structure.md)不套这四层——它们本身就是"别人的 infrastructure",内部只有 client 实现 + 对外暴露的接口和传输模型,规则见 05-integration-layer.md

各层职责

  • apiController(只做参数校验 + 调用 application)、请求/响应 DTO。不写业务逻辑,不 import infrastructure 包下的任何类型(包括 Entity)。
  • applicationService,编排用例、事务边界(@Transactional 一般加在这一层)。依赖 domain 定义的接口(有 domain 层时),或直接依赖 infrastructure 暴露的接口(跳过 domain 层时)。Entity/领域模型 → Response 的转换在这一层完成application/mapper/,见 06-api-design.md)。
  • domain(可选):领域模型(可以是纯 Kotlin data class,不一定是 JPA entity)、repository/client 接口、封装多步骤业务规则或状态机的领域服务。不依赖 Spring Web/JPA 相关类型,可以脱离容器单独做单元测试。
  • infrastructuredomain(或 application,跳过 domain 层时)里接口的具体实现——JPA repository 实现、基于 RestClient 的外部调用实现。

对象命名约定(PO / DAO / BO / DTO / VO

Java 生态里这几个缩写来源不一、经常被混用,这里把我们实际用的名字和这些通用叫法对应清楚,避免团队内部各叫各的:

通用叫法 全称 所在层 我们的命名
PO Persistent Object infrastructure XxxEntityJPA entity,见 03-persistence.md
DAO Data Access Object infrastructure XxxJpaRepositorySpring 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 层时,EntityPO)由 application 层转换成 Response,不会凭空多出一个 BO。

  • Entity 边界规则(这一条在 06-api-design.md10-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 一个 JpaRepositoryEntityManager 到处用。

  • 可以跳过:简单 CRUD、没有跨 repository 协调、没有状态机——application 直接依赖 infrastructure 里定义的 repository/client 接口即可(接口和实现放在同一层)。
  • 必须要有:多步骤业务规则(如 WebView 换票的状态校验)、需要协调多个数据源(如 workbench 聚合多个 Mini 域)、包含状态机或需要独立于容器做单元测试的核心业务逻辑——接口定义在 domaininfrastructure 反向实现。

判断不确定时按"先跳过、需要时再补"处理:从"无 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,两套规则一个管模块内、一个管模块间,都由 10-testing.md 里的 ArchUnit 测试检查。

示例一:有 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)
        // 领域模型 → 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 层不依赖 Spring10-testing.md 有一条 ArchUnit 规则盯着这件事)。它成为 bean 的方式是在 infrastructure/config/ 里显式声明:

// infrastructure/config/DomainServiceConfig.kt
@Configuration
class DomainServiceConfig {
    @Bean
    fun issueWebviewTicketService(repository: WebviewTicketRepository, clock: Clock) =
        IssueWebviewTicketService(repository, clock)
}

多写这几行换来的是:领域规则这一层可以脱离 Spring 单独编译和测试。domain 层类不多,这个成本是可控的。

示例二:跳过 domain 层(identity-store,门店列表——简单查询)

// 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 协调,接口和实现直接放在 infrastructureapplication 直接依赖 StoreRepository 这个接口,省掉一层 domain 目录。

写操作或者确实需要整个实体的场景,StoreRepository 也可以返回 StoreEntity——那时候 Entity 进到 application 是允许的(见上面的边界规则),只要它不出现在 api 层、也不跨出这个模块就行。投影是查询场景下的优选,不是硬性要求。

附录:为什么要分层,以及依赖倒置在这里怎么体现

如果 Controller 里直接写 EntityManager 查询、直接 RestClient.create(...) 调用 F6——短期能跑,但会导致:

  1. 业务规则没法脱离容器单独测试:想验证"换票是否要判断过期时间",得连 Spring 容器、连数据库一起跑测试。
  2. 换底层实现要动到业务代码:比如把 JPA 换成 jOOQ,或者把 F6 调用从 RestTemplate 换成 RestClient,如果业务代码直接依赖具体实现类,改动会散落得到处都是。

分层的关键不是"分了几层",而是依赖方向单向流动domain 只定义接口("我需要一个能查到 WebviewTicket 的东西"),不关心 infrastructure 具体怎么实现——这是依赖倒置原则。我们只取这套思想里最实用的一层隔离,不套用完整的 DDD 战术模式(聚合根、值对象、领域事件那一整套),避免简单模块也被迫按重量级模板写代码。

参考链接