- 비즈니스 로직과 기술 세부사항 완전 분리
- 프레임워크 교체 가능 구조 확보
- 테스트 용이성 극대화
- 도메인 중심 설계 유지
- 장기 유지보수 가능한 코드베이스 구축
Presentation
↓
Application
↓
Domain
↑
Infrastructure
- 의존성은 항상 안쪽(Domain) 방향으로만 향한다.
- Domain은 어떤 외부 프레임워크도 모른다.
- Infrastructure는 가장 바깥에서 기술을 담당한다.
- 핵심 비즈니스 규칙
- Aggregate Root
- Entity
- Value Object
- Domain Policy
- Domain Event
- 비즈니스 예외 정의
- Spring 의존성
- JPA 어노테이션
- HTTP 관련 코드
- DB 접근 코드
- 메시징 코드
- 외부 API 호출
package com.example.domain.sample.model
class Aggregate private constructor(
private var status: Status
) {
companion object {
fun create(): Aggregate {
return Aggregate(Status.PENDING)
}
}
fun changeStatus(newStatus: Status) {
if (!status.canTransitionTo(newStatus)) {
throw InvalidStateException("Invalid state transition")
}
this.status = newStatus
}
fun getStatus(): Status = status
}
enum class Status {
PENDING,
CONFIRMED;
fun canTransitionTo(newStatus: Status): Boolean {
return this == PENDING && newStatus == CONFIRMED
}
}
class InvalidStateException(
message: String
) : RuntimeException(message)- 유스케이스 오케스트레이션
- 트랜잭션 경계 설정
- Port 인터페이스 호출
- DTO ↔ Domain 변환
- 도메인 이벤트 발행 트리거
- @Service
- @Transactional
- 기술 구현 세부사항 직접 포함
- DB 엔티티 직접 사용
- 비즈니스 규칙 구현 (Domain에 위임)
package com.example.application.sample.service
import com.example.application.sample.port.RepositoryPort
import com.example.application.sample.usecase.CreateUseCase
import com.example.domain.sample.model.Aggregate
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
@Service
@Transactional
class CreateUseCaseService(
private val repositoryPort: RepositoryPort
) : CreateUseCase {
override fun execute(command: CreateUseCase.Command): CreateUseCase.Result {
val aggregate = Aggregate.create()
val saved = repositoryPort.save(aggregate)
return CreateUseCase.Result.from(saved)
}
}- DB 연동
- 외부 API 연동
- 메시징 시스템 연동
- 캐시
- 파일 시스템
- 프레임워크 설정
- Port 인터페이스 구현
- 기술 예외 → 도메인/애플리케이션 예외로 변환
package com.example.infrastructure.persistence.sample
import com.example.application.sample.port.RepositoryPort
import com.example.domain.sample.model.Aggregate
import org.springframework.stereotype.Component
@Component
class JpaRepositoryAdapter(
private val repository: SpringDataRepository
) : RepositoryPort {
override fun save(aggregate: Aggregate): Aggregate {
val entity = Mapper.toEntity(aggregate)
val saved = repository.save(entity)
return Mapper.toDomain(saved)
}
}- HTTP 요청/응답 처리
- 입력 검증
- 인증/인가
- API 문서화
- 비즈니스 로직 작성
- DB 접근
package com.example.presentation.sample
import com.example.application.sample.usecase.CreateUseCase
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/resources")
class ResourceController(
private val createUseCase: CreateUseCase
) {
@PostMapping
fun create(
@RequestBody request: RequestDto
): ResponseEntity<ResponseDto> {
val command = RequestMapper.toCommand(request)
val result = createUseCase.execute(command)
return ResponseEntity.ok(
ResponseMapper.toResponse(result)
)
}
data class RequestDto(
val id: Long
)
data class ResponseDto(
val id: Long,
val status: String
)
}위치: Application
package com.example.application.sample.usecase
interface CreateUseCase {
fun execute(command: Command): Result
data class Command(
val id: Long
)
data class Result(
val id: Long,
val status: String
) {
companion object {
fun from(aggregate: com.example.domain.sample.model.Aggregate): Result {
return Result(
id = 0L,
status = aggregate.getStatus().name
)
}
}
}
}위치: Domain 또는 Application (팀 컨벤션에 따라 통일)
package com.example.application.sample.port
import com.example.domain.sample.model.Aggregate
interface RepositoryPort {
fun save(aggregate: Aggregate): Aggregate
fun findById(id: Long): Aggregate?
}Infrastructure는 Port를 구현한다.
Application은 Port 인터페이스에만 의존한다.
이 프로젝트는 Domain 레이어를 물리적으로 분리한 Nested Submodule 구조를 사용한다.
{service}/ ← 컨테이너 (build.gradle.kts 최소화)
├── domain/ ← :{service}:domain Gradle 서브모듈 (순수 Kotlin)
│ └── src/main/kotlin/com/kgd/{service}/
│ └── domain/
│ ├── model/
│ ├── policy/
│ ├── event/
│ └── exception/
└── app/ ← :{service}:app Gradle 서브모듈 (Spring Boot)
└── src/main/kotlin/com/kgd/{service}/
├── application/
│ └── {entity}/
│ ├── usecase/
│ ├── service/
│ ├── port/
│ └── dto/
├── infrastructure/
│ ├── persistence/
│ ├── client/
│ ├── messaging/
│ └── config/
└── presentation/
└── {entity}/
├── controller/
└── dto/
domain 모듈 제약: Spring/JPA 의존성이 없으므로 어노테이션 사용 시 컴파일 에러로 차단된다.
Request DTO
→ Command
→ UseCase
→ Domain
→ Port
→ Infrastructure
Infrastructure
→ Domain
→ Result
→ Response DTO
- 비즈니스 규칙 위반
- RuntimeException 상속
- 프레임워크 독립
- 기술적 오류
- 반드시 Domain/Application 예외로 변환 후 전파
- GlobalExceptionHandler에서 최종 변환
- HTTP 상태 코드 매핑
- 순수 단위 테스트
- Mock 사용 금지
- Port는 Mock 사용
- 유스케이스 중심 테스트
- 통합 테스트
- 실제 DB/외부 시스템 테스트
- API 테스트
- 요청/응답 검증
- Application → Infrastructure 직접 참조 0건
- Domain → Spring/JPA 의존 0건
- 순환 참조 0건
- Domain 테스트 커버리지 90% 이상
- 주요 유스케이스 통합 테스트 100%
- Domain은 프레임워크를 모른다.
- 의존성은 항상 안쪽으로 향한다.
- Infrastructure는 Port를 구현한다.
- Application은 흐름을 조정하고 비즈니스 규칙은 Domain에 위임한다.
- Controller는 UseCase만 호출한다.
이 문서는 모든 도메인에서 공통적으로 적용 가능한 Kotlin 기반 Clean Architecture 기준 문서이다.