12 Commits

Author SHA1 Message Date
jini
eb87c250b6 🔖 v1.0.1 2022-11-30 05:38:50 +09:00
jini
81a6f9d86b 📝 README 추가 2022-11-30 05:34:06 +09:00
jini
ad70bb1f2e 🔥 2022-11-30 04:57:31 +09:00
jini
2f2adebbf2 ♻️ code 정리 2022-11-30 04:55:12 +09:00
jini
31b6c85b77 🚧 common-util 모듈 추가 2022-11-30 04:31:38 +09:00
jini
23cff9d75f 📝 banner 추가 2022-11-30 03:41:53 +09:00
jini
15e952893b 불필요한 의존성 제거, notices 조회 input model 별도 생성 2022-11-30 03:32:54 +09:00
jini
1c855f60b0 불필요한 의존성 제거 2022-11-30 03:08:23 +09:00
jini
4983029a8f 🔥 불필요한 코드 제거 2022-11-30 03:08:03 +09:00
jini
b2561d6608 🏗️ 프로젝트 구조 web, core, infrastructure 모듈로 분리 2022-11-30 02:59:47 +09:00
jini
c72f7be010 🚚 2022-11-30 01:08:06 +09:00
jini
273e3cbf62 persistence adapter JavaConfig 방식으로 조립 2022-11-30 01:00:59 +09:00
32 changed files with 276 additions and 159 deletions

65
README.md Normal file
View File

@@ -0,0 +1,65 @@
# demo-hexagonal
### Hexagonal Architecture 구조로 만든 코프링 웹 애플리케이션<br>
Kotlin + Spring Boot 2 + Spring MVC + Spring Data JPA
<p>
<img src="https://img.shields.io/static/v1?label=OpenJDK&message=17.0.2&color=007396&logo=openjdk" alt="OpenJDK">
<img src="https://img.shields.io/static/v1?label=Kotlin&message=1.6.21&color=7F52FF&logo=kotlin&logoColor=fff" alt="Kotlin">
<img src="https://img.shields.io/static/v1?label=Spring%20Boot&message=2.7.3&color=6DB33F&logo=springboot&logoColor=fff" alt="Spring Boot">
<img src="https://img.shields.io/static/v1?label=Gradle&message=7.5.1&color=02303A&logo=Gradle&logoColor=fff" alt="Gradle">
<img src="https://img.shields.io/static/v1?label=MariaDB&message=10.8.3&color=003545&logo=MariaDB" alt="MariaDB">
<img src="https://img.shields.io/static/v1?label=Swagger&message=3.0.3&color=85EA2D&logo=swagger&logoColor=fff" alt="Swagger 3">
</p>
***
## Tech Stacks
- JDK 17
- Kotlin 1.6.21
- Build Tools
- Gradle
- Kotlin DSL
- Spring Boot 2
- Spring Data JPA
- MariaDB 10.8.3
- OpenAPI Specification
- Swagger v3
- springdoc-openapi ui 1.6.13
***
## 프로젝트 구성
프로젝트는 멀티 모듈로 구성되어있습니다.
<img width="344" alt="architecture-1" src="https://user-images.githubusercontent.com/31076826/204638756-a9a8b9b8-d0e5-4a27-bf14-4c8f12e93448.png">
- core
- 헥사고날 아키텍처의 application core 영역에 해당
- 프로젝트의 핵심적인 비즈니스 로직이 들어있습니다
- domain, port를 포함하고 있으며, 모든 클래스는 public 접근제한자로 설정되어 있습니다.
- infrastructure
- 헥사고날 아키텍처의 `adapter - out` 영역에 해당
- datasource에 관련된 모듈이 들어갑니다.
- persistence adapter, JpaRepository, ORM Entity 가 포함되어있습니다.
- server
- 헥사고날 아키텍처의 `adapter - in` 영역에 해당
- controller를 포함하고 있으며, 모든 클래스는 internal 접근제한자로 설정되어 있습니다.
- util
- core, server, infrastructure 모듈에서 공통적으로 사용할 유틸이 들어있습니다.
***
## demo-app
demo 웹 애플리케이션의 실행 진입점은 `server - demo-app` 모듈의 DemoAppApplication 입니다.
<img width="779" alt="DemoAppApplication" src="https://user-images.githubusercontent.com/31076826/204640445-cfcfb9db-a35b-492c-b6d2-4cf7c05030fb.png">
<br><br>
demo-app은 아래와 같이 `:util:common-util`, `:core:demo-core`, `:infrastructure:datastore-mariadb` 모듈을 포함합니다.
<img width="724" alt="build.gradle.kts" src="https://user-images.githubusercontent.com/31076826/204640772-f1846649-a21d-459a-9883-3dae61b44536.png">

