├── .github ├── ISSUE_TEMPLATE │ ├── 01. main-issue.yml │ ├── 02. sub-issue.yml │ ├── 03. bug-report.yml │ └── config.yml └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── README.md ├── build.gradle.kts ├── common ├── build.gradle.kts ├── common.settings.gradle.kts └── src │ ├── main │ └── java │ │ └── nettee │ │ └── common │ │ ├── CustomException.java │ │ ├── ErrorCode.java │ │ ├── marker │ │ └── TypeSafeMarker.java │ │ ├── status │ │ ├── CustomStatusParameters.java │ │ ├── CustomStatusParametersSupplier.java │ │ ├── StatusCodeConstants.java │ │ ├── StatusCodeUtil.java │ │ ├── StatusParameters.java │ │ └── exception │ │ │ ├── StatusCodeErrorCode.java │ │ │ └── StatusCodeException.java │ │ └── util │ │ └── EnumUtil.java │ └── test │ └── kotlin │ └── nettee │ └── common │ └── status │ ├── StatusCodeUtilTest.kt │ └── StatusCodeUtilTest_CustomStatusParameters.kt ├── compose-monolith ├── compose-monolith.cmd ├── core ├── client │ ├── nettee-client-api │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── nettee │ │ │ └── client │ │ │ ├── propeties │ │ │ └── ClientProperties.java │ │ │ └── request │ │ │ └── NetteeRequest.java │ └── nettee-rest-client │ │ ├── build.gradle.kts │ │ └── src │ │ ├── main │ │ └── java │ │ │ └── nettee │ │ │ └── restclient │ │ │ ├── NetteeClient.java │ │ │ └── config │ │ │ └── RestClientConfig.java │ │ └── test │ │ ├── java │ │ └── nettee │ │ │ ├── RestClientTestApplication.java │ │ │ ├── client │ │ │ └── NetteeClientJavaTest.java │ │ │ └── student │ │ │ ├── api │ │ │ └── StudentApi.java │ │ │ ├── entity │ │ │ └── Student.java │ │ │ └── persistence │ │ │ └── StudentRepository.java │ │ ├── kotlin │ │ └── nettee │ │ │ └── client │ │ │ └── NetteeClientTest.kt │ │ └── resources │ │ └── application.yml ├── core.settings.gradle.kts ├── cors │ ├── nettee-cors-api │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── nettee │ │ │ └── properties │ │ │ ├── CorsProperties.java │ │ │ ├── MappedCorsProperties.java │ │ │ ├── allowed │ │ │ └── CorsAllowedProperties.java │ │ │ └── exposed │ │ │ └── CorsExposedProperties.java │ └── nettee-cors-webmvc │ │ ├── build.gradle.kts │ │ └── src │ │ └── main │ │ └── java │ │ └── nettee │ │ └── config │ │ └── WebMvcCorsConfig.java ├── exception-handler-core │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── java │ │ └── nettee │ │ └── exception │ │ ├── handler │ │ └── GlobalExceptionHandler.java │ │ └── response │ │ └── ApiErrorResponse.java ├── jpa-core │ ├── build.gradle.kts │ └── src │ │ └── main │ │ ├── generated │ │ └── nettee │ │ │ └── jpa │ │ │ └── support │ │ │ ├── QBaseTimeEntity.java │ │ │ ├── QLongBaseEntity.java │ │ │ ├── QLongBaseTimeEntity.java │ │ │ ├── QSnowflakeBaseEntity.java │ │ │ ├── QSnowflakeBaseTimeEntity.java │ │ │ ├── QUuidBaseEntity.java │ │ │ └── QUuidBaseTimeEntity.java │ │ └── java │ │ └── nettee │ │ └── jpa │ │ ├── config │ │ └── JpaConfig.java │ │ └── support │ │ ├── BaseTimeEntity.java │ │ ├── LongBaseEntity.java │ │ ├── LongBaseTimeEntity.java │ │ ├── SnowflakeBaseEntity.java │ │ ├── SnowflakeBaseTimeEntity.java │ │ ├── UuidBaseEntity.java │ │ └── UuidBaseTimeEntity.java ├── snowflake │ ├── nettee-snowflake-id-api │ │ ├── build.gradle.kts │ │ └── src │ │ │ ├── main │ │ │ └── java │ │ │ │ └── nettee │ │ │ │ └── snowflake │ │ │ │ ├── constants │ │ │ │ └── SnowflakeConstants.java │ │ │ │ ├── exception │ │ │ │ ├── InvalidDatacenterIdException.java │ │ │ │ └── InvalidWorkerIdException.java │ │ │ │ ├── persistence │ │ │ │ └── id │ │ │ │ │ └── Snowflake.java │ │ │ │ ├── properties │ │ │ │ └── SnowflakeProperties.java │ │ │ │ └── validator │ │ │ │ └── SnowflakeConstructingValidator.java │ │ │ └── test │ │ │ ├── java │ │ │ └── nettee │ │ │ │ └── snowflake │ │ │ │ └── time │ │ │ │ └── TestMilliseconds.java │ │ │ └── kotlin │ │ │ └── nettee │ │ │ └── snowflake │ │ │ ├── persistence │ │ │ └── id │ │ │ │ └── SnowflakeTest.kt │ │ │ └── properties │ │ │ └── SnowflakePropertiesTest.kt │ └── nettee-snowflake-id-hibernate │ │ ├── build.gradle.kts │ │ └── src │ │ └── main │ │ └── java │ │ └── nettee │ │ └── hibenate │ │ ├── annotation │ │ └── SnowflakeGenerated.java │ │ └── generator │ │ └── SnowflakeIdGenerator.java └── time-util │ ├── build.gradle.kts │ └── src │ ├── main │ └── java │ │ └── nettee │ │ └── time │ │ ├── MillisecondsSupplier.java │ │ └── SystemMilliseconds.java │ └── test │ └── kotlin │ └── nettee │ └── time │ └── SystemMillisecondsTest.kt ├── docker-compose-monolith.yml ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── monolith ├── main-runner │ ├── build.gradle.kts │ └── src │ │ ├── main │ │ ├── java │ │ │ └── nettee │ │ │ │ └── main │ │ │ │ └── MainApplication.java │ │ └── resources │ │ │ ├── application-local.yml │ │ │ ├── application.yml │ │ │ ├── db │ │ │ └── migration │ │ │ │ ├── local │ │ │ │ └── R__dummy_post_views_count.sql │ │ │ │ └── v1_0 │ │ │ │ ├── V1_0_0__enable_uuid.sql │ │ │ │ ├── V1_0_1__create_tb_board.sql │ │ │ │ ├── V1_0_2__alter_board_status_as_integer.sql │ │ │ │ ├── V1_0_3__create_tb_article.sql │ │ │ │ ├── V1_0_4__create_tb_draft.sql │ │ │ │ ├── V1_0_5__create_tb_comment.sql │ │ │ │ ├── V1_0_6__create_tb_reply.sql │ │ │ │ └── V1_0_7__create_tb_post_views_count.sql │ │ │ └── properties │ │ │ ├── client │ │ │ └── main.client.yml │ │ │ ├── persistence │ │ │ └── main.snowflake.yml │ │ │ └── web │ │ │ └── main.cors.yml │ │ └── test │ │ ├── java │ │ └── nettee │ │ │ └── main │ │ │ ├── MainApplicationTests.java │ │ │ └── sample │ │ │ ├── entity │ │ │ └── Sample.java │ │ │ └── persistence │ │ │ └── SampleRepository.java │ │ ├── kotlin │ │ └── nettee │ │ │ └── main │ │ │ ├── config │ │ │ └── WebMvcCorsConfigTest.kt │ │ │ └── snowflake │ │ │ └── SnowflakeTest.kt │ │ └── resources │ │ └── application.yml └── monolith.settings.gradle.kts ├── services ├── article │ ├── api │ │ ├── build.gradle.kts │ │ ├── domain │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── java │ │ │ │ └── nettee │ │ │ │ ├── article │ │ │ │ └── domain │ │ │ │ │ ├── Article.java │ │ │ │ │ └── type │ │ │ │ │ └── ArticleStatus.java │ │ │ │ └── draft │ │ │ │ └── domain │ │ │ │ ├── Draft.java │ │ │ │ └── type │ │ │ │ └── DraftStatus.java │ │ ├── exception │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── java │ │ │ │ └── nettee │ │ │ │ ├── article │ │ │ │ └── exception │ │ │ │ │ ├── ArticleCommandErrorCode.java │ │ │ │ │ ├── ArticleCommandException.java │ │ │ │ │ ├── ArticleQueryErrorCode.java │ │ │ │ │ └── ArticleQueryException.java │ │ │ │ └── draft │ │ │ │ └── exception │ │ │ │ ├── DraftCommandErrorCode.java │ │ │ │ ├── DraftCommandException.java │ │ │ │ ├── DraftQueryErrorCode.java │ │ │ │ └── DraftQueryException.java │ │ └── readmodel │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── nettee │ │ │ ├── article │ │ │ └── readmodel │ │ │ │ └── ArticleQueryModels.java │ │ │ └── draft │ │ │ └── readmodel │ │ │ └── DraftQueryModels.java │ ├── application │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── nettee │ │ │ ├── article │ │ │ └── application │ │ │ │ ├── port │ │ │ │ ├── ArticleCommandPort.java │ │ │ │ └── ArticleQueryPort.java │ │ │ │ ├── service │ │ │ │ ├── ArticleCommandService.java │ │ │ │ └── ArticleQueryService.java │ │ │ │ └── usecase │ │ │ │ ├── ArticleCreateUseCase.java │ │ │ │ ├── ArticleDeleteUseCase.java │ │ │ │ ├── ArticleReadByStatusesUseCase.java │ │ │ │ ├── ArticleReadUseCase.java │ │ │ │ └── ArticleUpdateUseCase.java │ │ │ └── draft │ │ │ └── application │ │ │ ├── port │ │ │ ├── DraftCommandPort.java │ │ │ └── DraftQueryPort.java │ │ │ ├── service │ │ │ ├── DraftCommandService.java │ │ │ └── DraftQueryService.java │ │ │ └── usecase │ │ │ ├── DraftCreateUseCase.java │ │ │ ├── DraftDeleteUseCase.java │ │ │ ├── DraftReadByStatusesUseCase.java │ │ │ ├── DraftReadUseCase.java │ │ │ └── DraftUpdateUseCase.java │ ├── article.settings.gradle.kts │ ├── build.gradle.kts │ ├── driven │ │ └── rdb │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ ├── java │ │ │ └── nettee │ │ │ │ ├── article │ │ │ │ └── driven │ │ │ │ │ └── rdb │ │ │ │ │ ├── ArticleCommandAdapter.java │ │ │ │ │ ├── ArticleJpaRepository.java │ │ │ │ │ ├── ArticleQueryAdapter.java │ │ │ │ │ ├── entity │ │ │ │ │ ├── ArticleEntity.java │ │ │ │ │ └── type │ │ │ │ │ │ ├── ArticleEntityStatus.java │ │ │ │ │ │ ├── ArticleEntityStatusConverter.java │ │ │ │ │ │ ├── ArticleStatusParameters.java │ │ │ │ │ │ └── builder │ │ │ │ │ │ └── TypeSafeMarkers.java │ │ │ │ │ └── persistence │ │ │ │ │ └── mapper │ │ │ │ │ └── ArticleEntityMapper.java │ │ │ │ └── draft │ │ │ │ └── driven │ │ │ │ └── rdb │ │ │ │ ├── DraftCommandAdapter.java │ │ │ │ ├── DraftJpaRepository.java │ │ │ │ ├── DraftQueryAdapter.java │ │ │ │ ├── entity │ │ │ │ ├── DraftEntity.java │ │ │ │ └── type │ │ │ │ │ ├── DraftEntityStatus.java │ │ │ │ │ ├── DraftEntityStatusConverter.java │ │ │ │ │ ├── DraftStatusParameters.java │ │ │ │ │ └── builder │ │ │ │ │ └── TypeSafeMarkers.java │ │ │ │ └── persistence │ │ │ │ └── mapper │ │ │ │ └── DraftEntityMapper.java │ │ │ └── resources │ │ │ ├── db │ │ │ └── postgresql │ │ │ │ └── migration │ │ │ │ └── v1_0 │ │ │ │ ├── V1_0_3__create_tb_article.sql │ │ │ │ ├── V1_0_4__alter_article_status_as_integer.sql │ │ │ │ ├── V1_0_5__create_tb_draft.sql │ │ │ │ └── V1_0_6__alter_draft_status_as_integer.sql │ │ │ └── properties │ │ │ └── db │ │ │ ├── article.database-local.yml │ │ │ └── article.database.yml │ ├── driving │ │ └── web-mvc │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ ├── java │ │ │ └── nettee │ │ │ │ ├── article │ │ │ │ └── driving │ │ │ │ │ └── web │ │ │ │ │ ├── ArticleCommandApi.java │ │ │ │ │ ├── ArticleQueryApi.java │ │ │ │ │ ├── dto │ │ │ │ │ ├── ArticleCommandDto.java │ │ │ │ │ └── ArticleQueryDto.java │ │ │ │ │ └── mapper │ │ │ │ │ └── ArticleDtoMapper.java │ │ │ │ └── draft │ │ │ │ └── driving │ │ │ │ └── web │ │ │ │ ├── DraftCommandApi.java │ │ │ │ ├── DraftQueryApi.java │ │ │ │ ├── dto │ │ │ │ ├── DraftCommandDto.java │ │ │ │ └── DraftQueryDto.java │ │ │ │ └── mapper │ │ │ │ └── DraftDtoMapper.java │ │ │ └── resources │ │ │ └── article-web.yml │ └── src │ │ └── main │ │ └── resources │ │ └── article.yml ├── board │ ├── api │ │ ├── build.gradle.kts │ │ ├── domain │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── java │ │ │ │ └── nettee │ │ │ │ └── board │ │ │ │ └── domain │ │ │ │ ├── Board.java │ │ │ │ └── type │ │ │ │ └── BoardStatus.java │ │ ├── exception │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── java │ │ │ │ └── nettee │ │ │ │ └── board │ │ │ │ └── exception │ │ │ │ ├── BoardErrorCode.java │ │ │ │ └── BoardException.java │ │ └── readmodel │ │ │ └── build.gradle.kts │ ├── application │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── nettee │ │ │ └── board │ │ │ ├── application │ │ │ ├── port │ │ │ │ └── BoardCommandRepositoryPort.java │ │ │ ├── service │ │ │ │ └── BoardCommandService.java │ │ │ └── usecase │ │ │ │ └── BoardCreateUseCase.java │ │ │ └── port │ │ │ └── BoardCommandNetteeClientPort.java │ ├── board.settings.gradle.kts │ ├── build.gradle.kts │ ├── driven │ │ ├── board-nettee-client │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── java │ │ │ │ └── nettee │ │ │ │ └── board │ │ │ │ └── client │ │ │ │ └── BoardCommandNetteeClient.java │ │ └── rdb │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ ├── generated │ │ │ └── nettee │ │ │ │ └── board │ │ │ │ ├── entity │ │ │ │ └── QBoardEntity.java │ │ │ │ └── persistence │ │ │ │ └── mapper │ │ │ │ └── BoardEntityMapperImpl.java │ │ │ ├── java │ │ │ └── nettee │ │ │ │ └── board │ │ │ │ └── driven │ │ │ │ └── rdb │ │ │ │ ├── BoardCommandAdapter.java │ │ │ │ ├── BoardJpaRepository.java │ │ │ │ ├── entity │ │ │ │ ├── BoardEntity.java │ │ │ │ └── type │ │ │ │ │ ├── BoardEntityStatus.java │ │ │ │ │ └── BoardEntityStatusConverter.java │ │ │ │ └── mapper │ │ │ │ └── BoardEntityMapper.java │ │ │ └── resources │ │ │ └── properties │ │ │ └── db │ │ │ ├── board.database-local.yml │ │ │ └── board.database.yml │ ├── driving │ │ └── web-mvc │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ ├── generated │ │ │ └── nettee │ │ │ │ └── board │ │ │ │ └── web │ │ │ │ └── mapper │ │ │ │ └── BoardDtoMapperImpl.java │ │ │ ├── java │ │ │ └── nettee │ │ │ │ └── board │ │ │ │ └── driving │ │ │ │ └── web │ │ │ │ ├── BoardCommandApi.java │ │ │ │ ├── dto │ │ │ │ └── BoardCommandDto.java │ │ │ │ └── mapper │ │ │ │ └── BoardDtoMapper.java │ │ │ └── resources │ │ │ └── board-web.yml │ └── src │ │ └── main │ │ └── resources │ │ └── board.yml ├── comment │ ├── api │ │ ├── build.gradle.kts │ │ ├── domain │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── java │ │ │ │ └── nettee │ │ │ │ ├── comment │ │ │ │ └── domain │ │ │ │ │ ├── Comment.java │ │ │ │ │ └── type │ │ │ │ │ └── CommentStatus.java │ │ │ │ └── reply │ │ │ │ └── domain │ │ │ │ ├── Reply.java │ │ │ │ └── type │ │ │ │ └── ReplyStatus.java │ │ ├── exception │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── java │ │ │ │ └── nettee │ │ │ │ ├── comment │ │ │ │ └── exception │ │ │ │ │ ├── CommentCommandErrorCode.java │ │ │ │ │ ├── CommentCommandException.java │ │ │ │ │ ├── CommentQueryErrorCode.java │ │ │ │ │ └── CommentQueryException.java │ │ │ │ └── reply │ │ │ │ └── exception │ │ │ │ ├── ReplyCommandErrorCode.java │ │ │ │ ├── ReplyCommandException.java │ │ │ │ ├── ReplyQueryErrorCode.java │ │ │ │ └── ReplyQueryException.java │ │ └── readmodel │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── nettee │ │ │ ├── comment │ │ │ └── model │ │ │ │ └── CommentQueryModels.java │ │ │ └── reply │ │ │ └── model │ │ │ └── ReplyQueryModels.java │ ├── application │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── nettee │ │ │ ├── comment │ │ │ └── application │ │ │ │ ├── port │ │ │ │ ├── CommentCommandRepositoryPort.java │ │ │ │ └── CommentQueryRepositoryPort.java │ │ │ │ ├── service │ │ │ │ ├── CommentCommandService.java │ │ │ │ └── CommentQueryService.java │ │ │ │ └── usecase │ │ │ │ ├── CommentCreateUseCase.java │ │ │ │ ├── CommentDeleteUseCase.java │ │ │ │ └── CommentUpdateUseCase.java │ │ │ └── reply │ │ │ └── application │ │ │ ├── port │ │ │ ├── ReplyCommandRepositoryPort.java │ │ │ └── ReplyQueryRepositoryPort.java │ │ │ ├── service │ │ │ ├── ReplyCommandService.java │ │ │ └── ReplyQueryService.java │ │ │ └── usecase │ │ │ ├── ReplyCreateUseCase.java │ │ │ ├── ReplyDeleteUseCase.java │ │ │ └── ReplyUpdateUseCase.java │ ├── build.gradle.kts │ ├── comment.settings.gradle.kts │ ├── driven │ │ └── rdb │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ ├── generated │ │ │ └── nettee │ │ │ │ ├── comment │ │ │ │ └── entity │ │ │ │ │ └── QCommentEntity.java │ │ │ │ └── reply │ │ │ │ └── entity │ │ │ │ └── QReplyEntity.java │ │ │ ├── java │ │ │ └── nettee │ │ │ │ ├── comment │ │ │ │ └── driven │ │ │ │ │ └── rdb │ │ │ │ │ ├── entity │ │ │ │ │ ├── CommentEntity.java │ │ │ │ │ └── type │ │ │ │ │ │ ├── CommentEntityStatus.java │ │ │ │ │ │ └── CommentEntityStatusConverter.java │ │ │ │ │ └── persistence │ │ │ │ │ ├── CommentCommandAdapter.java │ │ │ │ │ ├── CommentJpaRepository.java │ │ │ │ │ ├── CommentQueryAdapter.java │ │ │ │ │ └── mapper │ │ │ │ │ └── CommentEntityMapper.java │ │ │ │ └── reply │ │ │ │ └── driven │ │ │ │ └── rdb │ │ │ │ ├── entity │ │ │ │ ├── ReplyEntity.java │ │ │ │ └── type │ │ │ │ │ ├── ReplyEntityStatus.java │ │ │ │ │ └── ReplyEntityStatusConverter.java │ │ │ │ └── persistence │ │ │ │ ├── ReplyCommandAdapter.java │ │ │ │ ├── ReplyJpaRepository.java │ │ │ │ ├── ReplyQueryAdapter.java │ │ │ │ └── mapper │ │ │ │ └── ReplyEntityMapper.java │ │ │ └── resources │ │ │ └── properties │ │ │ └── db │ │ │ ├── comment.database-local.yml │ │ │ └── comment.database.yml │ ├── driving │ │ └── web-mvc │ │ │ ├── build.gradle.kts │ │ │ └── src │ │ │ └── main │ │ │ ├── java │ │ │ └── nettee │ │ │ │ ├── comment │ │ │ │ └── web │ │ │ │ │ ├── CommentCommandApi.java │ │ │ │ │ ├── CommentQueryApi.java │ │ │ │ │ ├── dto │ │ │ │ │ └── CommentCommandDto.java │ │ │ │ │ └── mapper │ │ │ │ │ └── CommentDtoMapper.java │ │ │ │ └── reply │ │ │ │ └── web │ │ │ │ ├── ReplyCommandApi.java │ │ │ │ ├── ReplyQueryApi.java │ │ │ │ ├── dto │ │ │ │ └── ReplyCommandDto.java │ │ │ │ └── mapper │ │ │ │ └── ReplyDtoMapper.java │ │ │ └── resources │ │ │ └── comment-web.yml │ └── src │ │ └── main │ │ └── resources │ │ └── comment.yml └── views │ ├── api │ ├── build.gradle.kts │ └── domain │ │ ├── build.gradle.kts │ │ └── src │ │ └── main │ │ └── java │ │ └── nettee │ │ └── views │ │ └── Views.java │ ├── application │ ├── build.gradle.kts │ └── src │ │ └── main │ │ └── java │ │ └── nettee │ │ └── views │ │ ├── port │ │ ├── ViewsCacheRepositoryPort.java │ │ ├── ViewsCommandRepositoryPort.java │ │ └── ViewsQueryRepositoryPort.java │ │ ├── service │ │ ├── ViewsCommandService.java │ │ └── ViewsQueryService.java │ │ └── usecase │ │ ├── ViewsReadUseCase.java │ │ └── ViewsUpdateUseCase.java │ ├── build.gradle.kts │ ├── driven │ ├── rdb │ │ ├── build.gradle.kts │ │ └── src │ │ │ └── main │ │ │ └── java │ │ │ └── nettee │ │ │ └── views │ │ │ ├── adapter │ │ │ └── ViewsCommandRepositoryAdapter.java │ │ │ ├── entity │ │ │ └── ViewsEntity.java │ │ │ └── repository │ │ │ └── ViewsCountBackupRepository.java │ └── redis │ │ ├── build.gradle.kts │ │ └── src │ │ └── main │ │ └── java │ │ └── nettee │ │ └── views │ │ ├── adapter │ │ ├── ViewsCacheAdapter.java │ │ └── ViewsQueryAdapter.java │ │ └── repository │ │ ├── ViewsCountDistributedLockRepository.java │ │ └── ViewsCountRepository.java │ ├── driving │ └── web │ │ ├── build.gradle.kts │ │ └── src │ │ ├── main │ │ └── java │ │ │ └── nettee │ │ │ └── board │ │ │ └── web │ │ │ ├── ViewsCommandApi.java │ │ │ └── ViewsQueryApi.java │ │ └── test │ │ └── java │ │ └── nettee │ │ └── board │ │ └── web │ │ └── ViewsApiTest.kt │ └── views.settings.gradle.kts └── settings.gradle.kts /.github/ISSUE_TEMPLATE/02. sub-issue.yml: -------------------------------------------------------------------------------- 1 | name: Sub-Issue 2 | description: Describe sub issues. 3 | title: "[SUB-ISSUE] " 4 | body: 5 | - type: textarea 6 | id: content 7 | attributes: 8 | label: 이슈 내용 9 | description: 어떤 작업이 필요한지 설명해 주세요. 10 | placeholder: Tell us what you want! 11 | validations: 12 | required: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Issues 2 | 3 | - Resolves #0 4 | 5 | 6 | 7 | ## Description 8 | 9 | - 주요 변경 사항에 대한 간단한 설명을 작성해 주세요. 10 | - 관련 이슈 번호를 포함해 주세요 (예: `#123`). 11 | 12 |
13 | 14 | ## Review Points 15 | 16 | 17 | 18 | 19 | - 상세한 리뷰를 원하는 영역은 작업 의도와 함께 설명해 주세요. 20 | 21 |
22 | 23 | ## How Has This Been Tested? 24 | 25 | - 변경 사항을 테스트하는 방법에 대해 설명해 주세요. 26 | - 어떤 환경에서 테스트가 이루어졌는지 명시해 주세요. 27 | 28 | 29 | 30 |
31 | 32 | ## Additional Notes 33 | 34 | - 이 PR과 관련된 추가적인 정보가 있다면 여기에 기재해 주세요. 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Local ### 2 | db 3 | 4 | ### Gradle ### 5 | .gradle 6 | build/ 7 | !gradle/wrapper/gradle-wrapper.jar 8 | !**/src/main/**/build/ 9 | !**/src/test/**/build/ 10 | 11 | ### IntelliJ IDEA ### 12 | .idea/ 13 | .idea/modules.xml 14 | .idea/jarRepositories.xml 15 | .idea/compiler.xml 16 | .idea/libraries/ 17 | *.iws 18 | *.iml 19 | *.ipr 20 | out/ 21 | !**/src/main/**/out/ 22 | !**/src/test/**/out/ 23 | 24 | ### Eclipse ### 25 | .apt_generated 26 | .classpath 27 | .factorypath 28 | .project 29 | .settings 30 | .springBeans 31 | .sts4-cache 32 | bin/ 33 | !**/src/main/**/bin/ 34 | !**/src/test/**/bin/ 35 | 36 | ### NetBeans ### 37 | /nbproject/private/ 38 | /nbbuild/ 39 | /dist/ 40 | /nbdist/ 41 | /.nb-gradle/ 42 | 43 | ### VS Code ### 44 | .vscode/ 45 | 46 | ### Mac OS ### 47 | .DS_Store -------------------------------------------------------------------------------- /common/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // FIXME remove this after root build.gradle.kts supplies below dependencies. 2 | dependencies { 3 | testImplementation("org.springframework:spring-web:6.2.3") 4 | 5 | testImplementation("io.kotest:kotest-runner-junit5:5.9.1") 6 | testImplementation("io.mockk:mockk:1.13.12") 7 | testImplementation(kotlin("script-runtime")) 8 | testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3") 9 | } 10 | 11 | // FIXME remove this after root build.gradle.kts supplies below task. 12 | kotlin { 13 | sourceSets { 14 | test { 15 | kotlin.srcDirs(listOf("src/test/kotlin")) 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /common/common.settings.gradle.kts: -------------------------------------------------------------------------------- 1 | include( 2 | "common", 3 | ) -------------------------------------------------------------------------------- /common/src/main/java/nettee/common/ErrorCode.java: -------------------------------------------------------------------------------- 1 | package nettee.common; 2 | 3 | import org.springframework.http.HttpStatus; 4 | 5 | import java.util.Map; 6 | import java.util.function.Supplier; 7 | 8 | public interface ErrorCode { 9 | String name(); 10 | String message(); 11 | 12 | HttpStatus httpStatus(); 13 | RuntimeException exception(); 14 | RuntimeException exception(Throwable cause); 15 | 16 | RuntimeException exception(Runnable runnable); 17 | RuntimeException exception(Runnable runnable, Throwable cause); 18 | 19 | RuntimeException exception(Supplier> appendPayload); 20 | RuntimeException exception(Supplier> appendPayload, Throwable cause); 21 | } 22 | -------------------------------------------------------------------------------- /common/src/main/java/nettee/common/marker/TypeSafeMarker.java: -------------------------------------------------------------------------------- 1 | package nettee.common.marker; 2 | 3 | public sealed interface TypeSafeMarker permits TypeSafeMarker.Present, TypeSafeMarker.Missing { 4 | final class Present implements TypeSafeMarker { private Present() {} } 5 | final class Missing implements TypeSafeMarker { private Missing() {} } 6 | } 7 | -------------------------------------------------------------------------------- /common/src/main/java/nettee/common/status/StatusCodeConstants.java: -------------------------------------------------------------------------------- 1 | package nettee.common.status; 2 | 3 | public final class StatusCodeConstants { 4 | private StatusCodeConstants() {} 5 | 6 | public static final class Default { 7 | private Default() {} 8 | 9 | public static final int GENERAL_PURPOSE_BIT_SIZE = 7; 10 | public static final int SYSTEM_INFORMATION_BIT_SIZE = 8; 11 | public static final int CATEGORY_BIT_SIZE = 8; 12 | public static final int INSTANCE_DETAIL_BIT_SIZE = 8; 13 | 14 | public static final int GENERAL_PURPOSE_SHIFT = 15 | SYSTEM_INFORMATION_BIT_SIZE + CATEGORY_BIT_SIZE + INSTANCE_DETAIL_BIT_SIZE; 16 | public static final int SYSTEM_INFORMATION_SHIFT = CATEGORY_BIT_SIZE + INSTANCE_DETAIL_BIT_SIZE; 17 | public static final int CATEGORY_SHIFT = INSTANCE_DETAIL_BIT_SIZE; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /common/src/main/java/nettee/common/status/StatusCodeUtil.java: -------------------------------------------------------------------------------- 1 | package nettee.common.status; 2 | 3 | import nettee.common.marker.TypeSafeMarker.Present; 4 | 5 | import static nettee.common.status.StatusCodeConstants.Default.CATEGORY_SHIFT; 6 | import static nettee.common.status.StatusCodeConstants.Default.GENERAL_PURPOSE_SHIFT; 7 | import static nettee.common.status.StatusCodeConstants.Default.SYSTEM_INFORMATION_SHIFT; 8 | 9 | public final class StatusCodeUtil { 10 | private StatusCodeUtil() {} 11 | 12 | public static int getAsInt(StatusParameters parameters) { 13 | return (parameters.generalPurposeBits() << GENERAL_PURPOSE_SHIFT) 14 | | (parameters.systemInfoBits() << SYSTEM_INFORMATION_SHIFT) 15 | | (parameters.categoryBits() << CATEGORY_SHIFT) 16 | | parameters.instanceBits(); 17 | } 18 | 19 | public static long getAsLong(CustomStatusParameters parameters) { 20 | int gpShift = parameters.generalPurposeBitsShift(); 21 | int sysShift = parameters.systemInfoBitsShift(); 22 | int cateShift = parameters.categoryBitsShift(); 23 | 24 | return (parameters.generalPurposeBits() << gpShift) 25 | | (parameters.systemInfoBits() << sysShift) 26 | | (parameters.categoryBits() << cateShift) 27 | | parameters.instanceBits(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /common/src/main/java/nettee/common/status/exception/StatusCodeException.java: -------------------------------------------------------------------------------- 1 | package nettee.common.status.exception; 2 | 3 | import nettee.common.CustomException; 4 | import nettee.common.ErrorCode; 5 | 6 | import java.util.Map; 7 | import java.util.function.Supplier; 8 | 9 | public class StatusCodeException extends CustomException { 10 | public StatusCodeException(ErrorCode errorCode) { 11 | super(errorCode); 12 | } 13 | 14 | public StatusCodeException(ErrorCode errorCode, Throwable cause) { 15 | super(errorCode, cause); 16 | } 17 | 18 | public StatusCodeException(ErrorCode errorCode, Runnable runnable) { 19 | super(errorCode, runnable); 20 | } 21 | 22 | public StatusCodeException(ErrorCode errorCode, Runnable runnable, Throwable cause) { 23 | super(errorCode, runnable, cause); 24 | } 25 | 26 | public StatusCodeException(ErrorCode errorCode, Supplier> payloadSupplier) { 27 | super(errorCode, payloadSupplier); 28 | } 29 | 30 | public StatusCodeException(ErrorCode errorCode, Supplier> payloadSupplier, Throwable cause) { 31 | super(errorCode, payloadSupplier, cause); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /common/src/main/java/nettee/common/util/EnumUtil.java: -------------------------------------------------------------------------------- 1 | package nettee.common.util; 2 | 3 | import java.util.EnumSet; 4 | import java.util.function.Function; 5 | import java.util.stream.Collectors; 6 | 7 | public class EnumUtil { 8 | 9 | private EnumUtil() {} 10 | 11 | public static , T> boolean isUniqueAllOf(Class enumClass, Function getter) { 12 | return EnumSet.allOf(enumClass).stream() 13 | .map(getter) 14 | .collect(Collectors.toSet()) 15 | .size() == enumClass.getEnumConstants().length; 16 | } 17 | } -------------------------------------------------------------------------------- /compose-monolith: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # User Guide 4 | # - 권한 부여 5 | # chmod +x compose-monolith 6 | # - 실행 예시 7 | # ./compose-monolith up -d 8 | 9 | # docker-compose-monolith.yml 파일이 현재 디렉토리에 있는지 확인 10 | if [ -f "$(pwd)/docker-compose-monolith.yml" ]; then 11 | compose_file="docker-compose-monolith.yml" 12 | elif [ -f "$(pwd)/docker-compose-monolith.yaml" ]; then 13 | compose_file="docker-compose-monolith.yaml" 14 | else 15 | echo "Error: 이 명령은 프로젝트 루트에서만 실행할 수 있습니다." 16 | echo "docker-compose-monolith.yml 또는 docker-compose-monolith.yaml 파일이 존재해야 합니다." 17 | exit 1 18 | fi 19 | 20 | # 전달받은 모든 인자를 그대로 docker compose 명령에 전달 21 | docker compose -f "$compose_file" "$@" -------------------------------------------------------------------------------- /compose-monolith.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal 3 | 4 | rem 현재 디렉토리에서 파일 존재 여부 확인 5 | if exist "%cd%\docker-compose-monolith.yml" ( 6 | set "compose_file=docker-compose-monolith.yml" 7 | ) else if exist "%cd%\docker-compose-monolith.yaml" ( 8 | set "compose_file=docker-compose-monolith.yaml" 9 | ) else ( 10 | echo Error: 이 명령은 프로젝트 루트에서만 실행할 수 있습니다. 11 | echo docker-compose-monolith.yml 또는 docker-compose-monolith.yaml 파일이 존재해야 합니다. 12 | exit /b 1 13 | ) 14 | 15 | rem 전달받은 모든 인자를 docker compose 명령에 전달 16 | docker compose -f %compose_file% %* -------------------------------------------------------------------------------- /core/client/nettee-client-api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies{ 2 | api("org.springframework.boot:spring-boot-autoconfigure:3.4.3") 3 | } -------------------------------------------------------------------------------- /core/client/nettee-client-api/src/main/java/nettee/client/propeties/ClientProperties.java: -------------------------------------------------------------------------------- 1 | package nettee.client.propeties; 2 | 3 | import lombok.extern.slf4j.Slf4j; 4 | import org.springframework.boot.context.properties.ConfigurationProperties; 5 | 6 | import java.util.Map; 7 | import java.util.Map.Entry; 8 | 9 | @Slf4j 10 | @ConfigurationProperties("app.client") 11 | public record ClientProperties( 12 | String baseUrl, 13 | Map url 14 | ) { 15 | public ClientProperties { 16 | log.debug("baseUrl url: {}", baseUrl); 17 | 18 | for(Entry entry : url.entrySet()) { 19 | log.debug((entry.getKey() + ": " + entry.getValue())); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /core/client/nettee-client-api/src/main/java/nettee/client/request/NetteeRequest.java: -------------------------------------------------------------------------------- 1 | package nettee.client.request; 2 | 3 | import lombok.Builder; 4 | 5 | import java.util.Objects; 6 | 7 | /** 8 | * HTTP 공통 요청 레코드 9 | * 10 | * @param domain 요청할 도메인 키 (예: "board", "auth") 11 | * @param path 요청할 경로 (예: "/comments/{id}") 12 | * @param body 요청할 본문 객체로 POST, PATCH, PUT 등의 사용 (null 허용) 13 | * @param responseType 응답을 매핑할 타입 클래스 14 | * @param unwrapKey Wrap 형식의 JSON 역직렬화 키 (null 허용) 15 | * @param uriVariables URI 경로 변수 (예: {id}에 들어갈 값들) 16 | * @param 응답 타입 제네릭 17 | */ 18 | @Builder 19 | public record NetteeRequest( 20 | String domain, 21 | String path, 22 | Object body, 23 | Class responseType, 24 | String unwrapKey, 25 | Object... uriVariables 26 | ) { 27 | public NetteeRequest { 28 | Objects.requireNonNull(domain, "domain is required"); 29 | Objects.requireNonNull(path, "path is required"); 30 | 31 | domain = domain.strip(); 32 | path = path.strip(); 33 | 34 | if(unwrapKey == null) { 35 | unwrapKey = domain; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /core/client/nettee-rest-client/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api(project(":client-api")) 3 | api("org.springframework:spring-web:6.2.3") 4 | compileOnly("com.fasterxml.jackson.core:jackson-databind") 5 | // test 6 | testImplementation("org.springframework.boot:spring-boot-starter-web") 7 | testImplementation("com.h2database:h2") 8 | testImplementation("org.springframework.boot:spring-boot-starter-test") 9 | testImplementation("org.springframework.boot:spring-boot-starter-data-jpa") 10 | } -------------------------------------------------------------------------------- /core/client/nettee-rest-client/src/main/java/nettee/restclient/config/RestClientConfig.java: -------------------------------------------------------------------------------- 1 | package nettee.restclient.config; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import nettee.restclient.NetteeClient; 5 | import nettee.client.propeties.ClientProperties; 6 | import org.springframework.boot.context.properties.EnableConfigurationProperties; 7 | import org.springframework.context.annotation.Bean; 8 | import org.springframework.context.annotation.Configuration; 9 | import org.springframework.web.client.RestClient; 10 | 11 | @Configuration 12 | @EnableConfigurationProperties(ClientProperties.class) 13 | public class RestClientConfig { 14 | 15 | @Bean 16 | public RestClient restClient(ClientProperties clientProperties) { 17 | return RestClient.builder() 18 | .baseUrl(clientProperties.baseUrl()) 19 | .build(); 20 | } 21 | 22 | @Bean 23 | public NetteeClient customClient(RestClient restClient, 24 | ClientProperties clientProperties, 25 | ObjectMapper objectMapper) { 26 | return new NetteeClient(restClient, clientProperties, objectMapper); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /core/client/nettee-rest-client/src/test/java/nettee/RestClientTestApplication.java: -------------------------------------------------------------------------------- 1 | package nettee; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class RestClientTestApplication { 8 | public static void main(String[] args) { 9 | SpringApplication.run(RestClientTestApplication.class, args); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /core/client/nettee-rest-client/src/test/java/nettee/student/api/StudentApi.java: -------------------------------------------------------------------------------- 1 | package nettee.student.api; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.student.entity.Student; 5 | import nettee.student.persistence.StudentRepository; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | import org.springframework.web.bind.annotation.PathVariable; 8 | import org.springframework.web.bind.annotation.PostMapping; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | import java.util.List; 13 | 14 | @RestController 15 | @RequestMapping("/api/v1/student") 16 | @RequiredArgsConstructor 17 | public class StudentApi { 18 | 19 | private final StudentRepository repository; 20 | 21 | @GetMapping 22 | public List getStudentList(){ 23 | return repository.findAll(); 24 | } 25 | 26 | @GetMapping("/{id}") 27 | public Student getStudent(@PathVariable long id){ 28 | return repository.findById(id).orElse(null); 29 | } 30 | 31 | @PostMapping 32 | public Student addStudent(){ 33 | return repository.save(new Student()); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /core/client/nettee-rest-client/src/test/java/nettee/student/entity/Student.java: -------------------------------------------------------------------------------- 1 | package nettee.student.entity; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.GeneratedValue; 5 | import jakarta.persistence.GenerationType; 6 | import jakarta.persistence.Id; 7 | 8 | @Entity 9 | public class Student { 10 | 11 | @Id @GeneratedValue(strategy = GenerationType.SEQUENCE) 12 | public Long id; 13 | } 14 | -------------------------------------------------------------------------------- /core/client/nettee-rest-client/src/test/java/nettee/student/persistence/StudentRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.student.persistence; 2 | 3 | import nettee.student.entity.Student; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface StudentRepository extends JpaRepository { 7 | } 8 | -------------------------------------------------------------------------------- /core/client/nettee-rest-client/src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application.name: rest-client 3 | h2: 4 | console: 5 | enabled: true 6 | path: /h2-console 7 | datasource: 8 | driver-class-name: org.h2.Driver 9 | # https://www.h2database.com/html/features.html#in_memory_databases 참조 10 | url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE; 11 | username: sa 12 | password: 13 | jpa: 14 | generate-ddl: 'true' 15 | hibernate: 16 | ddl-auto: create 17 | properties: 18 | hibernate: 19 | show_sql: true 20 | format_sql: true 21 | use_sql_comments: true 22 | 23 | server: 24 | port: 9999 25 | 26 | app: 27 | client: 28 | base-url: "http://localhost:9999" 29 | url: 30 | student: "http://localhost:9999" 31 | 32 | -------------------------------------------------------------------------------- /core/core.settings.gradle.kts: -------------------------------------------------------------------------------- 1 | val core = rootDir.resolve("core") 2 | .walkTopDown() 3 | .maxDepth(3) 4 | .filter(File::isDirectory) 5 | .associateBy(File::getName) 6 | 7 | 8 | include( 9 | ":time-util", 10 | ":jpa-core", 11 | ":exception-handler-core", 12 | ":cors-api", 13 | ":cors-webmvc", 14 | ":snowflake-id-api", 15 | ":snowflake-id-hibernate", 16 | ":client-api", 17 | ":rest-client" 18 | ) 19 | 20 | project(":time-util").projectDir = core["time-util"]!! 21 | project(":jpa-core").projectDir = core["jpa-core"]!! 22 | project(":exception-handler-core").projectDir = core["exception-handler-core"]!! 23 | project(":cors-webmvc").projectDir = core["nettee-cors-webmvc"]!! 24 | project(":cors-api").projectDir = core["nettee-cors-api"]!! 25 | project(":snowflake-id-api").projectDir = core["nettee-snowflake-id-api"]!! 26 | project(":snowflake-id-hibernate").projectDir = core["nettee-snowflake-id-hibernate"]!! 27 | project(":client-api").projectDir = core["nettee-client-api"]!! 28 | project(":rest-client").projectDir = core["nettee-rest-client"]!! -------------------------------------------------------------------------------- /core/cors/nettee-cors-api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | compileOnly("org.springframework.boot:spring-boot-autoconfigure:3.4.3") 3 | } -------------------------------------------------------------------------------- /core/cors/nettee-cors-api/src/main/java/nettee/properties/CorsProperties.java: -------------------------------------------------------------------------------- 1 | package nettee.properties; 2 | 3 | import org.springframework.boot.context.properties.ConfigurationProperties; 4 | 5 | @ConfigurationProperties("app.cors") 6 | public record CorsProperties ( 7 | MappedCorsProperties[] endpoints 8 | ) { 9 | } -------------------------------------------------------------------------------- /core/cors/nettee-cors-api/src/main/java/nettee/properties/MappedCorsProperties.java: -------------------------------------------------------------------------------- 1 | package nettee.properties; 2 | 3 | import nettee.properties.allowed.CorsAllowedProperties; 4 | import nettee.properties.exposed.CorsExposedProperties; 5 | 6 | public record MappedCorsProperties( 7 | String path, 8 | CorsAllowedProperties allowed, 9 | CorsExposedProperties exposed, 10 | Long maxAge 11 | ) { 12 | public MappedCorsProperties { 13 | if (maxAge == null) { 14 | maxAge = 3600L; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /core/cors/nettee-cors-api/src/main/java/nettee/properties/allowed/CorsAllowedProperties.java: -------------------------------------------------------------------------------- 1 | package nettee.properties.allowed; 2 | 3 | public record CorsAllowedProperties( 4 | String[] headers, 5 | String[] methods, 6 | String[] origins, 7 | Boolean credentials, 8 | Boolean privateNetwork 9 | ) { 10 | public CorsAllowedProperties { 11 | if(credentials == null){ 12 | credentials = true; 13 | } 14 | if(privateNetwork == null){ 15 | privateNetwork = true; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/cors/nettee-cors-api/src/main/java/nettee/properties/exposed/CorsExposedProperties.java: -------------------------------------------------------------------------------- 1 | package nettee.properties.exposed; 2 | 3 | public record CorsExposedProperties( 4 | String[] headers 5 | ) { 6 | } 7 | -------------------------------------------------------------------------------- /core/cors/nettee-cors-webmvc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api(project(":cors-api")) 3 | compileOnly("org.springframework:spring-webmvc:6.2.2") 4 | } -------------------------------------------------------------------------------- /core/cors/nettee-cors-webmvc/src/main/java/nettee/config/WebMvcCorsConfig.java: -------------------------------------------------------------------------------- 1 | package nettee.config; 2 | 3 | import nettee.properties.CorsProperties; 4 | import org.jetbrains.annotations.NotNull; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.servlet.config.annotation.CorsRegistry; 7 | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; 8 | 9 | @Configuration 10 | public class WebMvcCorsConfig implements WebMvcConfigurer { 11 | 12 | private final CorsProperties corsProperties; 13 | 14 | public WebMvcCorsConfig(CorsProperties corsProperties) { 15 | this.corsProperties = corsProperties; 16 | } 17 | 18 | @Override 19 | public void addCorsMappings(@NotNull CorsRegistry registry) { 20 | for(var endpoint: corsProperties.endpoints()){ 21 | var path = endpoint.path(); 22 | var allowed = endpoint.allowed(); 23 | var exposed = endpoint.exposed(); 24 | var maxAge = endpoint.maxAge(); 25 | 26 | registry.addMapping(path) 27 | .allowedHeaders(allowed.headers()) 28 | .allowedMethods(allowed.methods()) 29 | .allowedOrigins(allowed.origins()) 30 | .allowCredentials(allowed.credentials()) 31 | .allowPrivateNetwork(allowed.privateNetwork()) 32 | .exposedHeaders(exposed.headers()) 33 | .maxAge(maxAge); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /core/exception-handler-core/build.gradle.kts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nettee-space/backend-sample-multi-module/a24f6e1e80b0368afeb25b23cdb538146b244d1d/core/exception-handler-core/build.gradle.kts -------------------------------------------------------------------------------- /core/exception-handler-core/src/main/java/nettee/exception/handler/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package nettee.exception.handler; 2 | 3 | import nettee.common.CustomException; 4 | import nettee.common.ErrorCode; 5 | import org.springframework.http.ResponseEntity; 6 | import org.springframework.web.bind.annotation.ExceptionHandler; 7 | import org.springframework.web.bind.annotation.RestControllerAdvice; 8 | import nettee.exception.response.ApiErrorResponse; 9 | 10 | @RestControllerAdvice 11 | public class GlobalExceptionHandler { 12 | @ExceptionHandler(CustomException.class) // 모든 커스텀 익셉션 13 | public ResponseEntity handleCustomException(CustomException exception) { 14 | 15 | ErrorCode errorCode = exception.getErrorCode(); 16 | 17 | exception.execute(); 18 | 19 | var responseBody = ApiErrorResponse.builder() 20 | .status(errorCode.httpStatus().value()) 21 | .code(errorCode.name()) 22 | .message(exception.getMessage()) // same to errorCode.message 23 | .payload(exception.getPayload()) 24 | .build(); 25 | 26 | return ResponseEntity 27 | .status(errorCode.httpStatus()) 28 | .body(responseBody); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /core/exception-handler-core/src/main/java/nettee/exception/response/ApiErrorResponse.java: -------------------------------------------------------------------------------- 1 | package nettee.exception.response; 2 | 3 | import lombok.Builder; 4 | 5 | import java.util.Map; 6 | 7 | @Builder 8 | public record ApiErrorResponse( 9 | int status, 10 | String code, 11 | String message, 12 | Map payload 13 | ) { 14 | public ApiErrorResponse { 15 | if (payload != null && payload.isEmpty()) { 16 | payload = null; 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /core/jpa-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api(project(":snowflake-id-hibernate")) 3 | api("org.springframework.boot:spring-boot-starter-data-jpa") 4 | 5 | // querydsl 6 | implementation("com.querydsl:querydsl-jpa:${dependencyManagement.importedProperties["querydsl.version"]}:jakarta") 7 | annotationProcessor("com.querydsl:querydsl-apt:${dependencyManagement.importedProperties["querydsl.version"]}:jakarta") 8 | annotationProcessor("jakarta.persistence:jakarta.persistence-api") 9 | annotationProcessor("jakarta.annotation:jakarta.annotation-api") 10 | } -------------------------------------------------------------------------------- /core/jpa-core/src/main/generated/nettee/jpa/support/QBaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QBaseTimeEntity is a Querydsl query type for BaseTimeEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultSupertypeSerializer") 16 | public class QBaseTimeEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = -81876662L; 19 | 20 | public static final QBaseTimeEntity baseTimeEntity = new QBaseTimeEntity("baseTimeEntity"); 21 | 22 | public final DateTimePath createdAt = createDateTime("createdAt", java.time.Instant.class); 23 | 24 | public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.Instant.class); 25 | 26 | public QBaseTimeEntity(String variable) { 27 | super(BaseTimeEntity.class, forVariable(variable)); 28 | } 29 | 30 | public QBaseTimeEntity(Path path) { 31 | super(path.getType(), path.getMetadata()); 32 | } 33 | 34 | public QBaseTimeEntity(PathMetadata metadata) { 35 | super(BaseTimeEntity.class, metadata); 36 | } 37 | 38 | } 39 | 40 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/generated/nettee/jpa/support/QLongBaseEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QLongBaseEntity is a Querydsl query type for LongBaseEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultSupertypeSerializer") 16 | public class QLongBaseEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = 2084417593L; 19 | 20 | public static final QLongBaseEntity longBaseEntity = new QLongBaseEntity("longBaseEntity"); 21 | 22 | public final NumberPath id = createNumber("id", Long.class); 23 | 24 | public QLongBaseEntity(String variable) { 25 | super(LongBaseEntity.class, forVariable(variable)); 26 | } 27 | 28 | public QLongBaseEntity(Path path) { 29 | super(path.getType(), path.getMetadata()); 30 | } 31 | 32 | public QLongBaseEntity(PathMetadata metadata) { 33 | super(LongBaseEntity.class, metadata); 34 | } 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/generated/nettee/jpa/support/QLongBaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QLongBaseTimeEntity is a Querydsl query type for LongBaseTimeEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultSupertypeSerializer") 16 | public class QLongBaseTimeEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = 950453862L; 19 | 20 | public static final QLongBaseTimeEntity longBaseTimeEntity = new QLongBaseTimeEntity("longBaseTimeEntity"); 21 | 22 | public final QLongBaseEntity _super = new QLongBaseEntity(this); 23 | 24 | public final DateTimePath createdAt = createDateTime("createdAt", java.time.Instant.class); 25 | 26 | //inherited 27 | public final NumberPath id = _super.id; 28 | 29 | public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.Instant.class); 30 | 31 | public QLongBaseTimeEntity(String variable) { 32 | super(LongBaseTimeEntity.class, forVariable(variable)); 33 | } 34 | 35 | public QLongBaseTimeEntity(Path path) { 36 | super(path.getType(), path.getMetadata()); 37 | } 38 | 39 | public QLongBaseTimeEntity(PathMetadata metadata) { 40 | super(LongBaseTimeEntity.class, metadata); 41 | } 42 | 43 | } 44 | 45 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/generated/nettee/jpa/support/QSnowflakeBaseEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QSnowflakeBaseEntity is a Querydsl query type for SnowflakeBaseEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultSupertypeSerializer") 16 | public class QSnowflakeBaseEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = -1627228643L; 19 | 20 | public static final QSnowflakeBaseEntity snowflakeBaseEntity = new QSnowflakeBaseEntity("snowflakeBaseEntity"); 21 | 22 | public final NumberPath id = createNumber("id", Long.class); 23 | 24 | public QSnowflakeBaseEntity(String variable) { 25 | super(SnowflakeBaseEntity.class, forVariable(variable)); 26 | } 27 | 28 | public QSnowflakeBaseEntity(Path path) { 29 | super(path.getType(), path.getMetadata()); 30 | } 31 | 32 | public QSnowflakeBaseEntity(PathMetadata metadata) { 33 | super(SnowflakeBaseEntity.class, metadata); 34 | } 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/generated/nettee/jpa/support/QSnowflakeBaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QSnowflakeBaseTimeEntity is a Querydsl query type for SnowflakeBaseTimeEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultSupertypeSerializer") 16 | public class QSnowflakeBaseTimeEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = 1041103434L; 19 | 20 | public static final QSnowflakeBaseTimeEntity snowflakeBaseTimeEntity = new QSnowflakeBaseTimeEntity("snowflakeBaseTimeEntity"); 21 | 22 | public final QSnowflakeBaseEntity _super = new QSnowflakeBaseEntity(this); 23 | 24 | public final DateTimePath createdAt = createDateTime("createdAt", java.time.Instant.class); 25 | 26 | //inherited 27 | public final NumberPath id = _super.id; 28 | 29 | public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.Instant.class); 30 | 31 | public QSnowflakeBaseTimeEntity(String variable) { 32 | super(SnowflakeBaseTimeEntity.class, forVariable(variable)); 33 | } 34 | 35 | public QSnowflakeBaseTimeEntity(Path path) { 36 | super(path.getType(), path.getMetadata()); 37 | } 38 | 39 | public QSnowflakeBaseTimeEntity(PathMetadata metadata) { 40 | super(SnowflakeBaseTimeEntity.class, metadata); 41 | } 42 | 43 | } 44 | 45 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/generated/nettee/jpa/support/QUuidBaseEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QUuidBaseEntity is a Querydsl query type for UuidBaseEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultSupertypeSerializer") 16 | public class QUuidBaseEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = 519412408L; 19 | 20 | public static final QUuidBaseEntity uuidBaseEntity = new QUuidBaseEntity("uuidBaseEntity"); 21 | 22 | public final ComparablePath id = createComparable("id", java.util.UUID.class); 23 | 24 | public QUuidBaseEntity(String variable) { 25 | super(UuidBaseEntity.class, forVariable(variable)); 26 | } 27 | 28 | public QUuidBaseEntity(Path path) { 29 | super(path.getType(), path.getMetadata()); 30 | } 31 | 32 | public QUuidBaseEntity(PathMetadata metadata) { 33 | super(UuidBaseEntity.class, metadata); 34 | } 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/generated/nettee/jpa/support/QUuidBaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QUuidBaseTimeEntity is a Querydsl query type for UuidBaseTimeEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultSupertypeSerializer") 16 | public class QUuidBaseTimeEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = -1873323675L; 19 | 20 | public static final QUuidBaseTimeEntity uuidBaseTimeEntity = new QUuidBaseTimeEntity("uuidBaseTimeEntity"); 21 | 22 | public final QUuidBaseEntity _super = new QUuidBaseEntity(this); 23 | 24 | public final DateTimePath createdAt = createDateTime("createdAt", java.time.Instant.class); 25 | 26 | //inherited 27 | public final ComparablePath id = _super.id; 28 | 29 | public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.Instant.class); 30 | 31 | public QUuidBaseTimeEntity(String variable) { 32 | super(UuidBaseTimeEntity.class, forVariable(variable)); 33 | } 34 | 35 | public QUuidBaseTimeEntity(Path path) { 36 | super(path.getType(), path.getMetadata()); 37 | } 38 | 39 | public QUuidBaseTimeEntity(PathMetadata metadata) { 40 | super(UuidBaseTimeEntity.class, metadata); 41 | } 42 | 43 | } 44 | 45 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/java/nettee/jpa/config/JpaConfig.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.config; 2 | 3 | import org.springframework.boot.autoconfigure.domain.EntityScan; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.data.jpa.repository.config.EnableJpaAuditing; 6 | import org.springframework.data.jpa.repository.config.EnableJpaRepositories; 7 | 8 | @Configuration 9 | @EnableJpaAuditing 10 | @EntityScan(basePackages = "nettee") // 엔티티 패키지 지정 11 | @EnableJpaRepositories(basePackages = "nettee") // 리포지토리 패키지 지정 12 | public class JpaConfig { 13 | 14 | } 15 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/java/nettee/jpa/support/BaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import jakarta.persistence.EntityListeners; 4 | import jakarta.persistence.MappedSuperclass; 5 | import lombok.Getter; 6 | import org.springframework.data.annotation.CreatedDate; 7 | import org.springframework.data.annotation.LastModifiedDate; 8 | import org.springframework.data.jpa.domain.support.AuditingEntityListener; 9 | 10 | import java.io.Serializable; 11 | import java.time.Instant; 12 | 13 | @Getter 14 | @MappedSuperclass 15 | @EntityListeners(AuditingEntityListener.class) 16 | public abstract class BaseTimeEntity implements Serializable { 17 | @CreatedDate 18 | private Instant createdAt; 19 | 20 | @LastModifiedDate 21 | private Instant updatedAt; 22 | } -------------------------------------------------------------------------------- /core/jpa-core/src/main/java/nettee/jpa/support/LongBaseEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import jakarta.persistence.GeneratedValue; 4 | import jakarta.persistence.GenerationType; 5 | import jakarta.persistence.Id; 6 | import jakarta.persistence.MappedSuperclass; 7 | import lombok.Getter; 8 | 9 | import java.io.Serializable; 10 | 11 | @Getter 12 | @MappedSuperclass 13 | public abstract class LongBaseEntity implements Serializable { 14 | @Id 15 | @GeneratedValue(strategy = GenerationType.IDENTITY) 16 | private Long id; 17 | } 18 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/java/nettee/jpa/support/LongBaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import jakarta.persistence.EntityListeners; 4 | import jakarta.persistence.MappedSuperclass; 5 | import lombok.Getter; 6 | import org.springframework.data.annotation.CreatedDate; 7 | import org.springframework.data.annotation.LastModifiedDate; 8 | import org.springframework.data.jpa.domain.support.AuditingEntityListener; 9 | 10 | import java.time.Instant; 11 | 12 | @Getter 13 | @MappedSuperclass 14 | @EntityListeners(AuditingEntityListener.class) 15 | public abstract class LongBaseTimeEntity extends LongBaseEntity { 16 | @CreatedDate 17 | private Instant createdAt; 18 | 19 | @LastModifiedDate 20 | private Instant updatedAt; 21 | } 22 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/java/nettee/jpa/support/SnowflakeBaseEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import jakarta.persistence.Id; 4 | import jakarta.persistence.MappedSuperclass; 5 | import lombok.Getter; 6 | import nettee.hibenate.annotation.SnowflakeGenerated; 7 | 8 | import java.io.Serializable; 9 | 10 | @Getter 11 | @MappedSuperclass 12 | public abstract class SnowflakeBaseEntity implements Serializable { 13 | @Id 14 | @SnowflakeGenerated 15 | private Long id; 16 | } 17 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/java/nettee/jpa/support/SnowflakeBaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import jakarta.persistence.EntityListeners; 4 | import jakarta.persistence.MappedSuperclass; 5 | import lombok.Getter; 6 | import org.springframework.data.annotation.CreatedDate; 7 | import org.springframework.data.annotation.LastModifiedDate; 8 | import org.springframework.data.jpa.domain.support.AuditingEntityListener; 9 | 10 | import java.time.Instant; 11 | 12 | @Getter 13 | @MappedSuperclass 14 | @EntityListeners(AuditingEntityListener.class) 15 | public class SnowflakeBaseTimeEntity extends SnowflakeBaseEntity { 16 | @CreatedDate 17 | private Instant createdAt; 18 | 19 | @LastModifiedDate 20 | private Instant updatedAt; 21 | } 22 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/java/nettee/jpa/support/UuidBaseEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import jakarta.persistence.GeneratedValue; 4 | import jakarta.persistence.GenerationType; 5 | import jakarta.persistence.Id; 6 | import jakarta.persistence.MappedSuperclass; 7 | import lombok.Getter; 8 | 9 | import java.io.Serializable; 10 | import java.util.UUID; 11 | 12 | @Getter 13 | @MappedSuperclass 14 | public abstract class UuidBaseEntity implements Serializable { 15 | @Id 16 | @GeneratedValue(strategy = GenerationType.UUID) 17 | private UUID id; 18 | } 19 | -------------------------------------------------------------------------------- /core/jpa-core/src/main/java/nettee/jpa/support/UuidBaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.jpa.support; 2 | 3 | import jakarta.persistence.EntityListeners; 4 | import jakarta.persistence.MappedSuperclass; 5 | import lombok.Getter; 6 | import org.springframework.data.annotation.CreatedDate; 7 | import org.springframework.data.annotation.LastModifiedDate; 8 | import org.springframework.data.jpa.domain.support.AuditingEntityListener; 9 | 10 | import java.time.Instant; 11 | 12 | @Getter 13 | @MappedSuperclass 14 | @EntityListeners(AuditingEntityListener.class) 15 | public abstract class UuidBaseTimeEntity extends UuidBaseEntity { 16 | @CreatedDate 17 | private Instant createdAt; 18 | 19 | @LastModifiedDate 20 | private Instant updatedAt; 21 | } 22 | -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api(project(":time-util")) 3 | compileOnly("org.springframework.boot:spring-boot-autoconfigure:3.4.3") 4 | } -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/constants/SnowflakeConstants.java: -------------------------------------------------------------------------------- 1 | package nettee.snowflake.constants; 2 | 3 | public final class SnowflakeConstants { 4 | // 한국 시간(KST): 2025-03-26 23:40:00 기준점 5 | public static final long NETTEE_EPOCH = 1_743_000_000_000L; 6 | public static final String PREFIX = "nettee.persistence.snowflake"; 7 | 8 | private SnowflakeConstants() {} 9 | 10 | public static final class SnowflakeDefault { 11 | public static final int WORKER_ID_BIT_SIZE = 5; 12 | public static final int DATACENTER_ID_BIT_SIZE = 5; 13 | public static final int SEQUENCE_BIT_SIZE = 12; 14 | 15 | public static final int WORKER_ID_SHIFT = SEQUENCE_BIT_SIZE; 16 | public static final int DATACENTER_ID_SHIFT = SEQUENCE_BIT_SIZE + WORKER_ID_BIT_SIZE; 17 | public static final int TIMESTAMP_LEFT_SHIFT = SEQUENCE_BIT_SIZE + WORKER_ID_BIT_SIZE + DATACENTER_ID_BIT_SIZE; 18 | 19 | public static final long SEQUENCE_MASK = ~(-1L << SEQUENCE_BIT_SIZE); 20 | public static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BIT_SIZE); 21 | public static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BIT_SIZE); 22 | 23 | private SnowflakeDefault() {} 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/exception/InvalidDatacenterIdException.java: -------------------------------------------------------------------------------- 1 | package nettee.snowflake.exception; 2 | 3 | import static nettee.snowflake.constants.SnowflakeConstants.SnowflakeDefault.MAX_DATACENTER_ID; 4 | 5 | public class InvalidDatacenterIdException extends RuntimeException { 6 | 7 | private final long datacenterId; 8 | 9 | public InvalidDatacenterIdException(long datacenterId) { 10 | super("Datacenter ID can't be greater than %d or less than 0. Input: %d" 11 | .formatted(MAX_DATACENTER_ID, datacenterId)); 12 | this.datacenterId = datacenterId; 13 | } 14 | 15 | public long getDatacenterId() { 16 | return datacenterId; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/exception/InvalidWorkerIdException.java: -------------------------------------------------------------------------------- 1 | package nettee.snowflake.exception; 2 | 3 | import static nettee.snowflake.constants.SnowflakeConstants.SnowflakeDefault.MAX_WORKER_ID; 4 | 5 | public class InvalidWorkerIdException extends RuntimeException{ 6 | 7 | private final long workerId; 8 | 9 | public InvalidWorkerIdException(final long workerId) { 10 | super("Worker ID can't be greater than %d or less than 0. Input: %d" 11 | .formatted(MAX_WORKER_ID, workerId)); 12 | this.workerId = workerId; 13 | } 14 | 15 | public long getWorkerId() { 16 | return workerId; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/properties/SnowflakeProperties.java: -------------------------------------------------------------------------------- 1 | package nettee.snowflake.properties; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.boot.context.properties.ConfigurationProperties; 6 | 7 | import static nettee.snowflake.constants.SnowflakeConstants.NETTEE_EPOCH; 8 | import static nettee.snowflake.constants.SnowflakeConstants.PREFIX; 9 | 10 | @ConfigurationProperties(PREFIX) 11 | public record SnowflakeProperties( 12 | Long datacenterId, 13 | Long workerId, 14 | Long epoch 15 | ) { 16 | private static final Logger log = LoggerFactory.getLogger(SnowflakeProperties.class); 17 | 18 | public SnowflakeProperties { 19 | if (datacenterId == null) { 20 | datacenterId = 0L; 21 | log.warn(PREFIX + ".datacenter-id must not be null."); 22 | } 23 | 24 | if (workerId == null) { 25 | workerId = 0L; 26 | log.warn(PREFIX + ".worker-id must not be null."); 27 | } 28 | 29 | if (epoch == null) { 30 | epoch = NETTEE_EPOCH; 31 | } else if (epoch < 0) { 32 | epoch = NETTEE_EPOCH; 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-api/src/main/java/nettee/snowflake/validator/SnowflakeConstructingValidator.java: -------------------------------------------------------------------------------- 1 | package nettee.snowflake.validator; 2 | 3 | import nettee.snowflake.exception.InvalidDatacenterIdException; 4 | import nettee.snowflake.exception.InvalidWorkerIdException; 5 | 6 | import static nettee.snowflake.constants.SnowflakeConstants.SnowflakeDefault.MAX_DATACENTER_ID; 7 | import static nettee.snowflake.constants.SnowflakeConstants.SnowflakeDefault.MAX_WORKER_ID; 8 | 9 | public class SnowflakeConstructingValidator { 10 | private SnowflakeConstructingValidator() {} 11 | 12 | public static void validateDatacenterId(long datacenterId) { 13 | if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) { 14 | throw new InvalidDatacenterIdException(datacenterId); 15 | } 16 | } 17 | 18 | public static void validateWorkerId(long workerId) { 19 | if (workerId > MAX_WORKER_ID || workerId < 0) { 20 | throw new InvalidWorkerIdException(workerId); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-api/src/test/java/nettee/snowflake/time/TestMilliseconds.java: -------------------------------------------------------------------------------- 1 | package nettee.snowflake.time; 2 | 3 | import nettee.time.MillisecondsSupplier; 4 | 5 | public final class TestMilliseconds implements MillisecondsSupplier { 6 | public long currentMilliseconds; 7 | 8 | public TestMilliseconds() { 9 | currentMilliseconds = System.currentTimeMillis(); 10 | } 11 | 12 | @Override 13 | public long getAsLong() { 14 | return currentMilliseconds; 15 | } 16 | 17 | public void nextMillisecond() { 18 | currentMilliseconds += 1L; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-api/src/test/kotlin/nettee/snowflake/persistence/id/SnowflakeTest.kt: -------------------------------------------------------------------------------- 1 | package nettee.snowflake.persistence.id 2 | 3 | import io.kotest.core.spec.style.FreeSpec 4 | import io.kotest.matchers.shouldBe 5 | import io.kotest.matchers.shouldNotBe 6 | import nettee.snowflake.constants.SnowflakeConstants.NETTEE_EPOCH 7 | import nettee.snowflake.time.TestMilliseconds 8 | 9 | class SnowflakeTest : FreeSpec({ 10 | "[snowflake ID 채번] 특정 밀리세컨드 시간이 주어질 때" - { 11 | val testMilliseconds = TestMilliseconds() 12 | val testSnowflake = Snowflake(0, 0, NETTEE_EPOCH, testMilliseconds) 13 | 14 | println("현재 밀리세컨드: ${testMilliseconds.currentMilliseconds}") 15 | 16 | "총 4096개의 키 생성" { 17 | val ids = (0..4095).map { testSnowflake.nextId() } 18 | 19 | ids.toSet().size shouldBe 4096 20 | } 21 | 22 | "이 후 특정 밀리세컨드가 증가하지 않는 상태에서 키 생성 시" - { 23 | val thread = Thread { 24 | testSnowflake.nextId() 25 | } 26 | 27 | thread.start() 28 | 29 | thread.join(500) 30 | 31 | "무한 루프 발생" { 32 | // 현재도 쓰레드가 살아 있다면 무한 루프 33 | thread.isAlive shouldBe true 34 | thread.interrupt() 35 | } 36 | 37 | "특정 밀리세컨드에서 증가 할 때 정상 키 생성" { 38 | testMilliseconds.nextMillisecond() 39 | 40 | println("다음 밀리세컨드: ${testMilliseconds.currentMilliseconds}") 41 | val id = testSnowflake.nextId() 42 | 43 | id shouldNotBe null 44 | thread.isAlive shouldBe false 45 | } 46 | } 47 | } 48 | }) -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-api/src/test/kotlin/nettee/snowflake/properties/SnowflakePropertiesTest.kt: -------------------------------------------------------------------------------- 1 | package nettee.snowflake.properties 2 | 3 | import io.kotest.core.spec.style.FreeSpec 4 | import io.kotest.matchers.shouldBe 5 | import nettee.snowflake.constants.SnowflakeConstants.NETTEE_EPOCH 6 | 7 | class SnowflakePropertiesTest : FreeSpec({ 8 | "[초기화] 프로퍼티 입력 값이 null 일 경우" -{ 9 | val snowflakeProperties = SnowflakeProperties(null, null, null) 10 | 11 | "datacenterId의 값은 0을 반환" { 12 | snowflakeProperties.datacenterId shouldBe 0 13 | } 14 | 15 | "workerId의 값은 0을 반환" { 16 | snowflakeProperties.workerId shouldBe 0 17 | } 18 | 19 | "epoch의 값은 NETTEE_EPOCH 반환" { 20 | snowflakeProperties.epoch.shouldBe(NETTEE_EPOCH) 21 | } 22 | } 23 | }) 24 | -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-hibernate/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies{ 2 | api(project(":snowflake-id-api")) 3 | compileOnly("org.hibernate.orm:hibernate-core") 4 | } -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-hibernate/src/main/java/nettee/hibenate/annotation/SnowflakeGenerated.java: -------------------------------------------------------------------------------- 1 | package nettee.hibenate.annotation; 2 | 3 | import nettee.hibenate.generator.SnowflakeIdGenerator; 4 | import org.hibernate.annotations.IdGeneratorType; 5 | 6 | import java.lang.annotation.Retention; 7 | import java.lang.annotation.Target; 8 | 9 | import static java.lang.annotation.ElementType.FIELD; 10 | import static java.lang.annotation.RetentionPolicy.RUNTIME; 11 | 12 | @Retention(RUNTIME) 13 | @Target(FIELD) 14 | @IdGeneratorType(SnowflakeIdGenerator.class) 15 | public @interface SnowflakeGenerated { 16 | } 17 | -------------------------------------------------------------------------------- /core/snowflake/nettee-snowflake-id-hibernate/src/main/java/nettee/hibenate/generator/SnowflakeIdGenerator.java: -------------------------------------------------------------------------------- 1 | package nettee.hibenate.generator; 2 | 3 | import nettee.snowflake.persistence.id.Snowflake; 4 | import nettee.snowflake.properties.SnowflakeProperties; 5 | import org.hibernate.engine.spi.SharedSessionContractImplementor; 6 | import org.hibernate.id.IdentifierGenerator; 7 | 8 | public class SnowflakeIdGenerator implements IdentifierGenerator { 9 | 10 | private final Snowflake snowflake; 11 | 12 | public SnowflakeIdGenerator(SnowflakeProperties snowflakeProperties) { 13 | this.snowflake = new Snowflake(snowflakeProperties); 14 | } 15 | 16 | @Override 17 | public Long generate(SharedSessionContractImplementor sharedSessionContractImplementor, Object o) { 18 | return snowflake.nextId(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /core/time-util/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // FIXME remove this after root build.gradle.kts supplies below dependencies. 2 | dependencies { 3 | testImplementation("io.kotest:kotest-runner-junit5:5.9.1") 4 | testImplementation("io.mockk:mockk:1.13.12") 5 | testImplementation(kotlin("script-runtime")) 6 | testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3") 7 | } 8 | 9 | // FIXME remove this after root build.gradle.kts supplies below task. 10 | kotlin { 11 | sourceSets { 12 | test { 13 | kotlin.srcDirs(listOf("src/test/kotlin")) 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /core/time-util/src/main/java/nettee/time/MillisecondsSupplier.java: -------------------------------------------------------------------------------- 1 | package nettee.time; 2 | 3 | import java.util.function.LongSupplier; 4 | 5 | public interface MillisecondsSupplier extends LongSupplier { 6 | } 7 | -------------------------------------------------------------------------------- /core/time-util/src/main/java/nettee/time/SystemMilliseconds.java: -------------------------------------------------------------------------------- 1 | package nettee.time; 2 | 3 | public final class SystemMilliseconds implements MillisecondsSupplier { 4 | @Override 5 | public long getAsLong() { 6 | return System.currentTimeMillis(); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /core/time-util/src/test/kotlin/nettee/time/SystemMillisecondsTest.kt: -------------------------------------------------------------------------------- 1 | package nettee.time 2 | 3 | import io.kotest.core.spec.style.FreeSpec 4 | import io.kotest.matchers.longs.shouldBeGreaterThanOrEqual 5 | import io.kotest.matchers.longs.shouldBeLessThanOrEqual 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.coroutineScope 8 | import kotlinx.coroutines.joinAll 9 | import kotlinx.coroutines.launch 10 | 11 | class SystemMillisecondsTest: FreeSpec({ 12 | val milliseconds = SystemMilliseconds() 13 | 14 | "getAsLong" - { 15 | "Clock-Backward가 발생하지 않는다." { 16 | val t0 = System.currentTimeMillis() 17 | val result = milliseconds.asLong 18 | val t1 = System.currentTimeMillis() 19 | 20 | result shouldBeGreaterThanOrEqual t0 21 | result shouldBeLessThanOrEqual t1 22 | } 23 | 24 | "concurrent calls should all complete" { 25 | coroutineScope { 26 | val jobs = (1..10).map { 27 | launch(Dispatchers.Default) { milliseconds.asLong } 28 | } 29 | jobs.joinAll() // 모두 완료될 때까지 대기 30 | } 31 | } 32 | } 33 | }) -------------------------------------------------------------------------------- /docker-compose-monolith.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | demo_postgres14: 5 | image: postgres:14 6 | environment: 7 | TZ: Asia/Seoul 8 | POSTGRES_DB: demo 9 | POSTGRES_USER: root 10 | POSTGRES_PASSWORD: root 11 | POSTGRES_INITDB_ARGS: '--encoding=UTF-8 --lc-collate=C --lc-ctype=C' 12 | ports: 13 | - 5433:5432 14 | restart: on-failure 15 | volumes: 16 | - sticky_volume_demo_postgres14:/var/lib/postgresql/data 17 | - ./db/initdb.d:/docker-entrypoint-initdb.d:ro 18 | 19 | demo_redis: 20 | image: redis:7.4 21 | ports: 22 | - 6379:6379 23 | restart: on-failure 24 | 25 | volumes: 26 | sticky_volume_demo_postgres14: -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nettee-space/backend-sample-multi-module/a24f6e1e80b0368afeb25b23cdb538146b244d1d/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Mar 18 22:06:27 KST 2025 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 5 | zipStoreBase=GRADLE_USER_HOME 6 | zipStorePath=wrapper/dists 7 | -------------------------------------------------------------------------------- /monolith/main-runner/build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.springframework.boot.gradle.tasks.bundling.BootJar 2 | 3 | val board: String by project 4 | val article: String by project 5 | val comment: String by project 6 | val views: String by project 7 | 8 | version = "0.0.1-SNAPSHOT" 9 | 10 | dependencies { 11 | // core 12 | implementation(project(":exception-handler-core")) 13 | implementation(project(":jpa-core")) 14 | implementation(project(":cors-webmvc")) 15 | 16 | // services 17 | implementation(project(board)) 18 | implementation(project(article)) 19 | implementation(project(views)) 20 | implementation(project(comment)) 21 | implementation(project(":rest-client")) 22 | 23 | // webmvc 24 | implementation("org.springframework.boot:spring-boot-starter-web") 25 | 26 | // db 27 | runtimeOnly("org.postgresql:postgresql:42.7.4") 28 | 29 | // flyway 30 | implementation("org.flywaydb:flyway-database-postgresql") 31 | 32 | // test 33 | testImplementation("com.h2database:h2") 34 | testImplementation("org.springframework.boot:spring-boot-starter-test") 35 | testImplementation("com.fasterxml.jackson.core:jackson-databind") 36 | testImplementation("com.fasterxml.jackson.module:jackson-module-kotlin") 37 | } 38 | 39 | tasks.withType { 40 | useJUnitPlatform() 41 | } 42 | 43 | tasks.withType{ 44 | enabled = true 45 | } 46 | 47 | tasks.withType{ 48 | enabled = false 49 | } -------------------------------------------------------------------------------- /monolith/main-runner/src/main/java/nettee/main/MainApplication.java: -------------------------------------------------------------------------------- 1 | package nettee.main; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan; 6 | 7 | @SpringBootApplication(scanBasePackages = "nettee") 8 | @ConfigurationPropertiesScan(basePackages = "nettee") 9 | public class MainApplication { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(MainApplication.class, args); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/application-local.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: ${BOARD_POSTGRESQL_URL:jdbc:postgresql://localhost:5433/demo} 4 | username: ${BOARD_POSTGRESQL_USERNAME:root} 5 | password: ${BOARD_POSTGRESQL_PASSWORD:root} 6 | 7 | data: 8 | redis: 9 | host: ${REDIS_URL:127.0.0.1} 10 | port: ${REDIS_PORT:6379} 11 | 12 | flyway: 13 | locations: 14 | - db/migration/v1_0 15 | - db/migration/local 16 | -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: main 4 | 5 | datasource: 6 | driver-class-name: org.postgresql.Driver 7 | url: ${BOARD_POSTGRESQL_URL} 8 | username: ${BOARD_POSTGRESQL_USERNAME} 9 | password: ${BOARD_POSTGRESQL_PASSWORD} 10 | 11 | data: 12 | redis: 13 | host: ${REDIS_URL} 14 | port: ${REDIS_PORT} 15 | 16 | config: 17 | import: 18 | - properties/web/main.cors.yml 19 | - properties/persistence/main.snowflake.yml 20 | - board.yml 21 | - article.yml 22 | - properties/client/main.client.yml 23 | # - board.yml # NOTE this is for MSA projects (delete this line after confirmed) 24 | 25 | flyway: 26 | baseline-on-migrate: true 27 | locations: 28 | - db/migration/v1_0 29 | 30 | server: 31 | # 5000 포트를 사용하지 않기: macOS '제어 센터'가 5000, 7000 포트를 계속 사용함. (macOS Monterey 이후 AirPlay receiver 활성화 시) 32 | port: 8080 33 | -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/db/migration/local/R__dummy_post_views_count.sql: -------------------------------------------------------------------------------- 1 | -- 더미 데이터 삽입 (post_id = 1) 2 | INSERT INTO article.post_view_count (post_id, view_count) 3 | VALUES (1, 0) 4 | ON CONFLICT (post_id) DO NOTHING; 5 | -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/db/migration/v1_0/V1_0_0__enable_uuid.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/db/migration/v1_0/V1_0_1__create_tb_board.sql: -------------------------------------------------------------------------------- 1 | -- 스키마가 없다면 먼저 생성 2 | CREATE SCHEMA IF NOT EXISTS board; 3 | 4 | -- Schema: board 5 | CREATE TABLE IF NOT EXISTS board.board ( 6 | id BIGSERIAL, 7 | title VARCHAR(255), 8 | content TEXT, 9 | status VARCHAR(255), 10 | created_at TIMESTAMP DEFAULT NOW(), 11 | updated_at TIMESTAMP, 12 | 13 | CONSTRAINT pk_board PRIMARY KEY (id) 14 | ); 15 | 16 | --테이블 코멘트 17 | COMMENT ON TABLE board.board IS '게시판'; 18 | 19 | -- 컬럼 코멘트 20 | COMMENT ON COLUMN board.board.title IS '글 제목'; 21 | COMMENT ON COLUMN board.board.content IS '내용'; 22 | COMMENT ON COLUMN board.board.status IS '상태'; 23 | COMMENT ON COLUMN board.board.created_at IS '생성시간'; 24 | COMMENT ON COLUMN board.board.updated_at IS '마지막 수정시간'; -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/db/migration/v1_0/V1_0_2__alter_board_status_as_integer.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE board.board ALTER COLUMN status TYPE INTEGER USING status::INTEGER; -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/db/migration/v1_0/V1_0_3__create_tb_article.sql: -------------------------------------------------------------------------------- 1 | -- 스키마가 없다면 먼저 생성 2 | CREATE SCHEMA IF NOT EXISTS article; 3 | 4 | CREATE TABLE IF NOT EXISTS article.article ( 5 | id BIGSERIAL, 6 | blog_id BIGINT, 7 | 8 | title VARCHAR(255), 9 | content TEXT, 10 | 11 | total_views INTEGER, 12 | total_likes INTEGER, 13 | total_shares INTEGER, 14 | 15 | status INTEGER, 16 | created_at TIMESTAMP DEFAULT NOW(), 17 | updated_at TIMESTAMP, 18 | 19 | CONSTRAINT pk_article PRIMARY KEY (id) 20 | -- CONSTRAINT fk_post_blog FOREIGN KEY (blog_id) REFERENCES blog(id) ON DELETE CASCADE 21 | ); 22 | 23 | --테이블 코멘트 24 | COMMENT ON TABLE article.article IS '포스팅'; 25 | 26 | -- 컬럼 코멘트 27 | COMMENT ON COLUMN article.article.title IS '제목'; 28 | COMMENT ON COLUMN article.article.content IS '본문'; 29 | COMMENT ON COLUMN article.article.status IS '상태'; 30 | COMMENT ON COLUMN article.article.total_views IS '총 조회수'; 31 | COMMENT ON COLUMN article.article.total_likes IS '총 좋아요 수'; 32 | COMMENT ON COLUMN article.article.total_shares IS '총 공유수'; 33 | COMMENT ON COLUMN article.article.created_at IS '생성시간'; 34 | COMMENT ON COLUMN article.article.updated_at IS '마지막 수정시간'; 35 | COMMENT ON COLUMN article.article.blog_id IS '블로그 ID'; 36 | -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/db/migration/v1_0/V1_0_4__create_tb_draft.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS article.draft ( 2 | id BIGSERIAL, 3 | blog_id BIGINT, 4 | article_id BIGINT, 5 | 6 | title VARCHAR(255), 7 | content TEXT, 8 | 9 | status INTEGER, 10 | created_at TIMESTAMP DEFAULT NOW(), 11 | updated_at TIMESTAMP, 12 | 13 | CONSTRAINT pk_draft PRIMARY KEY (id) 14 | -- CONSTRAINT fk_draft_blog FOREIGN KEY (blog_id) REFERENCES blog(id) ON DELETE CASCADE 15 | -- CONSTRAINT fk_draft_post FOREIGN KEY (post_id) REFERENCES article(id) ON DELETE CASCADE 16 | ); 17 | 18 | --테이블 코멘트 19 | COMMENT ON TABLE article.draft IS '임시글'; 20 | 21 | -- 컬럼 코멘트 22 | COMMENT ON COLUMN article.draft.title IS '글 제목'; 23 | COMMENT ON COLUMN article.draft.content IS '내용'; 24 | COMMENT ON COLUMN article.draft.status IS '상태'; 25 | COMMENT ON COLUMN article.draft.created_at IS '생성시간'; 26 | COMMENT ON COLUMN article.draft.updated_at IS '마지막 수정시간'; 27 | COMMENT ON COLUMN article.draft.blog_id IS '블로그 ID'; 28 | COMMENT ON COLUMN article.draft.article_id IS '포스트 ID'; 29 | -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/db/migration/v1_0/V1_0_5__create_tb_comment.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS article.comment ( 2 | id BIGSERIAL, 3 | board_id BIGINT, 4 | 5 | content VARCHAR(255), 6 | 7 | status INTEGER, 8 | created_at TIMESTAMP DEFAULT NOW(), 9 | updated_at TIMESTAMP, 10 | 11 | CONSTRAINT pk_comment PRIMARY KEY (id) 12 | ); 13 | 14 | --테이블 코멘트 15 | COMMENT ON TABLE article.comment IS '댓글'; 16 | 17 | -- 컬럼 코멘트 18 | COMMENT ON COLUMN article.comment.content IS '내용'; 19 | COMMENT ON COLUMN article.comment.board_id IS '게시물 ID'; 20 | COMMENT ON COLUMN article.comment.status IS '상태'; 21 | COMMENT ON COLUMN article.comment.created_at IS '생성시간'; 22 | COMMENT ON COLUMN article.comment.updated_at IS '마지막 수정시간'; 23 | -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/db/migration/v1_0/V1_0_6__create_tb_reply.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS article.reply ( 2 | id BIGSERIAL, 3 | comment_id BIGINT, 4 | 5 | content VARCHAR(255), 6 | 7 | status INTEGER, 8 | created_at TIMESTAMP DEFAULT NOW(), 9 | updated_at TIMESTAMP, 10 | 11 | CONSTRAINT pk_reply PRIMARY KEY (id) 12 | ); 13 | 14 | --테이블 코멘트 15 | COMMENT ON TABLE article.reply IS '답글'; 16 | 17 | -- 컬럼 코멘트 18 | COMMENT ON COLUMN article.reply.comment_id IS '부모 댓글 ID'; 19 | COMMENT ON COLUMN article.reply.content IS '내용'; 20 | COMMENT ON COLUMN article.reply.status IS '상태'; 21 | COMMENT ON COLUMN article.reply.created_at IS '생성시간'; 22 | COMMENT ON COLUMN article.reply.updated_at IS '마지막 수정시간'; 23 | -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/db/migration/v1_0/V1_0_7__create_tb_post_views_count.sql: -------------------------------------------------------------------------------- 1 | -- 1. 테이블 생성 2 | CREATE TABLE IF NOT EXISTS article.post_view_count ( 3 | post_id BIGSERIAL, 4 | view_count BIGINT DEFAULT 0, 5 | 6 | CONSTRAINT pk_post_view_count PRIMARY KEY (post_id) 7 | ); 8 | 9 | -- 2. 테이블 설명 10 | COMMENT ON TABLE article.post_view_count IS '게시글별 조회수 집계 테이블'; 11 | 12 | -- 3. 컬럼 설명 13 | COMMENT ON COLUMN article.post_view_count.post_id IS '게시글 ID'; 14 | COMMENT ON COLUMN article.post_view_count.view_count IS '조회수'; 15 | -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/properties/client/main.client.yml: -------------------------------------------------------------------------------- 1 | app: 2 | client: 3 | base-url: "http://localhost:5000" 4 | url: 5 | board: "http://localhost:5000" -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/properties/persistence/main.snowflake.yml: -------------------------------------------------------------------------------- 1 | nettee.persistence.snowflake: 2 | datacenter-id: ${SNOWFLAKE_DC_ID:0} 3 | worker-id: ${SNOWFLAKE_WORKER_ID:0} -------------------------------------------------------------------------------- /monolith/main-runner/src/main/resources/properties/web/main.cors.yml: -------------------------------------------------------------------------------- 1 | app.cors.endpoints: 2 | - path: "/**" 3 | allowed: 4 | headers: "*" 5 | methods: "*" 6 | origins: 7 | - http://localhost:3000 8 | - https://localhost:3000 9 | credentials: true 10 | exposed: 11 | headers: "*" 12 | max-age: 3_600 -------------------------------------------------------------------------------- /monolith/main-runner/src/test/java/nettee/main/MainApplicationTests.java: -------------------------------------------------------------------------------- 1 | package nettee.main; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.springframework.boot.test.context.SpringBootTest; 5 | 6 | @SpringBootTest 7 | class MainApplicationTests { 8 | 9 | @Test 10 | void contextLoads() { 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /monolith/main-runner/src/test/java/nettee/main/sample/entity/Sample.java: -------------------------------------------------------------------------------- 1 | package nettee.main.sample.entity; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.Table; 5 | import nettee.jpa.support.SnowflakeBaseEntity; 6 | 7 | @Entity 8 | @Table(name = "sample") 9 | public class Sample extends SnowflakeBaseEntity { 10 | } 11 | -------------------------------------------------------------------------------- /monolith/main-runner/src/test/java/nettee/main/sample/persistence/SampleRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.main.sample.persistence; 2 | 3 | import nettee.main.sample.entity.Sample; 4 | import org.springframework.data.repository.CrudRepository; 5 | 6 | public interface SampleRepository extends CrudRepository { 7 | } -------------------------------------------------------------------------------- /monolith/main-runner/src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application.name: multi-module 3 | h2: 4 | console: 5 | enabled: true 6 | path: /h2-console 7 | datasource: 8 | driver-class-name: org.h2.Driver 9 | # https://www.h2database.com/html/features.html#in_memory_databases 참조 10 | url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE; 11 | username: sa 12 | password: 13 | jpa: 14 | generate-ddl: 'true' 15 | hibernate: 16 | ddl-auto: create 17 | properties: 18 | hibernate: 19 | show_sql: true 20 | format_sql: true 21 | use_sql_comments: true 22 | flyway: 23 | baseline-on-migrate: false 24 | enabled: false 25 | 26 | app.cors.endpoints: 27 | - path: "/**" 28 | allowed: 29 | headers: "*" 30 | methods: "*" 31 | origins: 32 | - http://localhost:3000 33 | - https://localhost:3000 34 | credentials: true 35 | exposed: 36 | headers: "*" 37 | max-age: 3_600 38 | 39 | -------------------------------------------------------------------------------- /monolith/monolith.settings.gradle.kts: -------------------------------------------------------------------------------- 1 | val monolith = rootDir.resolve("monolith") 2 | .walkTopDown() 3 | .maxDepth(3) 4 | .filter(File::isDirectory) 5 | .associateBy(File::getName) 6 | 7 | 8 | include( 9 | ":main-runner", 10 | ) 11 | 12 | project(":main-runner").projectDir = monolith["main-runner"]!! -------------------------------------------------------------------------------- /services/article/api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val articleDomain: String by project 2 | val articleException: String by project 3 | val articleReadModel: String by project 4 | 5 | dependencies { 6 | api(project(articleDomain)) 7 | api(project(articleException)) 8 | api(project(articleReadModel)) 9 | } -------------------------------------------------------------------------------- /services/article/api/domain/build.gradle.kts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nettee-space/backend-sample-multi-module/a24f6e1e80b0368afeb25b23cdb538146b244d1d/services/article/api/domain/build.gradle.kts -------------------------------------------------------------------------------- /services/article/api/domain/src/main/java/nettee/article/domain/type/ArticleStatus.java: -------------------------------------------------------------------------------- 1 | package nettee.article.domain.type; 2 | 3 | import java.util.EnumSet; 4 | import java.util.Set; 5 | 6 | public enum ArticleStatus { 7 | ACTIVE, 8 | SUSPENDED, 9 | DELETED, 10 | PENDING; 11 | 12 | public static final Set GENERAL_QUERY_STATUS = EnumSet.of(ACTIVE, SUSPENDED); 13 | 14 | public static Set getGeneralQueryStatus() { 15 | return GENERAL_QUERY_STATUS; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /services/article/api/domain/src/main/java/nettee/draft/domain/Draft.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.domain; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import nettee.draft.domain.type.DraftStatus; 8 | 9 | import java.time.Instant; 10 | import java.util.Objects; 11 | 12 | @Builder 13 | @Getter 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | public class Draft { 17 | private Long id; 18 | private String title; 19 | private String content; 20 | private DraftStatus status; 21 | private Instant createdAt; 22 | private Instant updatedAt; 23 | private Long blogId; 24 | private Long articleId; 25 | 26 | public static Draft of(String title, String content) { 27 | return Draft.builder() 28 | .title(title) 29 | .content(content) 30 | .status(DraftStatus.PENDING) // 기본 상태 설정 31 | .createdAt(Instant.now()) 32 | .updatedAt(Instant.now()) 33 | .build(); 34 | } 35 | 36 | @Builder( 37 | builderClassName = "updateDraftBuilder", 38 | builderMethodName = "prepareDraftUpdate", 39 | buildMethodName = "update" 40 | ) 41 | public void update(String title, String content) { 42 | Objects.requireNonNull(title, "Title cannot be null"); 43 | Objects.requireNonNull(content, "Content cannot be null"); 44 | 45 | this.title = title; 46 | this.content = content; 47 | this.updatedAt = Instant.now(); 48 | } 49 | 50 | public void softDelete() { this.status = DraftStatus.DELETED; }; 51 | } -------------------------------------------------------------------------------- /services/article/api/domain/src/main/java/nettee/draft/domain/type/DraftStatus.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.domain.type; 2 | 3 | import java.util.EnumSet; 4 | import java.util.Set; 5 | 6 | public enum DraftStatus { 7 | DRAFT, 8 | DONE, 9 | DELETED, 10 | PENDING; 11 | 12 | public static final Set GENERAL_QUERY_STATUS = EnumSet.of(DRAFT); 13 | 14 | public static Set getGeneralQueryStatus() { 15 | return GENERAL_QUERY_STATUS; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /services/article/api/exception/build.gradle.kts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nettee-space/backend-sample-multi-module/a24f6e1e80b0368afeb25b23cdb538146b244d1d/services/article/api/exception/build.gradle.kts -------------------------------------------------------------------------------- /services/article/api/exception/src/main/java/nettee/article/exception/ArticleCommandException.java: -------------------------------------------------------------------------------- 1 | package nettee.article.exception; 2 | 3 | import nettee.common.CustomException; 4 | import nettee.common.ErrorCode; 5 | 6 | import java.util.Map; 7 | import java.util.function.Supplier; 8 | 9 | public class ArticleCommandException extends CustomException { 10 | 11 | public ArticleCommandException(ErrorCode errorCode) { 12 | super(errorCode); 13 | } 14 | 15 | public ArticleCommandException(ErrorCode errorCode, Throwable cause) { 16 | super(errorCode, cause); 17 | } 18 | 19 | public ArticleCommandException(ErrorCode errorCode, Runnable runnable) { 20 | super(errorCode, runnable); 21 | } 22 | 23 | public ArticleCommandException(ErrorCode errorCode, Runnable runnable, Throwable cause) { 24 | super(errorCode, runnable, cause); 25 | } 26 | 27 | public ArticleCommandException(ErrorCode errorCode, Supplier> payload) { 28 | super(errorCode, payload); 29 | } 30 | 31 | public ArticleCommandException(ErrorCode errorCode, Supplier> payload, Throwable cause) { 32 | super(errorCode, payload, cause); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /services/article/api/exception/src/main/java/nettee/article/exception/ArticleQueryException.java: -------------------------------------------------------------------------------- 1 | package nettee.article.exception; 2 | 3 | import nettee.article.exception.ArticleQueryErrorCode; 4 | import nettee.common.CustomException; 5 | 6 | import java.util.Map; 7 | import java.util.function.Supplier; 8 | 9 | public class ArticleQueryException extends CustomException { 10 | public ArticleQueryException(ArticleQueryErrorCode errorCode) { 11 | super(errorCode); 12 | } 13 | 14 | public ArticleQueryException(ArticleQueryErrorCode errorCode, Throwable cause) { 15 | super(errorCode, cause); 16 | } 17 | 18 | public ArticleQueryException(ArticleQueryErrorCode errorCode, Runnable runnable) { 19 | super(errorCode, runnable); 20 | } 21 | 22 | public ArticleQueryException(ArticleQueryErrorCode errorCode, Runnable runnable, Throwable cause) { 23 | super(errorCode, runnable, cause); 24 | } 25 | 26 | public ArticleQueryException(ArticleQueryErrorCode errorCode, Supplier> payload) { 27 | super(errorCode, payload); 28 | } 29 | 30 | public ArticleQueryException(ArticleQueryErrorCode errorCode, Supplier> payload, Throwable cause) { 31 | super(errorCode, payload, cause); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /services/article/api/exception/src/main/java/nettee/draft/exception/DraftCommandException.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.exception; 2 | 3 | import nettee.common.CustomException; 4 | import nettee.common.ErrorCode; 5 | 6 | import java.util.Map; 7 | import java.util.function.Supplier; 8 | 9 | public class DraftCommandException extends CustomException { 10 | 11 | public DraftCommandException(ErrorCode errorCode) { 12 | super(errorCode); 13 | } 14 | 15 | public DraftCommandException(ErrorCode errorCode, Throwable cause) { 16 | super(errorCode, cause); 17 | } 18 | 19 | public DraftCommandException(ErrorCode errorCode, Runnable runnable) { 20 | super(errorCode, runnable); 21 | } 22 | 23 | public DraftCommandException(ErrorCode errorCode, Runnable runnable, Throwable cause) { 24 | super(errorCode, runnable, cause); 25 | } 26 | 27 | public DraftCommandException(ErrorCode errorCode, Supplier> payload) { 28 | super(errorCode, payload); 29 | } 30 | 31 | public DraftCommandException(ErrorCode errorCode, Supplier> payload, Throwable cause) { 32 | super(errorCode, payload, cause); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /services/article/api/exception/src/main/java/nettee/draft/exception/DraftQueryErrorCode.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.exception; 2 | 3 | import nettee.common.ErrorCode; 4 | import org.springframework.http.HttpStatus; 5 | 6 | import java.util.Map; 7 | import java.util.function.Supplier; 8 | 9 | public enum DraftQueryErrorCode implements ErrorCode { 10 | DRAFT_NOT_FOUND("임시글을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), 11 | DRAFT_GONE("더 이상 존재하지 않는 임시글입니다.", HttpStatus.GONE), 12 | DRAFT_FORBIDDEN("권한이 없습니다.", HttpStatus.FORBIDDEN), 13 | DEFAULT("임시글 조작 오류", HttpStatus.INTERNAL_SERVER_ERROR); 14 | 15 | private final String message; 16 | private final HttpStatus httpStatus; 17 | 18 | DraftQueryErrorCode(String message, HttpStatus httpStatus) { 19 | this.message = message; 20 | this.httpStatus = httpStatus; 21 | } 22 | 23 | @Override 24 | public String message() { 25 | return message; 26 | } 27 | 28 | @Override 29 | public HttpStatus httpStatus() { 30 | return httpStatus; 31 | } 32 | 33 | @Override 34 | public DraftQueryException exception() { 35 | return new DraftQueryException(this); 36 | } 37 | 38 | @Override 39 | public DraftQueryException exception(Throwable cause) { 40 | return new DraftQueryException(this, cause); 41 | } 42 | 43 | @Override 44 | public RuntimeException exception(Runnable runnable) { 45 | return new DraftQueryException(this, runnable); 46 | } 47 | 48 | @Override 49 | public RuntimeException exception(Runnable runnable, Throwable cause) { 50 | return new DraftQueryException(this, runnable, cause); 51 | } 52 | 53 | @Override 54 | public RuntimeException exception(Supplier> payload) { 55 | return new DraftQueryException(this, payload); 56 | } 57 | 58 | @Override 59 | public RuntimeException exception(Supplier> payload, Throwable cause) { 60 | return new DraftQueryException(this, payload, cause); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /services/article/api/exception/src/main/java/nettee/draft/exception/DraftQueryException.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.exception; 2 | 3 | import nettee.common.CustomException; 4 | import nettee.draft.exception.DraftQueryErrorCode; 5 | 6 | import java.util.Map; 7 | import java.util.function.Supplier; 8 | 9 | public class DraftQueryException extends CustomException { 10 | public DraftQueryException(DraftQueryErrorCode errorCode) { 11 | super(errorCode); 12 | } 13 | 14 | public DraftQueryException(DraftQueryErrorCode errorCode, Throwable cause) { 15 | super(errorCode, cause); 16 | } 17 | 18 | public DraftQueryException(DraftQueryErrorCode errorCode, Runnable runnable) { 19 | super(errorCode, runnable); 20 | } 21 | 22 | public DraftQueryException(DraftQueryErrorCode errorCode, Runnable runnable, Throwable cause) { 23 | super(errorCode, runnable, cause); 24 | } 25 | 26 | public DraftQueryException(DraftQueryErrorCode errorCode, Supplier> payload) { 27 | super(errorCode, payload); 28 | } 29 | 30 | public DraftQueryException(DraftQueryErrorCode errorCode, Supplier> payload, Throwable cause) { 31 | super(errorCode, payload, cause); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /services/article/api/readmodel/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val articleDomain: String by project 2 | 3 | dependencies { 4 | api(project(articleDomain)) 5 | } -------------------------------------------------------------------------------- /services/article/api/readmodel/src/main/java/nettee/article/readmodel/ArticleQueryModels.java: -------------------------------------------------------------------------------- 1 | package nettee.article.readmodel; 2 | 3 | import lombok.Builder; 4 | import nettee.article.domain.type.ArticleStatus; 5 | 6 | import java.time.Instant; 7 | public final class ArticleQueryModels { 8 | private ArticleQueryModels() { 9 | } 10 | 11 | @Builder 12 | public record ArticleDetail( 13 | Long id, 14 | String title, 15 | String content, 16 | ArticleStatus status, 17 | Integer totalViews, 18 | Integer totalLikes, 19 | Integer totalShares, 20 | Instant createdAt, 21 | Instant updatedAt 22 | 23 | ) { 24 | } 25 | 26 | @Builder 27 | public record ArticleSummary( 28 | Long id, 29 | String title, 30 | ArticleStatus status, 31 | Instant createdAt, 32 | Instant updatedAt 33 | ) { 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /services/article/api/readmodel/src/main/java/nettee/draft/readmodel/DraftQueryModels.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.readmodel; 2 | 3 | import lombok.Builder; 4 | import nettee.draft.domain.type.DraftStatus; 5 | 6 | import java.time.Instant; 7 | public final class DraftQueryModels { 8 | private DraftQueryModels() { 9 | } 10 | 11 | @Builder 12 | public record DraftDetail( 13 | Long id, 14 | String title, 15 | String content, 16 | DraftStatus status, 17 | Instant createdAt, 18 | Instant updatedAt 19 | 20 | ) { 21 | } 22 | @Builder 23 | public record DraftSummary( 24 | Long id, 25 | String title, 26 | DraftStatus status, 27 | Instant createdAt, 28 | Instant updatedAt 29 | ) { 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /services/article/application/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val articleDomain: String by project 2 | val articleException: String by project 3 | val articleReadModel: String by project 4 | 5 | dependencies { 6 | api(project(articleDomain)) 7 | api(project(articleException)) 8 | api(project(articleReadModel)) 9 | // spring 10 | implementation("org.springframework.data:spring-data-commons") 11 | implementation("org.springframework:spring-tx") 12 | } -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/article/application/port/ArticleCommandPort.java: -------------------------------------------------------------------------------- 1 | package nettee.article.application.port; 2 | 3 | import nettee.article.domain.Article; 4 | import nettee.article.readmodel.ArticleQueryModels.ArticleDetail; 5 | import nettee.article.domain.type.ArticleStatus; 6 | 7 | import java.util.Optional; 8 | 9 | public interface ArticleCommandPort { 10 | Optional findById(Long id); 11 | Article save(Article article); 12 | Article update(Article article); 13 | void updateStatus(Long id, ArticleStatus articleStatus); 14 | } 15 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/article/application/port/ArticleQueryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.article.application.port; 2 | 3 | import nettee.article.readmodel.ArticleQueryModels.ArticleDetail; 4 | import nettee.article.readmodel.ArticleQueryModels.ArticleSummary; 5 | import nettee.article.domain.type.ArticleStatus; 6 | import org.springframework.data.domain.Page; 7 | 8 | import java.time.Instant; 9 | import java.util.Optional; 10 | import java.util.Set; 11 | 12 | public interface ArticleQueryPort { 13 | Optional findById(Long id); 14 | Page findAllAfter(Instant lastCreatedAt, int size); 15 | Page findByStatusesAfter(Set statuses, Instant lastCreatedAt, int size); 16 | 17 | } 18 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/article/application/service/ArticleCommandService.java: -------------------------------------------------------------------------------- 1 | package nettee.article.application.service; 2 | 3 | import org.springframework.transaction.annotation.Transactional; 4 | import lombok.RequiredArgsConstructor; 5 | import nettee.article.domain.Article; 6 | import nettee.article.application.usecase.ArticleCreateUseCase; 7 | import nettee.article.application.usecase.ArticleUpdateUseCase; 8 | import nettee.article.application.port.ArticleCommandPort; 9 | import nettee.article.domain.type.ArticleStatus; 10 | import nettee.article.application.usecase.ArticleDeleteUseCase; 11 | import org.springframework.stereotype.Service; 12 | 13 | @Service 14 | @RequiredArgsConstructor 15 | @Transactional 16 | public class ArticleCommandService implements ArticleCreateUseCase, ArticleUpdateUseCase, ArticleDeleteUseCase { 17 | private final ArticleCommandPort articleCommandPort; 18 | 19 | @Override 20 | public Article createArticle(Article article) { 21 | return articleCommandPort.save(article); 22 | } 23 | 24 | @Override 25 | public Article updateArticle(Article article) { 26 | return articleCommandPort.update(article); 27 | } 28 | 29 | @Override 30 | public void deleteArticle(Long id) { 31 | articleCommandPort.updateStatus(id, ArticleStatus.DELETED); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/article/application/service/ArticleQueryService.java: -------------------------------------------------------------------------------- 1 | package nettee.article.application.service; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.article.application.port.ArticleQueryPort; 5 | import nettee.article.readmodel.ArticleQueryModels.ArticleDetail; 6 | import nettee.article.readmodel.ArticleQueryModels.ArticleSummary; 7 | import nettee.article.domain.type.ArticleStatus; 8 | import nettee.article.application.usecase.ArticleReadByStatusesUseCase; 9 | import nettee.article.application.usecase.ArticleReadUseCase; 10 | import org.springframework.data.domain.Page; 11 | import org.springframework.stereotype.Service; 12 | 13 | import java.time.Instant; 14 | import java.util.Optional; 15 | import java.util.Set; 16 | 17 | @Service 18 | @RequiredArgsConstructor 19 | public class ArticleQueryService implements ArticleReadUseCase, ArticleReadByStatusesUseCase { 20 | private final ArticleQueryPort articleQueryPort; 21 | @Override 22 | public Optional getArticle(Long id) { 23 | return articleQueryPort.findById(id); 24 | } 25 | 26 | @Override 27 | public Page getAllArticle(Instant lastCreatedAt, int size) { 28 | return articleQueryPort.findAllAfter(lastCreatedAt, size); 29 | } 30 | 31 | @Override 32 | public Page findByStatuses(Set statuses, Instant lastCreatedAt, int size) { 33 | return articleQueryPort.findByStatusesAfter(statuses, lastCreatedAt, size); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/article/application/usecase/ArticleCreateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.article.application.usecase; 2 | 3 | import nettee.article.domain.Article; 4 | 5 | public interface ArticleCreateUseCase { 6 | Article createArticle(Article article); 7 | } 8 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/article/application/usecase/ArticleDeleteUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.article.application.usecase; 2 | 3 | public interface ArticleDeleteUseCase { 4 | void deleteArticle(Long id); 5 | } 6 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/article/application/usecase/ArticleReadByStatusesUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.article.application.usecase; 2 | 3 | import nettee.article.readmodel.ArticleQueryModels.ArticleSummary; 4 | import nettee.article.domain.type.ArticleStatus; 5 | import org.springframework.data.domain.Page; 6 | 7 | import java.time.Instant; 8 | import java.util.Set; 9 | 10 | public interface ArticleReadByStatusesUseCase { 11 | Page findByStatuses(Set statuses, Instant lastCreatedAt, int size); 12 | } 13 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/article/application/usecase/ArticleReadUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.article.application.usecase; 2 | 3 | import nettee.article.readmodel.ArticleQueryModels.ArticleDetail; 4 | import nettee.article.readmodel.ArticleQueryModels.ArticleSummary; 5 | import org.springframework.data.domain.Page; 6 | 7 | import java.time.Instant; 8 | import java.util.Optional; 9 | 10 | public interface ArticleReadUseCase { 11 | Optional getArticle(Long id); 12 | 13 | Page getAllArticle(Instant lastCreatedAt, int size); 14 | } 15 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/article/application/usecase/ArticleUpdateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.article.application.usecase; 2 | 3 | import nettee.article.domain.Article; 4 | 5 | public interface ArticleUpdateUseCase { 6 | Article updateArticle(Article article); 7 | } 8 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/draft/application/port/DraftCommandPort.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.application.port; 2 | 3 | import nettee.draft.readmodel.DraftQueryModels.DraftDetail; 4 | import nettee.draft.domain.Draft; 5 | import nettee.draft.domain.type.DraftStatus; 6 | import java.util.Optional; 7 | 8 | public interface DraftCommandPort { 9 | Optional findById(Long id); 10 | Draft save(Draft draft); 11 | Draft update(Draft draft); 12 | void updateStatus(Long id, DraftStatus draftStatus); 13 | } 14 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/draft/application/port/DraftQueryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.application.port; 2 | 3 | import nettee.draft.readmodel.DraftQueryModels.DraftDetail; 4 | import nettee.draft.readmodel.DraftQueryModels.DraftSummary; 5 | import nettee.draft.domain.type.DraftStatus; 6 | import org.springframework.data.domain.Pageable; 7 | import org.springframework.data.domain.Page; 8 | 9 | import java.util.Optional; 10 | import java.util.Set; 11 | 12 | public interface DraftQueryPort { 13 | Optional findById(Long id); 14 | Page findAll(Pageable pageable); 15 | 16 | Page findByStatuses(Set statuses, Pageable pageable); 17 | 18 | } 19 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/draft/application/service/DraftCommandService.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.application.service; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.draft.application.port.DraftCommandPort; 5 | import nettee.draft.domain.Draft; 6 | import nettee.draft.domain.type.DraftStatus; 7 | import nettee.draft.application.usecase.DraftCreateUseCase; 8 | import nettee.draft.application.usecase.DraftDeleteUseCase; 9 | import nettee.draft.application.usecase.DraftUpdateUseCase; 10 | import org.springframework.stereotype.Service; 11 | 12 | @Service 13 | @RequiredArgsConstructor 14 | public class DraftCommandService implements DraftCreateUseCase, DraftUpdateUseCase, DraftDeleteUseCase { 15 | private final DraftCommandPort draftCommandPort; 16 | 17 | @Override 18 | public Draft createDraft(Draft draft) { 19 | return draftCommandPort.save(draft); 20 | } 21 | 22 | @Override 23 | public Draft updateDraft(Draft draft) { 24 | return draftCommandPort.update(draft); 25 | } 26 | 27 | @Override 28 | public void deleteDraft(Long id) { 29 | draftCommandPort.updateStatus(id, DraftStatus.DELETED); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/draft/application/service/DraftQueryService.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.application.service; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.draft.readmodel.DraftQueryModels.DraftDetail; 5 | import nettee.draft.readmodel.DraftQueryModels.DraftSummary; 6 | import nettee.draft.application.port.DraftQueryPort; 7 | import nettee.draft.domain.type.DraftStatus; 8 | import nettee.draft.application.usecase.DraftReadByStatusesUseCase; 9 | import nettee.draft.application.usecase.DraftReadUseCase; 10 | import org.springframework.data.domain.Page; 11 | import org.springframework.data.domain.PageRequest; 12 | import org.springframework.data.domain.Pageable; 13 | import org.springframework.data.domain.Sort; 14 | import org.springframework.data.domain.Sort.Direction; 15 | import org.springframework.stereotype.Service; 16 | 17 | import java.util.Optional; 18 | import java.util.Set; 19 | 20 | @Service 21 | @RequiredArgsConstructor 22 | public class DraftQueryService implements DraftReadUseCase, DraftReadByStatusesUseCase { 23 | private final DraftQueryPort draftQueryPort; 24 | 25 | @Override 26 | public Optional getDraft(Long id) { 27 | return draftQueryPort.findById(id); 28 | } 29 | 30 | @Override 31 | public Page getAllDraft(int size) { 32 | Pageable pageable = PageRequest.of(0, size, Sort.by(Direction.DESC, "createAt")); 33 | return draftQueryPort.findAll(pageable); 34 | } 35 | 36 | @Override 37 | public Page findByStatuses(Set statuses, int size) { 38 | Pageable pageable = PageRequest.of(0, size, Sort.by(Direction.DESC, "createAt")); 39 | return draftQueryPort.findByStatuses(statuses, pageable); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/draft/application/usecase/DraftCreateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.application.usecase; 2 | 3 | import nettee.draft.domain.Draft; 4 | 5 | public interface DraftCreateUseCase { 6 | Draft createDraft(Draft draft); 7 | } 8 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/draft/application/usecase/DraftDeleteUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.application.usecase; 2 | 3 | public interface DraftDeleteUseCase { 4 | void deleteDraft(Long id); 5 | } 6 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/draft/application/usecase/DraftReadByStatusesUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.application.usecase; 2 | 3 | import nettee.draft.readmodel.DraftQueryModels.DraftSummary; 4 | import nettee.draft.domain.type.DraftStatus; 5 | import org.springframework.data.domain.Page; 6 | 7 | import java.util.Set; 8 | 9 | public interface DraftReadByStatusesUseCase { 10 | Page findByStatuses(Set statuses, int size); 11 | } 12 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/draft/application/usecase/DraftReadUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.application.usecase; 2 | 3 | import nettee.draft.readmodel.DraftQueryModels.DraftDetail; 4 | import nettee.draft.readmodel.DraftQueryModels.DraftSummary; 5 | import org.springframework.data.domain.Page; 6 | 7 | import java.util.Optional; 8 | 9 | public interface DraftReadUseCase { 10 | Optional getDraft(Long id); 11 | Page getAllDraft(int size); 12 | } 13 | -------------------------------------------------------------------------------- /services/article/application/src/main/java/nettee/draft/application/usecase/DraftUpdateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.application.usecase; 2 | 3 | import nettee.draft.domain.Draft; 4 | 5 | public interface DraftUpdateUseCase { 6 | Draft updateDraft(Draft draft); 7 | } 8 | -------------------------------------------------------------------------------- /services/article/article.settings.gradle.kts: -------------------------------------------------------------------------------- 1 | val article: String by settings 2 | val articleApi: String by settings 3 | val articleDomain: String by settings 4 | val articleException: String by settings 5 | val articleReadModel: String by settings 6 | val articleApplication: String by settings 7 | val articleRdbAdapter: String by settings 8 | val articleWebMvcAdapter: String by settings 9 | 10 | fun getDirectories(vararg names: String): (String) -> File { 11 | var dir = rootDir 12 | for (name in names) { 13 | dir = dir.resolve(name) 14 | } 15 | return { targetName -> 16 | val directory = dir.walkTopDown().maxDepth(3) 17 | .filter(File::isDirectory) 18 | .associateBy { it.name } 19 | directory[targetName] ?: throw Error("그런 폴더가 없습니다: $targetName") 20 | } 21 | } 22 | 23 | val articleDirectory = getDirectories("services", "article") 24 | 25 | include ( 26 | article, 27 | articleApi, 28 | articleDomain, 29 | articleException, 30 | articleReadModel, 31 | articleApplication, 32 | articleRdbAdapter, 33 | articleWebMvcAdapter, 34 | ) 35 | 36 | project(article).projectDir = articleDirectory("article") 37 | project(articleApi).projectDir = articleDirectory("api") 38 | project(articleDomain).projectDir = articleDirectory("domain") 39 | project(articleException).projectDir = articleDirectory("exception") 40 | project(articleReadModel).projectDir = articleDirectory("readmodel") 41 | project(articleApplication).projectDir = articleDirectory("application") 42 | project(articleRdbAdapter).projectDir = articleDirectory("rdb") 43 | project(articleWebMvcAdapter).projectDir = articleDirectory("web-mvc") 44 | -------------------------------------------------------------------------------- /services/article/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val articleApplication: String by project 2 | val articleWebMvcAdapter: String by project 3 | val articleRdbAdapter: String by project 4 | 5 | dependencies { 6 | api(project(articleApplication)) 7 | api(project(articleWebMvcAdapter)) 8 | api(project(articleRdbAdapter)) 9 | } -------------------------------------------------------------------------------- /services/article/driven/rdb/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val articleDomain: String by project 2 | val articleException: String by project 3 | val articleReadModel: String by project 4 | val articleApplication: String by project 5 | 6 | plugins { 7 | id("java-library") 8 | } 9 | 10 | dependencies { 11 | val bom = dependencyManagement.importedProperties 12 | 13 | api(project(articleDomain)) 14 | api(project(articleException)) 15 | api(project(articleReadModel)) 16 | api(project(articleApplication)) 17 | api(project(":jpa-core")) 18 | 19 | // spring 20 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 21 | 22 | // querydsl 23 | implementation("com.querydsl:querydsl-jpa:${bom["querydsl.version"]}:jakarta") 24 | annotationProcessor("com.querydsl:querydsl-apt:${bom["querydsl.version"]}:jakarta") 25 | annotationProcessor("jakarta.persistence:jakarta.persistence-api") 26 | 27 | // mapstruct 28 | implementation("org.mapstruct:mapstruct:1.6.3") 29 | annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") 30 | annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") 31 | } -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/article/driven/rdb/ArticleJpaRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.article.driven.rdb; 2 | 3 | import nettee.article.driven.rdb.entity.ArticleEntity; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface ArticleJpaRepository extends JpaRepository { 7 | } 8 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/article/driven/rdb/entity/type/ArticleEntityStatusConverter.java: -------------------------------------------------------------------------------- 1 | package nettee.article.driven.rdb.entity.type; 2 | 3 | import jakarta.persistence.AttributeConverter; 4 | import jakarta.persistence.Converter; 5 | import nettee.article.exception.ArticleCommandErrorCode; 6 | import nettee.article.exception.ArticleCommandException; 7 | 8 | @Converter 9 | public class ArticleEntityStatusConverter implements AttributeConverter { 10 | @Override 11 | public Integer convertToDatabaseColumn(ArticleEntityStatus status) { 12 | return status != null ? status.getCode() : null; 13 | } 14 | 15 | @Override 16 | public ArticleEntityStatus convertToEntityAttribute(Integer value) { return ArticleEntityStatus.valueOf(value); } 17 | } 18 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/article/driven/rdb/entity/type/ArticleStatusParameters.java: -------------------------------------------------------------------------------- 1 | package nettee.article.driven.rdb.entity.type; 2 | 3 | import nettee.article.driven.rdb.entity.type.builder.TypeSafeMarkers; 4 | import nettee.article.driven.rdb.entity.type.builder.TypeSafeMarkers.Present; 5 | import nettee.article.driven.rdb.entity.type.builder.TypeSafeMarkers.Missing; 6 | 7 | public class ArticleStatusParameters< 8 | HAS_CAN_READ extends TypeSafeMarkers, 9 | HAS_CLASSIFYING_BITS extends TypeSafeMarkers 10 | > { 11 | boolean canRead; 12 | Integer classifyingBits; 13 | int detailBits; 14 | private ArticleStatusParameters() {} 15 | 16 | public static ArticleStatusParameters builder() { return new ArticleStatusParameters<>(); } 17 | 18 | public static ArticleStatusParameters generate() { 19 | return new ArticleStatusParameters<>(); 20 | } 21 | 22 | @SuppressWarnings("unchecked") 23 | public ArticleStatusParameters classifyingBits(Integer classifyingBits) { 24 | this.classifyingBits = classifyingBits; 25 | return (ArticleStatusParameters) this; 26 | } 27 | 28 | @SuppressWarnings("unchecked") 29 | public ArticleStatusParameters canRead(boolean canRead) { 30 | this.canRead = canRead; 31 | return (ArticleStatusParameters) this; 32 | } 33 | 34 | public ArticleStatusParameters detailBits(int detailBits) { 35 | this.detailBits = detailBits; 36 | return this; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/article/driven/rdb/entity/type/builder/TypeSafeMarkers.java: -------------------------------------------------------------------------------- 1 | package nettee.article.driven.rdb.entity.type.builder; 2 | 3 | import nettee.article.driven.rdb.entity.type.builder.TypeSafeMarkers.Present; 4 | import nettee.article.driven.rdb.entity.type.builder.TypeSafeMarkers.Missing; 5 | 6 | public sealed interface TypeSafeMarkers permits Present, Missing { 7 | non-sealed interface Present extends TypeSafeMarkers {} 8 | non-sealed interface Missing extends TypeSafeMarkers {} 9 | } 10 | 11 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/article/driven/rdb/persistence/mapper/ArticleEntityMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.article.driven.rdb.persistence.mapper; 2 | 3 | import nettee.article.domain.Article; 4 | import nettee.article.readmodel.ArticleQueryModels.ArticleDetail; 5 | import nettee.article.readmodel.ArticleQueryModels.ArticleSummary; 6 | import nettee.article.driven.rdb.entity.ArticleEntity; 7 | import org.mapstruct.Mapper; 8 | 9 | import java.util.Optional; 10 | 11 | @Mapper(componentModel = "spring") 12 | public interface ArticleEntityMapper { 13 | Article toDomain(ArticleEntity articleEntity); 14 | ArticleEntity toEntity(Article article); 15 | ArticleDetail toArticleDetail(ArticleEntity articleEntity); 16 | ArticleSummary toArticleSummary(ArticleEntity articleEntity); 17 | default Optional
toOptionalDomain(ArticleEntity articleEntity) { 18 | return Optional.ofNullable(toDomain(articleEntity)); 19 | } 20 | default Optional toOptionalArticleDetail(ArticleEntity articleEntity) { 21 | return Optional.ofNullable(toArticleDetail(articleEntity)); 22 | } 23 | default Optional toOptionalArticleSummary(ArticleEntity articleEntity) { 24 | return Optional.ofNullable(toArticleSummary(articleEntity)); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/draft/driven/rdb/DraftJpaRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.driven.rdb; 2 | 3 | import nettee.draft.driven.rdb.entity.DraftEntity; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface DraftJpaRepository extends JpaRepository { 7 | } 8 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/draft/driven/rdb/entity/type/DraftEntityStatusConverter.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.driven.rdb.entity.type; 2 | 3 | import jakarta.persistence.AttributeConverter; 4 | import jakarta.persistence.Converter; 5 | import nettee.draft.exception.DraftCommandErrorCode; 6 | import nettee.draft.exception.DraftCommandException; 7 | 8 | @Converter 9 | public class DraftEntityStatusConverter implements AttributeConverter { 10 | @Override 11 | public Integer convertToDatabaseColumn(DraftEntityStatus status) { 12 | return status != null ? status.getCode() : null; 13 | } 14 | 15 | @Override 16 | public DraftEntityStatus convertToEntityAttribute(Integer value) { return DraftEntityStatus.valueOf(value); } 17 | } 18 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/draft/driven/rdb/entity/type/DraftStatusParameters.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.driven.rdb.entity.type; 2 | 3 | import nettee.draft.driven.rdb.entity.type.builder.TypeSafeMarkers; 4 | import nettee.draft.driven.rdb.entity.type.builder.TypeSafeMarkers.Missing; 5 | import nettee.draft.driven.rdb.entity.type.builder.TypeSafeMarkers.Present; 6 | 7 | public class DraftStatusParameters< 8 | HAS_CAN_READ extends TypeSafeMarkers, 9 | HAS_CLASSIFYING_BITS extends TypeSafeMarkers 10 | > { 11 | boolean canRead; 12 | Integer classifyingBits; 13 | int detailBits; 14 | private DraftStatusParameters() {} 15 | 16 | public static DraftStatusParameters builder() { return new DraftStatusParameters<>(); } 17 | 18 | public static DraftStatusParameters generate() { 19 | return new DraftStatusParameters<>(); 20 | } 21 | 22 | @SuppressWarnings("unchecked") 23 | public DraftStatusParameters classifyingBits(Integer classifyingBits) { 24 | this.classifyingBits = classifyingBits; 25 | return (DraftStatusParameters) this; 26 | } 27 | 28 | @SuppressWarnings("unchecked") 29 | public DraftStatusParameters canRead(boolean canRead) { 30 | this.canRead = canRead; 31 | return (DraftStatusParameters) this; 32 | } 33 | 34 | public DraftStatusParameters detailBits(int detailBits) { 35 | this.detailBits = detailBits; 36 | return this; 37 | } 38 | 39 | } 40 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/draft/driven/rdb/entity/type/builder/TypeSafeMarkers.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.driven.rdb.entity.type.builder; 2 | 3 | import nettee.draft.driven.rdb.entity.type.builder.TypeSafeMarkers.Missing; 4 | import nettee.draft.driven.rdb.entity.type.builder.TypeSafeMarkers.Present; 5 | 6 | public sealed interface TypeSafeMarkers permits Present, Missing { 7 | non-sealed interface Present extends TypeSafeMarkers {} 8 | non-sealed interface Missing extends TypeSafeMarkers {} 9 | } 10 | 11 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/java/nettee/draft/driven/rdb/persistence/mapper/DraftEntityMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.driven.rdb.persistence.mapper; 2 | 3 | import nettee.draft.driven.rdb.entity.DraftEntity; 4 | import nettee.draft.readmodel.DraftQueryModels.DraftDetail; 5 | import nettee.draft.readmodel.DraftQueryModels.DraftSummary; 6 | import nettee.draft.domain.Draft; 7 | import org.mapstruct.Mapper; 8 | 9 | import java.util.Optional; 10 | 11 | @Mapper(componentModel = "spring") 12 | public interface DraftEntityMapper { 13 | Draft toDomain(DraftEntity draftEntity); 14 | DraftEntity toEntity(Draft draft); 15 | DraftDetail toDraftDetail(DraftEntity draftEntity); 16 | DraftSummary toDraftSummary(DraftEntity draftEntity); 17 | 18 | default Optional toOptionalDomain(DraftEntity draftEntity) { 19 | return Optional.ofNullable(toDomain(draftEntity)); 20 | } 21 | 22 | default Optional toOptionalDraftDetail(DraftEntity draftEntity) { 23 | return Optional.ofNullable(toDraftDetail(draftEntity)); 24 | } 25 | 26 | default Optional toOptionalDraftSummary(DraftEntity draftEntity) { 27 | return Optional.ofNullable(toDraftSummary(draftEntity)); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_3__create_tb_article.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS article ( 2 | id BIGSERIAL, 3 | title VARCHAR(255), 4 | content TEXT, 5 | status VARCHAR(255), 6 | total_views INTEGER, 7 | total_likes INTEGER, 8 | total_shares INTEGER, 9 | created_at TIMESTAMP DEFAULT NOW(), 10 | updated_at TIMESTAMP, 11 | blog_id BIGINT, 12 | 13 | 14 | CONSTRAINT pk_article PRIMARY KEY (id) 15 | -- CONSTRAINT fk_post_blog FOREIGN KEY (blog_id) REFERENCES blog(id) ON DELETE CASCADE 16 | ); 17 | 18 | --테이블 코멘트 19 | COMMENT ON TABLE article IS '아티클'; 20 | 21 | -- 컬럼 코멘트 22 | COMMENT ON COLUMN article.title IS '글 제목'; 23 | COMMENT ON COLUMN article.content IS '내용'; 24 | COMMENT ON COLUMN article.status IS '상태'; 25 | COMMENT ON COLUMN article.total_views IS '총 조회수'; 26 | COMMENT ON COLUMN article.total_likes IS '총 좋아요 수'; 27 | COMMENT ON COLUMN article.total_shares IS '총 공유수'; 28 | COMMENT ON COLUMN article.created_at IS '생성시간'; 29 | COMMENT ON COLUMN article.updated_at IS '마지막 수정시간'; 30 | COMMENT ON COLUMN article.blog_id IS '블로그 ID'; 31 | 32 | 33 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_4__alter_article_status_as_integer.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE article ALTER COLUMN status TYPE INTEGER USING status::INTEGER; -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_5__create_tb_draft.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS draft ( 2 | id BIGSERIAL, 3 | title VARCHAR(255), 4 | content TEXT, 5 | status VARCHAR(255), 6 | created_at TIMESTAMP DEFAULT NOW(), 7 | updated_at TIMESTAMP, 8 | blog_id BIGINT, 9 | article_id BIGINT, 10 | 11 | CONSTRAINT pk_draft PRIMARY KEY (id) 12 | -- CONSTRAINT fk_draft_blog FOREIGN KEY (blog_id) REFERENCES blog(id) ON DELETE CASCADE 13 | -- CONSTRAINT fk_draft_post FOREIGN KEY (post_id) REFERENCES article(id) ON DELETE CASCADE 14 | ); 15 | 16 | --테이블 코멘트 17 | COMMENT ON TABLE draft IS '임시글'; 18 | 19 | -- 컬럼 코멘트 20 | COMMENT ON COLUMN draft.title IS '글 제목'; 21 | COMMENT ON COLUMN draft.content IS '내용'; 22 | COMMENT ON COLUMN draft.status IS '상태'; 23 | COMMENT ON COLUMN draft.created_at IS '생성시간'; 24 | COMMENT ON COLUMN draft.updated_at IS '마지막 수정시간'; 25 | COMMENT ON COLUMN draft.blog_id IS '블로그 ID'; 26 | COMMENT ON COLUMN draft.article_id IS '포스트 ID'; 27 | 28 | 29 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/resources/db/postgresql/migration/v1_0/V1_0_6__alter_draft_status_as_integer.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE draft ALTER COLUMN status TYPE INTEGER USING status::INTEGER; -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/resources/properties/db/article.database-local.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | url: ${BOARD_POSTGRESQL_URL:jdbc:postgresql://localhost:5433/demo} 4 | username: ${BOARD_POSTGRESQL_USERNAME:root} 5 | password: ${BOARD_POSTGRESQL_PASSWORD:root} 6 | -------------------------------------------------------------------------------- /services/article/driven/rdb/src/main/resources/properties/db/article.database.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | driver-class-name: org.postgresql.Driver 4 | url: ${BOARD_POSTGRESQL_URL} 5 | username: ${BOARD_POSTGRESQL_USERNAME} 6 | password: ${BOARD_POSTGRESQL_PASSWORD} 7 | 8 | flyway: 9 | baseline-on-migrate: true 10 | locations: 11 | - db/postgresql/migration/v1_0 -------------------------------------------------------------------------------- /services/article/driving/web-mvc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val articleApplication: String by project 2 | val articleDomain: String by project 3 | val articleException: String by project 4 | val articleReadModel: String by project 5 | 6 | plugins { 7 | id("java-library") 8 | } 9 | 10 | dependencies { 11 | api(project(articleDomain)) 12 | api(project(articleException)) 13 | api(project(articleReadModel)) 14 | api(project(articleApplication)) 15 | 16 | // validation 17 | compileOnly("jakarta.validation:jakarta.validation-api") 18 | compileOnly("jakarta.annotation:jakarta.annotation-api") 19 | 20 | // mapstruct 21 | compileOnly("org.mapstruct:mapstruct:1.6.3") 22 | annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") 23 | annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") 24 | 25 | // spring 26 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 27 | 28 | } -------------------------------------------------------------------------------- /services/article/driving/web-mvc/src/main/java/nettee/article/driving/web/dto/ArticleQueryDto.java: -------------------------------------------------------------------------------- 1 | package nettee.article.driving.web.dto; 2 | 3 | import lombok.Builder; 4 | import nettee.article.readmodel.ArticleQueryModels.ArticleDetail; 5 | 6 | public class ArticleQueryDto { 7 | private ArticleQueryDto() { 8 | 9 | } 10 | 11 | @Builder 12 | public record ArticleDetailResponse( 13 | ArticleDetail articleDetail 14 | ) { 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /services/article/driving/web-mvc/src/main/java/nettee/article/driving/web/mapper/ArticleDtoMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.article.driving.web.mapper; 2 | 3 | import nettee.article.domain.Article; 4 | import nettee.article.driving.web.dto.ArticleCommandDto.ArticleCreateCommand; 5 | import nettee.article.driving.web.dto.ArticleCommandDto.ArticleUpdateCommand; 6 | import nettee.article.driving.web.dto.ArticleCommandDto.ArticleUpdateTotalViewsCommand; 7 | import nettee.article.driving.web.dto.ArticleCommandDto.ArticleUpdateTotalSharesCommand; 8 | import nettee.article.driving.web.dto.ArticleCommandDto.ArticleUpdateTotalLikesCommand; 9 | import org.mapstruct.Mapper; 10 | 11 | @Mapper(componentModel = "spring") 12 | public interface ArticleDtoMapper { 13 | Article toDomain(ArticleCreateCommand command); 14 | Article toDomain(Long id, ArticleUpdateCommand command); 15 | Article toDomain(Long id, ArticleUpdateTotalSharesCommand command); 16 | Article toDomain(Long id, ArticleUpdateTotalLikesCommand command); 17 | Article toDomain(Long id, ArticleUpdateTotalViewsCommand command); 18 | } 19 | -------------------------------------------------------------------------------- /services/article/driving/web-mvc/src/main/java/nettee/draft/driving/web/DraftQueryApi.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.driving.web; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.draft.driving.web.dto.DraftQueryDto.DraftDetailResponse; 5 | import nettee.draft.readmodel.DraftQueryModels.DraftSummary; 6 | import nettee.draft.application.usecase.DraftReadByStatusesUseCase; 7 | import nettee.draft.application.usecase.DraftReadUseCase; 8 | import nettee.draft.readmodel.DraftQueryModels; 9 | import nettee.draft.domain.type.DraftStatus; 10 | import org.springframework.data.domain.Page; 11 | import org.springframework.web.bind.annotation.GetMapping; 12 | import org.springframework.web.bind.annotation.PathVariable; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.RequestParam; 15 | import org.springframework.web.bind.annotation.RestController; 16 | import java.util.Set; 17 | import static nettee.draft.exception.DraftQueryErrorCode.DRAFT_NOT_FOUND; 18 | 19 | @RestController 20 | @RequestMapping("drafts") 21 | @RequiredArgsConstructor 22 | public class DraftQueryApi { 23 | private final DraftReadUseCase draftReadUseCase; 24 | private final DraftReadByStatusesUseCase draftReadByStatusesUseCase; 25 | // private final DraftDtoMapper mapper; 26 | 27 | @GetMapping("/{draftId}") 28 | public DraftDetailResponse getDraft(@PathVariable("draftId") long draftId) { 29 | DraftQueryModels.DraftDetail draftDetail = draftReadUseCase.getDraft(draftId) 30 | .orElseThrow(DRAFT_NOT_FOUND::exception); 31 | return new DraftDetailResponse(draftDetail); 32 | } 33 | 34 | @GetMapping 35 | public Page getDraftsByStatuses( 36 | @RequestParam(defaultValue = "PENDING, SUSPENDED") Set statuses, 37 | @RequestParam(defaultValue = "100") int size) { 38 | return draftReadByStatusesUseCase.findByStatuses(statuses, size); 39 | } 40 | } -------------------------------------------------------------------------------- /services/article/driving/web-mvc/src/main/java/nettee/draft/driving/web/dto/DraftQueryDto.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.driving.web.dto; 2 | 3 | import lombok.Builder; 4 | import nettee.draft.readmodel.DraftQueryModels.DraftDetail; 5 | 6 | public class DraftQueryDto { 7 | private DraftQueryDto() { 8 | 9 | } 10 | 11 | @Builder 12 | public record DraftDetailResponse( 13 | 14 | DraftDetail draft 15 | ) { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /services/article/driving/web-mvc/src/main/java/nettee/draft/driving/web/mapper/DraftDtoMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.draft.driving.web.mapper; 2 | 3 | import nettee.draft.domain.Draft; 4 | import nettee.draft.readmodel.DraftQueryModels.DraftDetail; 5 | import nettee.draft.driving.web.dto.DraftCommandDto.DraftCreateCommand; 6 | import nettee.draft.driving.web.dto.DraftCommandDto.DraftUpdateCommand; 7 | import nettee.draft.driving.web.dto.DraftCommandDto.DraftUpdateTotalLikesCommand; 8 | import nettee.draft.driving.web.dto.DraftCommandDto.DraftUpdateTotalSharesCommand; 9 | import nettee.draft.driving.web.dto.DraftCommandDto.DraftUpdateTotalViewsCommand; 10 | import nettee.draft.driving.web.dto.DraftQueryDto.DraftDetailResponse; 11 | import org.mapstruct.Mapper; 12 | 13 | import java.util.Optional; 14 | 15 | @Mapper(componentModel = "spring") 16 | public interface DraftDtoMapper { 17 | Draft toDomain(DraftCreateCommand command); 18 | Draft toDomain(Long id, DraftUpdateCommand command); 19 | Draft toDomain(Long id, DraftUpdateTotalSharesCommand command); 20 | Draft toDomain(Long id, DraftUpdateTotalLikesCommand command); 21 | Draft toDomain(Long id, DraftUpdateTotalViewsCommand command); 22 | DraftDetailResponse toDtoDetail(Optional board); 23 | } 24 | -------------------------------------------------------------------------------- /services/article/driving/web-mvc/src/main/resources/article-web.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | jackson: 3 | default-property-inclusion: non_null 4 | -------------------------------------------------------------------------------- /services/article/src/main/resources/article.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | config: 3 | import: 4 | - properties/db/article.database.yml 5 | - article-web.yml -------------------------------------------------------------------------------- /services/board/api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val boardDomain: String by project 2 | val boardException: String by project 3 | val boardReadModel: String by project 4 | 5 | dependencies { 6 | api(project(boardDomain)) 7 | api(project(boardException)) 8 | api(project(boardReadModel)) 9 | } -------------------------------------------------------------------------------- /services/board/api/domain/build.gradle.kts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nettee-space/backend-sample-multi-module/a24f6e1e80b0368afeb25b23cdb538146b244d1d/services/board/api/domain/build.gradle.kts -------------------------------------------------------------------------------- /services/board/api/domain/src/main/java/nettee/board/domain/Board.java: -------------------------------------------------------------------------------- 1 | package nettee.board.domain; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | import nettee.board.domain.type.BoardStatus; 8 | 9 | import java.time.Instant; 10 | import java.util.Objects; 11 | 12 | @Getter 13 | @Builder 14 | @NoArgsConstructor 15 | @AllArgsConstructor 16 | public class Board { 17 | 18 | private Long id; 19 | 20 | private String title; 21 | 22 | private String content; 23 | 24 | private BoardStatus status; 25 | 26 | private Instant createdAt; 27 | 28 | private Instant updatedAt; 29 | 30 | @Builder( 31 | builderClassName = "updateBoardBuilder", 32 | builderMethodName = "prepareUpdate", 33 | buildMethodName = "update" 34 | ) 35 | public void update(String title, String content) { 36 | Objects.requireNonNull(title, "Title cannot be null"); 37 | Objects.requireNonNull(content, "content cannot be null"); 38 | 39 | this.title = title; 40 | this.content = content; 41 | this.updatedAt = Instant.now(); 42 | } 43 | 44 | public void softDelete() { 45 | this.status = BoardStatus.REMOVED; 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /services/board/api/domain/src/main/java/nettee/board/domain/type/BoardStatus.java: -------------------------------------------------------------------------------- 1 | package nettee.board.domain.type; 2 | 3 | import java.util.EnumSet; 4 | import java.util.Set; 5 | 6 | public enum BoardStatus { 7 | 8 | PENDING, 9 | ACTIVE, 10 | SUSPENDED, 11 | REMOVED; 12 | 13 | private static final Set GENERAL_QUERY_STATUS = EnumSet.of(ACTIVE, SUSPENDED); 14 | 15 | public static Set getGeneralQueryStatus() { 16 | return GENERAL_QUERY_STATUS; 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /services/board/api/exception/build.gradle.kts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nettee-space/backend-sample-multi-module/a24f6e1e80b0368afeb25b23cdb538146b244d1d/services/board/api/exception/build.gradle.kts -------------------------------------------------------------------------------- /services/board/api/exception/src/main/java/nettee/board/exception/BoardException.java: -------------------------------------------------------------------------------- 1 | package nettee.board.exception; 2 | 3 | import nettee.common.CustomException; 4 | 5 | import java.util.Map; 6 | import java.util.function.Supplier; 7 | 8 | public class BoardException extends CustomException { 9 | 10 | /** 11 | * BoardErrorCodeLazyHolder를 파라미터로 받기 위해, ErrorCode 타입으로 임시 설정함. 12 | */ 13 | public BoardException(BoardErrorCode errorCode) { 14 | super(errorCode); 15 | } 16 | 17 | public BoardException(BoardErrorCode errorCode, Throwable cause) { 18 | super(errorCode, cause); 19 | } 20 | 21 | public BoardException(BoardErrorCode errorCode, Runnable runnable) { 22 | super(errorCode, runnable); 23 | } 24 | 25 | public BoardException(BoardErrorCode errorCode, Runnable runnable, Throwable cause) { 26 | super(errorCode, runnable, cause); 27 | } 28 | 29 | public BoardException(BoardErrorCode errorCode, Supplier> payload) { 30 | super(errorCode, payload); 31 | } 32 | 33 | public BoardException(BoardErrorCode errorCode, Supplier> payload, Throwable cause) { 34 | super(errorCode, payload, cause); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /services/board/api/readmodel/build.gradle.kts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nettee-space/backend-sample-multi-module/a24f6e1e80b0368afeb25b23cdb538146b244d1d/services/board/api/readmodel/build.gradle.kts -------------------------------------------------------------------------------- /services/board/application/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api(project(":board:board-api")) 3 | } -------------------------------------------------------------------------------- /services/board/application/src/main/java/nettee/board/application/port/BoardCommandRepositoryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.board.application.port; 2 | 3 | import nettee.board.domain.Board; 4 | 5 | public interface BoardCommandRepositoryPort { 6 | Board save(Board board); 7 | } 8 | -------------------------------------------------------------------------------- /services/board/application/src/main/java/nettee/board/application/service/BoardCommandService.java: -------------------------------------------------------------------------------- 1 | package nettee.board.application.service; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.board.domain.Board; 5 | import nettee.board.application.port.BoardCommandRepositoryPort; 6 | import nettee.board.application.usecase.BoardCreateUseCase; 7 | import org.springframework.stereotype.Service; 8 | 9 | @Service 10 | @RequiredArgsConstructor 11 | public class BoardCommandService implements BoardCreateUseCase { 12 | 13 | private final BoardCommandRepositoryPort boardCommandRepositoryPort; 14 | 15 | @Override 16 | public Board createBoard(Board board) { 17 | return boardCommandRepositoryPort.save(board); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /services/board/application/src/main/java/nettee/board/application/usecase/BoardCreateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.board.application.usecase; 2 | 3 | import nettee.board.domain.Board; 4 | 5 | public interface BoardCreateUseCase { 6 | Board createBoard(Board board); 7 | } -------------------------------------------------------------------------------- /services/board/application/src/main/java/nettee/board/port/BoardCommandNetteeClientPort.java: -------------------------------------------------------------------------------- 1 | package nettee.board.port; 2 | 3 | import nettee.board.domain.Board; 4 | 5 | public interface BoardCommandNetteeClientPort { 6 | Board save(); 7 | } 8 | -------------------------------------------------------------------------------- /services/board/board.settings.gradle.kts: -------------------------------------------------------------------------------- 1 | val board: String by settings 2 | val boardApi: String by settings 3 | val boardDomain: String by settings 4 | val boardException: String by settings 5 | val boardReadModel: String by settings 6 | val boardApplication: String by settings 7 | val boardRdbAdapter: String by settings 8 | val boardWebMvcAdapter: String by settings 9 | val boardRestClient: String by settings 10 | 11 | fun getDirectories(vararg names: String): (String) -> File { 12 | var dir = rootDir 13 | for (name in names) { 14 | dir = dir.resolve(name) 15 | } 16 | return { targetName -> 17 | val directory = dir.walkTopDown().maxDepth(3) 18 | .filter(File::isDirectory) 19 | .associateBy { it.name } 20 | directory[targetName] ?: throw Error("그런 폴더가 없습니다: $targetName") 21 | } 22 | } 23 | 24 | val boardDirectory = getDirectories("services", "board") 25 | 26 | // SERVICE/BOARD 27 | include( 28 | board, 29 | boardApi, 30 | boardDomain, 31 | boardException, 32 | boardReadModel, 33 | boardApplication, 34 | boardRdbAdapter, 35 | boardWebMvcAdapter, 36 | // boardRestClient, 37 | ) 38 | 39 | project(board).projectDir = boardDirectory("board") 40 | project(boardApi).projectDir = boardDirectory("api") 41 | project(boardDomain).projectDir = boardDirectory("domain") 42 | project(boardException).projectDir = boardDirectory("exception") 43 | project(boardReadModel).projectDir = boardDirectory("readmodel") 44 | project(boardApplication).projectDir = boardDirectory("application") 45 | project(boardRdbAdapter).projectDir = boardDirectory("rdb") 46 | project(boardWebMvcAdapter).projectDir = boardDirectory("web-mvc") 47 | //project(boardRestClient).projectDir = boardDirectory("board-nettee-client") // don't import yet, cuz including errors -------------------------------------------------------------------------------- /services/board/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val boardApi: String by project 2 | val boardApplication: String by project 3 | val boardRdbAdapter: String by project 4 | val boardWebMvcAdapter: String by project 5 | 6 | dependencies { 7 | api(project(boardApi)) 8 | api(project(boardApplication)) 9 | api(project(boardRdbAdapter)) 10 | api(project(boardWebMvcAdapter)) 11 | } -------------------------------------------------------------------------------- /services/board/driven/board-nettee-client/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api(project(":board:board-application")) 3 | api(project(":rest-client")) 4 | } -------------------------------------------------------------------------------- /services/board/driven/board-nettee-client/src/main/java/nettee/board/client/BoardCommandNetteeClient.java: -------------------------------------------------------------------------------- 1 | package nettee.board.client; 2 | 3 | import nettee.board.Board; 4 | import nettee.board.port.BoardCommandNetteeClientPort; 5 | import nettee.restclient.NetteeClient; 6 | import netttee.client.request.NetteeRequest; 7 | import org.springframework.stereotype.Component; 8 | 9 | import static nettee.board.BoardCommandErrorCode.BOARD_ALREADY_EXIST; 10 | 11 | @Component 12 | public class BoardCommandNetteeClient implements BoardCommandNetteeClientPort { 13 | 14 | @Override 15 | public Board save() { 16 | return NetteeClient.post(NetteeRequest.builder() 17 | .domain("board") 18 | .path("/boards") 19 | .customException(BOARD_ALREADY_EXIST::exception) 20 | .responseType(Board.class) 21 | .build() 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /services/board/driven/rdb/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | val bom = dependencyManagement.importedProperties 3 | 4 | api(project(":board:board-api")) 5 | api(project(":board:board-application")) 6 | api(project(":jpa-core")) 7 | 8 | // spring 9 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 10 | 11 | // querydsl 12 | implementation("com.querydsl:querydsl-jpa:${bom["querydsl.version"]}:jakarta") 13 | annotationProcessor("com.querydsl:querydsl-apt:${bom["querydsl.version"]}:jakarta") 14 | annotationProcessor("jakarta.persistence:jakarta.persistence-api") 15 | 16 | // mapstruct 17 | implementation("org.mapstruct:mapstruct:1.6.3") 18 | annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") 19 | annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") 20 | } -------------------------------------------------------------------------------- /services/board/driven/rdb/src/main/generated/nettee/board/entity/QBoardEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.board.entity; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QBoardEntity is a Querydsl query type for BoardEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultEntitySerializer") 16 | public class QBoardEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = 2065718653L; 19 | 20 | public static final QBoardEntity boardEntity = new QBoardEntity("boardEntity"); 21 | 22 | public final nettee.jpa.support.QLongBaseTimeEntity _super = new nettee.jpa.support.QLongBaseTimeEntity(this); 23 | 24 | public final StringPath content = createString("content"); 25 | 26 | //inherited 27 | public final DateTimePath createdAt = _super.createdAt; 28 | 29 | //inherited 30 | public final NumberPath id = _super.id; 31 | 32 | public final EnumPath status = createEnum("status", nettee.board.entity.type.BoardEntityStatus.class); 33 | 34 | public final StringPath title = createString("title"); 35 | 36 | //inherited 37 | public final DateTimePath updatedAt = _super.updatedAt; 38 | 39 | public QBoardEntity(String variable) { 40 | super(BoardEntity.class, forVariable(variable)); 41 | } 42 | 43 | public QBoardEntity(Path path) { 44 | super(path.getType(), path.getMetadata()); 45 | } 46 | 47 | public QBoardEntity(PathMetadata metadata) { 48 | super(BoardEntity.class, metadata); 49 | } 50 | 51 | } 52 | 53 | -------------------------------------------------------------------------------- /services/board/driven/rdb/src/main/java/nettee/board/driven/rdb/BoardCommandAdapter.java: -------------------------------------------------------------------------------- 1 | package nettee.board.driven.rdb; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.board.domain.Board; 5 | import nettee.board.driven.rdb.mapper.BoardEntityMapper; 6 | import nettee.board.application.port.BoardCommandRepositoryPort; 7 | import org.springframework.dao.DataAccessException; 8 | import org.springframework.stereotype.Repository; 9 | 10 | import static nettee.board.exception.BoardErrorCode.DEFAULT; 11 | 12 | @Repository 13 | @RequiredArgsConstructor 14 | public class BoardCommandAdapter implements BoardCommandRepositoryPort { 15 | 16 | private final BoardJpaRepository boardJpaRepository; 17 | private final BoardEntityMapper boardEntityMapper; 18 | 19 | @Override 20 | public Board save(Board board) { 21 | var boardEntity = boardEntityMapper.toEntity(board); 22 | try { 23 | var newBoard = boardJpaRepository.save(boardEntity); 24 | boardJpaRepository.flush(); 25 | return boardEntityMapper.toDomain(newBoard); 26 | } catch (DataAccessException e) { 27 | throw DEFAULT.exception(e); 28 | } 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /services/board/driven/rdb/src/main/java/nettee/board/driven/rdb/BoardJpaRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.board.driven.rdb; 2 | 3 | import nettee.board.driven.rdb.entity.BoardEntity; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface BoardJpaRepository extends JpaRepository { } -------------------------------------------------------------------------------- /services/board/driven/rdb/src/main/java/nettee/board/driven/rdb/entity/BoardEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.board.driven.rdb.entity; 2 | 3 | import jakarta.persistence.Convert; 4 | import jakarta.persistence.Entity; 5 | import jakarta.persistence.Table; 6 | import lombok.AccessLevel; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | import nettee.jpa.support.LongBaseTimeEntity; 11 | import nettee.board.driven.rdb.entity.type.BoardEntityStatus; 12 | import nettee.board.driven.rdb.entity.type.BoardEntityStatusConverter; 13 | import org.hibernate.annotations.DynamicUpdate; 14 | 15 | @Getter 16 | @DynamicUpdate 17 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 18 | @Entity 19 | @Table( 20 | schema = "board", 21 | catalog = "board", 22 | name = "board" 23 | ) 24 | public class BoardEntity extends LongBaseTimeEntity { 25 | public String title; 26 | public String content; 27 | 28 | @Convert(converter = BoardEntityStatusConverter.class) 29 | public BoardEntityStatus status; 30 | 31 | @Builder 32 | public BoardEntity(String title, String content, BoardEntityStatus status) { 33 | this.title = title; 34 | this.content = content; 35 | this.status = status; 36 | } 37 | } -------------------------------------------------------------------------------- /services/board/driven/rdb/src/main/java/nettee/board/driven/rdb/entity/type/BoardEntityStatusConverter.java: -------------------------------------------------------------------------------- 1 | package nettee.board.driven.rdb.entity.type; 2 | 3 | import jakarta.persistence.AttributeConverter; 4 | import jakarta.persistence.Converter; 5 | 6 | @Converter 7 | public class BoardEntityStatusConverter implements AttributeConverter { 8 | 9 | @Override 10 | public Integer convertToDatabaseColumn(BoardEntityStatus status) { 11 | return status.getCode(); 12 | } 13 | 14 | @Override 15 | public BoardEntityStatus convertToEntityAttribute(Integer value) { 16 | return BoardEntityStatus.valueOf(value); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /services/board/driven/rdb/src/main/java/nettee/board/driven/rdb/mapper/BoardEntityMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.board.driven.rdb.mapper; 2 | 3 | import nettee.board.domain.Board; 4 | import nettee.board.driven.rdb.entity.BoardEntity; 5 | import org.mapstruct.Mapper; 6 | 7 | @Mapper(componentModel = "spring") 8 | public interface BoardEntityMapper { 9 | 10 | Board toDomain(BoardEntity boardEntity); 11 | 12 | BoardEntity toEntity(Board board); 13 | 14 | } 15 | -------------------------------------------------------------------------------- /services/board/driven/rdb/src/main/resources/properties/db/board.database-local.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | driver-class-name: org.postgresql.Driver 4 | url: ${BOARD_POSTGRESQL_URL:jdbc:postgresql://localhost:5433/demo} 5 | username: ${BOARD_POSTGRESQL_USERNAME:root} 6 | password: ${BOARD_POSTGRESQL_PASSWORD:root} 7 | 8 | flyway: 9 | baseline-on-migrate: true 10 | locations: 11 | - db/postgresql/migration/v1_0 -------------------------------------------------------------------------------- /services/board/driven/rdb/src/main/resources/properties/db/board.database.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | driver-class-name: org.postgresql.Driver 4 | url: ${BOARD_POSTGRESQL_URL} 5 | username: ${BOARD_POSTGRESQL_USERNAME} 6 | password: ${BOARD_POSTGRESQL_PASSWORD} 7 | 8 | flyway: 9 | baseline-on-migrate: true 10 | locations: 11 | - db/postgresql/migration/v1_0 -------------------------------------------------------------------------------- /services/board/driving/web-mvc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api(project(":board:board-api")) 3 | api(project(":board:board-application")) 4 | 5 | // validation 6 | compileOnly("jakarta.validation:jakarta.validation-api") 7 | compileOnly("jakarta.annotation:jakarta.annotation-api") 8 | 9 | // mapstruct 10 | compileOnly("org.mapstruct:mapstruct:1.6.3") 11 | annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") 12 | annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") 13 | } -------------------------------------------------------------------------------- /services/board/driving/web-mvc/src/main/generated/nettee/board/web/mapper/BoardDtoMapperImpl.java: -------------------------------------------------------------------------------- 1 | package nettee.board.web.mapper; 2 | 3 | import javax.annotation.processing.Generated; 4 | import nettee.board.Board; 5 | import nettee.board.type.BoardStatus; 6 | import nettee.board.web.dto.BoardCommandDto; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Generated( 10 | value = "org.mapstruct.ap.MappingProcessor", 11 | date = "2025-05-11T21:31:15+0900", 12 | comments = "version: 1.6.3, compiler: javac, environment: Java 21.0.6 (Amazon.com Inc.)" 13 | ) 14 | @Component 15 | public class BoardDtoMapperImpl implements BoardDtoMapper { 16 | 17 | @Override 18 | public Board toDomain(BoardCommandDto.BoardCreateCommand command, BoardStatus status) { 19 | if ( command == null && status == null ) { 20 | return null; 21 | } 22 | 23 | Board.BoardBuilder board = Board.builder(); 24 | 25 | if ( command != null ) { 26 | board.title( command.title() ); 27 | board.content( command.content() ); 28 | board.status( command.status() ); 29 | } 30 | 31 | return board.build(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /services/board/driving/web-mvc/src/main/java/nettee/board/driving/web/BoardCommandApi.java: -------------------------------------------------------------------------------- 1 | package nettee.board.driving.web; 2 | 3 | import jakarta.validation.Valid; 4 | import lombok.RequiredArgsConstructor; 5 | import nettee.board.domain.type.BoardStatus; 6 | import nettee.board.application.usecase.BoardCreateUseCase; 7 | import nettee.board.driving.web.dto.BoardCommandDto.BoardCreateCommand; 8 | import nettee.board.driving.web.dto.BoardCommandDto.BoardCreateResponse; 9 | import nettee.board.driving.web.mapper.BoardDtoMapper; 10 | import org.springframework.http.HttpStatus; 11 | import org.springframework.web.bind.annotation.PostMapping; 12 | import org.springframework.web.bind.annotation.RequestBody; 13 | import org.springframework.web.bind.annotation.RequestMapping; 14 | import org.springframework.web.bind.annotation.ResponseStatus; 15 | import org.springframework.web.bind.annotation.RestController; 16 | 17 | @RestController 18 | @RequestMapping("/boards") 19 | @RequiredArgsConstructor 20 | public class BoardCommandApi { 21 | private final BoardCreateUseCase boardCreateUseCase; 22 | private final BoardDtoMapper mapper; 23 | 24 | @PostMapping 25 | @ResponseStatus(HttpStatus.CREATED) 26 | public BoardCreateResponse create( 27 | @RequestBody @Valid BoardCreateCommand requestBody 28 | ) { 29 | var board = mapper.toDomain(requestBody, BoardStatus.ACTIVE); 30 | 31 | return BoardCreateResponse.builder() 32 | .board(boardCreateUseCase.createBoard(board)) 33 | .build(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /services/board/driving/web-mvc/src/main/java/nettee/board/driving/web/dto/BoardCommandDto.java: -------------------------------------------------------------------------------- 1 | package nettee.board.driving.web.dto; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | import jakarta.validation.constraints.NotNull; 5 | import jakarta.validation.constraints.Size; 6 | import lombok.Builder; 7 | import nettee.board.domain.Board; 8 | import nettee.board.domain.type.BoardStatus; 9 | 10 | public final class BoardCommandDto { 11 | 12 | private BoardCommandDto() { 13 | } 14 | 15 | @Builder 16 | public record BoardCreateCommand( 17 | @NotBlank(message = "제목을 입력하십시오.") 18 | @Size(min = 3, message = "제목은 세 글자 이상 입력하세요.") 19 | String title, 20 | 21 | @NotBlank(message = "본문을 입력하십시오") 22 | @Size(min = 3, message = "제목은 세 글자 이상 입력하세요.") 23 | String content, 24 | 25 | @NotNull(message = "상태를 입력하십시오") 26 | BoardStatus status 27 | ) { 28 | } 29 | 30 | @Builder 31 | public record BoardCreateResponse( 32 | Board board 33 | ) { 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /services/board/driving/web-mvc/src/main/java/nettee/board/driving/web/mapper/BoardDtoMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.board.driving.web.mapper; 2 | 3 | import nettee.board.domain.Board; 4 | import nettee.board.domain.type.BoardStatus; 5 | import nettee.board.driving.web.dto.BoardCommandDto.BoardCreateCommand; 6 | import org.mapstruct.Mapper; 7 | 8 | @Mapper(componentModel = "spring") 9 | public interface BoardDtoMapper { 10 | 11 | Board toDomain(BoardCreateCommand command, BoardStatus status); 12 | 13 | } 14 | -------------------------------------------------------------------------------- /services/board/driving/web-mvc/src/main/resources/board-web.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | jackson: 3 | default-property-inclusion: non_null 4 | -------------------------------------------------------------------------------- /services/board/src/main/resources/board.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | config: 3 | import: 4 | - properties/db/board.database.yml 5 | - board-web.yml -------------------------------------------------------------------------------- /services/comment/api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val commentDomain: String by project 2 | val commentException: String by project 3 | val commentReadModel: String by project 4 | 5 | dependencies { 6 | api(project(commentDomain)) 7 | api(project(commentException)) 8 | api(project(commentReadModel)) 9 | } -------------------------------------------------------------------------------- /services/comment/api/domain/src/main/java/nettee/comment/domain/Comment.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.domain; 2 | 3 | import java.time.Instant; 4 | import java.util.Objects; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Getter; 8 | import lombok.NoArgsConstructor; 9 | import nettee.comment.domain.type.CommentStatus; 10 | 11 | @Getter 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class Comment { 16 | 17 | private Long id; 18 | 19 | private Long boardId; 20 | 21 | private String content; 22 | 23 | private CommentStatus status; 24 | 25 | private Instant createdAt; 26 | 27 | private Instant updatedAt; 28 | 29 | @Builder( 30 | builderClassName = "updateCommentBuilder", 31 | builderMethodName = "prepareUpdate", 32 | buildMethodName = "update" 33 | ) 34 | public void update(String content) { 35 | Objects.requireNonNull(content, "content cannot be null"); 36 | 37 | this.content = content; 38 | this.updatedAt = Instant.now(); 39 | } 40 | 41 | public void softDelete() { 42 | this.status = CommentStatus.REMOVED; 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /services/comment/api/domain/src/main/java/nettee/comment/domain/type/CommentStatus.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.domain.type; 2 | 3 | import java.util.EnumSet; 4 | import java.util.Set; 5 | 6 | public enum CommentStatus { 7 | 8 | PENDING, 9 | ACTIVE, 10 | REMOVED; 11 | 12 | private static final Set GENERAL_QUERY_STATUS = EnumSet.of(ACTIVE); 13 | 14 | public static Set getGeneralQueryStatus() { 15 | return GENERAL_QUERY_STATUS; 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /services/comment/api/domain/src/main/java/nettee/reply/domain/Reply.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.domain; 2 | 3 | import java.time.Instant; 4 | import java.util.Objects; 5 | import lombok.AllArgsConstructor; 6 | import lombok.Builder; 7 | import lombok.Getter; 8 | import lombok.NoArgsConstructor; 9 | import nettee.reply.domain.type.ReplyStatus; 10 | 11 | @Getter 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class Reply { 16 | 17 | private Long id; 18 | 19 | private Long commentId; 20 | 21 | private String content; 22 | 23 | private ReplyStatus status; 24 | 25 | private Instant createdAt; 26 | 27 | private Instant updatedAt; 28 | 29 | @Builder( 30 | builderClassName = "updateReplyBuilder", 31 | builderMethodName = "prepareUpdate", 32 | buildMethodName = "update" 33 | ) 34 | public void update(String content) { 35 | Objects.requireNonNull(content, "content cannot be null"); 36 | 37 | this.content = content; 38 | this.updatedAt = Instant.now(); 39 | } 40 | 41 | public void softDelete() { 42 | this.status = ReplyStatus.REMOVED; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /services/comment/api/domain/src/main/java/nettee/reply/domain/type/ReplyStatus.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.domain.type; 2 | 3 | import java.util.EnumSet; 4 | import java.util.Set; 5 | 6 | public enum ReplyStatus { 7 | 8 | PENDING, 9 | ACTIVE, 10 | REMOVED; 11 | 12 | private static final Set GENERAL_QUERY_STATUS = EnumSet.of(ACTIVE); 13 | 14 | public static Set getGeneralQueryStatus() { 15 | return GENERAL_QUERY_STATUS; 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /services/comment/api/exception/src/main/java/nettee/comment/exception/CommentCommandException.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.exception; 2 | 3 | import java.util.Map; 4 | import java.util.function.Supplier; 5 | import nettee.common.CustomException; 6 | import nettee.common.ErrorCode; 7 | 8 | public class CommentCommandException extends CustomException { 9 | 10 | /** 11 | * CommentErrorCodeLazyHolder를 파라미터로 받기 위해, ErrorCode 타입으로 임시 설정함. 12 | */ 13 | public CommentCommandException(ErrorCode errorCode) { 14 | super(errorCode); 15 | } 16 | 17 | public CommentCommandException(ErrorCode errorCode, Throwable cause) { 18 | super(errorCode, cause); 19 | } 20 | 21 | public CommentCommandException(ErrorCode errorCode, Runnable runnable) { 22 | super(errorCode, runnable); 23 | } 24 | 25 | public CommentCommandException(ErrorCode errorCode, Runnable runnable, Throwable cause) { 26 | super(errorCode, runnable, cause); 27 | } 28 | 29 | public CommentCommandException(ErrorCode errorCode, Supplier> payload) { 30 | super(errorCode, payload); 31 | } 32 | 33 | public CommentCommandException(ErrorCode errorCode, Supplier> payload, Throwable cause) { 34 | super(errorCode, payload, cause); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /services/comment/api/exception/src/main/java/nettee/comment/exception/CommentQueryException.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.exception; 2 | 3 | import java.util.Map; 4 | import java.util.function.Supplier; 5 | import nettee.common.CustomException; 6 | 7 | public class CommentQueryException extends CustomException { 8 | public CommentQueryException(CommentQueryErrorCode errorCode) { 9 | super(errorCode); 10 | } 11 | 12 | public CommentQueryException(CommentQueryErrorCode errorCode, Throwable cause) { 13 | super(errorCode, cause); 14 | } 15 | 16 | public CommentQueryException(CommentQueryErrorCode errorCode, Runnable runnable) { 17 | super(errorCode, runnable); 18 | } 19 | 20 | public CommentQueryException(CommentQueryErrorCode errorCode, Runnable runnable, Throwable cause) { 21 | super(errorCode, runnable, cause); 22 | } 23 | 24 | public CommentQueryException(CommentQueryErrorCode errorCode, Supplier> payload) { 25 | super(errorCode, payload); 26 | } 27 | 28 | public CommentQueryException(CommentQueryErrorCode errorCode, Supplier> payload, Throwable cause) { 29 | super(errorCode, payload, cause); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyCommandException.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.exception; 2 | 3 | import java.util.Map; 4 | import java.util.function.Supplier; 5 | import nettee.common.CustomException; 6 | import nettee.common.ErrorCode; 7 | 8 | public class ReplyCommandException extends CustomException { 9 | 10 | /** 11 | * ReplyErrorCodeLazyHolder를 파라미터로 받기 위해, ErrorCode 타입으로 임시 설정함. 12 | */ 13 | public ReplyCommandException(ErrorCode errorCode) { 14 | super(errorCode); 15 | } 16 | 17 | public ReplyCommandException(ErrorCode errorCode, Throwable cause) { 18 | super(errorCode, cause); 19 | } 20 | 21 | public ReplyCommandException(ErrorCode errorCode, Runnable runnable) { 22 | super(errorCode, runnable); 23 | } 24 | 25 | public ReplyCommandException(ErrorCode errorCode, Runnable runnable, Throwable cause) { 26 | super(errorCode, runnable, cause); 27 | } 28 | 29 | public ReplyCommandException(ErrorCode errorCode, Supplier> payload) { 30 | super(errorCode, payload); 31 | } 32 | 33 | public ReplyCommandException(ErrorCode errorCode, Supplier> payload, Throwable cause) { 34 | super(errorCode, payload, cause); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyQueryErrorCode.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.exception; 2 | 3 | import java.util.Map; 4 | import java.util.function.Supplier; 5 | import nettee.common.ErrorCode; 6 | import org.springframework.http.HttpStatus; 7 | 8 | public enum ReplyQueryErrorCode implements ErrorCode { 9 | REPLY_NOT_FOUND("답글을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), 10 | REPLY_GONE("더 이상 존재하지 않는 답글입니다.", HttpStatus.GONE), 11 | REPLY_FORBIDDEN("권한이 없습니다.", HttpStatus.FORBIDDEN), 12 | DEFAULT("답글 조작 오류", HttpStatus.INTERNAL_SERVER_ERROR); 13 | 14 | private final String message; 15 | private final HttpStatus httpStatus; 16 | 17 | ReplyQueryErrorCode(String message, HttpStatus httpStatus) { 18 | this.message = message; 19 | this.httpStatus = httpStatus; 20 | } 21 | 22 | @Override 23 | public String message() { 24 | return message; 25 | } 26 | 27 | @Override 28 | public HttpStatus httpStatus() { 29 | return httpStatus; 30 | } 31 | 32 | @Override 33 | public ReplyQueryException exception() { 34 | return new ReplyQueryException(this); 35 | } 36 | 37 | @Override 38 | public ReplyQueryException exception(Throwable cause) { 39 | return new ReplyQueryException(this, cause); 40 | } 41 | 42 | @Override 43 | public RuntimeException exception(Runnable runnable) { 44 | return new ReplyQueryException(this, runnable); 45 | } 46 | 47 | @Override 48 | public RuntimeException exception(Runnable runnable, Throwable cause) { 49 | return new ReplyQueryException(this, runnable, cause); 50 | } 51 | 52 | @Override 53 | public RuntimeException exception(Supplier> payload) { 54 | return new ReplyQueryException(this, payload); 55 | } 56 | 57 | @Override 58 | public RuntimeException exception(Supplier> payload, Throwable cause) { 59 | return new ReplyQueryException(this, payload, cause); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /services/comment/api/exception/src/main/java/nettee/reply/exception/ReplyQueryException.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.exception; 2 | 3 | import java.util.Map; 4 | import java.util.function.Supplier; 5 | import nettee.common.CustomException; 6 | 7 | public class ReplyQueryException extends CustomException { 8 | public ReplyQueryException(ReplyQueryErrorCode errorCode) { 9 | super(errorCode); 10 | } 11 | 12 | public ReplyQueryException(ReplyQueryErrorCode errorCode, Throwable cause) { 13 | super(errorCode, cause); 14 | } 15 | 16 | public ReplyQueryException(ReplyQueryErrorCode errorCode, Runnable runnable) { 17 | super(errorCode, runnable); 18 | } 19 | 20 | public ReplyQueryException(ReplyQueryErrorCode errorCode, Runnable runnable, Throwable cause) { 21 | super(errorCode, runnable, cause); 22 | } 23 | 24 | public ReplyQueryException(ReplyQueryErrorCode errorCode, Supplier> payload) { 25 | super(errorCode, payload); 26 | } 27 | 28 | public ReplyQueryException(ReplyQueryErrorCode errorCode, Supplier> payload, Throwable cause) { 29 | super(errorCode, payload, cause); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /services/comment/api/readmodel/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val commentDomain: String by project 2 | 3 | dependencies { 4 | api(project(commentDomain)) 5 | } -------------------------------------------------------------------------------- /services/comment/api/readmodel/src/main/java/nettee/comment/model/CommentQueryModels.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.model; 2 | 3 | import java.util.List; 4 | import lombok.Builder; 5 | 6 | import java.time.Instant; 7 | import nettee.comment.domain.type.CommentStatus; 8 | import nettee.reply.model.ReplyQueryModels.ReplyDetail; 9 | 10 | public final class CommentQueryModels { 11 | 12 | private CommentQueryModels() { 13 | } 14 | 15 | @Builder 16 | public record CommentDetail( 17 | Long id, 18 | Long boardId, 19 | String content, 20 | CommentStatus status, 21 | Instant createdAt, 22 | Instant updatedAt, 23 | List replies 24 | ) { 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /services/comment/api/readmodel/src/main/java/nettee/reply/model/ReplyQueryModels.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.model; 2 | 3 | import java.time.Instant; 4 | import lombok.Builder; 5 | import nettee.reply.domain.type.ReplyStatus; 6 | 7 | public final class ReplyQueryModels { 8 | 9 | private ReplyQueryModels() { 10 | } 11 | 12 | @Builder 13 | public record ReplyDetail( 14 | Long id, 15 | Long commentId, 16 | String content, 17 | ReplyStatus status, 18 | Instant createdAt, 19 | Instant updatedAt 20 | ) { 21 | } 22 | 23 | } -------------------------------------------------------------------------------- /services/comment/application/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | api(project(":comment:comment-api")) 3 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 4 | } -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/comment/application/port/CommentCommandRepositoryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.application.port; 2 | 3 | import nettee.comment.domain.Comment; 4 | import nettee.comment.domain.type.CommentStatus; 5 | 6 | public interface CommentCommandRepositoryPort { 7 | 8 | Comment save(Comment comment); 9 | 10 | Comment update(Comment comment); 11 | 12 | void updateStatus(Long id, CommentStatus status); 13 | } 14 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/comment/application/port/CommentQueryRepositoryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.application.port; 2 | 3 | import java.time.Instant; 4 | import java.util.List; 5 | import java.util.Optional; 6 | import nettee.comment.model.CommentQueryModels.CommentDetail; 7 | 8 | public interface CommentQueryRepositoryPort { 9 | Optional findById(Long id); 10 | 11 | // board_id에 해당하는 comment 목록 조회 12 | List findPageByBoardId(Long boardId, int offset, int size); 13 | } 14 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/comment/application/service/CommentCommandService.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.application.service; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.comment.application.usecase.CommentDeleteUseCase; 5 | import nettee.comment.domain.Comment; 6 | import nettee.comment.application.port.CommentCommandRepositoryPort; 7 | import nettee.comment.domain.type.CommentStatus; 8 | import nettee.comment.application.usecase.CommentCreateUseCase; 9 | import nettee.comment.application.usecase.CommentUpdateUseCase; 10 | import org.springframework.stereotype.Service; 11 | import org.springframework.transaction.annotation.Transactional; 12 | 13 | @Service 14 | @RequiredArgsConstructor 15 | @Transactional 16 | public class CommentCommandService implements CommentCreateUseCase, CommentUpdateUseCase, 17 | CommentDeleteUseCase { 18 | 19 | private final CommentCommandRepositoryPort commentCommandRepositoryPort; 20 | 21 | @Override 22 | public Comment createComment(Comment comment) { 23 | return commentCommandRepositoryPort.save(comment); 24 | } 25 | 26 | @Override 27 | public Comment updateComment(Comment comment) { 28 | return commentCommandRepositoryPort.update(comment); 29 | } 30 | 31 | @Override 32 | public void deleteComment(Long id) { 33 | commentCommandRepositoryPort.updateStatus(id, CommentStatus.REMOVED); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/comment/application/service/CommentQueryService.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.application.service; 2 | 3 | import java.util.List; 4 | import java.util.stream.Collectors; 5 | import lombok.RequiredArgsConstructor; 6 | import nettee.comment.model.CommentQueryModels.CommentDetail; 7 | import nettee.comment.application.port.CommentQueryRepositoryPort; 8 | import nettee.reply.application.port.ReplyQueryRepositoryPort; 9 | import org.springframework.stereotype.Service; 10 | 11 | @Service 12 | @RequiredArgsConstructor 13 | public class CommentQueryService { 14 | 15 | private final CommentQueryRepositoryPort commentQueryRepositoryPort; 16 | private final ReplyQueryRepositoryPort replyQueryRepositoryPort; 17 | 18 | public List getCommentsByBoardId(Long boardId) { 19 | var comments = commentQueryRepositoryPort.findPageByBoardId(boardId, 0, 10); 20 | 21 | // comment별로 reply를 10개씩 가져옴 22 | // 현재 N+1 이므로, 최대 11(1+10)개의 쿼리를 발생시킴 23 | var result = comments.stream() 24 | .map(comment -> { 25 | var replies = replyQueryRepositoryPort.findPageByCommentId(comment.id(), 0, 10); 26 | 27 | return CommentDetail.builder() 28 | .id(comment.id()) 29 | .boardId(comment.boardId()) 30 | .content(comment.content()) 31 | .status(comment.status()) 32 | .createdAt(comment.createdAt()) 33 | .updatedAt(comment.updatedAt()) 34 | .replies(replies) 35 | .build(); 36 | }).collect(Collectors.toList()); 37 | 38 | return result; 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/comment/application/usecase/CommentCreateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.application.usecase; 2 | 3 | import nettee.comment.domain.Comment; 4 | 5 | public interface CommentCreateUseCase { 6 | Comment createComment(Comment comment); 7 | } 8 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/comment/application/usecase/CommentDeleteUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.application.usecase; 2 | 3 | import nettee.comment.domain.Comment; 4 | 5 | public interface CommentDeleteUseCase { 6 | void deleteComment(Long id); 7 | } 8 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/comment/application/usecase/CommentUpdateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.application.usecase; 2 | 3 | import nettee.comment.domain.Comment; 4 | 5 | public interface CommentUpdateUseCase { 6 | Comment updateComment(Comment comment); 7 | } 8 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/reply/application/port/ReplyCommandRepositoryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.application.port; 2 | 3 | import nettee.reply.domain.Reply; 4 | import nettee.reply.domain.type.ReplyStatus; 5 | 6 | public interface ReplyCommandRepositoryPort { 7 | 8 | Reply save(Reply reply); 9 | 10 | Reply update(Reply reply); 11 | 12 | void updateStatus(Long id, ReplyStatus status); 13 | } 14 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/reply/application/port/ReplyQueryRepositoryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.application.port; 2 | 3 | import java.time.Instant; 4 | import java.util.List; 5 | import java.util.Optional; 6 | import nettee.reply.model.ReplyQueryModels.ReplyDetail; 7 | 8 | public interface ReplyQueryRepositoryPort { 9 | 10 | Optional findById(Long id); 11 | 12 | // comment_id에 해당하는 reply 목록 조회 13 | List findPageByCommentId(Long commentId, int offset, int size); 14 | 15 | // comment_id, 현재 페이지의 마지막 이후의 reply 목록 조회 16 | List findPageByCommentIdAfter(Long commentId, Instant createdAt, int size); 17 | } 18 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/reply/application/service/ReplyCommandService.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.application.service; 2 | 3 | import jakarta.transaction.Transactional; 4 | import lombok.RequiredArgsConstructor; 5 | import nettee.reply.domain.Reply; 6 | import nettee.reply.application.port.ReplyCommandRepositoryPort; 7 | import nettee.reply.domain.type.ReplyStatus; 8 | import nettee.reply.application.usecase.ReplyCreateUseCase; 9 | import nettee.reply.application.usecase.ReplyDeleteUseCase; 10 | import nettee.reply.application.usecase.ReplyUpdateUseCase; 11 | import org.springframework.stereotype.Service; 12 | 13 | @Service 14 | @RequiredArgsConstructor 15 | @Transactional 16 | public class ReplyCommandService implements ReplyCreateUseCase, ReplyUpdateUseCase, ReplyDeleteUseCase { 17 | 18 | private final ReplyCommandRepositoryPort replyCommandRepositoryPort; 19 | 20 | @Override 21 | public Reply createReply(Reply reply) { 22 | return replyCommandRepositoryPort.save(reply); 23 | } 24 | 25 | @Override 26 | public void deleteReply(Long id) { 27 | replyCommandRepositoryPort.updateStatus(id, ReplyStatus.REMOVED); 28 | } 29 | 30 | @Override 31 | public Reply updateReply(Reply reply) { 32 | return replyCommandRepositoryPort.update(reply); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/reply/application/service/ReplyQueryService.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.application.service; 2 | 3 | import java.time.Instant; 4 | import java.util.List; 5 | import lombok.RequiredArgsConstructor; 6 | import nettee.reply.model.ReplyQueryModels.ReplyDetail; 7 | import nettee.reply.application.port.ReplyQueryRepositoryPort; 8 | import org.springframework.stereotype.Service; 9 | 10 | @Service 11 | @RequiredArgsConstructor 12 | public class ReplyQueryService { 13 | 14 | private final ReplyQueryRepositoryPort replyQueryRepositoryPort; 15 | 16 | public List getReplyListByCommentId(Long commentId) { 17 | return replyQueryRepositoryPort.findPageByCommentId(commentId, 0, 10); 18 | } 19 | 20 | public List getReplyListByCommentIdAfter(Long commentId, Instant createdAt) { 21 | return replyQueryRepositoryPort.findPageByCommentIdAfter(commentId, createdAt, 10); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyCreateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.application.usecase; 2 | 3 | import nettee.reply.domain.Reply; 4 | 5 | public interface ReplyCreateUseCase { 6 | Reply createReply(Reply reply); 7 | } 8 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyDeleteUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.application.usecase; 2 | 3 | public interface ReplyDeleteUseCase { 4 | public void deleteReply(Long id); 5 | } 6 | -------------------------------------------------------------------------------- /services/comment/application/src/main/java/nettee/reply/application/usecase/ReplyUpdateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.application.usecase; 2 | 3 | import nettee.reply.domain.Reply; 4 | 5 | public interface ReplyUpdateUseCase { 6 | public Reply updateReply(Reply reply); 7 | } 8 | -------------------------------------------------------------------------------- /services/comment/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val commentApi: String by project 2 | val commentApplication: String by project 3 | val commentRdbAdapter: String by project 4 | val commentWebMvcAdapter: String by project 5 | 6 | dependencies { 7 | api(project(commentApi)) 8 | api(project(commentApplication)) 9 | api(project(commentRdbAdapter)) 10 | api(project(commentWebMvcAdapter)) 11 | } -------------------------------------------------------------------------------- /services/comment/comment.settings.gradle.kts: -------------------------------------------------------------------------------- 1 | val comment: String by settings 2 | val commentApi: String by settings 3 | val commentDomain: String by settings 4 | val commentException: String by settings 5 | val commentReadModel: String by settings 6 | val commentApplication: String by settings 7 | val commentRdbAdapter: String by settings 8 | val commentWebMvcAdapter: String by settings 9 | 10 | 11 | fun getDirectories(vararg names: String): (String) -> File { 12 | var dir = rootDir 13 | for (name in names) { 14 | dir = dir.resolve(name) 15 | } 16 | return { targetName -> 17 | val directory = dir.walkTopDown().maxDepth(3) 18 | .filter(File::isDirectory) 19 | .associateBy { it.name } 20 | directory[targetName] ?: throw Error("그런 폴더가 없습니다: $targetName") 21 | } 22 | } 23 | 24 | val commentDirectory = getDirectories("services", "comment") 25 | 26 | // SERVICE/COMMENT 27 | include( 28 | comment, 29 | commentApi, 30 | commentDomain, 31 | commentException, 32 | commentReadModel, 33 | commentApplication, 34 | commentRdbAdapter, 35 | commentWebMvcAdapter, 36 | ) 37 | 38 | project(comment).projectDir = commentDirectory("comment") 39 | project(commentApi).projectDir = commentDirectory("api") 40 | project(commentDomain).projectDir = commentDirectory("domain") 41 | project(commentException).projectDir = commentDirectory("exception") 42 | project(commentReadModel).projectDir = commentDirectory("readmodel") 43 | project(commentApplication).projectDir = commentDirectory("application") 44 | project(commentRdbAdapter).projectDir = commentDirectory("rdb") 45 | project(commentWebMvcAdapter).projectDir = commentDirectory("web-mvc") 46 | -------------------------------------------------------------------------------- /services/comment/driven/rdb/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | val bom = dependencyManagement.importedProperties 3 | 4 | api(project(":comment:comment-api")) 5 | api(project(":comment:comment-application")) 6 | api(project(":jpa-core")) 7 | 8 | 9 | // querydsl 10 | implementation("com.querydsl:querydsl-jpa:${bom["querydsl.version"]}:jakarta") 11 | annotationProcessor("com.querydsl:querydsl-apt:${bom["querydsl.version"]}:jakarta") 12 | annotationProcessor("jakarta.persistence:jakarta.persistence-api") 13 | 14 | // mapstruct 15 | implementation("org.mapstruct:mapstruct:1.6.3") 16 | annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") 17 | annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") 18 | } -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/generated/nettee/comment/entity/QCommentEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.entity; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QCommentEntity is a Querydsl query type for CommentEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultEntitySerializer") 16 | public class QCommentEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = 2101173661L; 19 | 20 | public static final QCommentEntity commentEntity = new QCommentEntity("commentEntity"); 21 | 22 | public final nettee.jpa.support.QLongBaseTimeEntity _super = new nettee.jpa.support.QLongBaseTimeEntity(this); 23 | 24 | public final StringPath content = createString("content"); 25 | 26 | //inherited 27 | public final DateTimePath createdAt = _super.createdAt; 28 | 29 | //inherited 30 | public final NumberPath id = _super.id; 31 | 32 | public final EnumPath status = createEnum("status", nettee.comment.entity.type.CommentEntityStatus.class); 33 | 34 | //inherited 35 | public final DateTimePath updatedAt = _super.updatedAt; 36 | 37 | public QCommentEntity(String variable) { 38 | super(CommentEntity.class, forVariable(variable)); 39 | } 40 | 41 | public QCommentEntity(Path path) { 42 | super(path.getType(), path.getMetadata()); 43 | } 44 | 45 | public QCommentEntity(PathMetadata metadata) { 46 | super(CommentEntity.class, metadata); 47 | } 48 | 49 | } 50 | 51 | -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/generated/nettee/reply/entity/QReplyEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.entity; 2 | 3 | import static com.querydsl.core.types.PathMetadataFactory.*; 4 | 5 | import com.querydsl.core.types.dsl.*; 6 | 7 | import com.querydsl.core.types.PathMetadata; 8 | import javax.annotation.processing.Generated; 9 | import com.querydsl.core.types.Path; 10 | 11 | 12 | /** 13 | * QReplyEntity is a Querydsl query type for ReplyEntity 14 | */ 15 | @Generated("com.querydsl.codegen.DefaultEntitySerializer") 16 | public class QReplyEntity extends EntityPathBase { 17 | 18 | private static final long serialVersionUID = 1188766717L; 19 | 20 | public static final QReplyEntity replyEntity = new QReplyEntity("replyEntity"); 21 | 22 | public final nettee.jpa.support.QLongBaseTimeEntity _super = new nettee.jpa.support.QLongBaseTimeEntity(this); 23 | 24 | public final StringPath content = createString("content"); 25 | 26 | //inherited 27 | public final DateTimePath createdAt = _super.createdAt; 28 | 29 | //inherited 30 | public final NumberPath id = _super.id; 31 | 32 | public final EnumPath status = createEnum("status", nettee.reply.entity.type.ReplyEntityStatus.class); 33 | 34 | //inherited 35 | public final DateTimePath updatedAt = _super.updatedAt; 36 | 37 | public QReplyEntity(String variable) { 38 | super(ReplyEntity.class, forVariable(variable)); 39 | } 40 | 41 | public QReplyEntity(Path path) { 42 | super(path.getType(), path.getMetadata()); 43 | } 44 | 45 | public QReplyEntity(PathMetadata metadata) { 46 | super(ReplyEntity.class, metadata); 47 | } 48 | 49 | } 50 | 51 | -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/CommentEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.driven.rdb.entity; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.Convert; 5 | import jakarta.persistence.Entity; 6 | import java.util.Objects; 7 | import lombok.AccessLevel; 8 | import lombok.Builder; 9 | import lombok.NoArgsConstructor; 10 | import nettee.comment.driven.rdb.entity.type.CommentEntityStatus; 11 | import nettee.comment.driven.rdb.entity.type.CommentEntityStatusConverter; 12 | import nettee.jpa.support.LongBaseTimeEntity; 13 | import org.hibernate.annotations.DynamicUpdate; 14 | 15 | @DynamicUpdate 16 | @Entity(name = "comment") 17 | public class CommentEntity extends LongBaseTimeEntity { 18 | 19 | @Column(nullable = false) 20 | public Long boardId; 21 | 22 | public String content; 23 | 24 | @Convert(converter = CommentEntityStatusConverter.class) 25 | public CommentEntityStatus status; 26 | 27 | @Builder 28 | public CommentEntity(Long boardId, String content, CommentEntityStatus status) { 29 | this.boardId = boardId; 30 | this.content = content; 31 | this.status = status; 32 | } 33 | 34 | @Builder( 35 | builderClassName = "updateCommentEntityBuilder", 36 | builderMethodName = "prepareCommentEntityUpdate", 37 | buildMethodName = "update" 38 | ) 39 | public void update(String content) { 40 | Objects.requireNonNull(content, "Content cannot be null!"); 41 | 42 | this.content = content; 43 | } 44 | 45 | @Builder( 46 | builderClassName = "updateStatusCommentEntityBuilder", 47 | builderMethodName = "prepareCommentEntityStatusUpdate", 48 | buildMethodName = "updateStatus" 49 | ) 50 | public void updateStatus(CommentEntityStatus status) { 51 | Objects.requireNonNull(status, "status cannot be null"); 52 | 53 | this.status = status; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/entity/type/CommentEntityStatusConverter.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.driven.rdb.entity.type; 2 | 3 | import jakarta.persistence.AttributeConverter; 4 | import jakarta.persistence.Converter; 5 | 6 | @Converter 7 | public class CommentEntityStatusConverter implements AttributeConverter { 8 | 9 | @Override 10 | public Integer convertToDatabaseColumn(CommentEntityStatus status) { 11 | return status.getCode(); 12 | } 13 | 14 | @Override 15 | public CommentEntityStatus convertToEntityAttribute(Integer value) { 16 | return CommentEntityStatus.valueOf(value); 17 | } 18 | } -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/CommentJpaRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.driven.rdb.persistence; 2 | 3 | import nettee.comment.driven.rdb.entity.CommentEntity; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface CommentJpaRepository extends JpaRepository { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/java/nettee/comment/driven/rdb/persistence/mapper/CommentEntityMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.driven.rdb.persistence.mapper; 2 | 3 | import java.util.Optional; 4 | import nettee.comment.domain.Comment; 5 | import nettee.comment.driven.rdb.entity.CommentEntity; 6 | import nettee.comment.model.CommentQueryModels.CommentDetail; 7 | import org.mapstruct.Mapper; 8 | 9 | @Mapper(componentModel = "spring") 10 | public interface CommentEntityMapper { 11 | 12 | Comment toDomain(CommentEntity commentEntity); 13 | CommentDetail toCommentDetail(CommentEntity commentEntity); 14 | CommentEntity toEntity(Comment comment); 15 | 16 | default Optional toOptionalCommentDetail(CommentEntity commentEntity) { 17 | return Optional.ofNullable(toCommentDetail(commentEntity)); 18 | } 19 | } -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/ReplyEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.driven.rdb.entity; 2 | 3 | import jakarta.persistence.Column; 4 | import jakarta.persistence.Convert; 5 | import jakarta.persistence.Entity; 6 | import java.util.Objects; 7 | import lombok.AccessLevel; 8 | import lombok.Builder; 9 | import lombok.NoArgsConstructor; 10 | import nettee.jpa.support.LongBaseTimeEntity; 11 | import nettee.reply.driven.rdb.entity.type.ReplyEntityStatus; 12 | import nettee.reply.driven.rdb.entity.type.ReplyEntityStatusConverter; 13 | import org.hibernate.annotations.DynamicUpdate; 14 | 15 | @DynamicUpdate 16 | @Entity(name = "reply") 17 | public class ReplyEntity extends LongBaseTimeEntity { 18 | 19 | @Column(nullable = false) 20 | public Long commentId; 21 | 22 | public String content; 23 | 24 | @Convert(converter = ReplyEntityStatusConverter.class) 25 | public ReplyEntityStatus status; 26 | 27 | @Builder 28 | public ReplyEntity(Long commentId, String content, ReplyEntityStatus status) { 29 | this.commentId = commentId; 30 | this.content = content; 31 | this.status = status; 32 | } 33 | 34 | @Builder( 35 | builderClassName = "updateReplyEntityBuilder", 36 | builderMethodName = "prepareReplyEntityUpdate", 37 | buildMethodName = "update" 38 | ) 39 | public void update(String content) { 40 | Objects.requireNonNull(content, "Content cannot be null!"); 41 | 42 | this.content = content; 43 | } 44 | 45 | @Builder( 46 | builderClassName = "updateStatusReplyEntityBuilder", 47 | builderMethodName = "prepareReplyEntityStatusUpdate", 48 | buildMethodName = "updateStatus" 49 | ) 50 | public void updateStatus(ReplyEntityStatus status) { 51 | Objects.requireNonNull(status, "status cannot be null"); 52 | 53 | this.status = status; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/entity/type/ReplyEntityStatusConverter.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.driven.rdb.entity.type; 2 | 3 | import jakarta.persistence.AttributeConverter; 4 | import jakarta.persistence.Converter; 5 | 6 | 7 | @Converter 8 | public class ReplyEntityStatusConverter implements AttributeConverter { 9 | 10 | @Override 11 | public Integer convertToDatabaseColumn(ReplyEntityStatus status) { 12 | return status.getCode(); 13 | } 14 | 15 | @Override 16 | public ReplyEntityStatus convertToEntityAttribute(Integer value) { 17 | return ReplyEntityStatus.valueOf(value); 18 | } 19 | } -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/ReplyJpaRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.driven.rdb.persistence; 2 | 3 | import nettee.reply.driven.rdb.entity.ReplyEntity; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | 6 | public interface ReplyJpaRepository extends JpaRepository { 7 | 8 | } 9 | -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/java/nettee/reply/driven/rdb/persistence/mapper/ReplyEntityMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.driven.rdb.persistence.mapper; 2 | 3 | import java.util.Optional; 4 | import nettee.reply.domain.Reply; 5 | import nettee.reply.driven.rdb.entity.ReplyEntity; 6 | import nettee.reply.model.ReplyQueryModels.ReplyDetail; 7 | import org.mapstruct.Mapper; 8 | 9 | @Mapper(componentModel = "spring") 10 | public interface ReplyEntityMapper { 11 | 12 | Reply toDomain(ReplyEntity replyEntity); 13 | 14 | ReplyDetail toReplyDetail(ReplyEntity replyEntity); 15 | 16 | ReplyEntity toEntity(Reply reply); 17 | 18 | default Optional toOptionalReplyDetail(ReplyEntity replyEntity) { 19 | return Optional.ofNullable(toReplyDetail(replyEntity)); 20 | } 21 | 22 | } -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/resources/properties/db/comment.database-local.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | driver-class-name: org.postgresql.Driver 4 | url: ${COMMENT_POSTGRESQL_URL:jdbc:postgresql://localhost:5433/demo} 5 | username: ${COMMENT_POSTGRESQL_USERNAME:root} 6 | password: ${COMMENT_POSTGRESQL_PASSWORD:root} 7 | 8 | flyway: 9 | baseline-on-migrate: true 10 | locations: 11 | - db/postgresql/migration/v1_0 -------------------------------------------------------------------------------- /services/comment/driven/rdb/src/main/resources/properties/db/comment.database.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | driver-class-name: org.postgresql.Driver 4 | url: ${COMMENT_POSTGRESQL_URL} 5 | username: ${COMMENT_POSTGRESQL_USERNAME} 6 | password: ${COMMENT_POSTGRESQL_PASSWORD} 7 | 8 | flyway: 9 | baseline-on-migrate: true 10 | locations: 11 | - db/postgresql/migration/v1_0 -------------------------------------------------------------------------------- /services/comment/driving/web-mvc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val commentApplication: String by project 2 | 3 | dependencies { 4 | api(project(commentApplication)) 5 | 6 | // validation 7 | compileOnly("jakarta.validation:jakarta.validation-api") 8 | compileOnly("jakarta.annotation:jakarta.annotation-api") 9 | 10 | // mapstruct 11 | compileOnly("org.mapstruct:mapstruct:1.6.3") 12 | annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") 13 | annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") 14 | } 15 | 16 | -------------------------------------------------------------------------------- /services/comment/driving/web-mvc/src/main/java/nettee/comment/web/CommentQueryApi.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.web; 2 | 3 | import java.util.List; 4 | import lombok.RequiredArgsConstructor; 5 | import nettee.comment.model.CommentQueryModels.CommentDetail; 6 | import nettee.comment.application.service.CommentQueryService; 7 | import org.springframework.web.bind.annotation.GetMapping; 8 | import org.springframework.web.bind.annotation.PathVariable; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | @RestController 13 | @RequestMapping("/comments") 14 | @RequiredArgsConstructor 15 | public class CommentQueryApi { 16 | 17 | private final CommentQueryService commentQueryService; 18 | 19 | @GetMapping("/{boardId}") 20 | public List getCommentsByBoardId(@PathVariable("boardId") Long boardId) { 21 | return commentQueryService.getCommentsByBoardId(boardId); 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /services/comment/driving/web-mvc/src/main/java/nettee/comment/web/dto/CommentCommandDto.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.web.dto; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | import jakarta.validation.constraints.NotNull; 5 | import lombok.Builder; 6 | import nettee.comment.domain.Comment; 7 | import nettee.comment.domain.type.CommentStatus; 8 | 9 | public class CommentCommandDto { 10 | 11 | private CommentCommandDto() { 12 | 13 | } 14 | 15 | @Builder 16 | public record CommentCreateCommand( 17 | @NotNull(message = "boardId를 입력하십시오") 18 | Long boardId, 19 | @NotBlank(message = "본문을 입력하십시오") 20 | String content, 21 | @NotNull(message = "상태를 입력하십시오") 22 | CommentStatus status 23 | ) { 24 | 25 | } 26 | 27 | @Builder 28 | public record CommentUpdateCommand( 29 | @NotNull(message = "id를 입력하십시오") 30 | Long id, 31 | @NotBlank(message = "본문을 입력하십시오") 32 | String content, 33 | @NotNull(message = "상태를 입력하십시오") 34 | CommentStatus status 35 | ){ 36 | 37 | } 38 | 39 | @Builder 40 | public record CommentCommandResponse( 41 | Comment comment 42 | ){ 43 | 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /services/comment/driving/web-mvc/src/main/java/nettee/comment/web/mapper/CommentDtoMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.comment.web.mapper; 2 | 3 | import nettee.comment.domain.Comment; 4 | import nettee.comment.web.dto.CommentCommandDto.CommentCreateCommand; 5 | import nettee.comment.web.dto.CommentCommandDto.CommentUpdateCommand; 6 | import org.mapstruct.Mapper; 7 | 8 | @Mapper(componentModel = "spring") 9 | public interface CommentDtoMapper { 10 | Comment toDomain(CommentCreateCommand command); 11 | Comment toDomain(CommentUpdateCommand command); 12 | } 13 | -------------------------------------------------------------------------------- /services/comment/driving/web-mvc/src/main/java/nettee/reply/web/ReplyQueryApi.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.web; 2 | 3 | import java.time.Instant; 4 | import java.util.List; 5 | import lombok.RequiredArgsConstructor; 6 | import nettee.reply.model.ReplyQueryModels.ReplyDetail; 7 | import nettee.reply.application.service.ReplyQueryService; 8 | import org.springframework.format.annotation.DateTimeFormat; 9 | import org.springframework.web.bind.annotation.GetMapping; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.RequestMapping; 12 | import org.springframework.web.bind.annotation.RequestParam; 13 | import org.springframework.web.bind.annotation.RestController; 14 | 15 | @RestController 16 | @RequestMapping("/replies") 17 | @RequiredArgsConstructor 18 | public class ReplyQueryApi { 19 | 20 | private final ReplyQueryService replyQueryService; 21 | 22 | // '답글 더보기' 요청 23 | @GetMapping("/{commentId}") 24 | public List getRepliesByCommentIdAfter( 25 | @PathVariable("commentId") Long commentId, 26 | @RequestParam("after") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant createdAt 27 | ){ 28 | return replyQueryService.getReplyListByCommentIdAfter(commentId, createdAt); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /services/comment/driving/web-mvc/src/main/java/nettee/reply/web/dto/ReplyCommandDto.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.web.dto; 2 | 3 | import jakarta.validation.constraints.NotBlank; 4 | import jakarta.validation.constraints.NotNull; 5 | import lombok.Builder; 6 | import nettee.reply.domain.Reply; 7 | import nettee.reply.domain.type.ReplyStatus; 8 | 9 | public class ReplyCommandDto { 10 | 11 | private ReplyCommandDto() { 12 | 13 | } 14 | 15 | @Builder 16 | public record ReplyCreateCommand( 17 | @NotNull(message = "commentId를 입력하십시오") 18 | Long commentId, 19 | @NotBlank(message = "본문을 입력하십시오") 20 | String content, 21 | @NotNull(message = "상태를 입력하십시오") 22 | ReplyStatus status 23 | ){ 24 | 25 | } 26 | 27 | @Builder 28 | public record ReplyUpdateCommand( 29 | @NotNull(message = "id를 입력하십시오") 30 | Long id, 31 | @NotBlank(message = "본문을 입력하십시오") 32 | String content, 33 | @NotNull(message = "상태를 입력하십시오") 34 | ReplyStatus status 35 | ){ 36 | 37 | } 38 | 39 | @Builder 40 | public record ReplyCommandResponse( 41 | Reply reply 42 | ) { 43 | 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /services/comment/driving/web-mvc/src/main/java/nettee/reply/web/mapper/ReplyDtoMapper.java: -------------------------------------------------------------------------------- 1 | package nettee.reply.web.mapper; 2 | 3 | import nettee.reply.domain.Reply; 4 | import nettee.reply.web.dto.ReplyCommandDto.ReplyCreateCommand; 5 | import nettee.reply.web.dto.ReplyCommandDto.ReplyUpdateCommand; 6 | import org.mapstruct.Mapper; 7 | 8 | @Mapper(componentModel = "spring") 9 | public interface ReplyDtoMapper { 10 | Reply toDomain(ReplyCreateCommand command); 11 | Reply toDomain(ReplyUpdateCommand command); 12 | } 13 | -------------------------------------------------------------------------------- /services/comment/driving/web-mvc/src/main/resources/comment-web.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | jackson: 3 | default-property-inclusion: non_null -------------------------------------------------------------------------------- /services/comment/src/main/resources/comment.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | config: 3 | import: 4 | - properties/db/comment.database.yml 5 | # - comment-web.yml -------------------------------------------------------------------------------- /services/views/api/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val viewsDomain: String by project 2 | 3 | dependencies { 4 | api(project(viewsDomain)) 5 | } -------------------------------------------------------------------------------- /services/views/api/domain/build.gradle.kts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nettee-space/backend-sample-multi-module/a24f6e1e80b0368afeb25b23cdb538146b244d1d/services/views/api/domain/build.gradle.kts -------------------------------------------------------------------------------- /services/views/api/domain/src/main/java/nettee/views/Views.java: -------------------------------------------------------------------------------- 1 | package nettee.views; 2 | 3 | import lombok.AllArgsConstructor; 4 | import lombok.Builder; 5 | import lombok.Getter; 6 | import lombok.NoArgsConstructor; 7 | 8 | @Getter 9 | @Builder 10 | @NoArgsConstructor 11 | @AllArgsConstructor 12 | public class Views { 13 | 14 | private Long postId; 15 | 16 | private Long userId; 17 | 18 | private String ipAddress; 19 | 20 | private String userAgent; 21 | } 22 | -------------------------------------------------------------------------------- /services/views/application/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val viewsApi: String by project 2 | 3 | dependencies { 4 | api(project(viewsApi)) 5 | } -------------------------------------------------------------------------------- /services/views/application/src/main/java/nettee/views/port/ViewsCacheRepositoryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.views.port; 2 | 3 | 4 | import nettee.views.Views; 5 | 6 | // redis 7 | public interface ViewsCacheRepositoryPort { 8 | Long increase(Long postId); 9 | 10 | boolean getLock(Views views, java.time.Duration ttl); 11 | } 12 | -------------------------------------------------------------------------------- /services/views/application/src/main/java/nettee/views/port/ViewsCommandRepositoryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.views.port; 2 | 3 | 4 | public interface ViewsCommandRepositoryPort { 5 | int updateViewCount(Long postId, Long viewCount); 6 | 7 | void save(Long postId, Long viewCount); 8 | } 9 | -------------------------------------------------------------------------------- /services/views/application/src/main/java/nettee/views/port/ViewsQueryRepositoryPort.java: -------------------------------------------------------------------------------- 1 | package nettee.views.port; 2 | 3 | public interface ViewsQueryRepositoryPort { 4 | Long getViews(Long postId); 5 | } 6 | -------------------------------------------------------------------------------- /services/views/application/src/main/java/nettee/views/service/ViewsCommandService.java: -------------------------------------------------------------------------------- 1 | package nettee.views.service; 2 | 3 | import java.time.Duration; 4 | import nettee.views.Views; 5 | import nettee.views.port.ViewsCommandRepositoryPort; 6 | import nettee.views.port.ViewsCacheRepositoryPort; 7 | import org.springframework.stereotype.Service; 8 | import lombok.RequiredArgsConstructor; 9 | import nettee.views.usecase.ViewsUpdateUseCase; 10 | 11 | @Service 12 | @RequiredArgsConstructor 13 | public class ViewsCommandService implements ViewsUpdateUseCase { 14 | 15 | private final ViewsCacheRepositoryPort viewsCacheRepositoryPort; 16 | private final ViewsCommandRepositoryPort viewsRdbPort; 17 | 18 | private static final int BACK_UP_BACH_SIZE = 100; 19 | private static final Duration TTL = Duration.ofMinutes(10); 20 | 21 | @Override 22 | public void addViewCount(Views views) { 23 | // Distributed Lock 획득 실패 시, increase 하지 않음 24 | if (!viewsCacheRepositoryPort.getLock(views, TTL)) { 25 | return; 26 | } 27 | 28 | // Redis 조회수 증가 29 | Long postId = views.getPostId(); 30 | Long viewCount = viewsCacheRepositoryPort.increase(postId); 31 | 32 | // BATCH_SIZE 시, RDB 저장 33 | if (viewCount % BACK_UP_BACH_SIZE == 0) { 34 | int result = viewsRdbPort.updateViewCount(postId, viewCount); 35 | 36 | // DB에 값이 없을 경우, INSERT 37 | // 게시글 생성 시 조회수 0으로 초기화 할 수도 있음 38 | if (result == 0) { 39 | viewsRdbPort.save(postId, viewCount); 40 | } 41 | } 42 | 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /services/views/application/src/main/java/nettee/views/service/ViewsQueryService.java: -------------------------------------------------------------------------------- 1 | package nettee.views.service; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.views.port.ViewsQueryRepositoryPort; 5 | import nettee.views.usecase.ViewsReadUseCase; 6 | import org.springframework.stereotype.Service; 7 | 8 | @Service 9 | @RequiredArgsConstructor 10 | public class ViewsQueryService implements ViewsReadUseCase { 11 | 12 | private final ViewsQueryRepositoryPort viewsQueryRepositoryPort; 13 | 14 | @Override 15 | public Long getViews(Long postId) { 16 | return viewsQueryRepositoryPort.getViews(postId); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /services/views/application/src/main/java/nettee/views/usecase/ViewsReadUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.views.usecase; 2 | 3 | public interface ViewsReadUseCase { 4 | Long getViews(Long postId); 5 | } -------------------------------------------------------------------------------- /services/views/application/src/main/java/nettee/views/usecase/ViewsUpdateUseCase.java: -------------------------------------------------------------------------------- 1 | package nettee.views.usecase; 2 | 3 | import nettee.views.Views; 4 | 5 | public interface ViewsUpdateUseCase { 6 | void addViewCount(Views views); 7 | } -------------------------------------------------------------------------------- /services/views/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val viewsApi: String by project 2 | val viewsApplication: String by project 3 | val viewsRedisAdapter: String by project 4 | val viewsRdbAdapter: String by project 5 | val viewsWebMvcAdapter: String by project 6 | 7 | dependencies { 8 | api(project(viewsApi)) 9 | api(project(viewsApplication)) 10 | api(project(viewsRedisAdapter)) 11 | api(project(viewsRdbAdapter)) 12 | api(project(viewsWebMvcAdapter)) 13 | } -------------------------------------------------------------------------------- /services/views/driven/rdb/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val viewsApi: String by project 2 | val viewsApplication: String by project 3 | 4 | dependencies { 5 | 6 | api(project(viewsApi)) 7 | api(project(viewsApplication)) 8 | api(project(":jpa-core")) 9 | 10 | // spring 11 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 12 | } -------------------------------------------------------------------------------- /services/views/driven/rdb/src/main/java/nettee/views/adapter/ViewsCommandRepositoryAdapter.java: -------------------------------------------------------------------------------- 1 | package nettee.views.adapter; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.views.entity.ViewsEntity; 5 | import nettee.views.port.ViewsCommandRepositoryPort; 6 | import nettee.views.repository.ViewsCountBackupRepository; 7 | import org.springframework.stereotype.Component; 8 | 9 | @Component 10 | @RequiredArgsConstructor 11 | public class ViewsCommandRepositoryAdapter implements ViewsCommandRepositoryPort { 12 | 13 | private final ViewsCountBackupRepository viewsCountBackupRepository; 14 | 15 | @Override 16 | public int updateViewCount(Long postId, Long viewCount) { 17 | return viewsCountBackupRepository.updateViewCount(postId, viewCount); 18 | } 19 | 20 | @Override 21 | public void save(Long postId, Long viewCount) { 22 | viewsCountBackupRepository.save(new ViewsEntity(postId, viewCount)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /services/views/driven/rdb/src/main/java/nettee/views/entity/ViewsEntity.java: -------------------------------------------------------------------------------- 1 | package nettee.views.entity; 2 | 3 | import jakarta.persistence.Entity; 4 | import jakarta.persistence.Id; 5 | import jakarta.persistence.Table; 6 | import lombok.AccessLevel; 7 | import lombok.AllArgsConstructor; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | 11 | @Table(name = "post_view_count") 12 | @Entity 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class ViewsEntity { 16 | 17 | @Id 18 | public Long postId; 19 | 20 | public Long viewCount; 21 | } 22 | -------------------------------------------------------------------------------- /services/views/driven/rdb/src/main/java/nettee/views/repository/ViewsCountBackupRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.views.repository; 2 | 3 | import nettee.views.entity.ViewsEntity; 4 | import org.springframework.data.jpa.repository.JpaRepository; 5 | import org.springframework.data.jpa.repository.Modifying; 6 | import org.springframework.data.jpa.repository.Query; 7 | import org.springframework.data.repository.query.Param; 8 | 9 | public interface ViewsCountBackupRepository extends JpaRepository { 10 | 11 | @Query(""" 12 | update ViewsEntity v set v.viewCount = :viewCount 13 | where v.postId = :postId and v.viewCount < :viewCount 14 | """) 15 | @Modifying 16 | int updateViewCount(@Param("postId") Long postId, @Param("viewCount") Long viewCount); 17 | } 18 | -------------------------------------------------------------------------------- /services/views/driven/redis/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val viewsApi: String by project 2 | val viewsApplication: String by project 3 | 4 | dependencies { 5 | 6 | api(project(viewsApi)) 7 | api(project(viewsApplication)) 8 | api(project(":jpa-core")) 9 | 10 | // spring 11 | implementation("org.springframework.boot:spring-boot-starter-data-redis") 12 | implementation("commons-codec:commons-codec:1.16.0") 13 | } -------------------------------------------------------------------------------- /services/views/driven/redis/src/main/java/nettee/views/adapter/ViewsCacheAdapter.java: -------------------------------------------------------------------------------- 1 | package nettee.views.adapter; 2 | 3 | import java.time.Duration; 4 | import lombok.RequiredArgsConstructor; 5 | import nettee.views.Views; 6 | import nettee.views.port.ViewsCacheRepositoryPort; 7 | import nettee.views.repository.ViewsCountDistributedLockRepository; 8 | import nettee.views.repository.ViewsCountRepository; 9 | import org.springframework.stereotype.Component; 10 | 11 | @Component 12 | @RequiredArgsConstructor 13 | public class ViewsCacheAdapter implements ViewsCacheRepositoryPort { 14 | 15 | private final ViewsCountRepository viewsCountRepository; 16 | private final ViewsCountDistributedLockRepository viewsCountDistributedLockRepository; 17 | 18 | 19 | @Override 20 | public boolean getLock(Views views, Duration ttl) { 21 | return viewsCountDistributedLockRepository.lock(views, ttl); 22 | } 23 | 24 | @Override 25 | public Long increase(Long postId) { 26 | return viewsCountRepository.increase(postId); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /services/views/driven/redis/src/main/java/nettee/views/adapter/ViewsQueryAdapter.java: -------------------------------------------------------------------------------- 1 | package nettee.views.adapter; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.views.port.ViewsQueryRepositoryPort; 5 | import nettee.views.repository.ViewsCountRepository; 6 | import org.springframework.stereotype.Component; 7 | 8 | @Component 9 | @RequiredArgsConstructor 10 | public class ViewsQueryAdapter implements ViewsQueryRepositoryPort { 11 | 12 | private final ViewsCountRepository viewsCountRepository; 13 | 14 | public Long getViews(Long postId) { 15 | return viewsCountRepository.read(postId); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /services/views/driven/redis/src/main/java/nettee/views/repository/ViewsCountDistributedLockRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.views.repository; 2 | 3 | import java.time.Duration; 4 | import lombok.RequiredArgsConstructor; 5 | import nettee.views.Views; 6 | import org.apache.commons.codec.digest.DigestUtils; 7 | import org.springframework.data.redis.core.StringRedisTemplate; 8 | import org.springframework.stereotype.Repository; 9 | 10 | @Repository 11 | @RequiredArgsConstructor 12 | public class ViewsCountDistributedLockRepository { 13 | 14 | private final StringRedisTemplate redisTemplate; 15 | 16 | // post/{post_id}/user/{user_id}/lock 17 | private static final String KEY_FORMAT = "view-lock/post/%s/hash/%s"; 18 | 19 | /** 20 | * Distributed lock 획득 21 | */ 22 | public boolean lock(Views views, Duration ttl) { 23 | String key = generateKey(views); 24 | return Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(key, "", ttl)); 25 | } 26 | 27 | /** 28 | * redis key 생성 29 | */ 30 | private String generateKey(Views views) { 31 | Long postId = views.getPostId(); 32 | Long userId = views.getUserId(); 33 | String ipAddress = views.getIpAddress(); 34 | String userAgent = views.getUserAgent(); 35 | 36 | String hash; 37 | if (userId == null) { 38 | String identifier = (ipAddress != null ? ipAddress : "") + (userAgent != null ? userAgent : ""); 39 | hash = DigestUtils.sha256Hex(identifier).substring(0, 16); 40 | } else { 41 | String identifier = String.valueOf(userId); 42 | hash = DigestUtils.sha256Hex(identifier).substring(0, 16); 43 | } 44 | 45 | return KEY_FORMAT.formatted(postId, hash); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /services/views/driven/redis/src/main/java/nettee/views/repository/ViewsCountRepository.java: -------------------------------------------------------------------------------- 1 | package nettee.views.repository; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import org.springframework.data.redis.core.StringRedisTemplate; 5 | import org.springframework.stereotype.Repository; 6 | 7 | @Repository 8 | @RequiredArgsConstructor 9 | public class ViewsCountRepository { 10 | private final StringRedisTemplate redisTemplate; 11 | 12 | // post/{post_id}/view_count 13 | private static final String KEY_FORMAT = "post/%s/view_count"; 14 | 15 | /** 16 | * 조회수 조회 17 | */ 18 | public Long read(Long postId) { 19 | String result = redisTemplate.opsForValue().get(generateKey(postId)); 20 | 21 | // redis 에 값이 없을 경우 0을 리턴 22 | return result == null ? 0L : Long.valueOf(result); 23 | } 24 | 25 | /** 26 | * 조회수 증가 27 | */ 28 | public Long increase(Long postId) { 29 | return redisTemplate.opsForValue().increment(generateKey(postId)); 30 | } 31 | 32 | /** 33 | * redis key 생성 34 | */ 35 | private String generateKey(Long postId) { 36 | return KEY_FORMAT.formatted(postId); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /services/views/driving/web/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val viewsApi: String by project 2 | val viewsApplication: String by project 3 | 4 | dependencies { 5 | api(project(viewsApi)) 6 | api(project(viewsApplication)) 7 | 8 | implementation("org.springframework.boot:spring-boot-starter-web") 9 | 10 | // validation 11 | compileOnly("jakarta.validation:jakarta.validation-api") 12 | compileOnly("jakarta.annotation:jakarta.annotation-api") 13 | 14 | // mapstruct 15 | compileOnly("org.mapstruct:mapstruct:1.6.3") 16 | annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") 17 | annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") 18 | } -------------------------------------------------------------------------------- /services/views/driving/web/src/main/java/nettee/board/web/ViewsCommandApi.java: -------------------------------------------------------------------------------- 1 | package nettee.board.web; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.views.Views; 5 | import nettee.views.usecase.ViewsUpdateUseCase; 6 | import org.springframework.web.bind.annotation.PostMapping; 7 | import org.springframework.web.bind.annotation.RequestBody; 8 | import org.springframework.web.bind.annotation.RequestMapping; 9 | import org.springframework.web.bind.annotation.RestController; 10 | 11 | @RestController 12 | @RequestMapping("views") 13 | @RequiredArgsConstructor 14 | public class ViewsCommandApi { 15 | private final ViewsUpdateUseCase viewsUpdateUseCase; 16 | 17 | @PostMapping("/increase") 18 | public void increase(@RequestBody Views views) { 19 | viewsUpdateUseCase.addViewCount(views); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /services/views/driving/web/src/main/java/nettee/board/web/ViewsQueryApi.java: -------------------------------------------------------------------------------- 1 | package nettee.board.web; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import nettee.views.usecase.ViewsReadUseCase; 5 | import nettee.views.usecase.ViewsUpdateUseCase; 6 | import org.springframework.web.bind.annotation.GetMapping; 7 | import org.springframework.web.bind.annotation.PathVariable; 8 | import org.springframework.web.bind.annotation.PostMapping; 9 | import org.springframework.web.bind.annotation.RequestMapping; 10 | import org.springframework.web.bind.annotation.RestController; 11 | 12 | @RestController 13 | @RequestMapping("views") 14 | @RequiredArgsConstructor 15 | public class ViewsQueryApi { 16 | private final ViewsReadUseCase viewsReadUseCase; 17 | 18 | @GetMapping("/increase/{postId}/count") 19 | public Long viewCount(@PathVariable Long postId) { 20 | return viewsReadUseCase.getViews(postId); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /services/views/driving/web/src/test/java/nettee/board/web/ViewsApiTest.kt: -------------------------------------------------------------------------------- 1 | package nettee.board.web 2 | 3 | import nettee.views.Views 4 | import org.junit.jupiter.api.Assertions.assertEquals 5 | import org.junit.jupiter.api.Test 6 | import org.springframework.web.client.RestClient 7 | import java.util.concurrent.CountDownLatch 8 | import java.util.concurrent.Executors 9 | 10 | 11 | class ViewsApiTest { 12 | 13 | private val restClient: RestClient = RestClient.create("http://localhost:5000") 14 | 15 | @Test 16 | fun viewIncreaseTest() { 17 | val executorService = Executors.newFixedThreadPool(100) 18 | val countDownLatch = CountDownLatch(100) 19 | 20 | val views = Views.builder() 21 | .postId(1L) 22 | .userId(1L) 23 | .build() 24 | 25 | repeat(100) { 26 | executorService.submit { 27 | restClient.post() 28 | .uri("/views/increase") 29 | .body(views) 30 | .retrieve() 31 | .toBodilessEntity() 32 | 33 | countDownLatch.countDown() 34 | } 35 | } 36 | 37 | countDownLatch.await() 38 | 39 | val viewCount = restClient.get() 40 | .uri("/views/increase/{postId}/count", views.postId) 41 | .retrieve() 42 | .body(Long::class.java) 43 | 44 | assertEquals(1L, viewCount) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /services/views/views.settings.gradle.kts: -------------------------------------------------------------------------------- 1 | val views: String by settings 2 | val viewsApi: String by settings 3 | val viewsDomain: String by settings 4 | val viewsApplication: String by settings 5 | val viewsRedisAdapter: String by settings 6 | val viewsRdbAdapter: String by settings 7 | val viewsWebMvcAdapter: String by settings 8 | 9 | fun getDirectories(vararg names: String): (String) -> File { 10 | var dir = rootDir 11 | for (name in names) { 12 | dir = dir.resolve(name) 13 | } 14 | return { targetName -> 15 | val directory = dir.walkTopDown().maxDepth(3) 16 | .filter(File::isDirectory) 17 | .associateBy { it.name } 18 | directory[targetName] ?: throw Error("그런 폴더가 없습니다: $targetName") 19 | } 20 | } 21 | 22 | val viewsDirectory = getDirectories("services", "views") 23 | 24 | // SERVICE/BOARD 25 | include( 26 | views, 27 | viewsApi, 28 | viewsDomain, 29 | viewsApplication, 30 | viewsRedisAdapter, 31 | viewsRdbAdapter, 32 | viewsWebMvcAdapter, 33 | ) 34 | 35 | project(views).projectDir = viewsDirectory("views") 36 | project(viewsApi).projectDir = viewsDirectory("api") 37 | project(viewsDomain).projectDir = viewsDirectory("domain") 38 | project(viewsApplication).projectDir = viewsDirectory("application") 39 | project(viewsRedisAdapter).projectDir = viewsDirectory("redis") 40 | project(viewsRdbAdapter).projectDir = viewsDirectory("rdb") 41 | project(viewsWebMvcAdapter).projectDir = viewsDirectory("web") 42 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "backend-sample-multi-module" 2 | 3 | val services = "${rootProject.projectDir}/services" 4 | 5 | apply(from = "common/common.settings.gradle.kts") 6 | apply(from = "core/core.settings.gradle.kts") 7 | apply(from = "monolith/monolith.settings.gradle.kts") 8 | 9 | apply(from = "$services/board/board.settings.gradle.kts") 10 | apply(from = "$services/article/article.settings.gradle.kts") 11 | apply(from = "$services/comment/comment.settings.gradle.kts") 12 | apply(from = "$services/views/views.settings.gradle.kts") 13 | --------------------------------------------------------------------------------