AgentSkillsCN

layer-architecture

基于Holiday功能参考实现的分层结构、职责划分、DTO流转与相关约定

SKILL.md
--- frontmatter
name: layer-architecture
description: Layer structure, responsibilities, DTO flow, and conventions based on Holiday feature reference implementation
triggers:
  - layer
  - architecture
  - facade
  - application
  - dto flow
  - cqrs
argument-hint: ""

Layer Architecture

Overview

This project follows a 4-Layer Architecture.

Key Principle: Upper layers depend on lower layers only. Reverse dependencies are prohibited.

code
Bootstrap (Controller → Facade)
    ↓
Domain Application (QueryApplication / CommandApplication)
    ↓
Domain Service (Business Logic)
    ↓
Domain Repository (JpaRepository / QueryRepository)
    ↓
Domain Entity (JPA Entity)

Layer 1: Bootstrap (HTTP entry point)

Module: modules/bootstrap/{app-name}/

The entry point for handling HTTP requests and responses. Responsible only for API DTO conversion and routing.

Controller

RuleDescription
Location{appname}/api/{Feature}Controller.kt
ResponsibilityDefine HTTP endpoints, route requests
DependencyInject Facade only (direct injection of Service, Application, or Repository is prohibited)
ReturnResponseEntity<ApiResource<T>>
ConversionAPI Request DTO to Domain Request DTO (simple conversion only)
kotlin
@RestController
@RequestMapping("/api/holidays")
class HolidayController(
    private val holidayFacade: HolidayFacade,  // Inject Facade only
) {
    @GetMapping("/{year}")
    fun getByYear(@PathVariable year: Int): ResponseEntity<ApiResource<HolidaysResponse>> =
        ApiResource.success(holidayFacade.findByYear(year))

    @PostMapping
    fun create(@RequestBody request: CreateHolidayApiRequest): ResponseEntity<ApiResource<HolidayDto>> {
        val createRequest = CreateHolidayRequest(request.holidayDate, request.name)
        return ApiResource.success(holidayFacade.create(createRequest))
    }
}

Facade

RuleDescription
Location{appname}/facade/{Feature}Facade.kt
ResponsibilityConvert between API DTO and Domain DTO, orchestrate Application calls
DependencyInject QueryApplication and CommandApplication
Annotation@Component
kotlin
@Component
class HolidayFacade(
    private val holidayQueryApplication: HolidayQueryApplication,
    private val holidayCommandApplication: HolidayCommandApplication,
) {
    // Convert Domain DTO to API DTO
    fun findByYear(year: Int): HolidaysResponse {
        val holidays = holidayQueryApplication.findByYear(year)
        return HolidaysResponse.from(holidays)
    }

    fun create(request: CreateHolidayRequest): HolidayDto {
        val holiday = holidayCommandApplication.create(request)
        return HolidayDto.from(holiday)
    }
}

API DTOs

RuleDescription
Request location{appname}/dto/request/{Feature}ApiRequest.kt
Response location{appname}/dto/response/{Feature}ApiResponse.kt
NamingRequest: {Action}{Feature}ApiRequest, Response: {Feature}Dto, {Feature}sResponse

Layer 2: Domain application (orchestration)

Module: modules/domain/ Package: domain.{feature}.application

A thin delegation layer that manages transaction boundaries. Separates Query and Command (CQRS-light).

QueryApplication (query)

RuleDescription
Locationdomain/{feature}/application/{Feature}QueryApplication.kt
Annotation@Service, @Transactional(readOnly = true) (class-level)
DependencyInject Service only
ReturnDomain DTO ({Feature}Info)
kotlin
@Service
@Transactional(readOnly = true)
class HolidayQueryApplication(
    private val holidayService: HolidayService,
) {
    fun findByYear(year: Int): List<HolidayInfo> =
        holidayService.findByYear(year)

    fun findById(id: Long): HolidayInfo =
        holidayService.findById(id)
}

CommandApplication (create/update/delete)