View File

@@ -3,6 +3,7 @@ plugins {
id("io.spring.dependency-management") version "1.0.13.RELEASE"
kotlin("jvm") version "1.6.21"
kotlin("plugin.spring") version "1.6.21"
kotlin("plugin.jpa") version "1.6.21"
kotlin("kapt") version "1.6.21"
}
@@ -18,7 +19,7 @@ configurations {
allprojects {
group = "me.jiniworld"
version = "1.0.0"
version = "1.0.1"
repositories {
mavenCentral()

View File

@@ -0,0 +1,6 @@
dependencies {
implementation("org.springframework.data:spring-data-commons")
implementation("org.springframework:spring-tx")
implementation(project(":util:common-util"))
}

View File

@@ -1,8 +1,8 @@
package me.jiniworld.demohx.domain
package me.jiniworld.demohx.application.notice.domain
import me.jiniworld.demohx.application.port.output.NoticeDetail
import me.jiniworld.demohx.application.port.output.NoticeSimple
import me.jiniworld.demohx.util.DateTimeUtils
import me.jiniworld.demohx.DateTimeUtils
import me.jiniworld.demohx.application.notice.port.output.NoticeDetail
import me.jiniworld.demohx.application.notice.port.output.NoticeSimple
import java.time.LocalDateTime
data class Notice(

View File

@@ -0,0 +1,10 @@
package me.jiniworld.demohx.application.notice.port.input
import me.jiniworld.demohx.application.notice.port.output.NoticeDetail
import me.jiniworld.demohx.application.notice.port.output.NoticeSimple
import org.springframework.data.domain.Pageable
interface GetNoticeQuery {
fun getNoticeSimple(command: GetNoticesCommand): List<NoticeSimple>?
fun getNoticeDetail(noticeId: Long): NoticeDetail?
}

View File

@@ -0,0 +1,6 @@
package me.jiniworld.demohx.application.notice.port.input
data class GetNoticesCommand(
val page: Int,
val size: Int,
)

View File

@@ -0,0 +1,9 @@
package me.jiniworld.demohx.application.notice.port.output
import me.jiniworld.demohx.application.notice.domain.Notice
import org.springframework.data.domain.Pageable
interface LoadNoticePort {
fun loadNotices(pageable: Pageable): List<Notice>?
fun loadNotice(id: Long): Notice?
}

View File

@@ -1,4 +1,4 @@
package me.jiniworld.demohx.application.port.output
package me.jiniworld.demohx.application.notice.port.output
data class NoticeDetail(
val id: Long,

View File

@@ -1,4 +1,4 @@
package me.jiniworld.demohx.application.port.output
package me.jiniworld.demohx.application.notice.port.output
data class NoticeSimple(
val id: Long,

View File

@@ -0,0 +1,24 @@
package me.jiniworld.demohx.application.notice.service
import me.jiniworld.demohx.annotation.UseCase
import me.jiniworld.demohx.application.notice.port.input.GetNoticeQuery
import me.jiniworld.demohx.application.notice.port.input.GetNoticesCommand
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
import me.jiniworld.demohx.application.notice.port.output.NoticeDetail
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.transaction.annotation.Transactional
@Transactional(readOnly = true)
@UseCase
internal class GetNoticeService(
private val loadNoticePort: LoadNoticePort,
) : GetNoticeQuery {
override fun getNoticeSimple(command: GetNoticesCommand) =
loadNoticePort.loadNotices(PageRequest.of(command.page, command.size, Sort.by(Sort.Order.desc("id"))))
?.map { it.mapToNoticeSimple() }
override fun getNoticeDetail(id: Long): NoticeDetail? =
loadNoticePort.loadNotice(id)?.mapToNoticeDetail()
}

View File

@@ -0,0 +1,9 @@
apply(plugin = "kotlin-jpa")
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
implementation(project(":util:common-util"))
implementation(project(":core:demo-core"))
}

View File

@@ -1,6 +1,6 @@
package me.jiniworld.demohx.adapter.output.persistence
package me.jiniworld.demohx.persistence.notice
import me.jiniworld.demohx.domain.Notice
import me.jiniworld.demohx.application.notice.domain.Notice
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import java.time.LocalDateTime

View File

@@ -0,0 +1,20 @@
package me.jiniworld.demohx.persistence.notice
import me.jiniworld.demohx.annotation.PersistenceAdapter
import me.jiniworld.demohx.application.notice.domain.Notice
import me.jiniworld.demohx.application.notice.port.output.LoadNoticePort
import org.springframework.data.domain.Pageable
import org.springframework.data.repository.findByIdOrNull
@PersistenceAdapter
internal class NoticePersistenceAdapter(
private val noticeRepository: NoticeRepository,
) : LoadNoticePort {
override fun loadNotices(pageable: Pageable): List<Notice>? {
return noticeRepository.findAllBy(pageable).map { it.mapToNotice() }.toList()
}
override fun loadNotice(id: Long): Notice? {
return noticeRepository.findByIdOrNull(id)?.mapToNotice()
}
}

View File

@@ -1,8 +1,10 @@
package me.jiniworld.demohx.adapter.output.persistence
package me.jiniworld.demohx.persistence.notice
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
@Repository
internal interface NoticeRepository: JpaRepository<NoticeEntity, Long> {
fun findAllBy(pageable: Pageable): List<NoticeEntity>
}

View File

@@ -1,8 +1,10 @@
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation")
compileOnly("org.springframework.boot:spring-boot-configuration-processor")
implementation("org.springdoc:springdoc-openapi-ui:1.6.13")
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
implementation(project(":util:common-util"))
implementation(project(":core:demo-core"))
implementation(project(":infrastructure:datastore-mariadb"))
}

View File

@@ -0,0 +1,18 @@
package me.jiniworld.demohx
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import java.util.*
@SpringBootApplication(scanBasePackages = [
"me.jiniworld.demohx.config",
"me.jiniworld.demohx.web",
"me.jiniworld.demohx.application",
"me.jiniworld.demohx.persistence",
])
class DemoAppApplication
fun main(args: Array<String>) {
Locale.setDefault(Locale.KOREA)
runApplication<DemoAppApplication>(*args)
}

View File

@@ -0,0 +1,30 @@
package me.jiniworld.demohx.web.notice
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import me.jiniworld.demohx.annotation.WebAdapter
import me.jiniworld.demohx.application.notice.port.input.GetNoticeQuery
import me.jiniworld.demohx.application.notice.port.input.GetNoticesCommand
import org.springframework.web.bind.annotation.*
@WebAdapter
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
@RestController
@RequestMapping("/v1/notices")
internal class GetNoticeController(
private val getNoticeQuery: GetNoticeQuery,
) {
@Operation(summary = "공지사항 목록")
@GetMapping("")
fun notices(
@RequestParam(value = "page", required = false, defaultValue = "0") page: Int,
@RequestParam(value = "size", required = false, defaultValue = "10") size: Int,
) = getNoticeQuery.getNoticeSimple(GetNoticesCommand(page = page, size = size))
@Operation(summary = "공지사항 상세조회")
@GetMapping("/{notice_id}")
fun notice(@PathVariable("notice_id") noticeId: Long) =
getNoticeQuery.getNoticeDetail(noticeId)
}

View File

@@ -1,6 +1,6 @@
spring:
application:
name: chaeking
name: demo-app
profiles:
active: local
config:
@@ -31,8 +31,8 @@ spring:
open-in-view: false
show-sql: true
devtools:
add-properties: false
# devtools:
# add-properties: false
server:
port: 8080

View File

@@ -0,0 +1,7 @@
___ ____ _ _ ____ ____ ___ ___
| \ |___ |\/| | | __ |__| |__] |__]
|__/ |___ | | |__| | | | |
${Ansi.GREEN}:: Spring Boot :: ${Ansi.NORMAL}${spring-boot.formatted-version}${Ansi.NORMAL}
${application.title}${application.formatted-version}

View File

@@ -1,3 +1,11 @@
rootProject.name = "demo-hexagonal"
include("web")
include("core:demo-core")
findProject(":core:demo-core")?.name = "demo-core"
include("infrastructure:datastore-mariadb")
findProject(":infrastructure:datastore-mariadb")?.name = "datastore-mariadb"
include("server")
include("server:demo-app")
findProject(":server:demo-app")?.name = "demo-app"
include("util:common-util")
findProject(":util:common-util")?.name = "common-util"

View File

@@ -0,0 +1,2 @@
dependencies {
}

View File

@@ -1,14 +1,12 @@
package me.jiniworld.demohx.util
package me.jiniworld.demohx
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoField
import java.time.temporal.TemporalAdjusters
object DateTimeUtils {
private const val SIMPLE_PATTERN_MONTH = "yyyyMM"
private const val SIMPLE_PATTERN_DATE = "yyyyMMdd"
private const val DEFAULT_PATTERN_DATE = "yyyy-MM-dd"
@@ -36,27 +34,9 @@ object DateTimeUtils {
fun getLastDateTime(date: LocalDate): LocalDateTime =
LocalDateTime.of(date.with(TemporalAdjusters.lastDayOfMonth()), LOCALTIME_END)
fun getFirstDateTime(date: LocalDate, type: AnalysisType): LocalDateTime =
when (type) {
AnalysisType.weekly -> LocalDateTime.of(date.minusDays((date[ChronoField.DAY_OF_WEEK] - 1).toLong()).minusWeeks(6), LOCALTIME_START)
AnalysisType.monthly -> date.minusDays((date[ChronoField.DAY_OF_MONTH] - 1).toLong()).minusMonths(6).atStartOfDay()
else -> LocalDateTime.of(date.minusDays((date[ChronoField.DAY_OF_WEEK] - 1).toLong()), LOCALTIME_START)
}
fun toString(date: LocalDate): String = date.format(FORMATTER_DATE)
fun getLastDateTime(date: LocalDate, type: AnalysisType): LocalDateTime =
when (type) {
AnalysisType.monthly -> LocalDateTime.of(date.minusDays(date[ChronoField.DAY_OF_MONTH].toLong()).plusMonths(1), LOCALTIME_END)
else -> LocalDateTime.of(date.plusDays((7 - date[ChronoField.DAY_OF_WEEK]).toLong()), LOCALTIME_END)
}
fun toString(dateTime: LocalDateTime): String = dateTime.format(FORMATTER_DATETIME)
fun toString(date: LocalDate) = date.format(FORMATTER_DATE)
fun toString(dateTime: LocalDateTime) = dateTime.format(FORMATTER_DATETIME)
fun toDateString(dateTime: LocalDateTime) = dateTime.format(FORMATTER_DATE)
}
enum class AnalysisType {
daily, weekly, monthly
fun toDateString(dateTime: LocalDateTime) : String = dateTime.format(FORMATTER_DATE)
}

View File

@@ -0,0 +1,12 @@
package me.jiniworld.demohx.annotation
import org.springframework.stereotype.Component
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Component
annotation class PersistenceAdapter(
val value: String = ""
)

View File

@@ -0,0 +1,13 @@
package me.jiniworld.demohx.annotation
import org.springframework.stereotype.Component
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Component
annotation class UseCase(
val value: String = ""
)

View File

@@ -0,0 +1,12 @@
package me.jiniworld.demohx.annotation
import org.springframework.stereotype.Component
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@Component
annotation class WebAdapter(
val value: String = ""
)

View File

@@ -1,13 +0,0 @@
package me.jiniworld.demohx
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import java.util.*
@SpringBootApplication
class DemoHxApplication
fun main(args: Array<String>) {
Locale.setDefault(Locale.KOREA)
runApplication<DemoHxApplication>(*args)
}

View File

@@ -1,34 +0,0 @@
package me.jiniworld.demohx.adapter.input.web
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import me.jiniworld.demohx.application.service.GetNoticeService
import me.jiniworld.demohx.domain.Notice
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
import org.springframework.stereotype.Component
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@Component
//@WebAdapter
@Tag(name = "setting-system", description = "설정-시스템(공지사항, FAQ, 이용약관, 메타정보 등)")
@RestController
@RequestMapping("/v1/notices")
internal class GetNoticeController(
private val getNoticeQuery: GetNoticeService,
) {
@Operation(summary = "공지사항 목록")
@GetMapping("")
fun notices(
@RequestParam(value = "page", required = false, defaultValue = "0") page: Int,
@RequestParam(value = "size", required = false, defaultValue = "10") size: Int,
): List<Notice> {
println(">>>>>>>>> 1")
return getNoticeQuery.getNoticeSimple(PageRequest.of(page, size, Sort.by(Sort.Order.desc("id"))))
}
}

View File

@@ -1,23 +0,0 @@
package me.jiniworld.demohx.adapter.output.persistence
import me.jiniworld.demohx.application.port.output.LoadNoticePort
import me.jiniworld.demohx.domain.Notice
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Component
import java.time.LocalDateTime
@Component
internal class NoticePersistenceAdapter(
private val noticeRepository: NoticeRepository,
) : LoadNoticePort {
override fun loadNotices(pageable: Pageable): List<Notice> {
println(">>>>>>>>> 3")
return noticeRepository.findAllBy(pageable).map { it.mapToNotice() }.toList()
}
override fun loadNotice(id: Long): Notice? {
return Notice(id = id, title = "zzz", content = "fff", createdAt = LocalDateTime.now())
// return noticeRepository.findById(id)?.map(NoticeEntity::mapToNotice)
}
}

View File

@@ -1,11 +0,0 @@
package me.jiniworld.demohx.application.port.input
import me.jiniworld.demohx.domain.Notice
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Component
@Component
interface GetNoticeQuery {
fun getNoticeSimple(pageable: Pageable): List<Notice>
fun getNoticeDetail(noticeId: Long): Notice?
}

View File

@@ -1,11 +0,0 @@
package me.jiniworld.demohx.application.port.output
import me.jiniworld.demohx.domain.Notice
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Component
@Component
interface LoadNoticePort {
fun loadNotices(pageable: Pageable): List<Notice>
fun loadNotice(id: Long): Notice?
}

View File

@@ -1,27 +0,0 @@
package me.jiniworld.demohx.application.service
import me.jiniworld.demohx.application.port.input.GetNoticeQuery
import me.jiniworld.demohx.application.port.output.LoadNoticePort
import me.jiniworld.demohx.domain.Notice
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.sql.DriverManager.println
@Transactional(readOnly = true)
@Component
internal class GetNoticeService(
private val loadNoticePort: LoadNoticePort,
) : GetNoticeQuery {
override fun getNoticeSimple(pageable: Pageable): List<Notice> {
println(">>>>>>>>> 2")
return loadNoticePort.loadNotices(pageable)
// return loadNoticePort.loadNotices(pageable).map { it.mapToNoticeSimple() }.toList()
}
override fun getNoticeDetail(id: Long): Notice? {
return loadNoticePort.loadNotice(id)
// return loadNoticePort.loadNotice(id)?.mapToNoticeDetail()
}
}