| name | domain-model |
| description | Use when creating or modifying domain data classes (in ssolv-domain) vs JPA entities (in ssolv-infrastructure) and their Mappers. Covers Kotlin-JDSL 3.8.0 complex queries, `ErrorCode` enum patterns, `@ConfigurationProperties` groups (never `@Value` direct injection), and keeping domain logic free of Spring/JPA imports. Trigger on any `*Entity.kt`, `*Mapper.kt`, `*Query.kt`, domain data class, or new `@ConfigurationProperties` class. |
Domain Model Patterns
์ด ํ๋ก์ ํธ๋ ๋๋ฉ์ธ ๋ชจ๋ธ๊ณผ JPA ์ํฐํฐ๋ฅผ ์์ ํ ๋ถ๋ฆฌํ๋ค.
ssolv-domain์ data class๊ฐ ๋น์ฆ๋์ค ์ง์ค์ ๊ทผ๊ฑฐ(source of truth)์ด๋ฉฐ,
ssolv-infrastructure์ Entity๋ DB ๋งคํ ์ ์ฉ์ด๋ค.
๊ณ์ธต ๊ตฌ์กฐ
ssolv-domain โ ๋น์ฆ๋์ค ๊ท์น, ์ธํฐํ์ด์ค ์ ์, ์์ธ
ssolv-infrastructure โ JPA Entity, Repository ๊ตฌํ์ฒด, Mapper
ssolv-api-common โ ๊ณตํต ์ด๋
ธํ
์ด์
, ํํฐ, ๋ณด์ ์ค์
ssolv-global-utils โ ErrorCode, DpmException, DpmApiResponse
Domain vs Entity
๋๋ฉ์ธ (ssolv-domain)
data class Meeting(
val id: Long? = null,
val name: String,
val hostUserId: Long,
val isClosed: Boolean = false,
val endAt: LocalDateTime? = null,
override val createdAt: LocalDateTime? = null,
override val updatedAt: LocalDateTime? = null,
) : BaseTimeDomain(createdAt, updatedAt) {
fun close(): Meeting = copy(isClosed = true, endAt = LocalDateTime.now())
}
JPA Entity (ssolv-infrastructure)
@Entity
@Table(name = "tb_meetings")
class MeetingEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,
@Column(nullable = false)
var name: String = "",
@OneToMany(mappedBy = "meeting", cascade = [CascadeType.ALL], orphanRemoval = true)
val attendees: MutableList<MeetingAttendeeEntity> = mutableListOf(),
) : BaseTimeEntity()
ํต์ฌ ๊ท์น:
- Entity์ ๋น์ฆ๋์ค ๋ฉ์๋ ์์ฑ ๊ธ์ง
- Domain์
@Entity, @Column ๋ฑ JPA ์ด๋
ธํ
์ด์
๊ธ์ง
@OneToMany๋ ๋ฐ๋์ cascade = [CascadeType.ALL], orphanRemoval = true
BaseTimeEntity vs BaseTimeDomain
| BaseTimeEntity (infrastructure) | BaseTimeDomain (domain) |
|---|
| ์์น | ssolv-infrastructure | ssolv-domain |
| ๋ชฉ์ | JPA Auditing ์ฐ๋ | ๋๋ฉ์ธ ๊ฐ์ฒด ์๊ฐ ํ๋ |
createdAt visibility | protected set โ ์ธ๋ถ์์ ์ง์ ์ค์ ๋ถ๊ฐ | val (read-only) |
| ๊ด๋ฆฌ ์ฃผ์ฒด | JPA @EntityListeners(AuditingEntityListener) | Mapper์์ entity โ domain ๋ณํ ์ ์ ๋ฌ |
override fun toEntity(domain: User): UserEntity {
return UserEntity(
createdAt = domain.createdAt
)
}
override fun toEntity(domain: User): UserEntity {
return UserEntity(
id = domain.id,
email = domain.email,
)
}
DomainMapper ํจํด
๋ชจ๋ Mapper๋ DomainMapper<D, E> ์ธํฐํ์ด์ค๋ฅผ ๊ตฌํํ๋ค.
interface DomainMapper<D, E> {
fun toDomain(entity: E): D
fun toEntity(domain: D): E
}
@Component
class UserMapper : DomainMapper<User, UserEntity> {
override fun toDomain(entity: UserEntity): User {
return User(
id = entity.id,
email = entity.email,
createdAt = entity.createdAt,
updatedAt = entity.updatedAt,
)
}
override fun toEntity(domain: User): UserEntity {
return UserEntity(
id = domain.id,
email = domain.email,
)
}
}
CommandRepository / QueryRepository ๋ถ๋ฆฌ
๋๋ฉ์ธ์์ ์ฐ๊ธฐ/์ฝ๊ธฐ Repository๋ฅผ ๋ถ๋ฆฌํ์ฌ ์ ์ํ๋ค.
interface UserCommandRepository {
suspend fun save(user: User): User
suspend fun delete(id: Long)
}
interface UserQueryRepository {
suspend fun findById(id: Long): User?
suspend fun findByEmail(email: String): User?
}
@Repository
class UserCommandRepositoryImpl(
private val userJpaRepository: UserJpaRepository,
private val userMapper: UserMapper,
) : UserCommandRepository {
override suspend fun save(user: User): User {
return userMapper.toDomain(userJpaRepository.save(userMapper.toEntity(user)))
}
}
@Repository
class UserQueryRepositoryImpl(
private val userJpaRepository: UserJpaRepository,
private val userMapper: UserMapper,
) : UserQueryRepository {
override suspend fun findById(id: Long): User? {
return userJpaRepository.findById(id).map { userMapper.toDomain(it) }.orElse(null)
}
}
Kotlin-JDSL ๋ณต์ก ์ฟผ๋ฆฌ
JpaRepository๋ง์ผ๋ก ํํํ๊ธฐ ์ด๋ ค์ด ๋์ ์กฐ๊ฑด ์ฟผ๋ฆฌ๋ KotlinJdslJpqlExecutor๋ฅผ ์ฌ์ฉํ๋ค.
@Repository
class SurveyCategoryQuery(
private val mapper: SurveyCategoryMapper,
private val jpaRepository: SurveyCategoryJpaRepository,
) : SurveyCategoryRepository {
override suspend fun existsByNameAndParentId(name: String, parentId: Long?): Boolean {
val results = jpaRepository.findAll {
select(entity(SurveyCategoryEntity::class))
.from(entity(SurveyCategoryEntity::class))
.where(
and(
path(SurveyCategoryEntity::name).eq(name),
path(SurveyCategoryEntity::isDeleted).eq(false),
if (parentId == null) {
path(SurveyCategoryEntity::parent).isNull()
} else {
path(SurveyCategoryEntity::parent).path(SurveyCategoryEntity::id).eq(parentId)
}
)
)
}
return results.filterNotNull().isNotEmpty()
}
}
JDSL ์ฌ์ฉ ์ JpaRepository์ KotlinJdslJpqlExecutor ์์ ์ถ๊ฐ ํ์:
interface SurveyCategoryJpaRepository :
JpaRepository<SurveyCategoryEntity, Long>,
KotlinJdslJpqlExecutor
ErrorCode ๋๋ฉ์ธ๋ณ ๋ฒ์
์ ErrorCode ์ถ๊ฐ ์ ์๋ ๋ฒ์๋ฅผ ์ง์ผ์ผ ํ๋ค.
| ์ ๋์ด | ๋๋ฉ์ธ | ์์ |
|---|
C001~C099 | ๊ณตํต ํด๋ผ์ด์ธํธ ์ค๋ฅ | INVALID_REQUEST |
C4xx | ๊ณตํต HTTP ์ํ ๊ธฐ๋ฐ ์ค๋ฅ | C404, C409 |
S001~S099 | ์๋ฒ ๋ด๋ถ ์ค๋ฅ | INTERNAL_SERVER_ERROR |
O001~O099 | OAuth (Kakao/Apple) | KAKAO_AUTH_FAILED |
J001~J099 | JWT ํ ํฐ | REFRESH_TOKEN_MISMATCH |
T001~T099 | ์ด๋ ํ ํฐ | INVALID_INVITE_TOKEN |
N001~N099 | ์๋ฆผ | NOTIFICATION_NOT_FOUND |
P001~P099 | Place (Google Places) | PLACE_API_ERROR |
class MeetingException(
errorCode: ErrorCode,
detail: Map<String, Any?>? = null,
) : DpmException(errorCode, detail)
throw MeetingException(ErrorCode.MEETING_NOT_FOUND, mapOf("meetingId" to id))
@ConfigurationProperties ๊ท์น
@Value๋ก ํ๋กํผํฐ๋ฅผ ์ง์ ์ฃผ์
ํ์ง ์๋๋ค. ๋ฐ๋์ @ConfigurationProperties ํด๋์ค๋ฅผ ๋ง๋ ๋ค.
@Value("\${jwt.secret}") private lateinit var secret: String
@Component
@ConfigurationProperties(prefix = "jwt")
data class JwtProperties(
var secret: String = "",
var accessTokenValidity: Long = 3600000,
)