├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── -task-list-.md │ ├── basic-issue-template.md │ └── config.yml └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── README.md ├── build.gradle.kts ├── docker-compose.yml ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── src ├── main ├── java │ └── me │ │ └── nettee │ │ ├── HexagonalApplication.java │ │ ├── board │ │ ├── adapter │ │ │ ├── driven │ │ │ │ └── persistence │ │ │ │ │ ├── BoardCommandAdapter.java │ │ │ │ │ ├── BoardJpaRepository.java │ │ │ │ │ ├── BoardQueryAdapter.java │ │ │ │ │ ├── entity │ │ │ │ │ ├── BoardEntity.java │ │ │ │ │ └── type │ │ │ │ │ │ ├── BoardEntityStatus.java │ │ │ │ │ │ └── BoardEntityStatusConverter.java │ │ │ │ │ └── mapper │ │ │ │ │ └── BoardEntityMapper.java │ │ │ └── driving │ │ │ │ └── web │ │ │ │ ├── BoardCommandApi.java │ │ │ │ ├── BoardQueryApi.java │ │ │ │ ├── dto │ │ │ │ ├── BoardCommandDto.java │ │ │ │ └── BoardQueryDto.java │ │ │ │ └── mapper │ │ │ │ └── BoardDtoMapper.java │ │ └── application │ │ │ ├── domain │ │ │ ├── Board.java │ │ │ └── type │ │ │ │ └── BoardStatus.java │ │ │ ├── exception │ │ │ ├── BoardCommandErrorCode.java │ │ │ ├── BoardCommandException.java │ │ │ ├── BoardQueryErrorCode.java │ │ │ └── BoardQueryException.java │ │ │ ├── model │ │ │ └── BoardQueryModels.java │ │ │ ├── port │ │ │ ├── BoardCommandPort.java │ │ │ └── BoardQueryPort.java │ │ │ ├── service │ │ │ ├── BoardCommandService.java │ │ │ └── BoardQueryService.java │ │ │ └── usecase │ │ │ ├── BoardCreateUseCase.java │ │ │ ├── BoardDeleteUseCase.java │ │ │ ├── BoardReadByStatusesUseCase.java │ │ │ ├── BoardReadUseCase.java │ │ │ └── BoardUpdateUseCase.java │ │ ├── common │ │ └── exeption │ │ │ ├── CustomException.java │ │ │ ├── ErrorCode.java │ │ │ ├── GlobalExceptionHandler.java │ │ │ └── response │ │ │ └── ApiErrorResponse.java │ │ └── core │ │ └── jpa │ │ ├── config │ │ └── JpaConfig.java │ │ └── support │ │ ├── BaseTimeEntity.java │ │ ├── LongBaseEntity.java │ │ ├── LongBaseTimeEntity.java │ │ ├── UuidBaseEntity.java │ │ └── UuidBaseTimeEntity.java └── resources │ ├── application-local.yml │ ├── application.yml │ └── db │ └── migration │ └── v1_0 │ ├── V1_0_0__init_schema.sql │ ├── V1_0_1__create_tb_board.sql │ └── V1_0_2__alter_board_status_type_to_integer.sql └── test ├── kotlin └── me │ └── nettee │ ├── HexagonalApplicationTests.kt │ ├── board │ ├── adapter │ │ ├── driven │ │ │ └── persistence │ │ │ │ ├── BoardCommandAdapterTest.kt │ │ │ │ └── BoardQueryAdapterTest.kt │ │ └── driving │ │ │ └── web │ │ │ ├── BoardCommandApiTest.kt │ │ │ └── BoardQueryApiTest.kt │ └── application │ │ └── service │ │ ├── BoardCommandServiceTest.kt │ │ └── BoardQueryServiceTest.kt │ ├── common │ └── ApiErrorResponseTest.kt │ └── core │ └── jpa │ └── JpaTransactionalFreeSpec.kt └── resources └── application.yml /.gitattributes: -------------------------------------------------------------------------------- 1 | /gradlew text eol=lf 2 | *.bat text eol=crlf 3 | *.jar binary 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-task-list-.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "[TASK LIST]" 3 | about: 할당된 작업 및 이슈를 모아 보는 상위 이슈입니다. 4 | title: "[TASK LIST] OOO 작업 목록 (~, 2025)" 5 | labels: 'status: new, type: task' 6 | assignees: '' 7 | 8 | --- 9 | 10 | # 작업 개요 11 | 12 | 13 | 14 | ## 작업 목록 및 서브 이슈 15 | 16 | 17 | 18 | - [ ] 19 | - [ ] 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/basic-issue-template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Basic Issue Template 3 | about: issue 등록을 위한 template입니다. 4 | title: issue 제목을 입력해주세요. 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | # 이슈 개요 11 | 12 | 13 | 14 | ## 작업 목록 15 | 16 | - [ ] 17 | - [ ] 18 | 19 | # 부연 설명 20 | 21 | 22 | 23 | ## 필요 시 제목을 붙여 작성 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 존잘남 깃허브 바로가기 4 | url: https://github.com/silberbullet 5 | about: 품절남 6 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | ## Issues 4 | 5 | Resolves #0 6 | 7 | 8 | 9 | ## Description 10 | 11 | - 주요 변경 사항에 대한 간단한 설명을 작성해 주세요. 12 | - 관련 이슈 번호를 포함해 주세요 (예: `#123`). 13 | 14 | ## How Has This Been Tested? 15 | 16 | - 변경 사항을 테스트하는 방법에 대해 설명해 주세요. 17 | - 어떤 환경에서 테스트가 이루어졌는지 명시해 주세요. 18 | 19 | ## Additional Notes 20 | 21 | - 이 PR과 관련된 추가적인 정보가 있다면 여기에 기재해 주세요. 22 | 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | - [**Sample Code Registry**](https://github.com/nettee-space/backend-sample-code-registry) 2 | 1. ⠀⠀ [**Layerd**](https://github.com/nettee-space/backend-sample-layered-simple-crud) 3 | 2. ▶⠀ **Hexagonal** (Here) 4 | 3. ⠀⠀ [**Multi-Module Project**](https://github.com/nettee-space/backend-sample-multi-module) 5 | 6 | # Introduction. 7 | 8 | 이 샘플 프로젝트는 레이어드 아키텍처를 넘어 확장성과 유연성을 보장하고 DDD 철학에 걸맞는 헥사고날 아키텍처 기반으로 구현되었습니다. 9 | 10 | 기본적인 CRUD 동작과 헥사고날 아키텍처의 구조를 파악하고 관점을 익힐 수 있도록 하였습니다. 11 | 12 | 다음 항목을 포함합니다. 13 | 14 | - 헥사고날 아키텍처의 의미 수준의 구현을 따릅니다. 15 | - 폴더 구조의 시작점은 **높은 응집도를 위해** 도메인 기준으로 설계합니다. 16 | 17 | ``` 18 | me.nettee 19 | ├── common 20 | └── board 21 | ├── adapter -- 외부와의 상호작용을 처리하는 계층, application 입출력을 담당 22 | │ ├── driving -- 외부로부터의 입력을 처리하는 어댑터 23 | │ │ └── web -- Web 요청을 처리하는 컨트롤러 및 DTO 정의 24 | │ │ ├── dto 25 | │ │ └── mapper -- DTO ↔ Domain 매핑 클래스 26 | │ └── driven -- 외부로의 출력을 처리하는 어댑터 (DB, Event, Messaging 처리) 27 | │ └── persistence -- 영속성 계층의 구현체 (예: JPA, MyBatis 등) 28 | │ ├── entity 29 | │ └── mapper -- Entity ↔ Domain 매핑 클래스 30 | └── application -- 핵심 비즈니스 로직이 포함된 계층, 도메인과 유스케이스를 정의 31 | ├── domain -- 비즈니스 도메인 정의 32 | ├── port -- adpater와 상호작용을 위한 인터페이스 정의 33 | ├── service -- 비즈니스 로직을 처리하는 서비스 클래스 34 | └── usecase -- 특정 유스케이스(기능) 인터페이스 정의 35 | ``` 36 | 37 | Adapter는 Application 계층에 정의된 port를 구현합니다. 38 | 39 | - Driving Adapter는 시스템 외부에서 들어오는 요청을 담당합니다. (HTTP 요청, 메시지 소비) 이후 애플리케이션에 전달합니다. 40 | - Driven Adpater는 시스템이 외부로 나가는 작업을 담당합니다. (DB CRUD 작업, 외부 API 호출, 메시지 전달) 41 | - 실습에서 Driven Adapter는 RDB Adapter로, Spring Data JPA를 사용합니다. 42 | 43 | # Prerequisites 44 | 45 | - JDK 21 46 | You can use OpenJDK e.g. Amazon Corretto 21 47 | 48 | # Branch Rule 49 | 50 | 개발자들은 다음과 같은 Branch Rule을 꼭 숙지하고 준수해 주시기 바랍니다. (간소화된 브랜치 운영) 51 | 52 | - **main 브랜치는 읽기 전용 입니다.** 53 | - main 브랜치는 관리자([`@merge-simpson`](https://github.com/merge-simpson), [`@silberbullet`](https://github.com/silberbullet))만 force push가 가능합니다. 54 | - **feature 브랜치**: 모든 변경 사항은 feature 브랜치를 생성 후, main 브랜치로 병합해야 합니다. 55 | - `feature/기능명` 양식으로 명명하며, 영문 소문자, 숫자 및 하이픈(케밥 케이스)를 사용합니다. (추가적인 슬래시를 사용하지 않습니다.) 56 | 57 | ```mermaid 58 | gitGraph 59 | commit 60 | commit 61 | branch feature/board-example 62 | branch feature/board-something 63 | checkout feature/board-example 64 | commit 65 | checkout feature/board-something 66 | commit 67 | commit 68 | checkout feature/board-example 69 | commit 70 | checkout main 71 | merge feature/board-example 72 | checkout main 73 | merge feature/board-something 74 | commit 75 | ``` 76 | 77 | - **주요 브랜치에 병합 전 Pull Request(PR)는 필수입니다.** 78 | - Pull Request를 생성할 때, 최소 2명의 reviewer를 지정해야 합니다. 79 | - 관리자([@merge-simpson](https://github.com/merge-simpson), [@silberbullet](https://github.com/silberbullet))는 리뷰 없이 병합이 가능합니다. 80 | - **코드에 대한 모든 논의(conversations)가 해결(resolved)되지 않은 상태에서는 Pull Request를 병합할 수 없습니다.** 81 |
82 | conversations 예시 보기 83 | 84 | 1. @silberbullet 님이 pull request 생성 후, reviewer를 @merge-simpson 에게 신청하였습니다. 85 | 2. @merge-simpson 님은 코드 수정을 위해 comment를 남겼습니다. 86 | 3. @silberbullet 님은 해당 코드를 수정하여 push 후 @merge-simpson 님이 남긴 comment에 수정사항을 적어 놓았습니다. 87 | 4. @merge-simpson "Resolve conversation" 버튼을 클릭하여 피드백이 해결되었음을 표시합니다. 88 | 5. 비로소 @silberbullet 님은 코드 병합이 가능합니다. 89 | 90 |
91 | 92 | # Commit Message 93 | 94 | 커밋 메시지의 제1 규칙은 '알아볼 수 있는 메시지 전달'입니다. 95 | 보편적인 앵귤러 커밋 메시지 컨벤션을 따르면서, 각 포맷의 바운더리와 표현 수준은 팀에 맞게 차근차근 조정해 가면 좋겠습니다. 96 | 97 | ## Basic Commit Message Format 98 | 99 | 커밋 메시지의 첫 단어는 작업의 목적을 명확히 하기 위해 커밋 타입으로 시작합니다. 100 | 101 | > **type**(scope): subject in lowercase 102 | 103 | 아래의 타입을 실습으로 사용해 보시면 좋습니다. 104 | 105 | - **feat**: 새로운 기능 추가 106 | - **fix**: 버그 수정 107 | - **docs**: 문서 생성 및 수정 (README.md 등) 108 | - **refactor**: 코드 리팩토링 (기능 변화 없음: 성능 개선, 패키지 이동, 파일·식별자 수정 등) 109 | - **test**: 테스트 코드 추가 또는 수정 110 | - **chore**: 코드의 구조나 동작에 영향을 주지 않는 기타 작업 111 | - **build**: 빌드 관련 작업, 패키지 매니저 설정 등 112 | 113 | # Contact. 114 | 115 | - [:octocat: Merge Simpson](https://github.com/merge-simpson) 116 | - [:octocat: Silberbullet](https://github.com/silberbullet) (No silver bullet) 117 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 3 | 4 | plugins { 5 | java 6 | id("org.springframework.boot") version "3.4.1" 7 | id("io.spring.dependency-management") version "1.1.7" 8 | kotlin("jvm") version "1.9.22" 9 | kotlin("plugin.spring") version "1.9.22" 10 | } 11 | 12 | group = "me.nettee" 13 | version = "0.0.1-SNAPSHOT" 14 | 15 | java { 16 | toolchain { 17 | languageVersion = JavaLanguageVersion.of(21) 18 | sourceCompatibility = JavaVersion.VERSION_21 19 | targetCompatibility = JavaVersion.VERSION_21 20 | } 21 | } 22 | 23 | configurations { 24 | compileOnly { 25 | extendsFrom(configurations.annotationProcessor.get()) 26 | } 27 | configureEach { 28 | exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging") 29 | } 30 | } 31 | 32 | repositories { 33 | mavenCentral() 34 | } 35 | 36 | dependencies { 37 | implementation("org.springframework.boot:spring-boot-starter-web") 38 | implementation("org.springframework.boot:spring-boot-starter-data-jpa") 39 | implementation("org.springframework.boot:spring-boot-starter-validation") 40 | 41 | // logging 42 | implementation("org.springframework.boot:spring-boot-starter-log4j2") 43 | 44 | // database 45 | runtimeOnly("org.postgresql:postgresql:42.7.4") 46 | 47 | // flyway 48 | implementation("org.flywaydb:flyway-database-postgresql") 49 | 50 | // lombok 51 | compileOnly("org.projectlombok:lombok") 52 | annotationProcessor("org.projectlombok:lombok") 53 | testCompileOnly("org.projectlombok:lombok") 54 | testAnnotationProcessor("org.projectlombok:lombok") 55 | 56 | // mapstruct 57 | implementation("org.mapstruct:mapstruct:1.6.3") 58 | annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") 59 | annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") 60 | 61 | // jackson 62 | implementation("com.fasterxml.jackson.core:jackson-annotations:2.15.2") 63 | 64 | // querydsl 65 | implementation("com.querydsl:querydsl-jpa:${dependencyManagement.importedProperties["querydsl.version"]}:jakarta") 66 | annotationProcessor("com.querydsl:querydsl-apt:${dependencyManagement.importedProperties["querydsl.version"]}:jakarta") 67 | annotationProcessor("jakarta.persistence:jakarta.persistence-api") 68 | annotationProcessor("jakarta.annotation:jakarta.annotation-api") 69 | 70 | // test 71 | testImplementation("org.springframework.boot:spring-boot-starter-test") 72 | testImplementation("com.h2database:h2") 73 | 74 | // test tool 75 | testImplementation("io.kotest:kotest-runner-junit5:5.9.1") 76 | testImplementation("io.mockk:mockk:1.13.12") 77 | testImplementation(kotlin("script-runtime")) 78 | testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.3") 79 | } 80 | 81 | kotlin{ 82 | sourceSets { 83 | test { 84 | kotlin.srcDirs(listOf("src/test/kotlin")) 85 | } 86 | } 87 | } 88 | 89 | tasks.withType { 90 | useJUnitPlatform() 91 | jvmArgs("--enable-preview") 92 | } 93 | 94 | tasks.withType { 95 | compilerOptions { 96 | jvmTarget = JvmTarget.JVM_21 97 | } 98 | } 99 | 100 | tasks.withType { 101 | options.compilerArgs.addAll(listOf( 102 | "--enable-preview", 103 | "-Amapstruct.defaultComponentModel=spring", 104 | )) 105 | } 106 | 107 | tasks.named("bootRun") { 108 | jvmArgs("--enable-preview") 109 | } -------------------------------------------------------------------------------- /docker-compose.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 | volumes: 20 | sticky_volume_demo_postgres14: -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nettee-space/backend-sample-hexagonal-simple-crud/91120f5624db8e3f90173cbc07192392adc44c43/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s 90 | ' "$PWD" ) || exit 91 | 92 | # Use the maximum available, or set MAX_FD != -1 to use that value. 93 | MAX_FD=maximum 94 | 95 | warn () { 96 | echo "$*" 97 | } >&2 98 | 99 | die () { 100 | echo 101 | echo "$*" 102 | echo 103 | exit 1 104 | } >&2 105 | 106 | # OS specific support (must be 'true' or 'false'). 107 | cygwin=false 108 | msys=false 109 | darwin=false 110 | nonstop=false 111 | case "$( uname )" in #( 112 | CYGWIN* ) cygwin=true ;; #( 113 | Darwin* ) darwin=true ;; #( 114 | MSYS* | MINGW* ) msys=true ;; #( 115 | NONSTOP* ) nonstop=true ;; 116 | esac 117 | 118 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 119 | 120 | 121 | # Determine the Java command to use to start the JVM. 122 | if [ -n "$JAVA_HOME" ] ; then 123 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 124 | # IBM's JDK on AIX uses strange locations for the executables 125 | JAVACMD=$JAVA_HOME/jre/sh/java 126 | else 127 | JAVACMD=$JAVA_HOME/bin/java 128 | fi 129 | if [ ! -x "$JAVACMD" ] ; then 130 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 131 | 132 | Please set the JAVA_HOME variable in your environment to match the 133 | location of your Java installation." 134 | fi 135 | else 136 | JAVACMD=java 137 | if ! command -v java >/dev/null 2>&1 138 | then 139 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 140 | 141 | Please set the JAVA_HOME variable in your environment to match the 142 | location of your Java installation." 143 | fi 144 | fi 145 | 146 | # Increase the maximum file descriptors if we can. 147 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 148 | case $MAX_FD in #( 149 | max*) 150 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 151 | # shellcheck disable=SC2039,SC3045 152 | MAX_FD=$( ulimit -H -n ) || 153 | warn "Could not query maximum file descriptor limit" 154 | esac 155 | case $MAX_FD in #( 156 | '' | soft) :;; #( 157 | *) 158 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 159 | # shellcheck disable=SC2039,SC3045 160 | ulimit -n "$MAX_FD" || 161 | warn "Could not set maximum file descriptor limit to $MAX_FD" 162 | esac 163 | fi 164 | 165 | # Collect all arguments for the java command, stacking in reverse order: 166 | # * args from the command line 167 | # * the main class name 168 | # * -classpath 169 | # * -D...appname settings 170 | # * --module-path (only if needed) 171 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 172 | 173 | # For Cygwin or MSYS, switch paths to Windows format before running java 174 | if "$cygwin" || "$msys" ; then 175 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 176 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 177 | 178 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 179 | 180 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 181 | for arg do 182 | if 183 | case $arg in #( 184 | -*) false ;; # don't mess with options #( 185 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 186 | [ -e "$t" ] ;; #( 187 | *) false ;; 188 | esac 189 | then 190 | arg=$( cygpath --path --ignore --mixed "$arg" ) 191 | fi 192 | # Roll the args list around exactly as many times as the number of 193 | # args, so each arg winds up back in the position where it started, but 194 | # possibly modified. 195 | # 196 | # NB: a `for` loop captures its iteration list before it begins, so 197 | # changing the positional parameters here affects neither the number of 198 | # iterations, nor the values presented in `arg`. 199 | shift # remove old arg 200 | set -- "$@" "$arg" # push replacement arg 201 | done 202 | fi 203 | 204 | 205 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 206 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 207 | 208 | # Collect all arguments for the java command: 209 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 210 | # and any embedded shellness will be escaped. 211 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 212 | # treated as '${Hostname}' itself on the command line. 213 | 214 | set -- \ 215 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 216 | -classpath "$CLASSPATH" \ 217 | org.gradle.wrapper.GradleWrapperMain \ 218 | "$@" 219 | 220 | # Stop when "xargs" is not available. 221 | if ! command -v xargs >/dev/null 2>&1 222 | then 223 | die "xargs is not available" 224 | fi 225 | 226 | # Use "xargs" to parse quoted args. 227 | # 228 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 229 | # 230 | # In Bash we could simply go: 231 | # 232 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 233 | # set -- "${ARGS[@]}" "$@" 234 | # 235 | # but POSIX shell has neither arrays nor command substitution, so instead we 236 | # post-process each arg (as a line of input to sed) to backslash-escape any 237 | # character that might be a shell metacharacter, then use eval to reverse 238 | # that process (while maintaining the separation between arguments), and wrap 239 | # the whole thing up as a single "set" statement. 240 | # 241 | # This will of course break if any of these variables contains a newline or 242 | # an unmatched quote. 243 | # 244 | 245 | eval "set -- $( 246 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 247 | xargs -n1 | 248 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 249 | tr '\n' ' ' 250 | )" '"$@"' 251 | 252 | exec "$JAVACMD" "$@" 253 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | rootProject.name = "hexagonal" 2 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/HexagonalApplication.java: -------------------------------------------------------------------------------- 1 | package me.nettee; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | 7 | @SpringBootApplication 8 | public class HexagonalApplication { 9 | 10 | public static void main(String[] args) { 11 | SpringApplication.run(HexagonalApplication.class, args); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driven/persistence/BoardCommandAdapter.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driven.persistence; 2 | 3 | import static me.nettee.board.application.exception.BoardCommandErrorCode.BOARD_NOT_FOUND; 4 | import static me.nettee.board.application.exception.BoardCommandErrorCode.DEFAULT; 5 | 6 | import java.util.Optional; 7 | import lombok.RequiredArgsConstructor; 8 | import me.nettee.board.adapter.driven.persistence.entity.type.BoardEntityStatus; 9 | import me.nettee.board.adapter.driven.persistence.mapper.BoardEntityMapper; 10 | import me.nettee.board.application.domain.Board; 11 | import me.nettee.board.application.domain.type.BoardStatus; 12 | import me.nettee.board.application.port.BoardCommandPort; 13 | import org.springframework.dao.DataAccessException; 14 | import org.springframework.stereotype.Repository; 15 | 16 | @Repository 17 | @RequiredArgsConstructor 18 | public class BoardCommandAdapter implements BoardCommandPort { 19 | 20 | private final BoardJpaRepository boardJpaRepository; 21 | private final BoardEntityMapper boardEntityMapper; 22 | 23 | @Override 24 | public Optional findById(Long id) { 25 | var board = boardJpaRepository.findById(id) 26 | .orElseThrow(BOARD_NOT_FOUND::exception); 27 | 28 | return boardEntityMapper.toOptionalDomain(board); 29 | } 30 | 31 | @Override 32 | public Board create(Board board) { 33 | var boardEntity = boardEntityMapper.toEntity(board); 34 | try { 35 | var newBoard = boardJpaRepository.save(boardEntity); 36 | boardJpaRepository.flush(); 37 | return boardEntityMapper.toDomain(newBoard); 38 | } catch (DataAccessException e) { 39 | throw DEFAULT.exception(e); 40 | } 41 | } 42 | 43 | @Override 44 | public Board update(Board board) { 45 | var existBoard = boardJpaRepository.findById(board.getId()) 46 | .orElseThrow(BOARD_NOT_FOUND::exception); 47 | 48 | existBoard.prepareUpdate() 49 | .title(board.getTitle()) 50 | .content(board.getContent()) 51 | .status(BoardEntityStatus.valueOf(board.getStatus())) 52 | .update(); 53 | 54 | return boardEntityMapper.toDomain(boardJpaRepository.save(existBoard)); 55 | } 56 | 57 | @Override 58 | public void updateStatus(Long id, BoardStatus status) { 59 | var board = boardJpaRepository.findById(id) 60 | .orElseThrow(BOARD_NOT_FOUND::exception); 61 | 62 | board.prepareUpdateStatus() 63 | .status(BoardEntityStatus.valueOf(status)) 64 | .updateStatus(); 65 | 66 | boardJpaRepository.save(board); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driven/persistence/BoardJpaRepository.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driven.persistence; 2 | 3 | import me.nettee.board.adapter.driven.persistence.entity.BoardEntity; 4 | import me.nettee.board.application.domain.type.BoardStatus; 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.PageRequest; 7 | import org.springframework.data.domain.Pageable; 8 | import org.springframework.data.jpa.repository.JpaRepository; 9 | 10 | import java.util.Set; 11 | 12 | public interface BoardJpaRepository extends JpaRepository { 13 | Page findByStatusIn(Set statuses, Pageable pageable); 14 | Page findByStatus(BoardStatus status, PageRequest pageRequest); 15 | } -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driven/persistence/BoardQueryAdapter.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driven.persistence; 2 | 3 | import static me.nettee.board.adapter.driven.persistence.entity.QBoardEntity.boardEntity; 4 | 5 | import java.util.Map; 6 | import java.util.Optional; 7 | import java.util.Set; 8 | import java.util.concurrent.ConcurrentHashMap; 9 | import java.util.stream.Collectors; 10 | import me.nettee.board.adapter.driven.persistence.entity.BoardEntity; 11 | import me.nettee.board.adapter.driven.persistence.entity.type.BoardEntityStatus; 12 | import me.nettee.board.adapter.driven.persistence.mapper.BoardEntityMapper; 13 | import me.nettee.board.application.domain.type.BoardStatus; 14 | import me.nettee.board.application.model.BoardQueryModels.BoardDetail; 15 | import me.nettee.board.application.model.BoardQueryModels.BoardSummary; 16 | import me.nettee.board.application.port.BoardQueryPort; 17 | import org.springframework.data.domain.Page; 18 | import org.springframework.data.domain.Pageable; 19 | import org.springframework.data.jpa.repository.support.QuerydslRepositorySupport; 20 | import org.springframework.data.support.PageableExecutionUtils; 21 | import org.springframework.stereotype.Repository; 22 | 23 | @Repository 24 | public class BoardQueryAdapter extends QuerydslRepositorySupport implements BoardQueryPort { 25 | private final BoardEntityMapper boardEntityMapper; 26 | private Map, Set> statusMap = new ConcurrentHashMap<>(); 27 | 28 | public BoardQueryAdapter( 29 | BoardEntityMapper boardEntityMapper) { 30 | super(BoardEntity.class); 31 | this.boardEntityMapper = boardEntityMapper; 32 | } 33 | 34 | @Override 35 | public Optional findById(Long id) { 36 | return boardEntityMapper.toOptionalBoardDetail( 37 | getQuerydsl().createQuery() 38 | .select(boardEntity) 39 | .from(boardEntity) 40 | .where(boardEntity.id.eq(id) 41 | ).fetchOne() 42 | ); 43 | } 44 | 45 | @Override 46 | public Page findAll(Pageable pageable) { 47 | // 기본 쿼리 생성 48 | var query = getQuerydsl().createQuery() 49 | .select(boardEntity) 50 | .from(boardEntity) 51 | .where(); 52 | 53 | // pageable 정렬 조건 적용 54 | pageable.getSort().forEach(order -> { 55 | if (order.isAscending()) { 56 | query.orderBy(boardEntity.createdAt.asc()); // createdAt 필드를 기준으로 오름차순 정렬 57 | } else { 58 | query.orderBy(boardEntity.createdAt.desc()); // createdAt 필드를 기준으로 내림차순 정렬 59 | } 60 | }); 61 | 62 | var result = query 63 | .offset(pageable.getOffset()) // 현재 페이지의 오프셋 설정 64 | .limit(pageable.getPageSize())// 페이지 크기 설정 65 | .fetch(); // 쿼리 실행 66 | 67 | var totalCount = getQuerydsl().createQuery() 68 | .select(boardEntity.count()) 69 | .from(boardEntity) 70 | .where(); 71 | 72 | return PageableExecutionUtils.getPage( 73 | result.stream().map(boardEntityMapper::toBoardSummary).toList(), 74 | pageable, 75 | totalCount::fetchOne 76 | ); 77 | } 78 | 79 | @Override 80 | public Page findByStatusesList(Set statuses, Pageable pageable) { 81 | // 기본 쿼리 생성 82 | var boardEntityStatuses = statusMap.computeIfAbsent( 83 | statuses, 84 | (ignore) -> statuses.stream() 85 | .map(BoardEntityStatus::valueOf) 86 | .collect(Collectors.toSet()) 87 | ); 88 | 89 | var query = getQuerydsl().createQuery() 90 | .select(boardEntity) 91 | .from(boardEntity) 92 | .where(boardEntity.status.in(boardEntityStatuses)); 93 | 94 | // pageable 정렬 조건 적용 95 | pageable.getSort().forEach(order -> 96 | query.orderBy(order.isAscending() ? 97 | boardEntity.createdAt.asc() : 98 | boardEntity.createdAt.desc()) 99 | ); 100 | 101 | var result = query 102 | .offset(pageable.getOffset()) // 현재 페이지의 오프셋 설정 103 | .limit(pageable.getPageSize()) // 페이지 크기 설정 104 | .fetch(); 105 | 106 | var totalCount = getQuerydsl().createQuery() 107 | .select(boardEntity.count()) 108 | .from(boardEntity) 109 | .where(boardEntity.status.in(boardEntityStatuses)); 110 | 111 | return PageableExecutionUtils.getPage( 112 | result.stream() 113 | .map(boardEntityMapper::toBoardSummary) 114 | .toList(), 115 | pageable, 116 | totalCount::fetchOne 117 | ); 118 | } 119 | } -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driven/persistence/entity/BoardEntity.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driven.persistence.entity; 2 | 3 | import jakarta.persistence.Convert; 4 | import jakarta.persistence.Entity; 5 | import java.util.Objects; 6 | import lombok.AccessLevel; 7 | import lombok.Builder; 8 | import lombok.Getter; 9 | import lombok.NoArgsConstructor; 10 | import me.nettee.board.adapter.driven.persistence.entity.type.BoardEntityStatus; 11 | import me.nettee.board.adapter.driven.persistence.entity.type.BoardEntityStatusConverter; 12 | import me.nettee.core.jpa.support.LongBaseTimeEntity; 13 | import org.hibernate.annotations.DynamicUpdate; 14 | @Getter 15 | @DynamicUpdate 16 | @NoArgsConstructor(access = AccessLevel.PROTECTED) 17 | @Entity(name = "board") 18 | public class BoardEntity extends LongBaseTimeEntity { 19 | private String title; 20 | private String content; 21 | 22 | @Convert(converter = BoardEntityStatusConverter.class) 23 | private BoardEntityStatus status; 24 | 25 | @Builder 26 | public BoardEntity(String title, String content, BoardEntityStatus status) { 27 | this.title = title; 28 | this.content = content; 29 | this.status = status; 30 | } 31 | 32 | @Builder( 33 | builderClassName = "UpdateBoardBuilder", 34 | builderMethodName = "prepareUpdate", 35 | buildMethodName = "update" 36 | ) 37 | public void updateBoard(String title, String content, BoardEntityStatus status) { 38 | Objects.requireNonNull(title, "title cannot be null"); 39 | Objects.requireNonNull(content, "content cannot be null"); 40 | Objects.requireNonNull(status, "status cannot be null"); 41 | 42 | this.title = title; 43 | this.content = content; 44 | this.status = status; 45 | } 46 | 47 | @Builder( 48 | builderClassName = "UpdateStatusBoardBuilder", 49 | builderMethodName = "prepareUpdateStatus", 50 | buildMethodName = "updateStatus" 51 | ) 52 | public void updateStatus(BoardEntityStatus status) { 53 | Objects.requireNonNull(status, "status cannot be null"); 54 | this.status = status; 55 | } 56 | } -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driven/persistence/entity/type/BoardEntityStatus.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driven.persistence.entity.type; 2 | 3 | import me.nettee.board.application.domain.type.BoardStatus; 4 | 5 | import static me.nettee.board.application.exception.BoardCommandErrorCode.DEFAULT; 6 | 7 | public enum BoardEntityStatus { 8 | REMOVED(0), 9 | PENDING(10), 10 | ACTIVE(20), 11 | SUSPENDED(30); 12 | 13 | private final int value; 14 | 15 | BoardEntityStatus(int value) { 16 | this.value = value; 17 | } 18 | 19 | public int getValue() { 20 | return value; 21 | } 22 | 23 | public static BoardEntityStatus valueOf(BoardStatus boardStatus) { 24 | return switch (boardStatus){ 25 | case REMOVED -> REMOVED; 26 | case PENDING -> PENDING; 27 | case ACTIVE -> ACTIVE; 28 | case SUSPENDED -> SUSPENDED; 29 | }; 30 | } 31 | 32 | public static BoardEntityStatus valueOf(int value) { 33 | return switch (value) { 34 | case 0 -> REMOVED; 35 | case 10 -> PENDING; 36 | case 20 -> ACTIVE; 37 | case 30 -> SUSPENDED; 38 | default -> throw DEFAULT.exception(); 39 | }; 40 | } 41 | } -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driven/persistence/entity/type/BoardEntityStatusConverter.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driven.persistence.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.getValue(); 12 | } 13 | 14 | @Override 15 | public BoardEntityStatus convertToEntityAttribute(Integer value) { 16 | return BoardEntityStatus.valueOf(value); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driven/persistence/mapper/BoardEntityMapper.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driven.persistence.mapper; 2 | 3 | import java.util.Optional; 4 | import me.nettee.board.adapter.driven.persistence.entity.BoardEntity; 5 | import me.nettee.board.application.domain.Board; 6 | import me.nettee.board.application.model.BoardQueryModels.BoardDetail; 7 | import me.nettee.board.application.model.BoardQueryModels.BoardSummary; 8 | import org.mapstruct.Mapper; 9 | 10 | @Mapper(componentModel = "spring") 11 | public interface BoardEntityMapper { 12 | 13 | Board toDomain(BoardEntity boardEntity); 14 | 15 | BoardEntity toEntity(Board board); 16 | 17 | BoardDetail toBoardDetail(BoardEntity boardEntity); 18 | 19 | BoardSummary toBoardSummary(BoardEntity boardEntity); 20 | 21 | default Optional toOptionalDomain(BoardEntity boardEntity) { 22 | return Optional.ofNullable(toDomain(boardEntity)); 23 | } 24 | 25 | default Optional toOptionalBoardDetail(BoardEntity boardEntity) { 26 | return Optional.ofNullable(toBoardDetail(boardEntity)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driving/web/BoardCommandApi.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driving.web; 2 | 3 | import jakarta.validation.Valid; 4 | import lombok.RequiredArgsConstructor; 5 | import me.nettee.board.adapter.driving.web.dto.BoardCommandDto.BoardCommandResponse; 6 | import me.nettee.board.adapter.driving.web.dto.BoardCommandDto.BoardCreateCommand; 7 | import me.nettee.board.adapter.driving.web.dto.BoardCommandDto.BoardUpdateCommand; 8 | import me.nettee.board.adapter.driving.web.mapper.BoardDtoMapper; 9 | import me.nettee.board.application.usecase.BoardCreateUseCase; 10 | import me.nettee.board.application.usecase.BoardDeleteUseCase; 11 | import me.nettee.board.application.usecase.BoardUpdateUseCase; 12 | import org.springframework.http.HttpStatus; 13 | import org.springframework.web.bind.annotation.DeleteMapping; 14 | import org.springframework.web.bind.annotation.PatchMapping; 15 | import org.springframework.web.bind.annotation.PathVariable; 16 | import org.springframework.web.bind.annotation.PostMapping; 17 | import org.springframework.web.bind.annotation.RequestBody; 18 | import org.springframework.web.bind.annotation.RequestMapping; 19 | import org.springframework.web.bind.annotation.ResponseStatus; 20 | import org.springframework.web.bind.annotation.RestController; 21 | 22 | @RestController 23 | @RequestMapping("/api/v1/boards") 24 | @RequiredArgsConstructor 25 | public class BoardCommandApi { 26 | 27 | private final BoardCreateUseCase boardCreateUseCase; 28 | private final BoardUpdateUseCase boardUpdateUseCase; 29 | private final BoardDeleteUseCase boardDeleteUseCase; 30 | 31 | private final BoardDtoMapper mapper; 32 | 33 | @PostMapping 34 | @ResponseStatus(HttpStatus.CREATED) 35 | public BoardCommandResponse createBoard(@RequestBody @Valid BoardCreateCommand boardCreateCommand) { 36 | // Map to Domain 37 | var board = mapper.toDomain(boardCreateCommand); 38 | 39 | return BoardCommandResponse.builder() 40 | .board(boardCreateUseCase.createBoard(board)) 41 | .build(); 42 | } 43 | 44 | @PatchMapping("/{id}") 45 | @ResponseStatus(HttpStatus.OK) 46 | public BoardCommandResponse updateBoard( 47 | @PathVariable("id") Long id, 48 | @Valid @RequestBody BoardUpdateCommand boardUpdateCommand 49 | ) { 50 | // Map to Domain 51 | var board = mapper.toDomain(id, boardUpdateCommand); 52 | 53 | return BoardCommandResponse.builder() 54 | .board(boardUpdateUseCase.updateBoard(board)) 55 | .build(); 56 | } 57 | 58 | @DeleteMapping("/{id}") 59 | @ResponseStatus(HttpStatus.NO_CONTENT) 60 | public void deleteBoard(@PathVariable("id") Long id) { 61 | boardDeleteUseCase.deleteBoard(id); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driving/web/BoardQueryApi.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driving.web; 2 | 3 | import lombok.RequiredArgsConstructor; 4 | import me.nettee.board.adapter.driving.web.dto.BoardQueryDto.BoardDetailResponse; 5 | import me.nettee.board.adapter.driving.web.mapper.BoardDtoMapper; 6 | import me.nettee.board.application.domain.type.BoardStatus; 7 | import me.nettee.board.application.model.BoardQueryModels.BoardSummary; 8 | import me.nettee.board.application.usecase.BoardReadByStatusesUseCase; 9 | import me.nettee.board.application.usecase.BoardReadUseCase; 10 | import org.springframework.data.domain.Page; 11 | import org.springframework.data.domain.Pageable; 12 | import org.springframework.web.bind.annotation.GetMapping; 13 | import org.springframework.web.bind.annotation.PathVariable; 14 | import org.springframework.web.bind.annotation.RequestMapping; 15 | import org.springframework.web.bind.annotation.RequestParam; 16 | import org.springframework.web.bind.annotation.RestController; 17 | 18 | import java.util.Set; 19 | 20 | @RestController 21 | @RequestMapping("/api/v1/boards") 22 | @RequiredArgsConstructor 23 | public class BoardQueryApi { 24 | 25 | private final BoardReadUseCase boardReadUseCase; 26 | private final BoardReadByStatusesUseCase boardReadByStatusesUseCase; 27 | 28 | private final BoardDtoMapper mapper; 29 | 30 | @GetMapping("/{boardId}") 31 | public BoardDetailResponse getBoard(@PathVariable("boardId") long boardId) { 32 | var board = boardReadUseCase.getBoard(boardId); 33 | 34 | return mapper.toDtoDetail(board); 35 | } 36 | 37 | @GetMapping 38 | public Page getBoardsByStatuses(@RequestParam(defaultValue = "ACTIVE,SUSPENDED") Set statuses, Pageable pageable) { 39 | return boardReadByStatusesUseCase.findByStatuses(statuses, pageable); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driving/web/dto/BoardCommandDto.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driving.web.dto; 2 | 3 | import com.fasterxml.jackson.annotation.JsonRootName; 4 | import jakarta.validation.constraints.NotBlank; 5 | import jakarta.validation.constraints.Size; 6 | import lombok.Builder; 7 | import me.nettee.board.application.domain.Board; 8 | 9 | public final class BoardCommandDto { 10 | 11 | private BoardCommandDto() { 12 | } 13 | 14 | public record BoardCreateCommand( 15 | @NotBlank(message = "제목을 입력하십시오.") 16 | @Size(min = 3, message = "제목은 세 글자 이상 입력하세요.") 17 | String title, 18 | @NotBlank(message = "본문을 입력하십시오") 19 | @Size(min = 3, message = "제목은 세 글자 이상 입력하세요.") 20 | String content 21 | ) { 22 | } 23 | 24 | public record BoardUpdateCommand( 25 | @NotBlank(message = "제목을 입력하십시오.") 26 | @Size(min = 3, message = "제목은 세 글자 이상 입력하세요.") 27 | String title, 28 | @NotBlank(message = "본문을 입력하십시오") 29 | @Size(min = 3, message = "제목은 세 글자 이상 입력하세요.") 30 | String content 31 | ) { 32 | } 33 | 34 | @Builder 35 | @JsonRootName("board") 36 | public record BoardCommandResponse( 37 | Board board 38 | ) { 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driving/web/dto/BoardQueryDto.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driving.web.dto; 2 | 3 | import lombok.Builder; 4 | import me.nettee.board.application.model.BoardQueryModels.BoardDetail; 5 | 6 | public final class BoardQueryDto { 7 | private BoardQueryDto() {} 8 | 9 | @Builder 10 | public record BoardDetailResponse( 11 | BoardDetail board 12 | ){} 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/adapter/driving/web/mapper/BoardDtoMapper.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driving.web.mapper; 2 | 3 | import me.nettee.board.adapter.driving.web.dto.BoardCommandDto.BoardCreateCommand; 4 | import me.nettee.board.adapter.driving.web.dto.BoardCommandDto.BoardUpdateCommand; 5 | import me.nettee.board.adapter.driving.web.dto.BoardQueryDto.BoardDetailResponse; 6 | import me.nettee.board.application.domain.Board; 7 | import me.nettee.board.application.model.BoardQueryModels.BoardDetail; 8 | import org.mapstruct.Mapper; 9 | 10 | @Mapper(componentModel = "spring") 11 | public interface BoardDtoMapper { 12 | 13 | Board toDomain(BoardCreateCommand command); 14 | 15 | Board toDomain(Long id, BoardUpdateCommand command); 16 | 17 | BoardDetailResponse toDtoDetail(BoardDetail board); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/domain/Board.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.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 me.nettee.board.application.domain.type.BoardStatus; 10 | 11 | @Getter 12 | @Builder 13 | @NoArgsConstructor 14 | @AllArgsConstructor 15 | public class Board { 16 | 17 | private Long id; 18 | 19 | private String title; 20 | 21 | private String content; 22 | 23 | private BoardStatus status; 24 | 25 | private Instant createdAt; 26 | 27 | private Instant updatedAt; 28 | 29 | @Builder( 30 | builderClassName = "updateBoardBuilder", 31 | builderMethodName = "prepareUpdate", 32 | buildMethodName = "update" 33 | ) 34 | public void update(String title, String content) { 35 | Objects.requireNonNull(title, "Title cannot be null"); 36 | Objects.requireNonNull(content, "content cannot be null"); 37 | 38 | this.title = title; 39 | this.content = content; 40 | this.updatedAt = Instant.now(); 41 | } 42 | 43 | public void softDelete() { 44 | this.status = BoardStatus.REMOVED; 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/domain/type/BoardStatus.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.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 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/exception/BoardCommandErrorCode.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.exception; 2 | 3 | import java.util.Collections; 4 | import java.util.Map; 5 | import java.util.function.Supplier; 6 | import me.nettee.common.exeption.ErrorCode; 7 | import org.springframework.http.HttpStatus; 8 | 9 | public enum BoardCommandErrorCode implements ErrorCode { 10 | BOARD_NOT_FOUND("게시물을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), 11 | BOARD_GONE("더 이상 존재하지 않는 게시물입니다.", HttpStatus.GONE), 12 | BOARD_FORBIDDEN("권한이 없습니다.", HttpStatus.FORBIDDEN), 13 | DEFAULT("게시물 조작 오류", HttpStatus.INTERNAL_SERVER_ERROR), 14 | BOARD_ALREADY_EXIST("게시물이 이미 존재합니다.", HttpStatus.CONFLICT); 15 | 16 | private final String message; 17 | private final HttpStatus httpStatus; 18 | 19 | BoardCommandErrorCode(String message, HttpStatus httpStatus) { 20 | this.message = message; 21 | this.httpStatus = httpStatus; 22 | } 23 | 24 | @Override 25 | public String message() { 26 | return message; 27 | } 28 | 29 | @Override 30 | public HttpStatus httpStatus() { 31 | return httpStatus; 32 | } 33 | 34 | @Override 35 | public BoardCommandException exception() { 36 | return new BoardCommandException(this); 37 | } 38 | 39 | @Override 40 | public BoardCommandException exception(Throwable cause) { 41 | return new BoardCommandException(this, cause); 42 | } 43 | 44 | @Override 45 | public RuntimeException exception(Runnable runnable) { 46 | return new BoardCommandException(this, runnable); 47 | } 48 | 49 | @Override 50 | public RuntimeException exception(Runnable runnable, Throwable cause) { 51 | return new BoardCommandException(this, runnable, cause); 52 | } 53 | 54 | @Override 55 | public RuntimeException exception(Supplier> payload) { 56 | return new BoardCommandException(this, payload); 57 | } 58 | 59 | @Override 60 | public RuntimeException exception(Supplier> payload, Throwable cause) { 61 | return new BoardCommandException(this, payload, cause); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/exception/BoardCommandException.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.exception; 2 | 3 | import java.util.Map; 4 | import java.util.function.Supplier; 5 | import me.nettee.common.exeption.CustomException; 6 | import me.nettee.common.exeption.ErrorCode; 7 | 8 | public class BoardCommandException extends CustomException { 9 | 10 | /** 11 | * BoardErrorCodeLazyHolder를 파라미터로 받기 위해, ErrorCode 타입으로 임시 설정함. 12 | */ 13 | public BoardCommandException(ErrorCode errorCode) { 14 | super(errorCode); 15 | } 16 | 17 | public BoardCommandException(ErrorCode errorCode, Throwable cause) { 18 | super(errorCode, cause); 19 | } 20 | 21 | public BoardCommandException(ErrorCode errorCode, Runnable runnable) { 22 | super(errorCode, runnable); 23 | } 24 | 25 | public BoardCommandException(ErrorCode errorCode, Runnable runnable, Throwable cause) { 26 | super(errorCode, runnable, cause); 27 | } 28 | 29 | public BoardCommandException(ErrorCode errorCode, Supplier> payload) { 30 | super(errorCode, payload); 31 | } 32 | 33 | public BoardCommandException(ErrorCode errorCode, Supplier> payload, Throwable cause) { 34 | super(errorCode, payload, cause); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/exception/BoardQueryErrorCode.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.exception; 2 | 3 | import java.util.Collections; 4 | import java.util.Map; 5 | import java.util.function.Supplier; 6 | import me.nettee.common.exeption.ErrorCode; 7 | import org.springframework.http.HttpStatus; 8 | 9 | public enum BoardQueryErrorCode implements ErrorCode { 10 | BOARD_NOT_FOUND("게시물을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), 11 | BOARD_GONE("더 이상 존재하지 않는 게시물입니다.", HttpStatus.GONE), 12 | BOARD_FORBIDDEN("권한이 없습니다.", HttpStatus.FORBIDDEN), 13 | DEFAULT("게시물 조작 오류", HttpStatus.INTERNAL_SERVER_ERROR); 14 | 15 | private final String message; 16 | private final HttpStatus httpStatus; 17 | 18 | BoardQueryErrorCode(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 BoardQueryException exception() { 35 | return new BoardQueryException(this); 36 | } 37 | 38 | @Override 39 | public BoardQueryException exception(Throwable cause) { 40 | return new BoardQueryException(this, cause); 41 | } 42 | 43 | @Override 44 | public RuntimeException exception(Runnable runnable) { 45 | return new BoardQueryException(this, runnable); 46 | } 47 | 48 | @Override 49 | public RuntimeException exception(Runnable runnable, Throwable cause) { 50 | return new BoardQueryException(this, runnable, cause); 51 | } 52 | 53 | @Override 54 | public RuntimeException exception(Supplier> payload) { 55 | return new BoardQueryException(this, payload); 56 | } 57 | 58 | @Override 59 | public RuntimeException exception(Supplier> payload, Throwable cause) { 60 | return new BoardQueryException(this, payload, cause); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/exception/BoardQueryException.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.exception; 2 | 3 | import java.util.Map; 4 | import java.util.function.Supplier; 5 | import me.nettee.common.exeption.CustomException; 6 | 7 | public class BoardQueryException extends CustomException { 8 | public BoardQueryException(BoardQueryErrorCode errorCode) { 9 | super(errorCode); 10 | } 11 | 12 | public BoardQueryException(BoardQueryErrorCode errorCode, Throwable cause) { 13 | super(errorCode, cause); 14 | } 15 | 16 | public BoardQueryException(BoardQueryErrorCode errorCode, Runnable runnable) { 17 | super(errorCode, runnable); 18 | } 19 | 20 | public BoardQueryException(BoardQueryErrorCode errorCode, Runnable runnable, Throwable cause) { 21 | super(errorCode, runnable, cause); 22 | } 23 | 24 | public BoardQueryException(BoardQueryErrorCode errorCode, Supplier> payload) { 25 | super(errorCode, payload); 26 | } 27 | 28 | public BoardQueryException(BoardQueryErrorCode errorCode, Supplier> payload, Throwable cause) { 29 | super(errorCode, payload, cause); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/model/BoardQueryModels.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.model; 2 | 3 | import lombok.Builder; 4 | import me.nettee.board.application.domain.type.BoardStatus; 5 | 6 | import java.time.Instant; 7 | 8 | public final class BoardQueryModels { 9 | 10 | private BoardQueryModels() { 11 | } 12 | 13 | @Builder 14 | public record BoardDetail( 15 | Long id, 16 | String title, 17 | String content, 18 | BoardStatus status, 19 | Instant createdAt, 20 | Instant updatedAt 21 | ) { 22 | } 23 | 24 | @Builder 25 | public record BoardSummary( 26 | Long id, 27 | String title, 28 | BoardStatus status, 29 | Instant createdAt, 30 | Instant updatedAt 31 | ) { 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/port/BoardCommandPort.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.port; 2 | 3 | import java.util.Optional; 4 | import me.nettee.board.application.domain.Board; 5 | import me.nettee.board.application.domain.type.BoardStatus; 6 | 7 | public interface BoardCommandPort { 8 | 9 | Optional findById(Long id); 10 | 11 | Board create(Board board); 12 | 13 | Board update(Board board); 14 | 15 | void updateStatus(Long id, BoardStatus status); 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/port/BoardQueryPort.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.port; 2 | 3 | import java.util.Optional; 4 | import java.util.Set; 5 | import me.nettee.board.application.domain.type.BoardStatus; 6 | import me.nettee.board.application.model.BoardQueryModels.BoardDetail; 7 | import me.nettee.board.application.model.BoardQueryModels.BoardSummary; 8 | import org.springframework.data.domain.Page; 9 | import org.springframework.data.domain.Pageable; 10 | 11 | public interface BoardQueryPort { 12 | 13 | Optional findById(Long id); 14 | 15 | Page findAll(Pageable pageable); 16 | 17 | Page findByStatusesList(Set statuses, Pageable pageable); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/service/BoardCommandService.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.service; 2 | 3 | import static me.nettee.board.application.exception.BoardCommandErrorCode.BOARD_NOT_FOUND; 4 | import static me.nettee.board.application.exception.BoardCommandErrorCode.DEFAULT; 5 | 6 | import lombok.RequiredArgsConstructor; 7 | import me.nettee.board.application.domain.Board; 8 | import me.nettee.board.application.domain.type.BoardStatus; 9 | import me.nettee.board.application.port.BoardCommandPort; 10 | import me.nettee.board.application.usecase.BoardCreateUseCase; 11 | import me.nettee.board.application.usecase.BoardDeleteUseCase; 12 | import me.nettee.board.application.usecase.BoardUpdateUseCase; 13 | import org.springframework.stereotype.Service; 14 | 15 | @Service 16 | @RequiredArgsConstructor 17 | public class BoardCommandService implements BoardCreateUseCase, BoardUpdateUseCase, BoardDeleteUseCase { 18 | 19 | private final BoardCommandPort boardCommandPort; 20 | 21 | public Board createBoard(Board board) { 22 | return boardCommandPort.create(board); 23 | } 24 | 25 | public Board updateBoard(Board board) { 26 | return boardCommandPort.update(board); 27 | } 28 | 29 | public void deleteBoard(Long id) { 30 | // softDelete 명을 가진 메서드가 생기면 변경 31 | // 현재 updateStatus로 REMOVE 상태로 변경 32 | boardCommandPort.updateStatus(id, BoardStatus.REMOVED); 33 | 34 | // Hard Delete 됬는지 확인 - 제외 할 가능성 있음 35 | Board board = boardCommandPort.findById(id) 36 | .orElseThrow(BOARD_NOT_FOUND::exception); 37 | 38 | assert board.getStatus() == BoardStatus.REMOVED : DEFAULT; 39 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/service/BoardQueryService.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.service; 2 | 3 | import java.util.Set; 4 | import lombok.RequiredArgsConstructor; 5 | import me.nettee.board.application.domain.type.BoardStatus; 6 | import me.nettee.board.application.model.BoardQueryModels.BoardDetail; 7 | import me.nettee.board.application.model.BoardQueryModels.BoardSummary; 8 | import me.nettee.board.application.port.BoardQueryPort; 9 | import me.nettee.board.application.usecase.BoardReadByStatusesUseCase; 10 | import me.nettee.board.application.usecase.BoardReadUseCase; 11 | import org.springframework.data.domain.Page; 12 | import org.springframework.data.domain.Pageable; 13 | import org.springframework.stereotype.Service; 14 | 15 | import static me.nettee.board.application.exception.BoardQueryErrorCode.BOARD_NOT_FOUND; 16 | 17 | @Service 18 | @RequiredArgsConstructor 19 | public class BoardQueryService implements BoardReadUseCase, BoardReadByStatusesUseCase { 20 | 21 | private final BoardQueryPort boardQueryPort; 22 | 23 | @Override 24 | public BoardDetail getBoard(Long id) { 25 | return boardQueryPort.findById(id) 26 | .orElseThrow(BOARD_NOT_FOUND::exception); 27 | } 28 | 29 | @Override 30 | public Page findByStatuses(Set statuses, Pageable pageable) { 31 | return boardQueryPort.findByStatusesList(statuses, pageable); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/usecase/BoardCreateUseCase.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.usecase; 2 | 3 | import me.nettee.board.application.domain.Board; 4 | 5 | public interface BoardCreateUseCase { 6 | 7 | Board createBoard(Board board); 8 | } -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/usecase/BoardDeleteUseCase.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.usecase; 2 | 3 | public interface BoardDeleteUseCase { 4 | 5 | void deleteBoard(Long id); 6 | } -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/usecase/BoardReadByStatusesUseCase.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.usecase; 2 | 3 | import me.nettee.board.application.domain.type.BoardStatus; 4 | import me.nettee.board.application.model.BoardQueryModels.BoardSummary; 5 | import org.springframework.data.domain.Page; 6 | import org.springframework.data.domain.Pageable; 7 | 8 | import java.util.Set; 9 | 10 | public interface BoardReadByStatusesUseCase { 11 | 12 | Page findByStatuses(Set statuses, Pageable pageable); 13 | } 14 | 15 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/usecase/BoardReadUseCase.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.usecase; 2 | 3 | import me.nettee.board.application.model.BoardQueryModels.BoardDetail; 4 | 5 | public interface BoardReadUseCase { 6 | 7 | BoardDetail getBoard(Long id); 8 | } -------------------------------------------------------------------------------- /src/main/java/me/nettee/board/application/usecase/BoardUpdateUseCase.java: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.usecase; 2 | 3 | import me.nettee.board.application.domain.Board; 4 | 5 | public interface BoardUpdateUseCase { 6 | 7 | Board updateBoard(Board board); 8 | } -------------------------------------------------------------------------------- /src/main/java/me/nettee/common/exeption/CustomException.java: -------------------------------------------------------------------------------- 1 | package me.nettee.common.exeption; 2 | 3 | import java.util.Collections; 4 | import java.util.Map; 5 | import java.util.function.Supplier; 6 | 7 | public class CustomException extends RuntimeException { 8 | private final ErrorCode errorCode; 9 | private final Runnable runnable; 10 | private final Supplier> payloadSupplier; 11 | 12 | public CustomException(ErrorCode errorCode) { 13 | // ErrorCode의 기본 메시지를 RuntimeException에 전달하여 예외 메시지를 설정 14 | super(errorCode.message()); 15 | this.errorCode = errorCode; 16 | this.runnable = () -> {}; 17 | this.payloadSupplier = Collections::emptyMap; 18 | } 19 | 20 | public CustomException(ErrorCode errorCode, Throwable cause) { 21 | super(errorCode.message(), cause); 22 | this.errorCode = errorCode; 23 | this.runnable = () -> {}; 24 | this.payloadSupplier = Collections::emptyMap; 25 | } 26 | 27 | public CustomException(ErrorCode errorCode, Runnable runnable) { 28 | super(errorCode.message()); 29 | this.errorCode = errorCode; 30 | this.runnable = runnable; 31 | this.payloadSupplier = Collections::emptyMap; 32 | } 33 | 34 | public CustomException(ErrorCode errorCode, Runnable runnable, Throwable cause) { 35 | super(errorCode.message(), cause); 36 | this.errorCode = errorCode; 37 | this.runnable = runnable; 38 | this.payloadSupplier = Collections::emptyMap; 39 | } 40 | 41 | public CustomException(ErrorCode errorCode, Supplier> payloadSupplier) { 42 | super(errorCode.message()); 43 | this.errorCode = errorCode; 44 | this.runnable = () -> {}; 45 | this.payloadSupplier = payloadSupplier; 46 | } 47 | 48 | public CustomException(ErrorCode errorCode, Supplier> payloadSupplier, Throwable cause) { 49 | super(errorCode.message(), cause); 50 | this.errorCode = errorCode; 51 | this.runnable = () -> {}; 52 | this.payloadSupplier = payloadSupplier; 53 | } 54 | 55 | public ErrorCode getErrorCode() { 56 | return errorCode; 57 | } 58 | 59 | public void execute() { 60 | runnable.run(); 61 | } 62 | 63 | public Map getPayload() { 64 | return payloadSupplier.get(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/common/exeption/ErrorCode.java: -------------------------------------------------------------------------------- 1 | package me.nettee.common.exeption; 2 | 3 | import java.util.Map; 4 | import java.util.function.Supplier; 5 | import org.springframework.http.HttpStatus; 6 | 7 | public interface ErrorCode { 8 | String name(); // enum default method 9 | String message(); 10 | HttpStatus httpStatus(); 11 | RuntimeException exception(); 12 | RuntimeException exception(Throwable cause); 13 | 14 | // 예외 발생 시, 후속 작업 수행 가능 15 | RuntimeException exception(Runnable runnable); 16 | RuntimeException exception(Runnable runnable, Throwable cause); 17 | 18 | // 예외 발생 시, 추가적인 데이터 동적 제공 가능 19 | RuntimeException exception(Supplier> appendPayload); 20 | RuntimeException exception(Supplier> appendPayload, Throwable cause); 21 | } 22 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/common/exeption/GlobalExceptionHandler.java: -------------------------------------------------------------------------------- 1 | package me.nettee.common.exeption; 2 | 3 | import me.nettee.common.exeption.response.ApiErrorResponse; 4 | import org.springframework.http.ResponseEntity; 5 | import org.springframework.web.bind.annotation.ExceptionHandler; 6 | import org.springframework.web.bind.annotation.RestControllerAdvice; 7 | 8 | @RestControllerAdvice // 내장된 AOP (다른 라이브러리 없이 사용 가능한 AOP) 9 | public class GlobalExceptionHandler { 10 | @ExceptionHandler(CustomException.class) // 모든 커스텀 익셉션 11 | public ResponseEntity handleCustomException(CustomException exception) { 12 | 13 | ErrorCode errorCode = exception.getErrorCode(); 14 | 15 | exception.execute(); 16 | 17 | var responseBody = ApiErrorResponse.builder() 18 | .status(errorCode.httpStatus().value()) 19 | .code(errorCode.name()) 20 | .message(exception.getMessage()) // same to errorCode.message 21 | .payload(exception.getPayload()) 22 | .build(); 23 | 24 | return ResponseEntity 25 | .status(errorCode.httpStatus()) 26 | .body(responseBody); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/common/exeption/response/ApiErrorResponse.java: -------------------------------------------------------------------------------- 1 | package me.nettee.common.exeption.response; 2 | 3 | import java.util.Map; 4 | import lombok.Builder; 5 | 6 | @Builder 7 | public record ApiErrorResponse( 8 | int status, 9 | String code, 10 | String message, 11 | Map payload 12 | ) { 13 | public ApiErrorResponse { 14 | if (payload != null && payload.isEmpty()) { 15 | payload = null; 16 | } 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/core/jpa/config/JpaConfig.java: -------------------------------------------------------------------------------- 1 | package me.nettee.core.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 | 9 | @Configuration 10 | @EnableJpaAuditing 11 | @EntityScan(basePackages = "me.nettee") 12 | @EnableJpaRepositories(basePackages = "me.nettee") 13 | public class JpaConfig { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/core/jpa/support/BaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package me.nettee.core.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 | } -------------------------------------------------------------------------------- /src/main/java/me/nettee/core/jpa/support/LongBaseEntity.java: -------------------------------------------------------------------------------- 1 | package me.nettee.core.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 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/core/jpa/support/LongBaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package me.nettee.core.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 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/core/jpa/support/UuidBaseEntity.java: -------------------------------------------------------------------------------- 1 | package me.nettee.core.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 | -------------------------------------------------------------------------------- /src/main/java/me/nettee/core/jpa/support/UuidBaseTimeEntity.java: -------------------------------------------------------------------------------- 1 | package me.nettee.core.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 | -------------------------------------------------------------------------------- /src/main/resources/application-local.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | datasource: 3 | # ${환경변수:가없으면그때의기본값} 4 | url: ${DEMO_POSTGRESQL_URL:jdbc:postgresql://localhost:5433/demo} 5 | username: ${DEMO_POSTGRESQL_USERNAME:root} 6 | password: ${DEMO_POSTGRESQL_PASSWORD:root} 7 | 8 | logging: 9 | level: 10 | root: debug 11 | 12 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application.name: hexagonal 3 | datasource: 4 | driver-class-name: org.postgresql.Driver 5 | url: ${DEMO_POSTGRESQL_URL} 6 | username: ${DEMO_POSTGRESQL_USERNAME} 7 | password: ${DEMO_POSTGRESQL_PASSWORD} 8 | 9 | flyway: 10 | baseline-on-migrate: true 11 | locations: 12 | - classpath:db/migration/v1_0 13 | 14 | jackson: 15 | default-property-inclusion: non_null -------------------------------------------------------------------------------- /src/main/resources/db/migration/v1_0/V1_0_0__init_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -------------------------------------------------------------------------------- /src/main/resources/db/migration/v1_0/V1_0_1__create_tb_board.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS board ( 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 | 9 | CONSTRAINT pk_board PRIMARY KEY (id) 10 | ); 11 | 12 | --테이블 코멘트 13 | COMMENT ON TABLE board IS '게시판'; 14 | 15 | -- 컬럼 코멘트 16 | COMMENT ON COLUMN board.title IS '글 제목'; 17 | COMMENT ON COLUMN board.content IS '내용'; 18 | COMMENT ON COLUMN board.status IS '상태'; 19 | COMMENT ON COLUMN board.created_at IS '생성시간'; 20 | COMMENT ON COLUMN board.updated_at IS '마지막 수정시간'; -------------------------------------------------------------------------------- /src/main/resources/db/migration/v1_0/V1_0_2__alter_board_status_type_to_integer.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE board ALTER COLUMN status SET DATA TYPE INTEGER; -------------------------------------------------------------------------------- /src/test/kotlin/me/nettee/HexagonalApplicationTests.kt: -------------------------------------------------------------------------------- 1 | package me.nettee 2 | 3 | import org.junit.jupiter.api.Nested 4 | import org.junit.jupiter.api.Test 5 | import org.springframework.boot.test.context.SpringBootTest 6 | 7 | @Nested 8 | @SpringBootTest 9 | class HexagonalApplicationTests { 10 | 11 | @Test 12 | fun contextLoads() { 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/test/kotlin/me/nettee/board/adapter/driven/persistence/BoardCommandAdapterTest.kt: -------------------------------------------------------------------------------- 1 | package me.nettee.adapter.driven.persistence 2 | 3 | import io.kotest.assertions.throwables.shouldThrow 4 | import io.kotest.matchers.equals.shouldBeEqual 5 | import io.kotest.matchers.shouldBe 6 | import io.kotest.matchers.shouldNotBe 7 | import me.nettee.board.adapter.driven.persistence.BoardCommandAdapter 8 | import me.nettee.board.adapter.driven.persistence.BoardJpaRepository 9 | import me.nettee.board.adapter.driven.persistence.entity.type.BoardEntityStatus 10 | import me.nettee.board.adapter.driven.persistence.mapper.BoardEntityMapper 11 | import me.nettee.board.application.domain.Board 12 | import me.nettee.board.application.domain.type.BoardStatus 13 | import me.nettee.core.jpa.JpaTransactionalFreeSpec 14 | import org.springframework.beans.factory.annotation.Autowired 15 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest 16 | import org.springframework.context.annotation.ComponentScan 17 | import java.util.* 18 | 19 | @DataJpaTest 20 | @ComponentScan(basePackageClasses = [BoardEntityMapper::class]) 21 | class BoardCommandAdapterTest( 22 | @Autowired private val repository: BoardJpaRepository, 23 | @Autowired private val mapper : BoardEntityMapper 24 | ) : JpaTransactionalFreeSpec({ 25 | val adapter = BoardCommandAdapter(repository, mapper) 26 | val testTitle = "Test Title" 27 | val testContent = "Test Content" 28 | 29 | "[Pass] findById" - { 30 | val savedTitle = "search Title" 31 | val savedContent = "search Content" 32 | val savedStatus = BoardStatus.PENDING 33 | val board = Board.builder() 34 | .title(savedTitle) 35 | .content(savedContent) 36 | .status(savedStatus) 37 | .build() 38 | 39 | val savedId = repository.save(mapper.toEntity(board)).id 40 | val originBoardEntity = repository.findById(savedId).get() 41 | 42 | "조회할 Board -> 조회된 Board" - { 43 | val result = adapter.findById(savedId).get() 44 | 45 | "id 값 유지" { 46 | result.id shouldBeEqual savedId 47 | } 48 | 49 | "title, content, status 값 유지" { 50 | result.title shouldBeEqual savedTitle 51 | result.content shouldBeEqual savedContent 52 | result.status shouldBeEqual savedStatus 53 | } 54 | 55 | "createdAt 값 유지" { 56 | result.createdAt shouldBeEqual originBoardEntity.createdAt 57 | } 58 | 59 | "updatedAt 값 유지" { 60 | result.updatedAt shouldBeEqual originBoardEntity.updatedAt 61 | } 62 | } 63 | } 64 | 65 | "[Pass] Create(boardDomain)" - { 66 | val board = Board.builder() 67 | .title(testTitle) 68 | .content(testContent) 69 | .status(BoardStatus.ACTIVE) 70 | .build() 71 | 72 | "save: boardDomain -> boardDomain" - { 73 | val result = adapter.create(board) 74 | 75 | "id 생성" { 76 | result.id shouldNotBe null 77 | } 78 | 79 | "title, content 입력 일치 확인" { 80 | result.title shouldBeEqual testTitle 81 | result.content shouldBeEqual testContent 82 | } 83 | 84 | "status 값 변경: null -> BoardStatus.ACTIVE" { 85 | result.status shouldBeEqual BoardStatus.ACTIVE 86 | } 87 | 88 | "createdAt, updatedAt 시간 생성 확인" { 89 | result.createdAt shouldNotBe null 90 | result.updatedAt shouldNotBe null 91 | } 92 | 93 | // NOTE: createdAt, updatedAt을 동일하게 만들자는 요건 시 94 | "createdAt과 updatedAt 일치 확인" { 95 | result.createdAt shouldBeEqual result.updatedAt 96 | } 97 | } 98 | } 99 | 100 | "[Exception] Create(noTitleDomain)" - { 101 | val noTitleBoard = Board.builder() 102 | .content(testContent) 103 | .build() 104 | 105 | "noTitleDomain -> Exception(title cannot be null)" - { 106 | val result = shouldThrow { 107 | Objects.requireNonNull(noTitleBoard.title, "Board not found") 108 | } 109 | 110 | "title 값이 없으므로 Null 에러 발생" { 111 | result.message shouldBe "Board not found" 112 | } 113 | } 114 | } 115 | 116 | "[Exception] Create(noContentDomain)" - { 117 | val noContentBoard = Board.builder() 118 | .title(testTitle) 119 | .build() 120 | 121 | "noContentDomain -> Exception(content cannot be null)" - { 122 | val result = shouldThrow { 123 | Objects.requireNonNull(noContentBoard.content, "Board not found") 124 | } 125 | 126 | "content 값이 없으므로 Null 에러 발생" { 127 | result.message shouldBe "Board not found" 128 | } 129 | } 130 | } 131 | 132 | "[Pass] Update(boardDomain)" - { 133 | val editedTitle = "Update Title" 134 | val editedContent = "Update Content" 135 | val editedStatus = BoardStatus.PENDING 136 | val board = Board.builder() 137 | .title(testTitle) 138 | .content(testContent) 139 | .status(BoardStatus.ACTIVE) 140 | .build() 141 | 142 | val savedId = repository.save(mapper.toEntity(board)).id 143 | val originBoardEntity = repository.findById(savedId).get() 144 | 145 | val editedBoard = Board.builder() 146 | .id(savedId) 147 | .title(editedTitle) 148 | .content(editedContent) 149 | .status(editedStatus) 150 | .build() 151 | 152 | "수정할 Board -> 수정된 Board" - { 153 | val result = adapter.update(editedBoard) 154 | 155 | "id 값 유지" { 156 | result.id shouldBeEqual savedId 157 | } 158 | 159 | "title, content, status 값 변경" { 160 | result.title shouldBeEqual editedTitle 161 | result.content shouldBeEqual editedContent 162 | result.status shouldBeEqual editedStatus 163 | } 164 | 165 | "createdAt 값 유지" { 166 | result.createdAt shouldBeEqual originBoardEntity.createdAt 167 | } 168 | 169 | "updatedAt 값 변경" { 170 | result.updatedAt shouldNotBe originBoardEntity.updatedAt 171 | } 172 | } 173 | } 174 | 175 | "[Pass] updateStatus" - { 176 | val board = Board.builder() 177 | .title(testTitle) 178 | .content(testContent) 179 | .status(BoardStatus.ACTIVE) 180 | .build() 181 | 182 | val savedBoardEntity = repository.save(mapper.toEntity(board)) 183 | 184 | "ACTIVE -> REMOVED로 상태 변경시" - { 185 | adapter.updateStatus(savedBoardEntity.id, BoardStatus.REMOVED) 186 | 187 | "[성공 처리]REMOVED 확인" { 188 | val deletedBoard = repository.findById(savedBoardEntity.id) 189 | deletedBoard.isPresent shouldBe true 190 | deletedBoard.get().status shouldBe BoardEntityStatus.REMOVED 191 | } 192 | } 193 | } 194 | }) -------------------------------------------------------------------------------- /src/test/kotlin/me/nettee/board/adapter/driven/persistence/BoardQueryAdapterTest.kt: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driven.persistence 2 | 3 | import io.kotest.matchers.shouldBe 4 | import jakarta.persistence.EntityManager 5 | import me.nettee.board.adapter.driven.persistence.entity.BoardEntity 6 | import me.nettee.board.adapter.driven.persistence.entity.type.BoardEntityStatus 7 | import me.nettee.board.adapter.driven.persistence.mapper.BoardEntityMapper 8 | import me.nettee.board.application.domain.type.BoardStatus 9 | import me.nettee.core.jpa.JpaTransactionalFreeSpec 10 | import org.springframework.beans.factory.annotation.Autowired 11 | import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest 12 | import org.springframework.context.annotation.ComponentScan 13 | import org.springframework.data.domain.PageRequest 14 | import org.springframework.data.domain.Pageable 15 | 16 | @ComponentScan(basePackageClasses = [BoardEntityMapper::class]) 17 | @DataJpaTest 18 | class BoardQueryAdapterTest ( 19 | @Autowired private val boardJpaRepository: BoardJpaRepository, 20 | @Autowired private val boardEntityMapper: BoardEntityMapper, 21 | @Autowired private val entityManager: EntityManager, 22 | ) : JpaTransactionalFreeSpec({ 23 | val boardQueryAdapter = BoardQueryAdapter(boardEntityMapper) 24 | 25 | boardQueryAdapter.setEntityManager(entityManager) 26 | 27 | val boardEntity = BoardEntity.builder() 28 | .title("제목") 29 | .content("내용") 30 | .status(BoardEntityStatus.ACTIVE) 31 | .build() 32 | 33 | beforeSpec { 34 | boardJpaRepository.save(boardEntity) 35 | } 36 | 37 | beforeEach { 38 | boardJpaRepository.deleteAll() 39 | } 40 | 41 | "[Read] 게시글 단건 조회" - { // RootNode 42 | "[정상] 게시글이 존재할 때" - { 43 | // When: 게시글을 조회 44 | val fetchedBoard = boardQueryAdapter.findById(1L) 45 | 46 | "[검증1] 조회된 게시글이 존재하는지 검증" { 47 | fetchedBoard.isPresent() shouldBe true 48 | } 49 | 50 | "[검증2] 불러온 board가 저장한 boardEntity와 동일한지 검증" { 51 | val board = fetchedBoard?.get()!! 52 | board.title shouldBe boardEntity.title 53 | board.content shouldBe boardEntity.content 54 | board.status shouldBe boardEntity.status 55 | } 56 | } 57 | } 58 | 59 | "[Read] 게시글 목록 조회" - { // RootNode 60 | // Given: 여러 게시물들을 저장 61 | val boardEntities = (1..5).flatMap { 62 | listOf( 63 | BoardEntity.builder() 64 | .title("title$it") 65 | .content("content$it") 66 | .status(BoardEntityStatus.ACTIVE) 67 | .build() 68 | ) 69 | } 70 | 71 | boardJpaRepository.saveAll(boardEntities) 72 | 73 | "[정상] 게시글이 존재할 때" - { 74 | // When: 게시글 목록 조회 75 | val pageable: Pageable = PageRequest.of(0, 10) 76 | val fetchedBoards = boardQueryAdapter.findAll(pageable) 77 | val expectedSize = boardEntities.size 78 | 79 | "[검증1] 게시글들이 존재하는지 검증" { 80 | fetchedBoards.hasContent() shouldBe true 81 | } 82 | 83 | "[검증2] 조회된 게시글 수를 검증" { 84 | fetchedBoards.content.size shouldBe expectedSize 85 | } 86 | } 87 | } 88 | 89 | "[Read] 특정 상태 목록으로 게시글 목록을 조회" - { 90 | // Given: 특정 상태에 해당하는 게시글 저장 91 | val boardEntities = (1..3).flatMap { 92 | listOf( 93 | BoardEntity.builder() 94 | .title("게시글 $it") 95 | .content("내용 $it") 96 | .status(if (it % 2 == 0) { BoardEntityStatus.ACTIVE } else { BoardEntityStatus.PENDING }) 97 | .build() 98 | ) 99 | } 100 | 101 | boardJpaRepository.saveAll(boardEntities) 102 | 103 | // When: 특정 상태 목록으로 게시글을 조회 104 | val statuses = setOf(BoardStatus.ACTIVE, BoardStatus.PENDING) 105 | val pageable = PageRequest.of(0, 10) 106 | val page = boardQueryAdapter.findByStatusesList(statuses, pageable) 107 | val expectedSize = boardEntities.size 108 | 109 | "[검증1] 필터링된 게시글 총 개수를 검증" { 110 | page.totalElements shouldBe expectedSize 111 | } 112 | } 113 | }) -------------------------------------------------------------------------------- /src/test/kotlin/me/nettee/board/adapter/driving/web/BoardCommandApiTest.kt: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driving.web 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper 4 | import io.kotest.core.spec.style.FreeSpec 5 | import me.nettee.board.adapter.driving.web.dto.BoardCommandDto.BoardCreateCommand 6 | import me.nettee.board.adapter.driving.web.dto.BoardCommandDto.BoardUpdateCommand 7 | import me.nettee.board.adapter.driving.web.mapper.BoardDtoMapper 8 | import me.nettee.board.application.domain.Board 9 | import me.nettee.board.application.domain.type.BoardStatus 10 | import me.nettee.board.application.usecase.BoardCreateUseCase 11 | import me.nettee.board.application.usecase.BoardDeleteUseCase 12 | import me.nettee.board.application.usecase.BoardUpdateUseCase 13 | import org.junit.jupiter.api.extension.ExtendWith 14 | import org.mockito.ArgumentMatchers.any 15 | import org.mockito.Mockito.doNothing 16 | import org.mockito.Mockito.`when` 17 | import org.springframework.beans.factory.annotation.Autowired 18 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 19 | import org.springframework.http.HttpMethod 20 | import org.springframework.http.MediaType 21 | import org.springframework.test.context.bean.override.mockito.MockitoBean 22 | import org.springframework.test.context.junit.jupiter.SpringExtension 23 | import org.springframework.test.web.servlet.MockMvc 24 | import org.springframework.test.web.servlet.ResultActionsDsl 25 | import org.springframework.test.web.servlet.request 26 | 27 | @ExtendWith(SpringExtension::class) 28 | @WebMvcTest(BoardCommandApi::class) 29 | class BoardCommandApiTest( 30 | @Autowired private val mvc: MockMvc, 31 | @Autowired private val objectMapper: ObjectMapper, 32 | @MockitoBean private val boardCreateUseCase: BoardCreateUseCase, 33 | @MockitoBean private val boardUpdateUseCase: BoardUpdateUseCase, 34 | @MockitoBean private val boardDeleteUseCase: BoardDeleteUseCase, 35 | @MockitoBean private val boardDtoMapper: BoardDtoMapper 36 | ) : FreeSpec({ 37 | 38 | // given 39 | val boardDomain = Board.builder().id(1L).title("테스트게시판").content("테스트게시판내용").status(BoardStatus.ACTIVE).build() 40 | val mvcRequest: (HttpMethod, String, Map, Any?) -> ResultActionsDsl = 41 | { method, url, pathVariables, requestBody -> 42 | mvc.request(method, url, *pathVariables.values.toTypedArray()) { 43 | contentType = MediaType.APPLICATION_JSON 44 | if (requestBody != null) { 45 | content = objectMapper.writeValueAsString(requestBody) 46 | } 47 | } 48 | } 49 | 50 | "[POST] 게시판 생성 요청" - { 51 | // given 52 | val boardCreateResponse = boardDomain 53 | 54 | "[정상 요청] 제목과 내용이 3글자 이상과 공백이 아닐 때" - { 55 | // when 56 | val createCommand = BoardCreateCommand("테스트게시판", "테스트게시판내용") 57 | 58 | "2xx 응답 상태 반환" { 59 | // then 60 | mvcRequest(HttpMethod.POST, "/api/v1/boards", emptyMap(), createCommand) 61 | .andExpect { 62 | status { is2xxSuccessful() } 63 | jsonPath("board.id") { value(boardCreateResponse.id) } 64 | jsonPath("board.title") { value(boardCreateResponse.title) } 65 | jsonPath("board.content") { value(boardCreateResponse.content) } 66 | } 67 | .andDo { print() } 68 | .andReturn() 69 | } 70 | } 71 | 72 | "[실패 요청] 제목과 내용이 3글자 미만 일 때" - { 73 | // when 74 | val failCreateCommand = BoardCreateCommand("테스", "테스") 75 | 76 | // then 77 | "4xx 응답 상태 반환" { 78 | mvcRequest(HttpMethod.POST, "/api/v1/boards", emptyMap(), failCreateCommand) 79 | .andExpect { 80 | status { is4xxClientError() } 81 | } 82 | .andDo { print() } 83 | .andReturn() 84 | } 85 | } 86 | 87 | "[실패 요청] 제목과 내용이 공백 일 때" - { 88 | // when 89 | val failCreateCommand = BoardCreateCommand(null, null) 90 | 91 | // then 92 | "4xx 응답 상태 반환" { 93 | mvcRequest(HttpMethod.POST, "/api/v1/boards", emptyMap(), failCreateCommand) 94 | .andExpect { 95 | status { is4xxClientError() } 96 | } 97 | .andDo { print() } 98 | .andReturn() 99 | } 100 | } 101 | } 102 | 103 | "[PATCH] 게시판 수정 요청" - { 104 | // given 105 | val boardUpdateResponse = Board.builder() 106 | .id(1L).title("테스트게시판수정").content("테스트게시판내용수정").status(BoardStatus.ACTIVE).build() 107 | 108 | "[정상 요청] 제목과 내용이 3글자 이상과 공백이 아닐 때" - { 109 | // when 110 | val updateCommand = BoardUpdateCommand("테스트게시판", "테스트게시판내용") 111 | 112 | "2xx 정상 상태 반환" { 113 | // then 114 | mvcRequest(HttpMethod.PATCH, "/api/v1/boards/{id}", mapOf("id" to boardUpdateResponse.id), updateCommand) 115 | .andExpect { 116 | status { is2xxSuccessful() } 117 | jsonPath("board.id") { value(boardUpdateResponse.id) } 118 | jsonPath("board.title") { value(updateCommand.title) } 119 | jsonPath("board.content") { value(updateCommand.content) } 120 | } 121 | .andDo { print() } 122 | .andReturn() 123 | } 124 | } 125 | 126 | "[실패 요청] 제목과 내용이 3글자 이하 일 때" - { 127 | // when 128 | val failUpdateCommand = BoardUpdateCommand("테스", "테스") 129 | 130 | "4xx 응답 상태 반환" { 131 | // then 132 | mvcRequest( 133 | HttpMethod.PATCH, 134 | "/api/v1/boards/{id}", 135 | mapOf("id" to boardUpdateResponse.id), 136 | failUpdateCommand 137 | ) 138 | .andExpect { 139 | status { is4xxClientError() } 140 | } 141 | .andDo { print() } 142 | .andReturn() 143 | } 144 | } 145 | 146 | "[실패 요청] 제목과 내용이 공백 일 때" - { 147 | // when 148 | val failUpdateCommand = BoardUpdateCommand(null, null) 149 | 150 | "4xx 응답 상태 반환" { 151 | // then 152 | mvcRequest( 153 | HttpMethod.PATCH, 154 | "/api/v1/boards/{id}", 155 | mapOf("id" to boardUpdateResponse.id), 156 | failUpdateCommand 157 | ) 158 | .andExpect { 159 | status { is4xxClientError() } 160 | } 161 | .andDo { print() } 162 | .andReturn() 163 | } 164 | } 165 | } 166 | 167 | "[DELETE] 게시판 삭제 요청" - { 168 | "[정상 요청] 해당 커뮤니티 게시판 ID가 존재 할 때" - { 169 | "2xx 응답 상태 반환" { 170 | mvcRequest(HttpMethod.DELETE, "/api/v1/boards/{id}", mapOf("id" to boardDomain.id), null) 171 | .andExpect { 172 | status { is2xxSuccessful() } 173 | } 174 | } 175 | } 176 | } 177 | 178 | beforeSpec { 179 | `when`(boardDtoMapper.toDomain(any())).thenReturn(boardDomain) 180 | `when`(boardCreateUseCase.createBoard(any())).thenReturn(boardDomain) 181 | `when`(boardUpdateUseCase.updateBoard(any())).thenReturn(boardDomain) 182 | doNothing().`when`(boardDeleteUseCase).deleteBoard(any()) 183 | } 184 | }) -------------------------------------------------------------------------------- /src/test/kotlin/me/nettee/board/adapter/driving/web/BoardQueryApiTest.kt: -------------------------------------------------------------------------------- 1 | package me.nettee.board.adapter.driving.web 2 | 3 | import com.fasterxml.jackson.annotation.JsonInclude 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule 6 | import io.kotest.core.spec.style.FreeSpec 7 | import me.nettee.board.adapter.driving.web.dto.BoardQueryDto.BoardDetailResponse 8 | import me.nettee.board.adapter.driving.web.mapper.BoardDtoMapper 9 | import me.nettee.board.application.domain.type.BoardStatus 10 | import me.nettee.board.application.model.BoardQueryModels.BoardSummary 11 | import me.nettee.board.application.model.BoardQueryModels.BoardDetail 12 | import me.nettee.board.application.usecase.BoardReadByStatusesUseCase 13 | import me.nettee.board.application.usecase.BoardReadUseCase 14 | import org.junit.jupiter.api.extension.ExtendWith 15 | import org.mockito.ArgumentMatchers.any 16 | import org.mockito.ArgumentMatchers.argThat 17 | import org.mockito.Mockito.`when` 18 | import org.springframework.beans.factory.annotation.Autowired 19 | import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest 20 | import org.springframework.boot.test.context.TestConfiguration 21 | import org.springframework.context.annotation.Bean 22 | import org.springframework.data.domain.Page 23 | import org.springframework.data.domain.PageImpl 24 | import org.springframework.data.domain.Pageable 25 | import org.springframework.http.HttpStatus 26 | import org.springframework.http.MediaType 27 | import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter 28 | import org.springframework.test.context.bean.override.mockito.MockitoBean 29 | import org.springframework.test.context.junit.jupiter.SpringExtension 30 | import org.springframework.test.web.servlet.MockMvc 31 | import org.springframework.test.web.servlet.ResultActionsDsl 32 | import org.springframework.test.web.servlet.get 33 | import org.springframework.web.server.ResponseStatusException 34 | import java.time.Instant 35 | 36 | @WebMvcTest(BoardQueryApi::class) 37 | @ExtendWith(SpringExtension::class) 38 | class BoardQueryApiTest( 39 | @MockitoBean private val boardReadUseCase: BoardReadUseCase, 40 | @MockitoBean private val boardReadByStatusesUseCase: BoardReadByStatusesUseCase, 41 | @MockitoBean private val boardDtoMapper: BoardDtoMapper, 42 | @Autowired private val mvc: MockMvc 43 | ) : FreeSpec({ 44 | 45 | lateinit var boardList: List 46 | 47 | "[GET]게시판 상세 조회" - { 48 | val mvcGet = fun(boardId: Long): ResultActionsDsl { 49 | return mvc.get("/api/v1/boards/$boardId") { 50 | contentType = MediaType.APPLICATION_JSON 51 | } 52 | } 53 | 54 | "[2xx] null 포함된 데이터 조회 시 필드 제외 확인"{ 55 | mvc.get("/api/v1/boards/2") 56 | .andExpect { status { is2xxSuccessful() } } 57 | .andDo { print() } 58 | .andReturn() 59 | } 60 | 61 | "[2xx] 정상 요청 상태 반환" { 62 | mvcGet(1L) 63 | .andExpect { status { is2xxSuccessful() } } 64 | .andDo { print() } 65 | .andReturn() 66 | } 67 | 68 | "[4xx] 비정상 요청 상태 반환" { 69 | mvcGet(200L) 70 | .andExpect { status { is4xxClientError() } } 71 | .andDo { print() } 72 | .andReturn() 73 | } 74 | } 75 | 76 | "[GET] statuses 사용 게시판 목록 조회" - { 77 | val mvcGet = fun(statuses: Set, page: Int): ResultActionsDsl { 78 | return mvc.get("/api/v1/boards") { 79 | queryParam("statuses", statuses.joinToString(",") { it.name }) 80 | queryParam("page", page.toString()) 81 | queryParam("size", "10") 82 | contentType = MediaType.APPLICATION_JSON 83 | } 84 | } 85 | 86 | "[2xx] 정상 요청 상태 반환" { 87 | val statuses = setOf(BoardStatus.ACTIVE, BoardStatus.SUSPENDED) 88 | mvcGet(statuses, 1) 89 | .andExpect { status { is2xxSuccessful() } } 90 | .andDo { print() } 91 | .andReturn() 92 | } 93 | 94 | "[4xx] 비정상 요청 상태 반환" { 95 | val statuses = setOf(BoardStatus.REMOVED, BoardStatus.ACTIVE) 96 | mvcGet(statuses, 100) 97 | .andExpect { status { is4xxClientError() } } 98 | .andDo { print() } 99 | .andReturn() 100 | } 101 | } 102 | 103 | var boardReadSummaryModelPage: ( 104 | List, 105 | Pageable, 106 | Set 107 | ) -> Page 108 | 109 | beforeSpec { 110 | val objectMapper = ObjectMapper().apply { 111 | registerModule(JavaTimeModule()) 112 | setSerializationInclusion(JsonInclude.Include.NON_NULL) 113 | } 114 | 115 | boardReadSummaryModelPage = { boardList, pageable, boardStatus -> 116 | val filteredBoards = boardList 117 | .takeIf { it.isNotEmpty() } 118 | ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST) 119 | 120 | val pageContent = filteredBoards.drop(pageable.pageNumber * pageable.pageSize) 121 | .take(pageable.pageSize) 122 | .takeIf { it.isNotEmpty() } 123 | ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST) 124 | 125 | PageImpl(pageContent, pageable, filteredBoards.size.toLong()) 126 | } 127 | 128 | val boardDetail = BoardDetail(1L, "title1", "content1", BoardStatus.ACTIVE, Instant.now(), Instant.now()) 129 | val boardDetailWithNull = BoardDetail(2L, null, null, BoardStatus.ACTIVE, Instant.now(), Instant.now()) 130 | 131 | // list 반복문 완성 코드 *** 132 | boardList = (1..14).flatMap { 133 | listOf( 134 | BoardSummary(it.toLong(), "title$it", BoardStatus.ACTIVE, Instant.now(), Instant.now()), 135 | BoardSummary(it.toLong(), "title$it", BoardStatus.SUSPENDED, Instant.now(), Instant.now()) , 136 | BoardSummary(it.toLong(), null, BoardStatus.SUSPENDED, Instant.now(), Instant.now()) 137 | ) 138 | } 139 | 140 | val jsonResult = objectMapper.writeValueAsString(boardDetailWithNull) 141 | val nonNullResponse = objectMapper.readValue(jsonResult, BoardDetail::class.java) 142 | 143 | `when`(boardReadUseCase.getBoard(1L)).thenAnswer { boardDetail } 144 | `when`(boardReadUseCase.getBoard(2L)).thenAnswer { boardDetailWithNull } 145 | `when`(boardReadUseCase.getBoard(argThat { it != 1L && it != 2L })).thenThrow(ResponseStatusException(HttpStatus.NOT_FOUND)) 146 | `when`( 147 | boardReadByStatusesUseCase.findByStatuses( 148 | any>(), 149 | any() 150 | ) 151 | ).thenAnswer { invocation -> 152 | val statuses = invocation.getArgument>(0) 153 | val pageable = invocation.getArgument(1) 154 | boardReadSummaryModelPage(boardList, pageable, statuses) 155 | } 156 | `when`(boardDtoMapper.toDtoDetail(boardDetail)).thenReturn(BoardDetailResponse(boardDetail)) 157 | `when`(boardDtoMapper.toDtoDetail(boardDetailWithNull)).thenReturn(BoardDetailResponse(nonNullResponse)) 158 | } 159 | }) { 160 | @TestConfiguration 161 | class JacksonTestConfig { 162 | @Bean 163 | fun objectMapper(): ObjectMapper { 164 | return ObjectMapper() 165 | .registerModule(JavaTimeModule()) 166 | .setSerializationInclusion(JsonInclude.Include.NON_NULL) 167 | } 168 | 169 | @Bean 170 | fun mappingJackson2HttpMessageConverter(objectMapper: ObjectMapper): MappingJackson2HttpMessageConverter { 171 | return MappingJackson2HttpMessageConverter(objectMapper) 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/test/kotlin/me/nettee/board/application/service/BoardCommandServiceTest.kt: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.service 2 | 3 | import io.kotest.assertions.throwables.shouldThrow 4 | import io.kotest.core.spec.style.FreeSpec 5 | import io.kotest.matchers.shouldBe 6 | import io.mockk.clearMocks 7 | import io.mockk.every 8 | import io.mockk.mockk 9 | import io.mockk.verify 10 | import me.nettee.board.application.domain.Board 11 | import me.nettee.board.application.domain.type.BoardStatus.REMOVED 12 | import me.nettee.board.application.exception.BoardCommandException 13 | import me.nettee.board.application.port.BoardCommandPort 14 | import java.util.Optional 15 | import me.nettee.board.application.exception.BoardCommandErrorCode.BOARD_NOT_FOUND 16 | import me.nettee.board.application.exception.BoardCommandErrorCode.DEFAULT 17 | 18 | 19 | class BoardCommandServiceTest : FreeSpec({ 20 | 21 | val boardCommandPort = mockk() 22 | val boardCommandService = BoardCommandService(boardCommandPort) 23 | 24 | beforeTest { 25 | clearMocks(boardCommandPort, answers = true, recordedCalls = true) 26 | } 27 | 28 | "BoardCommandService" - { 29 | "create" { 30 | // given 31 | val board = Board() 32 | every { boardCommandPort.create(board) } returns board 33 | 34 | // when 35 | val result = boardCommandService.createBoard(board) 36 | 37 | // then 38 | result shouldBe board 39 | verify(exactly = 1) { boardCommandPort.create(board) } 40 | } 41 | 42 | "update" { 43 | // given 44 | val board = Board() 45 | every { boardCommandPort.update(board) } returns board 46 | 47 | // when 48 | val result = boardCommandService.updateBoard(board) 49 | 50 | // then 51 | result shouldBe board 52 | verify(exactly = 1) { boardCommandPort.update(board) } 53 | } 54 | 55 | "delete" - { 56 | "Soft Delete - 정상적으로 삭제된 경우" { 57 | // given 58 | val boardId = 1L 59 | val board = Board() 60 | 61 | every { boardCommandPort.updateStatus(boardId, REMOVED) } returns Unit 62 | every { boardCommandPort.findById(boardId) } returns Optional.of(board) 63 | 64 | // when 65 | boardCommandService.deleteBoard(boardId) 66 | 67 | // then 68 | verify(exactly = 1) { boardCommandPort.updateStatus(boardId, REMOVED) } 69 | verify(exactly = 1) { boardCommandPort.findById(boardId) } 70 | } 71 | 72 | "Exception - 게시판을 찾지 못하면 예외 처리" { 73 | // given 74 | val boardId = 1L 75 | 76 | every { boardCommandPort.updateStatus(boardId, REMOVED) } returns Unit 77 | every { boardCommandPort.findById(boardId) } returns Optional.empty() 78 | 79 | // when & then 80 | val exception = shouldThrow { 81 | boardCommandService.deleteBoard(boardId) 82 | } 83 | exception.errorCode shouldBe BOARD_NOT_FOUND 84 | 85 | verify(exactly = 1) { boardCommandPort.updateStatus(boardId, REMOVED) } 86 | verify(exactly = 1) { boardCommandPort.findById(boardId) } 87 | } 88 | 89 | "Exception - 삭제 후 조회된 Board의 상태가 REMOVED가 아니면 예외 처리" { 90 | // given 91 | val boardId = 1L 92 | val board = Board() 93 | 94 | every { boardCommandPort.updateStatus(boardId, REMOVED) } returns Unit 95 | every { boardCommandPort.findById(boardId) } returns Optional.of(board) 96 | 97 | // when & then 98 | val exception = shouldThrow { 99 | boardCommandService.deleteBoard(boardId) 100 | } 101 | exception.errorCode shouldBe DEFAULT 102 | 103 | verify(exactly = 1) { boardCommandPort.updateStatus(boardId, REMOVED) } 104 | verify(exactly = 1) { boardCommandPort.findById(boardId) } 105 | } 106 | } 107 | } 108 | }) 109 | -------------------------------------------------------------------------------- /src/test/kotlin/me/nettee/board/application/service/BoardQueryServiceTest.kt: -------------------------------------------------------------------------------- 1 | package me.nettee.board.application.service 2 | 3 | import io.kotest.assertions.throwables.shouldThrow 4 | import io.kotest.core.spec.style.FreeSpec 5 | import io.kotest.matchers.shouldBe 6 | import io.mockk.every 7 | import io.mockk.mockk 8 | import io.mockk.verify 9 | import me.nettee.board.application.domain.type.BoardStatus 10 | import me.nettee.board.application.exception.BoardCommandErrorCode.BOARD_NOT_FOUND 11 | import me.nettee.board.application.exception.BoardCommandException 12 | import me.nettee.board.application.model.BoardQueryModels.BoardDetail 13 | import me.nettee.board.application.model.BoardQueryModels.BoardSummary 14 | import me.nettee.board.application.port.BoardQueryPort 15 | import org.springframework.data.domain.Page 16 | import org.springframework.data.domain.PageImpl 17 | import org.springframework.data.domain.PageRequest 18 | import java.time.Instant 19 | import java.util.* 20 | 21 | class BoardQueryServiceTest : FreeSpec({ 22 | 23 | val boardQueryPort = mockk() // mocking 24 | val boardQueryService = BoardQueryService(boardQueryPort) // 주입 25 | 26 | "BoardQueryService" - { 27 | "getBoard" - { 28 | "board detail 조회 by board id" { 29 | // given 30 | val boardId = 1L 31 | val now = Instant.now() 32 | 33 | // 자바 record 이므로, 순서대로 인자 전달 34 | val expectedDetail = BoardDetail( 35 | boardId, 36 | "Test Title", 37 | "Test Content", 38 | BoardStatus.ACTIVE, 39 | now, 40 | now 41 | ) 42 | 43 | every { 44 | boardQueryPort.findById(boardId) 45 | } returns Optional.of(expectedDetail) 46 | 47 | // when 48 | val result = boardQueryService.getBoard(boardId) 49 | 50 | // then 51 | result shouldBe expectedDetail 52 | verify(exactly = 1) { boardQueryPort.findById(boardId) } 53 | } 54 | 55 | "board id에 해당하는 게시판이 없으면 예외 반환" { 56 | // given 57 | val boardId = 999L 58 | every { 59 | boardQueryPort.findById(boardId) 60 | } returns Optional.empty() 61 | 62 | // when & then 63 | val exception = shouldThrow { 64 | boardQueryService.getBoard(boardId) 65 | } 66 | exception.errorCode shouldBe BOARD_NOT_FOUND 67 | 68 | verify(exactly = 1) { boardQueryPort.findById(boardId) } 69 | } 70 | } 71 | 72 | "findByStatuses" - { 73 | "BoardStatus로 조회" { 74 | // given 75 | val statuses = setOf(BoardStatus.ACTIVE, BoardStatus.SUSPENDED) 76 | val pageable = PageRequest.of(0, 10) 77 | val now = Instant.now() 78 | 79 | // 자바 record 이므로, 순서대로 인자 전달 80 | val summaries = listOf( 81 | BoardSummary( 82 | 1L, 83 | "Active Board", 84 | BoardStatus.ACTIVE, 85 | now, 86 | now 87 | ), 88 | BoardSummary( 89 | 2L, 90 | "Suspended Board", 91 | BoardStatus.SUSPENDED, 92 | now, 93 | now 94 | ) 95 | ) 96 | 97 | val expectedPage: Page = 98 | PageImpl(summaries, pageable, summaries.size.toLong()) 99 | 100 | every { 101 | boardQueryPort.findByStatusesList(statuses, pageable) 102 | } returns expectedPage 103 | 104 | // when 105 | val result = boardQueryService.findByStatuses(statuses, pageable) 106 | 107 | // then 108 | result shouldBe expectedPage 109 | verify(exactly = 1) { boardQueryPort.findByStatusesList(statuses, pageable) } 110 | } 111 | 112 | "빈 상태 목록으로 조회하면 빈 페이지가 반환" { 113 | // given 114 | val statuses = emptySet() 115 | val pageable = PageRequest.of(0, 10) 116 | 117 | val emptySummaries = emptyList() 118 | val expectedPage: Page = 119 | PageImpl(emptySummaries, pageable, 0) 120 | 121 | every { 122 | boardQueryPort.findByStatusesList(statuses, pageable) 123 | } returns expectedPage 124 | 125 | // when 126 | val result = boardQueryService.findByStatuses(statuses, pageable) 127 | 128 | // then 129 | result shouldBe expectedPage 130 | verify(exactly = 1) { boardQueryPort.findByStatusesList(statuses, pageable) } 131 | } 132 | } 133 | } 134 | }) 135 | -------------------------------------------------------------------------------- /src/test/kotlin/me/nettee/common/ApiErrorResponseTest.kt: -------------------------------------------------------------------------------- 1 | package me.nettee.common 2 | 3 | import io.kotest.core.spec.style.FreeSpec 4 | import io.kotest.matchers.nulls.shouldBeNull 5 | import io.kotest.matchers.shouldBe 6 | import io.kotest.matchers.shouldNotBe 7 | import me.nettee.common.exeption.response.ApiErrorResponse 8 | import org.springframework.http.HttpStatus 9 | 10 | enum class ErrorCode(val message: String, val httpStatus: HttpStatus) { 11 | BOARD_NOT_FOUND("게시물을 찾을 수 없습니다.", HttpStatus.NOT_FOUND) 12 | } 13 | 14 | class ApiErrorResponseTest : FreeSpec({ 15 | // 공통 검증 로직 16 | fun ApiErrorResponse.shouldBeBoardNotFound() { 17 | status() shouldBe 404 18 | code() shouldBe "BOARD_NOT_FOUND" 19 | message() shouldBe "게시물을 찾을 수 없습니다." 20 | } 21 | 22 | // 공통 상수 23 | val status = ErrorCode.BOARD_NOT_FOUND.httpStatus.value() 24 | val code = ErrorCode.BOARD_NOT_FOUND.name 25 | val message = ErrorCode.BOARD_NOT_FOUND.message 26 | 27 | // 빌더 전역 변수 28 | val baseResponseBuilder = ApiErrorResponse.builder() 29 | .status(status) 30 | .code(code) 31 | .message(message) 32 | 33 | "생성자 테스트" - { 34 | "with null payload" { 35 | val response = ApiErrorResponse(status, code, message, null) 36 | response.shouldBeBoardNotFound() 37 | response.payload().shouldBeNull() 38 | } 39 | 40 | "with empty payload" { 41 | val response = ApiErrorResponse(status, code, message, emptyMap()) 42 | response.shouldBeBoardNotFound() 43 | response.payload().shouldBeNull() 44 | } 45 | 46 | "with non-empty payload" { 47 | val payload = mapOf("key" to "value") 48 | val response = ApiErrorResponse(status, code, message, payload) 49 | response.shouldBeBoardNotFound() 50 | response.payload() shouldNotBe null 51 | response.payload() shouldBe payload 52 | } 53 | } 54 | 55 | "빌더 테스트" - { 56 | "with null payload" { 57 | val response = baseResponseBuilder.payload(null).build() 58 | response.shouldBeBoardNotFound() 59 | response.payload().shouldBeNull() 60 | } 61 | 62 | "with empty payload" { 63 | val response = baseResponseBuilder.payload(emptyMap()).build() 64 | response.shouldBeBoardNotFound() 65 | response.payload().shouldBeNull() 66 | } 67 | 68 | "with non-empty payload" { 69 | val payload = mapOf("key" to "value") 70 | val response = baseResponseBuilder.payload(payload).build() 71 | response.shouldBeBoardNotFound() 72 | response.payload() shouldNotBe null 73 | response.payload() shouldBe payload 74 | } 75 | } 76 | }) -------------------------------------------------------------------------------- /src/test/kotlin/me/nettee/core/jpa/JpaTransactionalFreeSpec.kt: -------------------------------------------------------------------------------- 1 | package me.nettee.core.jpa 2 | 3 | import io.kotest.core.spec.style.FreeSpec 4 | import io.kotest.extensions.spring.SpringExtension 5 | import me.nettee.core.jpa.config.JpaConfig 6 | import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase 7 | import org.springframework.context.annotation.ComponentScan 8 | 9 | @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 10 | @ComponentScan(basePackageClasses = [JpaConfig::class]) 11 | abstract class JpaTransactionalFreeSpec(body: FreeSpec.() -> Unit) : FreeSpec(body) /* super(body) */ { 12 | override fun extensions() = listOf(SpringExtension) 13 | } -------------------------------------------------------------------------------- /src/test/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application.name: hexagonal 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 --------------------------------------------------------------------------------