add-or-update-infrastructure

GrowWeek's avatarfrom GrowWeek

Bounded Context에 새로운 infrastructure 를 추가하거나 수정할 때 사용하세요.

0stars🔀0forks📁View on GitHub🕐Updated Jan 10, 2026

When & Why to Use This Skill

This Claude skill streamlines the development of the infrastructure layer within a Bounded Context, ensuring architectural consistency and adherence to Domain-Driven Design (DDD) patterns. It provides standardized templates and rules for implementing database persistence using the Exposed ORM and managing external service integrations via OpenFeign clients.

Use Cases

  • Implementing Database Persistence: Use this skill to generate Exposed ORM table objects and repository implementations, including boilerplate for CRUD operations, pagination (offset and cursor-based), and transaction management.
  • Integrating External APIs: Quickly set up OpenFeign clients and necessary DTOs within the 'external' infrastructure directory to facilitate communication with other microservices or third-party providers.
  • Enforcing Directory Standards: Maintain a clean and scalable codebase by automatically organizing infrastructure code into dedicated 'persistence' and 'external' subdirectories according to project conventions.
  • Refactoring Infrastructure Code: Update existing repository implementations or external client configurations while ensuring they remain compliant with the established architectural boundaries and naming conventions.
nameadd-or-update-infrastructure
descriptionBounded Context에 새로운 infrastructure 를 추가하거나 수정할 때 사용하세요.

Add or Update Infrastructure

Instructions

새로운 인프라스트럭처를 추가하거나 기존 인프라스트럭처를 수정할 때 다음 규칙을 따르세요:

1. infrastructure 디렉토리 구조

└── infrastructure/
    ├── persistence/
    └── external/

2. persistence 디렉토리

persistence 디렉토리에는 Exposed ORM을 사용한 데이터베이스 관련 domain/repository 인터페이스의 구현체가 위치합니다. 예를 들어 :


class ExposedTaskRepositoryImpl : TaskRepository {
    @Transactional
    override fun saveTasks(tasks: List<Task>): List<Task> {
        val savedTasks = mutableListOf<Task>()

        tasks.forEach { task ->
            if (task.id == null) {
                // INSERT
                val insertedId = TaskTable.insert {
                    it[memberId] = task.memberId.value
                    it[weekId] = task.weekId.value
                    it[title] = task.title.value
                    it[description] = task.description?.value
                    it[step] = task.step.name
                    it[isDelete] = task.state.isDelete
                    it[isLocked] = task.state.isLocked
                    it[isCarriedOver] = task.state.isCarriedOver
                    it[isSensitive] = task.state.isSensitive
                    it[createdAt] = task.auditInfo.createdAt
                    it[updatedAt] = task.auditInfo.updatedAt
                } get TaskTable.id

                savedTasks.add(task.copy(id = TaskId(insertedId.value)))
            } else {
                // UPDATE
                TaskTable.update({ TaskTable.id eq task.id.value }) {
                    it[memberId] = task.memberId.value
                    it[weekId] = task.weekId.value
                    it[title] = task.title.value
                    it[description] = task.description?.value
                    it[step] = task.step.name
                    it[isDelete] = task.state.isDelete
                    it[isLocked] = task.state.isLocked
                    it[isCarriedOver] = task.state.isCarriedOver
                    it[isSensitive] = task.state.isSensitive
                    it[updatedAt] = LocalDateTime.now()
                }
                savedTasks.add(task)
            }
        }

        return savedTasks
    }

    @Transactional(readOnly = true)
    override fun findTasks(query: TaskRepository.TaskPageQuery): Page<Task> {
        return when (query) {
            is TaskRepository.TaskOffsetPageQuery -> findTasksWithOffset(query)
            is TaskRepository.TaskCursorPageQuery -> findTasksWithCursor(query)
        }
    }

    private fun findTasksWithOffset(query: TaskRepository.TaskOffsetPageQuery): OffsetPage<Task> {
        var baseQuery = TaskTable.selectAll()
            .where { TaskTable.isDelete eq false }

        // 필터 적용
        if (query.taskIds.isNotEmpty()) {
            baseQuery = baseQuery.andWhere { TaskTable.id inList query.taskIds.map { it.value } }
        }
        query.weekId?.let { weekId ->
            baseQuery = baseQuery.andWhere { TaskTable.weekId eq weekId.value }
        }
        query.memberId?.let { memberId ->
            baseQuery = baseQuery.andWhere { TaskTable.memberId eq memberId.value }
        }

        // 정렬
        baseQuery = when (query.orderBy) {
            "createdAt" -> baseQuery.orderBy(TaskTable.createdAt to SortOrder.DESC)
            "updatedAt" -> baseQuery.orderBy(TaskTable.updatedAt to SortOrder.DESC)
            else -> baseQuery.orderBy(TaskTable.createdAt to SortOrder.DESC)
        }

        // 전체 개수
        val totalCount = baseQuery.count().toInt()
        val totalPage = totalCount / query.size + if (totalCount % query.size > 0) 1 else 0

        // 페이징
        val items = baseQuery
            .limit(query.size)
            .offset((query.page * query.size).toLong())
            .map { it.toTask() }

        return OffsetPage(
            items = items,
            page = query.page,
            size = query.size,
            totalPage = totalPage,
        )
    }

