| 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 |