| name | architecture |
| description | Use when making multi-module design decisions, adding a new domain, or deciding where code should live across ssolv-api-common / ssolv-api-core / ssolv-api-place / ssolv-batch / ssolv-domain / ssolv-infrastructure / ssolv-global-utils. Defines the module dependency graph and forbidden import directions (ssolv-api-core โ ssolv-api-place; ssolv-domain must not import from infrastructure or api-* modules). Trigger when crossing module boundaries or introducing a new top-level package. |
Architecture & Design Patterns
๋ชจ๋ ์์กด์ฑ ๊ท์น
ssolv-api-core (bootable JAR) โโ
ssolv-api-place (bootable JAR) โโคโ ssolv-api-common
โ
ssolv-domain
ssolv-infrastructure
ssolv-global-utils
ssolv-batch
์ ๋ ๊ธ์ง:
ssolv-api-core โ ssolv-api-place ์ํธ ์ฐธ์กฐ
ssolv-domain์ด ssolv-infrastructure์ ์์กด
ssolv-global-utils๊ฐ ๋ค๋ฅธ ๋ชจ๋์ ์์กด
๋ ์ด์ด ๊ตฌ์กฐ (๋๋ฉ์ธ ๋ด๋ถ)
{domain}/
โโโ controller/ # HTTP ์ง์
์ , ๋ณํ๋ง ๋ด๋น
โโโ application/ # ๋น์ฆ๋์ค ๋ก์ง, @Service ํด๋์ค
โ โโโ {feature}/
โ โโโ {Feature}Service.kt
โโโ command/ # ์๋น์ค ์
๋ ฅ ํ๋ผ๋ฏธํฐ ๋ฌถ์
โโโ dto/ # ์์ฒญ/์๋ต DTO
โโโ client/ # ์ธ๋ถ API ํด๋ผ์ด์ธํธ (Ktor)
โโโ properties/ # @ConfigurationProperties ํด๋์ค
โโโ exception/ # ๋๋ฉ์ธ ์์ธ (DpmException ์๋ธํด๋์ค)
JPA Entity, Repository ์ธํฐํ์ด์ค๋ ssolv-domain์ ์์น.
์ค์ JPA ๊ตฌํ์ฒด(Query ํด๋์ค, Mapper)๋ ssolv-infrastructure์ ์์น.
๋๋ฉ์ธ-์ธํ๋ผ ๋ถ๋ฆฌ ํจํด
Domain ๋ ์ด์ด โ ์์ ๋น์ฆ๋์ค ๊ฐ์ฒด, ์ธํ๋ผ ๋ถ๊ฐ์ง๋ก ์
interface MeetingRepository {
suspend fun save(meeting: Meeting): Meeting
suspend fun findById(id: Long): Meeting?
suspend fun findMeetingsByUserId(userId: Long): List<Meeting>
}
Infrastructure ๋ ์ด์ด โ JPA Entity + ์ด๋ํฐ ๊ตฌํ
@Entity @Table(name = "meeting")
class MeetingEntity(
@Id @GeneratedValue val id: Long? = null,
val name: String,
@ManyToOne val hostUser: UserEntity,
)
@Repository
class MeetingQuery(
private val meetingMapper: MeetingMapper,
private val meetingJpaRepository: MeetingJpaRepository,
) : MeetingRepository {
override suspend fun findById(id: Long): Meeting? =
meetingJpaRepository.findByIdOrNull(id)?.let { meetingMapper.toDomain(it) }
}
@Component
class MeetingMapper : DomainMapper<Meeting, MeetingEntity> {
override fun toDomain(entity: MeetingEntity): Meeting = Meeting(
id = entity.id!!,
name = entity.name,
hostUserId = entity.hostUser.id!!,
)
override fun toEntity(domain: Meeting): MeetingEntity = ...
}
์๋น์ค ํจํด
๋จ์ผ ์ฑ
์ ์๋น์ค + operator fun invoke ํจํด์ ์ฌ์ฉํ๋ค.
@Service
class GetMeetingService(
private val meetingRepository: MeetingRepository,
) {
suspend operator fun invoke(userId: Long): List<MeetingResponse> =
withContext(Dispatchers.IO) {
meetingRepository.findMeetingsByUserId(userId)
.map { it.toResponse() }
}
}
val result = getMeetingService(userId)
๋ธ๋กํน IO๋ ํญ์ withContext(Dispatchers.IO)๋ก ๊ฐ์ผ๋ค.
์ ๋๋ฉ์ธ ์ถ๊ฐ ์ฒดํฌ๋ฆฌ์คํธ
ssolv-api-core/src/main/kotlin/org/depromeet/team3/{domain}/ ๋๋ ํ ๋ฆฌ ์์ฑ
- ํ์ ํจํค์ง:
controller/, application/, command/, dto/, exception/
ssolv-domain์ Domain ๋ชจ๋ธ + Repository ์ธํฐํ์ด์ค ์ถ๊ฐ
ssolv-infrastructure์ Entity + JpaRepository + Query(์ด๋ํฐ) + Mapper ์ถ๊ฐ
- Infrastructure Bean์ Spring Context์ ๋ฑ๋ก (
@Repository, @Component)
- ๋๋ฉ์ธ ์์ธ ํด๋์ค ์์ฑ (
DpmException ์๋ธํด๋์ค)
- ํ์ํ
ErrorCode ํญ๋ชฉ ์ถ๊ฐ
์ญ์ ์์
์ฐ๊ด๊ด๊ณ๊ฐ ์๋ ์ํฐํฐ ์ญ์ ์, ์ธ๋ํค ์ ์ฝ์กฐ๊ฑด ์๋ฐ์ ๋ง๊ธฐ ์ํด ์์ โ ๋ถ๋ชจ ์์๋ก ์ญ์ .
ํธ๋์ญ์
๋ด์์ ์์์ ์ผ๋ก ์ฒ๋ฆฌํ๊ณ , ์ธ๋ถ ์๋น์ค ํธ์ถ(FCM, Redis)์ ํธ๋์ญ์
์ปค๋ฐ ์ดํ ์คํ.
โ / โ
๊ณผ๊ฑฐ ๊ตํ (Data Integrity โ 2026-04-13)
์ถ์ฒ: .claude/docs/DATA_INTEGRITY_FIXES.md
Cascade ์ค์ โ ์์ ์ํฐํฐ ๋๋ฝ ๊ธ์ง
@OneToMany(mappedBy = "user")
val meetings: MutableList<MeetingEntity>
@OneToMany(mappedBy = "meeting")
val attendees: MutableList<MeetingAttendeeEntity>
@OneToMany(mappedBy = "meeting", cascade = [CascadeType.ALL], orphanRemoval = true)
val attendees: MutableList<MeetingAttendeeEntity>
BaseTimeEntity โ ํ์์คํฌํ๋ protected set
var createdAt: LocalDateTime = LocalDateTime.now()
var createdAt: LocalDateTime = LocalDateTime.now()
protected set
Mapper.toEntity() โ JPA Auditing ๊ด๋ฆฌ ํ๋๋ ์๋ ํ ๋น ๊ธ์ง
override fun toEntity(domain: User): UserEntity = UserEntity(
createdAt = domain.createdAt,
)
override fun toEntity(domain: User): UserEntity = UserEntity(
id = domain.id,
)
ํํด ์ ๋น์ฆ๋์ค ๊ฒ์ฆ โ ์ฐ๊ด ๋ฐ์ดํฐ ๋ฌด๊ฒฐ์ฑ
์ฌ์ฉ์ ์ญ์ ์ฒ๋ผ cascade ๋ฒ์๊ฐ ๋์ ์์
์ ์ญ์ ์ ์ ๋ฐ๋์ ๋น์ฆ๋์ค ๊ท์น ๊ฒ์ฆ:
private fun validateHostedMeetings(userId: Long) {
val hosted = meetingRepository.findMeetingsByUserId(userId)
hosted.forEach { meeting ->
val hasOtherAttendees = attendeeRepository
.findByMeetingId(meeting.id!!)
.any { it.userId != userId }
if (hasOtherAttendees) throw AuthException(ErrorCode.CANNOT_WITHDRAW_WITH_ACTIVE_MEETINGS)
}
}