RuleDescription
Locationdomain/{feature}/application/{Feature}CommandApplication.kt
Annotation@Service, @Transactional (class-level)
DependencyInject Service only
ReturnDomain DTO ({Feature}Info)
kotlin
@Service
@Transactional
class HolidayCommandApplication(
    private val holidayService: HolidayService,
) {
    fun create(request: CreateHolidayRequest): HolidayInfo =
        holidayService.create(request)

    fun update(id: Long, request: UpdateHolidayRequest): HolidayInfo =
        holidayService.update(id, request)

    fun delete(id: Long) =
        holidayService.delete(id)
}

Layer 3: Domain service (business logic)

Module: modules/domain/ Package: domain.{feature}.service

Core business logic. Responsible for Entity manipulation and Domain DTO conversion.

RuleDescription
Locationdomain/{feature}/service/{Feature}Service.kt
Annotation@Service
DependencyInject Repository only (JpaRepository, QueryRepository)
ReturnDomain DTO ({Feature}Info)
ConversionEntity to Domain DTO ({Feature}Info.from(entity))
kotlin
@Service
class HolidayService(
    private val holidayJpaRepository: HolidayJpaRepository,
) {
    fun findByYear(year: Int): List<HolidayInfo> =
        holidayJpaRepository.findByYear(year).map { HolidayInfo.from(it) }

    fun create(request: CreateHolidayRequest): HolidayInfo {
        val holiday = Holiday.create(request.holidayDate, request.name)
        return HolidayInfo.from(holidayJpaRepository.save(holiday))
    }

    fun update(id: Long, request: UpdateHolidayRequest): HolidayInfo {
        val holiday = holidayJpaRepository.findById(id)
            .orElseThrow { HolidayNotFoundException(id) }
        holiday.update(request.holidayDate, request.name)
        return HolidayInfo.from(holiday)
    }

    fun delete(id: Long) {
        val holiday = holidayJpaRepository.findById(id)
            .orElseThrow { HolidayNotFoundException(id) }
        holidayJpaRepository.delete(holiday)
    }
}

Layer 4: Domain repository (persistence)

Module: modules/domain/ Package: domain.{feature}.repository

Data access layer. Two approaches: Spring Data JPA and QueryDSL.

JpaRepository

RuleDescription
Locationdomain/{feature}/repository/{Feature}JpaRepository.kt
ApproachSpring Data JPA interface
PurposeSimple CRUD, derived queries, custom @Query
kotlin
interface HolidayJpaRepository : JpaRepository<Holiday, Long> {
    fun findByHolidayDate(holidayDate: LocalDate): List<Holiday>

    @Query("select h from Holiday h where year(h.holidayDate) = :year order by h.holidayDate")
    fun findByYear(@Param("year") year: Int): List<Holiday>
}

QueryRepository

RuleDescription
Locationdomain/{feature}/repository/{Feature}QueryRepository.kt
ApproachQueryDSL, extends QuerydslRepositorySupport
PurposeDynamic conditions, pagination, complex joins
Namingfetch prefix required (see skill: querydsl)
kotlin
@Repository
class HolidayQueryRepository : QuerydslRepositorySupport(Holiday::class.java) {

    fun fetchPageByYear(year: Int, pageable: Pageable): Page<HolidayInfo> {
        return applyPagination(
            pageable,
            contentQuery = { queryFactory ->
                queryFactory.selectFrom(holiday)
                    .where(holiday.holidayDate.year().eq(year))
                    .orderBy(holiday.holidayDate.asc())
            },
            countQuery = { queryFactory ->
                queryFactory.select(holiday.count()).from(holiday)
                    .where(holiday.holidayDate.year().eq(year))
            },
        ).map { HolidayInfo.from(it) }
    }
}

Domain entity & DTO

Module: modules/domain/

Entity

IMPORTANT: Entity must not know about DTOs. Entity-to-DTO conversion uses the DTO's companion object { fun from(entity) } factory method.

