Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.yapp.apis.book.dto.request

import org.yapp.domain.userbook.BookStatus
import java.util.UUID


data class UpsertUserBookRequest private constructor(
val userId: UUID,
val bookIsbn: String,
val bookTitle: String,
val bookAuthor: String,
val bookPublisher: String,
val bookCoverImageUrl: String,
val status: BookStatus
) {
companion object {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공백 제거 부탁드립니다!

fun of(
userId: UUID,
bookIsbn: String,
bookTitle: String,
bookAuthor: String,
bookPublisher: String,
bookCoverImageUrl: String,
status: BookStatus
): UpsertUserBookRequest {
return UpsertUserBookRequest(
userId = userId,
bookIsbn = bookIsbn,
bookTitle = bookTitle,
bookAuthor = bookAuthor,
bookPublisher = bookPublisher,
bookCoverImageUrl = bookCoverImageUrl,
status = status
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.yapp.apis.book.dto.response

import org.yapp.domain.book.vo.BookVO

data class BookCreateResponse private constructor(
val isbn: String,
val title: String,
val author: String,
val publisher: String,
val coverImageUrl: String
) {
companion object {

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 공백제거 부탁드릴게요!

fun from(bookVO: BookVO): BookCreateResponse {
return BookCreateResponse(
isbn = bookVO.isbn,
title = bookVO.title,
author = bookVO.author,
publisher = bookVO.publisher,
coverImageUrl = bookVO.coverImageUrl
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ data class BookDetailResponse private constructor(
) {
companion object {


fun from(response: AladinBookDetailResponse): BookDetailResponse {
val bookItem = response.item?.firstOrNull()
?: throw IllegalArgumentException("No book item found in detail response.")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package org.yapp.apis.book.dto.response

import org.yapp.domain.userbook.BookStatus
import org.yapp.domain.userbook.UserBook
import org.yapp.domain.userbook.vo.UserBookVO
import java.time.format.DateTimeFormatter
import java.util.*
import java.util.UUID

data class UserBookResponse private constructor(
val userBookId: UUID,
Expand All @@ -20,7 +20,7 @@ data class UserBookResponse private constructor(

companion object {
fun from(
userBook: UserBook,
userBook: UserBookVO,
): UserBookResponse {
return UserBookResponse(
userBookId = userBook.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ package org.yapp.apis.book.service

import org.springframework.stereotype.Service
import org.yapp.apis.book.dto.request.BookCreateRequest
import org.yapp.domain.book.Book
import org.yapp.apis.book.dto.response.BookCreateResponse
import org.yapp.domain.book.BookDomainService

@Service
class BookManagementService(
private val bookDomainService: BookDomainService
) {
fun findOrCreateBook(request: BookCreateRequest): Book {
fun findOrCreateBook(request: BookCreateRequest): BookCreateResponse {
val isbn = request.validIsbn()

return bookDomainService.findByIsbn(isbn)
val bookVO = bookDomainService.findByIsbn(isbn)
?: bookDomainService.save(
isbn = isbn,
title = request.validTitle(),
Expand All @@ -22,6 +22,7 @@ class BookManagementService(
publicationYear = request.publicationYear,
description = request.description
)
return BookCreateResponse.from(bookVO)
}

}
19 changes: 15 additions & 4 deletions apis/src/main/kotlin/org/yapp/apis/book/service/UserBookService.kt
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fun upsertUserBook(upsertUserBookRequest: UpsertUserBookRequest): UserBookResponse {
    val userBook = userBookDomainService.upsertUserBook(
        userId = upsertUserBookRequest.userId,
        bookIsbn = upsertUserBookRequest.bookIsbn,
        bookPublisher = upsertUserBookRequest.bookPublisher,
        bookAuthor = upsertUserBookRequest.bookAuthor,
        bookTitle = upsertUserBookRequest.bookTitle,
        bookCoverImageUrl = upsertUserBookRequest.bookCoverImageUrl
    )

    return UserBookResponse.from(userBook)
}

fun findAllUserBooks(userId: UUID): List<UserBookResponse> {
    return userBookDomainService
        .findAllUserBooks(userId)
        .map { UserBookResponse.from(it) }
}
  • 현재 코틀린의 반환 타입 추론 방식으로 구현이 되어있는 것 같습니다!
  • 이 부분은 그때 저희 회의 나눴을 때, 자바 방식과 같이 반환 타입을 명시해주기로 했어서 위 코드와 같이 가면 좋을 것 같아요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇 ㅋㅋ 이부분 확인 못했는데 확인 감사합니다.

Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.yapp.apis.book.service

import org.springframework.stereotype.Service
import org.yapp.apis.book.dto.request.UpsertUserBookRequest
import org.yapp.apis.book.dto.response.UserBookResponse
import org.yapp.domain.book.Book
import org.yapp.domain.userbook.UserBookDomainService
import org.yapp.domain.userbook.BookStatus
Expand All @@ -10,9 +12,18 @@ import java.util.*
class UserBookService(
private val userBookDomainService: UserBookDomainService
) {
fun upsertUserBook(userId: UUID, book: Book, status: BookStatus) =
userBookDomainService.upsertUserBook(userId, book, status)
fun upsertUserBook(upsertUserBookRequest: UpsertUserBookRequest): UserBookResponse =
UserBookResponse.from(
userBookDomainService.upsertUserBook(
upsertUserBookRequest.userId,
upsertUserBookRequest.bookIsbn,
upsertUserBookRequest.bookPublisher,
upsertUserBookRequest.bookAuthor,
upsertUserBookRequest.bookTitle,
upsertUserBookRequest.bookCoverImageUrl,
)
)

fun findAllUserBooks(userId: UUID) =
userBookDomainService.findAllUserBooks(userId)
fun findAllUserBooks(userId: UUID): List<UserBookResponse> =
userBookDomainService.findAllUserBooks(userId).map { UserBookResponse.from(it) }
}
26 changes: 18 additions & 8 deletions apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import org.yapp.apis.book.dto.response.BookDetailResponse
import org.yapp.apis.book.dto.response.BookSearchResponse
import org.yapp.apis.book.dto.response.UserBookResponse
import org.yapp.apis.book.constant.BookQueryServiceQualifier
import org.yapp.apis.book.dto.request.UpsertUserBookRequest
import org.yapp.apis.book.service.BookManagementService
import org.yapp.apis.book.service.BookQueryService
import org.yapp.apis.book.service.UserBookService
Expand All @@ -20,10 +21,12 @@ import java.util.UUID
@UseCase
@Transactional(readOnly = true)
class BookUseCase(
private val userAuthService: UserAuthService,
private val userBookService: UserBookService,

@Qualifier(BookQueryServiceQualifier.ALADIN)
private val bookQueryService: BookQueryService,
Comment on lines 25 to 26
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sealed class가 아닌 Qualifier를 써서 주입을 하시게 된 이유가 궁금합니다!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

보통 Qualifier는 스프링에서 같은 타입의 Bean이 여러 개 있을 때, 특정 구현체를 명시적으로 주입하기 위해 사용하는 방식으로 알고있습니다.

그런데, 지금 상황에서는 시스템은 요청에 따라 동적으로 구현체를 선택해야 하는 상황으로 보입니다.(즉, 런타임 시점에 조건에 따라 구현체가 바뀌어야 함)

따라서 sealed interface + 구현체 방식으로 가는게 적합하지 않을까 해서 의견드립니다!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 의견 감사합니다! 🙇🏻‍♂️

말씀 주신 것처럼 보통 @qualifier는 동일한 타입의 Bean이 여러 개일 때 명시적으로 어떤 구현체를 주입할지를 지정할 때 사용되는 방식이고, sealed class/interface는 런타임 분기 처리에 적합하다는 점 공감합니다.

다만 현재 구조에서는 외부 API가 런타임에 따라 동적으로 바뀌어야 할 요구사항은 없는 것으로 알고 있어서, 단순하게 주입만 구분하면 되는 상황이라 @qualifier를 사용했습니다.

혹시 향후 외부 API를 요청 조건에 따라 다르게 선택해야 하는 요구사항이 생기거나, 또는 외부 API를 조합해 처리해야 하는 복잡한 흐름이 생긴다면 말씀해주신 sealed interface 기반 설계가 더 적절할 것 같습니다!

혹시 제가 놓친 시나리오가 있다면 말씀 부탁드릴게요 :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋은 말씀 감사합니다! 🙇🏻‍♂️

말씀하신 내용에 공감합니다. 현재는 단일 API만 사용하는 구조이기 때문에 @qualifier 방식도 충분히 동작하긴 하지만, 저는 추후 다른 외부 API를 연동하게 되는 상황까지 고려해보고 있었습니다!


private val userAuthService: UserAuthService,
private val userBookService: UserBookService,
private val bookManagementService: BookManagementService
) {
fun searchBooks(request: BookSearchRequest): BookSearchResponse {
Expand All @@ -38,20 +41,27 @@ class BookUseCase(
fun upsertBookToMyLibrary(userId: UUID, request: UserBookRegisterRequest): UserBookResponse {
userAuthService.validateUserExists(userId)

val detail = bookQueryService.getBookDetail(BookDetailRequest.of(request.validBookIsbn()))

val book = bookManagementService.findOrCreateBook(BookCreateRequest.create(detail))
val bookDetailResponse = bookQueryService.getBookDetail(BookDetailRequest.of(request.validBookIsbn()))

val userBook = userBookService.upsertUserBook(userId, book, request.bookStatus)
val bookCreateResponse = bookManagementService.findOrCreateBook(BookCreateRequest.create(bookDetailResponse))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추가적으로 메서드 네이밍을 통일해보면 좋을 것 같아요!!

저는 개인적으로 다른 dto의 정적 팩토리 메서드에서는 of or from 네이밍을 사용중이어서, 해당 부분의 경우 from으로 가면 더 통일성이 있을 것 같아요!
(create라는 네이밍은 아직 제게 도메인 객체 생성용 메서드라는 인상이 강한 것 같아요.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아아 좋아요 dto는 from of 도메인 객체 생성용 메서드는 create로 하는게 통일성 있어보이네요!

val upsertUserBookRequest = UpsertUserBookRequest.of(
userId = userId,
bookIsbn = bookCreateResponse.isbn,
bookTitle = bookCreateResponse.title,
bookAuthor = bookCreateResponse.author,
bookPublisher = bookCreateResponse.publisher,
bookCoverImageUrl = bookCreateResponse.coverImageUrl,
status = request.bookStatus
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

val upsertUserBookRequest = UpsertUserBookRequest.of(
    userId = userId,
    bookCreateResponse = bookCreateResponse,
    status = request.bookStatus
)
  • 해당 부분도 다른 dto와 마찬가지로 인자를 객체로 받아서 내부에서 getter를 호출하도록 리팩토링 하는게 어떨까요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조아요~

val userBookResponse = userBookService.upsertUserBook(upsertUserBookRequest)

return UserBookResponse.from(userBook)
return userBookResponse
}


fun getUserLibraryBooks(userId: UUID): List<UserBookResponse> {
userAuthService.validateUserExists(userId)

return userBookService.findAllUserBooks(userId)
.map(UserBookResponse::from)
}
}
16 changes: 7 additions & 9 deletions domain/src/main/kotlin/org/yapp/domain/book/BookDomainService.kt
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

너무 깔끔한 것 같습니다!!

BookVO를 반환함으로써, 외부에 도메인 엔티티를 직접 노출하지 않는 부분이 좋은 것 같아요!

Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
package org.yapp.domain.book

import org.yapp.domain.book.vo.BookVO
import org.yapp.globalutils.annotation.DomainService

@DomainService
class BookDomainService(
private val bookRepository: BookRepository
) {
fun findByIsbn(isbn: String): Book? {
return bookRepository.findByIsbn(isbn)
fun findByIsbn(isbn: String): BookVO? {
return bookRepository.findByIsbn(isbn)?.let { BookVO.newInstance(it) }
}

fun save(
Expand All @@ -18,12 +19,8 @@ class BookDomainService(
coverImageUrl: String,
publicationYear: Int? = null,
description: String? = null
): Book {
findByIsbn(isbn)?.let {
return it
}

val book = Book.create(
): BookVO {
val book = bookRepository.findByIsbn(isbn) ?: Book.create(
isbn = isbn,
title = title,
author = author,
Expand All @@ -33,6 +30,7 @@ class BookDomainService(
description = description
)

return bookRepository.save(book)
val savedBook = bookRepository.save(book)
return BookVO.newInstance(savedBook)
}
}
32 changes: 32 additions & 0 deletions domain/src/main/kotlin/org/yapp/domain/book/vo/BookVO.kt
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data class BookVO private constructor(
    val isbn: String,
    val title: String,
    val author: String,
    val publisher: String,
    val coverImageUrl: String,
    val publicationYear: Int?,
    val description: String?
) {
    init {
        // 생성 시점에 데이터의 유효성을 검증합니다.
        require(isbn.isNotBlank()) { "ISBN은 비어 있을 수 없습니다." }
        require(title.isNotBlank()) { "제목은 비어 있을 수 없습니다." }
        require(author.isNotBlank()) { "저자는 비어 있을 수 없습니다." }
        publicationYear?.let { require(it > 0) { "출판 연도는 0보다 커야 합니다." } }
    }

    companion object {
        fun newInstance(book: Book): BookVO {
            return BookVO(
                book.isbn,
                book.title,
                book.author,
                book.publisher,
                book.coverImageUrl,
                book.publicationYear,
                book.description
            )
        }
    }
}

(추천)

  • 사실 지금 도 너무 좋은 코드인 것 같긴 합니다 ㅎㅎ.
  • 생성 시점에 검증 로직을 추가하면 스스로의 유효성을 보장하는 진짜 값 객체(Value Object)로 사용할 수 있을 것 같다는 생각이 듭니다!!
  • ex. '제목이 비어있는 BookVO'는 원천적으로 존재할 수 없게 된다

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 이 부분을 고민했었는데요,
현재 저희 구조상 DB에 저장되기 전에 이미 DTO 단에서 1차 검증을 거치고, 이후 저장된 데이터를 다시 VO로 감싸서 사용하는 방식이다 보니, VO 생성 시에 또 init 블록에서 검증을 반복하는 게 과연 실제로 의미가 있을까? 라는 의문도 들었습니다.

즉, 이미 한 번 검증된 값을 다시 감싸는 단계에서 예외가 발생할 가능성은 사실상 없다고 생각하긴 했는데요,
혹시 이런 경우에도 init 유효성 체크가 불변성과 도메인 안전성을 보장하는 측면에서 필요한가?에 대해 다른 의견이나 경험 있으시면 공유 부탁드리겠습니다!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하신 것처럼 현재 구조에서는 DTO 단계에서 이미 1차적인 유효성 검증을 거치고, 이후 저장된 데이터를 기반으로 VO를 생성하고 있어서, VO의 init 블록에서 동일한 검증을 반복하는 것이 과연 실제로 의미가 있을까? 라는 의문이 드는 것도 충분히 이해됩니다.

다만 저는, 해당 VO가 반드시 현재 흐름에서만 사용된다는 보장이 없다는 점에서 조금 다른 입장을 갖고 있습니다.

향후 다른 비즈니스 로직이나 외부 모듈에서 해당 VO가 직접 생성되거나 사용되는 과정에서 검증이 누락될 가능성도 있다고 생각합니다.

그렇기 때문에 VO 내부에서 한 번 더 유효성 검증을 수행하는 것은 도메인 객체로서의 무결성과 불변성을 지키기 위한 최종 방어선의 역할을 한다고 보고 있습니다 😊

특히, 검증 로직이 교차적으로 중복되어 있지 않다면, 오히려 VO의 init 검증이 없을 경우 유효하지 않은 데이터가 도메인 계층을 뚫고 들어오는 위험도 있다고 생각합니다!!

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.yapp.domain.book.vo

import org.yapp.domain.book.Book

data class BookVO private constructor(
val isbn: String,
val title: String,
val author: String,
val publisher: String,
val coverImageUrl: String,
val publicationYear: Int?,
val description: String?
) {
companion object {
/**
* ✨ BookVO를 생성하는 팩토리 메서드
*/
fun newInstance(
book: Book
): BookVO {
return BookVO(
book.isbn,
book.title,
book.author,
book.publisher,
book.coverImageUrl,
book.publicationYear,
book.description
)
}
}
}
16 changes: 10 additions & 6 deletions domain/src/main/kotlin/org/yapp/domain/userbook/UserBook.kt
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,22 @@ data class UserBook private constructor(
companion object {
fun create(
userId: UUID,
book: Book,
coverImageUrl: String,
bookIsbn: String,
publisher: String,
title: String,
author: String,
initialStatus: BookStatus = BookStatus.BEFORE_READING
): UserBook {
val now = LocalDateTime.now()
return UserBook(
id = UuidGenerator.create(),
coverImageUrl = book.coverImageUrl,
publisher = book.publisher,
title = book.title,
author = book.author,
coverImageUrl = coverImageUrl,
publisher = publisher,
title = title,
author = author,
userId = userId,
bookIsbn = book.isbn,
bookIsbn = bookIsbn,
status = initialStatus,
createdAt = now,
updatedAt = now,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
package org.yapp.domain.userbook

import org.yapp.domain.book.Book
import org.yapp.domain.userbook.vo.UserBookVO
import org.yapp.globalutils.annotation.DomainService
import java.util.UUID

@DomainService
class UserBookDomainService(
private val userBookRepository: UserBookRepository
) {
fun upsertUserBook(
userId: UUID,
bookIsbn: String,
bookTitle: String,
bookAuthor: String,
bookPublisher: String,
bookCoverImageUrl: String,
): UserBookVO {
val userBook = userBookRepository.findByUserIdAndBookIsbn(userId, bookIsbn)
?.apply { updateStatus(status) }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 status를 매개변수로 받지 않고 있는데, status가 써있어서 코드레빗 리뷰처럼 코드 수정이 필요할 것 같습니다.

?: UserBook.create(
userId = userId,
bookIsbn = bookIsbn,
title = bookTitle,
author = bookAuthor,
publisher = bookPublisher,
coverImageUrl = bookCoverImageUrl,
)
Comment on lines +23 to +30
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

새로운 UserBook 생성 시 status 파라미터가 누락되었습니다.

UserBook.create 호출에서 status 파라미터가 전달되지 않아 새로 생성되는 UserBook의 상태가 올바르게 설정되지 않습니다.

다음과 같이 수정하세요:

?: UserBook.create(
    userId = userId,
    bookIsbn = bookIsbn,
    title = bookTitle,
    author = bookAuthor,
    publisher = bookPublisher,
    coverImageUrl = bookCoverImageUrl,
+   status = status
)
🤖 Prompt for AI Agents
In domain/src/main/kotlin/org/yapp/domain/userbook/UserBookDomainService.kt
around lines 23 to 30, the call to UserBook.create is missing the status
parameter, causing the new UserBook's status to be unset or incorrect. Add the
status argument to the UserBook.create call with the appropriate value to ensure
the UserBook's status is properly initialized when created.


fun upsertUserBook(userId: UUID, book: Book, status: BookStatus): UserBook {
val existing = userBookRepository.findByUserIdAndBookIsbn(userId, book.isbn)
return if (existing != null) {
val updated = existing.updateStatus(status)
userBookRepository.save(updated)
} else {
val created = UserBook.create(userId, book, status)
userBookRepository.save(created)
}
val savedUserBook = userBookRepository.save(userBook)
return UserBookVO.newInstance(savedUserBook)
}

fun findAllUserBooks(userId: UUID): List<UserBook> {
fun findAllUserBooks(userId: UUID): List<UserBookVO> {
return userBookRepository.findAllByUserId(userId)
.map(UserBookVO::newInstance)
}
}
Loading