    private fun findTasksWithCursor(query: TaskRepository.TaskCursorPageQuery): CursorPage<Task> {
        var baseQuery = TaskTable.selectAll()
            .where { TaskTable.isDelete eq false }

        // 필터 적용
        if (query.taskIds.isNotEmpty()) {
            baseQuery = baseQuery.andWhere { TaskTable.id inList query.taskIds.map { it.value } }
        }
        query.weekId?.let { weekId ->
            baseQuery = baseQuery.andWhere { TaskTable.weekId eq weekId.value }
        }
        query.memberId?.let { memberId ->
            baseQuery = baseQuery.andWhere { TaskTable.memberId eq memberId.value }
        }

        // 커서 적용
        query.cursor?.let { cursor ->
            val cursorId = cursor.toLongOrNull()
            if (cursorId != null) {
                baseQuery = baseQuery.andWhere { TaskTable.id less cursorId }
            }
        }

        // 정렬
        baseQuery = when (query.orderBy) {
            "createdAt" -> baseQuery.orderBy(TaskTable.createdAt to SortOrder.DESC)
            "updatedAt" -> baseQuery.orderBy(TaskTable.updatedAt to SortOrder.DESC)
            else -> baseQuery.orderBy(TaskTable.id to SortOrder.DESC)
        }

        // 페이징 (size + 1 조회하여 다음 페이지 존재 여부 확인)
        val items = baseQuery
            .limit(query.size + 1)
            .map { it.toTask() }

        val hasNext = items.size > query.size
        val resultItems = if (hasNext) items.dropLast(1) else items
        val nextCursor = if (hasNext) resultItems.lastOrNull()?.id?.value?.toString() else null

        return CursorPage(
            items = resultItems,
            cursor = query.cursor,
            size = query.size,
            nextCursor = nextCursor,
            hasNext = hasNext
        )
    }

    private fun ResultRow.toTask(): Task {
        return Task.load(
            id = TaskId(this[TaskTable.id].value),
            title = TaskTitle(this[TaskTable.title]),
            description = TaskDescription.orNull(this[TaskTable.description]),
            state = TaskState(
                isDelete = this[TaskTable.isDelete],
                isLocked = this[TaskTable.isLocked],
                isCarriedOver = this[TaskTable.isCarriedOver],
                isSensitive = this[TaskTable.isSensitive]
            ),
            step = TaskStep.valueOf(this[TaskTable.step]),
            memberId = MemberId(this[TaskTable.memberId]),
            weekId = WeekId(this[TaskTable.weekId]),
            auditInfo = AuditInfo(
                createdAt = this[TaskTable.createdAt],
                updatedAt = this[TaskTable.updatedAt]
            )
        )
    }
}

또한, Exposed 설정 파일이나 테이블 objects도 이 디렉토리에 위치합니다. 예를 들어 :

object TaskTable : LongIdTable("tasks") {
    // 기본 필드
    val memberId = long("member_id")
    val weekId = long("week_id")
    val title = varchar("title", 200)
    val description = text("description").nullable()

    // 상태
    val step = varchar("step", 20)

    // TaskState 플래그들
    val isDelete = bool("is_deleted").default(false)
    val isLocked = bool("is_locked").default(false)
    val isCarriedOver = bool("is_carried_over").default(false)
    val isSensitive = bool("is_sensitive").default(false)

    // 감사 정보
    val createdAt = datetime("created_at")
    val updatedAt = datetime("updated_at").nullable()

    init {
        // 복합 인덱스
        index(false, memberId, weekId)
        index(false, weekId)
        index(false, step)
        index(false, isDelete)
    }
}

3. external 디렉토리

external 디렉토리에는 OpenFeign 클라이언트를 사용한 외부 서비스 연동 관련 domain/repository 인터페이스의 구현체가 위치합니다. 기능상 필요하지 않다면 이 디렉토리 안에는 파일이 존재하지 않을 수 있습니다. 예를 들어 :

@FeignClient(name = "notificationService", url = "\${feign.notification-service.url}")
interface NotificationClient {
    @PostMapping("/api/v1/notifications")
    fun sendNotification(@RequestBody request: NotificationRequest): NotificationResponse
}

Open Feign 설정이나 관련 DTO들도 이 디렉토리에 위치합니다. 예를 들어 :

data class NotificationRequest(
    val userId: Long,
    val title: String,
    val message: String
)
data class NotificationResponse(
    val notificationId: Long,
    val status: String
)

만약, 서로 다른 외부 서비스 연동을 구분하기 위해 하위 디렉토리가 필요하다면, 해당 외부 서비스 이름으로 하위 디렉토리를 생성하여 관리할 수 있습니다. 예를 들어 :

└── infrastructure/
    ├── persistence/
    └── external/
        ├── kafka/
        └── aws/