RuleDescription
Locationdomain/{feature}/entity/{Feature}.kt
InheritanceBaseTimeEntity or BaseEntity
ImmutabilityPrivate setter, mutations via update() method
Factorycreate() method in companion object
Dependency directionEntity must not import DTO (DTO imports Entity)
kotlin
@Entity
@Table(name = "holidays")
class Holiday(
    holidayDate: LocalDate,
    name: String,
    id: Long? = null,
) : BaseTimeEntity() {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = id

    @Column(nullable = false)
    var holidayDate: LocalDate = holidayDate
        private set

    @Column(nullable = false, length = 50)
    var name: String = name
        private set

    fun update(holidayDate: LocalDate, name: String) {
        this.holidayDate = holidayDate
        this.name = name
    }

    companion object {
        fun create(holidayDate: LocalDate, name: String) = Holiday(holidayDate = holidayDate, name = name)
    }
}

Domain DTOs

IMPORTANT: Domain DTOs convert from Entity using a companion object { fun from(entity) } factory method. Dependency direction: DTO depends on Entity (DTO knows Entity).

RuleDescription
Locationdomain/{feature}/dto/
Query DTO{Feature}Info -- includes companion object { fun from(entity) } factory method
Create requestCreate{Feature}Request
Update requestUpdate{Feature}Request
Exception{Feature}NotFoundException (extends KnownException)
kotlin
data class HolidayInfo(
    val id: Long,
    val holidayDate: LocalDate,
    val name: String,
) {
    companion object {
        fun from(entity: Holiday) = HolidayInfo(
            id = entity.id!!,
            holidayDate = entity.holidayDate,
            name = entity.name,
        )
    }
}

data class CreateHolidayRequest(val holidayDate: LocalDate, val name: String)

data class UpdateHolidayRequest(val holidayDate: LocalDate, val name: String)

class HolidayNotFoundException(id: Long) : KnownException(
    ErrorCode.DATA_NOT_FOUND, "Holiday not found: $id"
)

DTO flow (data conversion points)

code
[HTTP Request JSON]
    ↓ deserialize
CreateHolidayApiRequest (Bootstrap: dto/request/)
    ↓ Convert in Controller
CreateHolidayRequest (Domain: dto/)
    ↓ Facade → Application → Service
Holiday Entity (Domain: entity/)
    ↓ HolidayInfo.from(entity)
HolidayInfo (Domain: dto/)
    ↓ Convert in Facade (HolidayDto.from())
HolidayDto (Bootstrap: dto/response/)
    ↓ ApiResource.success() wrapping
[HTTP Response JSON]

DTO Conversion Rules

ConversionWhereMethod
API Request to Domain RequestControllerDirect constructor call
Entity to Domain DTOService{Feature}Info.from(entity)
Domain DTO to API ResponseFacadeResponseDto.from(domainDto)

IMPORTANT: Controller must not handle Entity directly. Facade must not handle Entity directly.

Dependency direction rule (within domain)

IMPORTANT: Dependencies must be unidirectional. Entity importing DTO is prohibited.

code
Correct (unidirectional): DTO(HolidayInfo) --imports--> Entity(Holiday)
Violation (reversed):     Entity(Holiday)  --imports--> DTO(HolidayInfo)  -- Prohibited!
PatternDirectionAllowed
HolidayInfo.from(entity)DTO depends on EntityYes (correct)
entity.toInfo()Entity depends on DTONo (reversed)
HolidayDto.from(info)API DTO depends on Domain DTOYes (correct)

Dependency injection rules

LayerInjection TargetProhibited
ControllerFacade onlyDirect injection of Service, Application, or Repository
FacadeQueryApplication, CommandApplicationDirect injection of Service or Repository
ApplicationService onlyDirect injection of Repository
ServiceJpaRepository, QueryRepositoryOther Service injection is allowed (same layer)
code
Controller → Facade → Application → Service → Repository
    (Each layer injects only the layer directly below it)

Transaction rules

