| name | architecture |
| description | Load when designing new domains or modules, refactoring architecture, working with Clean Architecture layers, ports/adapters pattern, or cross-domain communication. |
Architecture & Design Patterns
Load this context when designing features, creating new domains, or refactoring.
Clean Architecture
┌─────────────────────────────────────────────────────────────┐
│ API Layer (Controllers, DTOs, Converters) │
│ - Handles HTTP requests/responses │
│ - Input validation │
│ - DTO transformation │
├─────────────────────────────────────────────────────────────┤
│ Application Layer (UseCases, Commands, Results, Ports) │
│ - Business logic orchestration │
│ - Transaction management │
│ - Defines ports (interfaces) for infrastructure │
├─────────────────────────────────────────────────────────────┤
│ Domain Layer (Entities, Value Objects, Enums) │
│ - Core business rules │
│ - Domain models │
│ - No external dependencies │
├─────────────────────────────────────────────────────────────┤
│ Infrastructure Layer (Adapters, Repositories, Configs) │
│ - Implements ports defined in application layer │
│ - External service integration │
│ - Database access │
└─────────────────────────────────────────────────────────────┘
Dependency Rules
| Layer | Can Depend On | Cannot Depend On |
|---|
| API | Application, Domain | Infrastructure (directly) |
| Application | Domain | API, Infrastructure |
| Domain | Nothing | Any other layer |
| Infrastructure | Application, Domain | API |
Critical: Inner layers MUST NOT depend on outer layers.
- ✅
application → domain
- ❌
application → api or infra
Domain Module Structure
src/main/kotlin/com/neki/
├── auth/ # Authentication domain
│ ├── api/ # Controllers, DTOs
│ ├── application/ # UseCases, Commands, Ports
│ ├── domain/ # Entities, Enums
│ └── infra/ # Adapters, Security configs
├── user/ # User domain
├── photo/ # Photo archiving domain
├── media/ # Media storage domain
└── common/ # Shared utilities
Per-Domain Structure
[domain]/
├── api/
│ ├── controller/ # REST controllers
│ ├── converter/ # Request→Command, Result→Response
│ └── dto/ # Request/Response DTOs
├── application/
│ ├── command/ # Input to use cases
│ ├── result/ # Output from use cases
│ ├── port/ # Interfaces for infrastructure
│ └── usecase/ # Business logic
├── domain/
│ ├── entity/ # JPA entities
│ └── enums/ # Domain enums
└── infra/
└── persist/ # Repository adapters
└── jpa/ # JPA repositories
Domain Isolation Rule
Domains MUST NOT import from other domains directly.
❌ Wrong:
import com.neki.user.domain.entity.User
✅ Correct - Use ports for cross-domain communication:
interface UserInfoPort {
fun getUserName(userId: Long): String
}
@Component
class UserInfoAdapter(
private val userRepository: UserRepository
) : UserInfoPort {
override fun getUserName(userId: Long): String {
return userRepository.findById(userId).name
}
}
UseCase Pattern
Services are annotated with @UseCase:
@UseCase
class CreateFolderUseCase(
private val folderRepository: FolderRepositoryPort
) {
@Transactional
fun execute(command: CreateFolderCommand): CreateFolderResult {
if (folderRepository.existsOwnedFolderName(command.userId, command.name)) {
throw BusinessException(ResultCode.CONFLICT_FOLDER)
}
val folder = Folder(
userId = command.userId,
name = command.name,
)
val saved = folderRepository.save(folder)
return CreateFolderResult(saved.id!!)
}
}
Reference: src/main/kotlin/com/neki/common/annotation/UseCase.kt
Port/Adapter Pattern
Port (Interface in Application Layer)
interface FolderRepositoryPort {
fun save(folder: Folder): Folder
fun findById(id: Long): Folder?
fun findAllByUserId(userId: Long): List<Folder>
fun existsOwnedFolderName(userId: Long, name: String): Boolean
fun deleteById(id: Long)
}
Adapter (Implementation in Infrastructure Layer)
@Repository
class FolderRepositoryAdapter(
private val jpaRepository: JpaFolderRepository
) : FolderRepositoryPort {
override fun save(folder: Folder): Folder {
return jpaRepository.save(folder)
}
override fun findById(id: Long): Folder? {
return jpaRepository.findByIdOrNull(id)
}
}
JPA Repository
interface JpaFolderRepository : JpaRepository<Folder, Long> {
fun findAllByUserId(userId: Long): List<Folder>
fun existsByUserIdAndName(userId: Long, name: String): Boolean
}
Port Method Naming Conventions
Use consistent verb names across all ports:
| Operation | Method Name | Example |
|---|
| Create | add, save, create | add(userId, photoId) |
| Read | find*, get*, exists | findById(id), existsByName(name) |
| Update | update, modify | update(entity) |
| Delete | delete, remove | delete(userId, photoId) |
| Count | count* | countByUserId(userId) |
Prefer delete over remove for consistency with SQL terminology.
Command/Query/Result Pattern
application DTO는 모두 application/dto/ 에 두고, 도메인 그룹별 object 하위 중첩 클래스로 묶는다.
쓰기 입력은 XxxCommand, 조회 입력은 XxxQuery, 출력은 XxxResult.
Command (쓰기 입력)
object FolderCommand {
data class CreateFolder(
val userId: Long,
val name: String,
)
data class DeleteFolders(
val userId: Long,
val folderIds: List<Long>,
)
}
Query (조회 입력)
object FolderQuery {
data class GetFolders(
val userId: Long,
val limit: Int?,
)
}
Result (출력)
object FolderResult {
data class CreateFolder(
val folderId: Long,
)
data class GetFolders(
val items: List<FolderInfo>,
) {
data class FolderInfo(val folderId: Long, val name: String, val storageKey: String?, val count: Long)
}
}
QueryDSL for Batch Operations
When you need batch operations (delete/update multiple records), use QueryDSL instead of Spring Data
JPA for better performance.
Naming Convention
- Spring Data JPA:
Jpa*Repository (e.g., JpaFolderRepository)
- QueryDSL:
*QueryRepository (e.g., FolderQueryRepository)
Example: Batch Delete
@Repository
class FavoritePhotoQueryRepository(private val queryFactory: JPAQueryFactory) {
fun deleteAllByUserIdAndPhotoIds(userId: Long, photoIds: List<Long>): Long =
queryFactory.delete(favoritePhoto)
.where(
favoritePhoto.id.userId.eq(userId),
favoritePhoto.id.photoId.`in`(photoIds),
)
.execute()
}
@Repository
class FavoriteImageRepositoryAdapter(
private val jpaRepository: JpaFavoriteImageRepository,
private val queryRepository: FavoritePhotoQueryRepository,
) : FavoriteImageRepositoryPort {
override fun delete(favoritePhoto: FavoritePhoto) =
jpaRepository.deleteById(favoritePhoto.id)
override fun deleteAll(userId: Long, photoIds: List<Long>) {
if (photoIds.isEmpty()) return
queryRepository.deleteAllByUserIdAndPhotoIds(userId, photoIds)
}
}
Performance: Single DELETE query vs N individual deletes
Entity Deletion Patterns
Cascade Deletion Order
When deleting entities with relationships, delete dependent entities FIRST to prevent orphan
records.
Example: Photo with Favorites
@UseCase
class DeletePhotoUseCase(
private val photoImageRepository: PhotoImageRepositoryPort,
private val favoriteImageRepository: FavoriteImageRepositoryPort,
private val mediaClient: MediaClientPort,
private val transactionRunner: TransactionRunner,
) {
fun execute(command: DeletePhotoCommand) {
val photo = transactionRunner.run {
favoriteImageRepository.delete(FavoritePhoto(command.userId, command.photoId))
photoImageRepository.deleteOwnedPhoto(command.userId, command.photoId)
} ?: throw BusinessException(ResultCode.NOT_FOUND)
mediaClient.deleteMedia(command.userId, photo.mediaId)
}
}
Key Points:
- Delete dependent entities before parent entities
- Use transactions to ensure atomicity
- External service calls (S3, etc.) happen AFTER transaction commits
- Prevents orphan records in the database
Checklist for New Domain
File References
| Component | Location |
|---|
| UseCase annotation | src/main/kotlin/com/neki/common/annotation/UseCase.kt |
| Base entity | src/main/kotlin/com/neki/common/domain/BaseTimeEntity.kt |
| Transaction runner | src/main/kotlin/com/neki/common/transaction/TransactionRunner.kt |