LayerTransactionReason
ControllerNoneHTTP layer, does not manage transactions
FacadeNoneDTO conversion only, no transaction needed
QueryApplication@Transactional(readOnly = true)Read-only optimization
CommandApplication@TransactionalWrite transaction
ServiceNone (propagated from Application)Prevents duplicate transactions

Cross-domain orchestration

Facade: multi-domain composition (separate transactions)

Facade can call multiple domain Applications to compose results. Since Facade has no @Transactional, each Application call runs in its own transaction.

kotlin
// Good: Compose multiple domain Applications (each in a separate transaction)
@Component
class BookingFacade(
    private val bookingCommandApplication: BookingCommandApplication,
    private val holidayQueryApplication: HolidayQueryApplication,
    private val userQueryApplication: UserQueryApplication,
) {
    fun create(request: CreateBookingApiRequest): BookingDto {
        val user = userQueryApplication.findById(request.userId)        // Transaction 1
        val holidays = holidayQueryApplication.findByYear(request.year) // Transaction 2
        val booking = bookingCommandApplication.create(request.toDomainRequest()) // Transaction 3
        return BookingDto.from(booking, user, holidays)
    }
}
AllowedNot Allowed
Call multiple domain ApplicationsBusiness logic (validation, calculation)
API DTO ↔ Domain DTO conversionDirect Repository access
Response data assemblyDirect Service injection

Note: Each Application call from Facade runs in a separate transaction. This is suitable for independent read compositions where atomicity is not required.

Cross-domain Application: single transaction across domains

When multiple domain write operations must be atomic, create a cross-domain Application that injects Services from different domains.

kotlin
// Good: Multiple domain Services in a single transaction
@Service
@Transactional
class BookingCommandApplication(
    private val bookingService: BookingService,
    private val paymentService: PaymentService,
    private val inventoryService: InventoryService,
) {
    fun createWithPayment(request: CreateBookingRequest): BookingInfo {
        val booking = bookingService.create(request)
        paymentService.charge(booking.id, request.amount)
        inventoryService.decreaseStock(request.productId)
        return booking
        // If any step fails, the entire transaction rolls back
    }
}
ApproachTransactionUse Case
Facade → multiple ApplicationsSeparate per callIndependent read compositions
Application → multiple ServicesSingle (atomic)Write operations requiring atomicity

IMPORTANT: Application can only inject Services. Inject Services from other domains directly to execute within a single @Transactional. Injecting another Application from an Application is prohibited.


Anti-patterns

Anti-PatternProblemCorrect
Calling Service directly from ControllerBypasses Facade, misses DTO conversionController → Facade → Application
Calling Repository from FacadeSkips layersFacade → Application → Service → Repository
Returning API DTO from ServiceReverses dependency on BootstrapService returns Domain DTO only
Returning Entity directly as API responseExposes internal structureConvert Entity → Info → Dto
Implementing business logic in ApplicationApplication delegates onlyBusiness logic belongs in Service
Adding @Transactional to ServiceDuplicates with ApplicationManage transactions in Application only
Defining toInfo() method on EntityEntity depends on DTO (dependency reversal)Use {Feature}Info.from(entity) factory
Adding @Transactional to Facade for atomicityFacade is not a transaction boundary, race conditions possibleCreate a cross-domain Application with multiple Services
Application injecting another ApplicationTangled transaction boundaries, circular riskApplication injects Services only
Putting business validation in FacadeNo transaction protection, domain logic leaks to BootstrapValidation belongs in Service

Summary checklist

Verify when adding a new feature:

  • Controller injects Facade only
  • Facade injects Application only
  • Application injects Service only
  • @Transactional exists at the Application level only
  • QueryApplication uses readOnly = true
  • Entity-to-Info conversion uses {Feature}Info.from(entity) pattern
  • Entity class has no DTO imports
  • Info-to-API DTO conversion is performed in Facade
  • API Request-to-Domain Request conversion is performed in Controller
  • Domain DTOs do not depend on Bootstrap module