├── .editorconfig ├── .github └── workflows │ ├── check_build.yaml │ └── select_build.yaml ├── .gitignore ├── LICENSE ├── README.md ├── account ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── htnk128 │ │ │ └── kotlin │ │ │ └── ddd │ │ │ └── sample │ │ │ └── account │ │ │ ├── adapter │ │ │ ├── controller │ │ │ │ ├── AccountController.kt │ │ │ │ ├── ErrorAdvice.kt │ │ │ │ └── resource │ │ │ │ │ ├── AccountCreateRequest.kt │ │ │ │ │ ├── AccountFindAllRequest.kt │ │ │ │ │ ├── AccountResponse.kt │ │ │ │ │ ├── AccountResponses.kt │ │ │ │ │ └── AccountUpdateRequest.kt │ │ │ ├── gateway │ │ │ │ ├── db │ │ │ │ │ └── AccountExposedRepository.kt │ │ │ │ ├── messaging │ │ │ │ │ ├── AccountEventSpringPublisher.kt │ │ │ │ │ └── AccountEventSpringSubscriber.kt │ │ │ │ └── rest │ │ │ │ │ └── AddressBookRestService.kt │ │ │ └── presenter │ │ │ │ └── AccountPresenter.kt │ │ │ ├── domain │ │ │ ├── model │ │ │ │ ├── account │ │ │ │ │ ├── Account.kt │ │ │ │ │ ├── AccountEvent.kt │ │ │ │ │ ├── AccountId.kt │ │ │ │ │ ├── AccountInvalidDataStateException.kt │ │ │ │ │ ├── AccountInvalidRequestException.kt │ │ │ │ │ ├── AccountNotFoundException.kt │ │ │ │ │ ├── AccountUpdateFailedException.kt │ │ │ │ │ ├── Email.kt │ │ │ │ │ ├── Name.kt │ │ │ │ │ ├── NamePronunciation.kt │ │ │ │ │ └── Password.kt │ │ │ │ └── addressbook │ │ │ │ │ ├── AccountAddress.kt │ │ │ │ │ ├── AccountAddressId.kt │ │ │ │ │ ├── AddressBook.kt │ │ │ │ │ ├── AddressBookInvalidRequestException.kt │ │ │ │ │ └── AddressBookService.kt │ │ │ └── repository │ │ │ │ └── AccountRepository.kt │ │ │ ├── external │ │ │ └── spring │ │ │ │ ├── Application.kt │ │ │ │ ├── configuration │ │ │ │ └── ApplicationConfiguration.kt │ │ │ │ └── rest │ │ │ │ └── AccountRestController.kt │ │ │ └── usecase │ │ │ ├── inputport │ │ │ ├── AccountUseCase.kt │ │ │ └── command │ │ │ │ ├── CreateAccountCommand.kt │ │ │ │ ├── DeleteAccountCommand.kt │ │ │ │ ├── FindAccountCommand.kt │ │ │ │ ├── FindAllAccountCommand.kt │ │ │ │ └── UpdateAccountCommand.kt │ │ │ ├── interactor │ │ │ └── AccountInteractor.kt │ │ │ └── outputport │ │ │ ├── AccountUseCase.kt │ │ │ └── dto │ │ │ └── AccountDTO.kt │ └── resources │ │ ├── application.yml │ │ └── db │ │ └── migration │ │ └── V1__account.sql │ └── test │ └── kotlin │ ├── htnk128 │ └── kotlin │ │ └── ddd │ │ └── sample │ │ └── account │ │ └── domain │ │ └── model │ │ ├── account │ │ ├── AccountIdSpec.kt │ │ ├── AccountSpec.kt │ │ ├── EmailSpec.kt │ │ ├── NamePronunciationSpec.kt │ │ ├── NameSpec.kt │ │ └── PasswordSpec.kt │ │ └── addressbook │ │ ├── AccountAddressIdSpec.kt │ │ ├── AccountAddressSpec.kt │ │ └── AddressBookSpec.kt │ └── io │ └── kotlintest │ └── provided │ └── ProjectConfig.kt ├── address ├── build.gradle.kts └── src │ ├── main │ ├── kotlin │ │ └── htnk128 │ │ │ └── kotlin │ │ │ └── ddd │ │ │ └── sample │ │ │ └── address │ │ │ ├── adapter │ │ │ ├── controller │ │ │ │ ├── AddressController.kt │ │ │ │ ├── ErrorAdvice.kt │ │ │ │ └── resource │ │ │ │ │ ├── AddressCreateRequest.kt │ │ │ │ │ ├── AddressFindAllRequest.kt │ │ │ │ │ ├── AddressResponse.kt │ │ │ │ │ ├── AddressResponses.kt │ │ │ │ │ └── AddressUpdateRequest.kt │ │ │ ├── gateway │ │ │ │ ├── db │ │ │ │ │ └── AddressExposedRepository.kt │ │ │ │ ├── messaging │ │ │ │ │ ├── AddressEventSpringPublisher.kt │ │ │ │ │ └── AddressEventSpringSubscriber.kt │ │ │ │ └── rest │ │ │ │ │ └── OwnerRestService.kt │ │ │ └── presenter │ │ │ │ └── AddressPresenter.kt │ │ │ ├── domain │ │ │ ├── model │ │ │ │ ├── address │ │ │ │ │ ├── Address.kt │ │ │ │ │ ├── AddressEvent.kt │ │ │ │ │ ├── AddressId.kt │ │ │ │ │ ├── AddressInvalidDataStateException.kt │ │ │ │ │ ├── AddressInvalidRequestException.kt │ │ │ │ │ ├── AddressNotFoundException.kt │ │ │ │ │ ├── AddressUpdateFailedException.kt │ │ │ │ │ ├── City.kt │ │ │ │ │ ├── FullName.kt │ │ │ │ │ ├── Line1.kt │ │ │ │ │ ├── Line2.kt │ │ │ │ │ ├── PhoneNumber.kt │ │ │ │ │ ├── StateOrRegion.kt │ │ │ │ │ └── ZipCode.kt │ │ │ │ └── owner │ │ │ │ │ ├── Owner.kt │ │ │ │ │ ├── OwnerId.kt │ │ │ │ │ ├── OwnerInvalidRequestException.kt │ │ │ │ │ ├── OwnerNotFoundException.kt │ │ │ │ │ └── OwnerService.kt │ │ │ └── repository │ │ │ │ └── AddressRepository.kt │ │ │ ├── external │ │ │ └── spring │ │ │ │ ├── Application.kt │ │ │ │ ├── configuration │ │ │ │ └── ApplicationConfiguration.kt │ │ │ │ └── rest │ │ │ │ └── AddressRestController.kt │ │ │ └── usecase │ │ │ ├── inputport │ │ │ ├── AddressUseCase.kt │ │ │ └── command │ │ │ │ ├── CreateAddressCommand.kt │ │ │ │ ├── DeleteAddressCommand.kt │ │ │ │ ├── FindAddressCommand.kt │ │ │ │ ├── FindAllAddressCommand.kt │ │ │ │ └── UpdateAddressCommand.kt │ │ │ ├── interactor │ │ │ └── AddressInteractor.kt │ │ │ └── outputport │ │ │ ├── AddressUseCase.kt │ │ │ └── dto │ │ │ └── AddressDTO.kt │ └── resources │ │ ├── application.yml │ │ └── db │ │ └── migration │ │ └── V1__address.sql │ └── test │ └── kotlin │ ├── htnk128 │ └── kotlin │ │ └── ddd │ │ └── sample │ │ └── address │ │ └── domain │ │ └── model │ │ ├── address │ │ ├── AddressIdSpec.kt │ │ ├── AddressSpec.kt │ │ ├── CitySpec.kt │ │ ├── FullNameSpec.kt │ │ ├── Line1Spec.kt │ │ ├── Line2Spec.kt │ │ ├── PhoneNumberSpec.kt │ │ ├── StateOrRegionSpec.kt │ │ └── ZipCodeSpec.kt │ │ └── owner │ │ ├── OwnerIdSpec.kt │ │ └── OwnerSpec.kt │ └── io │ └── kotlintest │ └── provided │ └── ProjectConfig.kt ├── build.gradle.kts ├── buildSrc ├── build.gradle.kts └── src │ └── main │ └── kotlin │ ├── Apply.kt │ ├── Dependencies.kt │ ├── Plugins.kt │ ├── Repositories.kt │ └── Versions.kt ├── ddd-core ├── build.gradle.kts └── src │ └── main │ └── kotlin │ └── htnk128 │ └── kotlin │ └── ddd │ └── sample │ └── ddd │ └── core │ └── domain │ ├── DomainEvent.kt │ ├── DomainEventPublisher.kt │ ├── DomainEventSubscriber.kt │ ├── Entity.kt │ ├── Identity.kt │ ├── SomeIdentity.kt │ ├── SomeValueObject.kt │ └── ValueObject.kt ├── docs ├── account-use-case.png ├── account-use-case.puml ├── address-use-case.png ├── address-use-case.puml ├── contextmap.drawio └── contextmap.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle.kts └── shared ├── build.gradle.kts └── src └── main └── kotlin └── htnk128 └── kotlin └── ddd └── sample └── shared ├── adapter ├── controller │ └── resource │ │ └── ErrorResponse.kt └── gateway │ └── db │ ├── ExposedTable.kt │ └── InstantColumnType.kt └── usecase ├── ApplicationException.kt └── outputport └── dto └── PaginationDTO.kt /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{kt, kts}] 2 | #---- Ktlint rules ---- 3 | # Comma-separated list of rules to disable (Since 0.34.0) 4 | # Note that rules in any ruleset other than the standard ruleset will need to be prefixed 5 | # by the ruleset identifier. 6 | disabled_rules = import-ordering, indent 7 | -------------------------------------------------------------------------------- /.github/workflows/check_build.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches-ignore: 4 | - master 5 | 6 | name: check_build 7 | 8 | jobs: 9 | build: 10 | name: build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Setup java runtime 17 | uses: actions/setup-java@v3 18 | with: 19 | distribution: 'zulu' 20 | java-version: '8' 21 | cache: 'gradle' 22 | 23 | # - name: KtlintCheck 24 | # env: 25 | # TZ: Asia/Tokyo 26 | # run: ./gradlew ktlintCheck 27 | 28 | - name: Build 29 | env: 30 | TZ: Asia/Tokyo 31 | run: ./gradlew build 32 | 33 | - name: Archive unit test results 34 | uses: actions/upload-artifact@v3 35 | with: 36 | name: unit-test-report 37 | path: | 38 | account/build/reports/tests/test 39 | address/build/reports/tests/test 40 | 41 | - name: Archive coverage results 42 | uses: actions/upload-artifact@v3 43 | with: 44 | name: code-coverage-report 45 | path: | 46 | account/build/reports/jacoco/test/html 47 | address/build/reports/jacoco/test/html 48 | 49 | check_build: 50 | name: check_build 51 | needs: [build] 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Finish 55 | run: echo "done" 56 | -------------------------------------------------------------------------------- /.github/workflows/select_build.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: 4 | - master 5 | types: 6 | - closed 7 | paths-ignore: 8 | - '.**' 9 | - '**.md' 10 | - 'docs/**' 11 | 12 | name: select_build 13 | 14 | jobs: 15 | build: 16 | name: build 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3 21 | 22 | - name: Setup java runtime 23 | uses: actions/setup-java@v3 24 | with: 25 | distribution: 'zulu' 26 | java-version: '8' 27 | 28 | - name: Build 29 | env: 30 | TZ: Asia/Tokyo 31 | run: | 32 | ./gradlew build 33 | 34 | select_build: 35 | name: select_build 36 | needs: [build] 37 | runs-on: ubuntu-latest 38 | steps: 39 | - name: Finish 40 | run: echo "done" 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | /build/ 3 | */build/ 4 | */out/ 5 | !gradle/wrapper/gradle-wrapper.jar 6 | .kotlintest 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | 17 | ### IntelliJ IDEA ### 18 | .idea 19 | *.iws 20 | *.iml 21 | *.ipr 22 | /out/ 23 | 24 | ### NetBeans ### 25 | /nbproject/private/ 26 | /nbbuild/ 27 | /dist/ 28 | /nbdist/ 29 | /.nb-gradle/ 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Hiroaki Tanaka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # kotlin-ddd-sample 2 | 3 | [![select_build](https://github.com/htnk128/kotlin-ddd-sample/actions/workflows/select_build.yaml/badge.svg)](https://github.com/htnk128/kotlin-ddd-sample/actions/workflows/select_build.yaml) 4 | 5 | This is a DDD sample using Kotlin. 6 | 7 | Modeled using Amazon's account function as a theme. 8 | 9 | - [Spring Boot](https://github.com/spring-projects/spring-boot) 10 | - [Exposed](https://github.com/JetBrains/Exposed) 11 | - [H2 Database](https://github.com/h2database/h2database) 12 | 13 | ## Run Application 14 | 15 | ### Account 16 | ``` bash 17 | $ ./gradlew :account:bootRun 18 | ``` 19 | 20 | ### Address 21 | ``` bash 22 | $ ./gradlew :address:bootRun 23 | ``` 24 | 25 | ## API 26 | 27 | ### Account 28 | http://localhost:8080/swagger-ui/ 29 | 30 | ### Address 31 | http://localhost:8081/swagger-ui/ 32 | 33 | ## Domain-driven design 34 | 35 | ### Context map 36 | 37 | ![contextmap](./docs/contextmap.png) 38 | 39 | ### Use case 40 | 41 | ![account-use-case](./docs/account-use-case.png) 42 | 43 | ![address-use-case](./docs/address-use-case.png) 44 | 45 | ### Language 46 | 47 | #### Account 48 | 49 | | japanese | english | 50 | | ---- | ------ | 51 | | アカウント | account | 52 | | アカウントのID | account id | 53 | | アカウントの氏名または会社名 | name | 54 | | アカウントの発音 | name pronunciation | 55 | | アカウントのメールアドレス | email | 56 | | アカウントのパスワード | password | 57 | | アカウントの住所 | account address | 58 | | アカウントの住所のID | account address id | 59 | | アカウントの住所録 | address book | 60 | 61 | #### Address 62 | 63 | | japanese | english | 64 | | ---- | ------ | 65 | | 住所 | address | 66 | | 住所の氏名または会社名 | full name | 67 | | 住所の郵便番号 | zip code | 68 | | 住所の都道府県 | state or region | 69 | | 住所の住所欄1 | line1 | 70 | | 住所の住所欄2 | line2 | 71 | | 住所の電話番号 | phone number | 72 | | 住所の電話番号 | phone number | 73 | | 住所の持ち主 | owner | 74 | | 住所の持ち主のID | owner id | 75 | 76 | ## Package configuration 77 | 78 | [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) の思想に則ったパッケージを用意する。 79 | 80 | | package | layer | description | 81 | ----|----|---- 82 | | domain | Enterprise Business Rules | ビジネスロジックを表現するレイヤー。 | 83 | | usecase | Application Business Rules | ビジネスロジックを用いてユースケースを実現するレイヤー。 | 84 | | adapter | Interface Adapters | REST APIを用いた外部からのリクエストやデータベースのような外部接続といった外界と内部のレイヤーの連携する役割を果たすレイヤー。 | 85 | | external | Frameworks & Drivers | 外界との境界ににあり相互に通信する役割を果たすレイヤー。Webフレームワークやデータベースなどに関連するコードを配置する。 | 86 | -------------------------------------------------------------------------------- /account/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | springPlugins() 3 | } 4 | 5 | apply { 6 | spring() 7 | } 8 | 9 | dependencies { 10 | sharedDependency() 11 | dddCoreDependency() 12 | springDependencies() 13 | jacksonDependencies() 14 | sqlDependencies() 15 | flywayDependencies() 16 | springfoxDependencies() 17 | loggingDependency() 18 | } 19 | 20 | kover { 21 | verify { 22 | rule { 23 | name = "Minimal line coverage rate in percents" 24 | bound { 25 | minValue = 26 // TODO 80%くらいにはしたい 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/controller/AccountController.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.controller 2 | 3 | import htnk128.kotlin.ddd.sample.account.adapter.controller.resource.AccountResponse 4 | import htnk128.kotlin.ddd.sample.account.adapter.controller.resource.AccountResponses 5 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.CreateAccountCommand 6 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.DeleteAccountCommand 7 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.FindAccountCommand 8 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.FindAllAccountCommand 9 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.UpdateAccountCommand 10 | import htnk128.kotlin.ddd.sample.account.usecase.outputport.dto.AccountDTO 11 | import org.springframework.stereotype.Component 12 | import reactor.core.publisher.Mono 13 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.AccountUseCase as InAccountUseCase 14 | import htnk128.kotlin.ddd.sample.account.usecase.outputport.AccountUseCase as OutAccountUseCase 15 | 16 | @Component 17 | class AccountController( 18 | private val accountUseCase: InAccountUseCase, 19 | private val accountPresenter: OutAccountUseCase 20 | ) { 21 | 22 | fun find( 23 | accountId: String 24 | ): Mono { 25 | val command = FindAccountCommand(accountId) 26 | return accountUseCase.find(command) 27 | .map { accountPresenter.toDTO(it) } 28 | .map { it.toResponse() } 29 | } 30 | 31 | fun findAll( 32 | limit: Int, 33 | offset: Int 34 | ): Mono { 35 | val command = FindAllAccountCommand(limit, offset) 36 | 37 | return accountUseCase.findAll(command) 38 | .map { (count, accounts) -> 39 | accountPresenter.toDTO(accounts, count, command.limit, command.offset) 40 | } 41 | .map { dto -> 42 | AccountResponses( 43 | dto.count, 44 | dto.hasMore, 45 | dto.data.map { it.toResponse() } 46 | ) 47 | } 48 | } 49 | 50 | fun create( 51 | name: String, 52 | namePronunciation: String, 53 | email: String, 54 | password: String 55 | ): Mono { 56 | val command = CreateAccountCommand( 57 | name, 58 | namePronunciation, 59 | email, 60 | password 61 | ) 62 | 63 | return accountUseCase.create(command) 64 | .map { accountPresenter.toDTO(it) } 65 | .map { it.toResponse() } 66 | } 67 | 68 | fun update( 69 | accountId: String, 70 | name: String?, 71 | namePronunciation: String?, 72 | email: String?, 73 | password: String? 74 | ): Mono { 75 | val command = UpdateAccountCommand( 76 | accountId, 77 | name, 78 | namePronunciation, 79 | email, 80 | password 81 | ) 82 | 83 | return accountUseCase.update(command) 84 | .map { accountPresenter.toDTO(it) } 85 | .map { it.toResponse() } 86 | } 87 | 88 | fun delete( 89 | accountId: String 90 | ): Mono { 91 | val command = DeleteAccountCommand(accountId) 92 | 93 | return accountUseCase.delete(command) 94 | .map { accountPresenter.toDTO(it) } 95 | .map { it.toResponse() } 96 | } 97 | 98 | private fun AccountDTO.toResponse() = 99 | AccountResponse.from(this) 100 | } 101 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/controller/ErrorAdvice.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.controller 2 | 3 | import htnk128.kotlin.ddd.sample.shared.adapter.controller.resource.ErrorResponse 4 | import htnk128.kotlin.ddd.sample.shared.usecase.ApplicationException 5 | import mu.KLogging 6 | import org.springframework.http.HttpStatus 7 | import org.springframework.http.ResponseEntity 8 | import org.springframework.web.bind.annotation.ExceptionHandler 9 | import org.springframework.web.bind.annotation.RestControllerAdvice 10 | 11 | // TODO external.spring.restに置きたいので、ApplicationExceptionの配置を考える 12 | @RestControllerAdvice 13 | class ErrorAdvice { 14 | 15 | @ExceptionHandler(ApplicationException::class) 16 | fun handleApplicationException(exception: ApplicationException): ResponseEntity { 17 | logger.error(exception) { "type=${exception.type}, status=${exception.status}, message=${exception.message}" } 18 | 19 | return ResponseEntity 20 | .status(exception.status) 21 | .body(errorResponse(exception.type, exception.status, exception.message)) 22 | } 23 | 24 | @ExceptionHandler(Exception::class) 25 | fun handleException(exception: Exception): ResponseEntity { 26 | val type = "server_error" 27 | val status = HttpStatus.INTERNAL_SERVER_ERROR.value() 28 | val message = "internal server error." 29 | 30 | logger.error(exception) { "type=$type, status=$status, message=$message" } 31 | 32 | return ResponseEntity 33 | .status(status) 34 | .body(errorResponse(type, status, message)) 35 | } 36 | 37 | private fun errorResponse(type: String, code: Int, message: String): ErrorResponse = 38 | ErrorResponse.from(type, code, message) 39 | 40 | companion object : KLogging() 41 | } 42 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/controller/resource/AccountCreateRequest.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.controller.resource 2 | 3 | import io.swagger.annotations.ApiModelProperty 4 | 5 | /** 6 | * アカウント作成時のリクエスト情報。 7 | */ 8 | data class AccountCreateRequest( 9 | @ApiModelProperty( 10 | value = "アカウントの氏名または会社名", example = "あいうえお", required = true, position = 1 11 | ) 12 | val name: String, 13 | @ApiModelProperty( 14 | value = "アカウントの発音", example = "アイウエオ", required = true, position = 2 15 | ) 16 | val namePronunciation: String, 17 | @ApiModelProperty( 18 | value = "アカウントのメールアドレス", example = "example@example.com", required = true, position = 3 19 | ) 20 | val email: String, 21 | @ApiModelProperty( 22 | value = "アカウントのパスワード", required = true, position = 4 23 | ) 24 | val password: String 25 | ) 26 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/controller/resource/AccountFindAllRequest.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.controller.resource 2 | 3 | import io.swagger.annotations.ApiModelProperty 4 | 5 | /** 6 | * すべてのアカウント取得時のリクエスト情報。 7 | */ 8 | data class AccountFindAllRequest( 9 | @ApiModelProperty( 10 | value = "取得するデータ数の最大値", example = "10", required = false, allowableValues = "range[1, 100]", position = 1 11 | ) 12 | val limit: Int = 10, 13 | @ApiModelProperty( 14 | value = "基準点からのデータ取得を行う開始位置", example = "0", required = false, allowableValues = "range[0, 1000]", position = 2 15 | ) 16 | val offset: Int = 0 17 | ) 18 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/controller/resource/AccountResponse.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.controller.resource 2 | 3 | import htnk128.kotlin.ddd.sample.account.usecase.outputport.dto.AccountDTO 4 | import io.swagger.annotations.ApiModelProperty 5 | 6 | /** 7 | * アカウントのレスポンス情報。 8 | */ 9 | data class AccountResponse( 10 | @ApiModelProperty( 11 | value = "アカウントのID", example = "AC_c5fb2cec-a77c-4886-b997-ffc2ef060e78", required = true, position = 1 12 | ) 13 | val accountId: String, 14 | @ApiModelProperty( 15 | value = "アカウントの氏名または会社名", example = "あいうえお", required = true, position = 2 16 | ) 17 | val name: String, 18 | @ApiModelProperty( 19 | value = "アカウントの発音", example = "アイウエオ", required = true, position = 3 20 | ) 21 | val namePronunciation: String, 22 | @ApiModelProperty( 23 | value = "アカウントのメールアドレス", example = "example@example.com", required = true, position = 4 24 | ) 25 | val email: String, 26 | @ApiModelProperty( 27 | value = "アカウントのパスワード", required = true, position = 5 28 | ) 29 | val password: String, 30 | @ApiModelProperty( 31 | value = "アカウントの作成日時", example = "1576120910973", required = true, position = 6 32 | ) 33 | val createdAt: Long, 34 | @ApiModelProperty( 35 | value = "アカウントの削除日時", example = "1576120910973", required = false, position = 7 36 | ) 37 | val deletedAt: Long?, 38 | @ApiModelProperty( 39 | value = "アカウントの更新日時", example = "1576120910973", required = true, position = 8 40 | ) 41 | val updatedAt: Long 42 | ) { 43 | 44 | companion object { 45 | 46 | fun from(dto: AccountDTO): AccountResponse = 47 | AccountResponse( 48 | dto.accountId, 49 | dto.name, 50 | dto.namePronunciation, 51 | dto.email, 52 | dto.password, 53 | dto.createdAt, 54 | dto.deletedAt, 55 | dto.updatedAt 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/controller/resource/AccountResponses.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.controller.resource 2 | 3 | import io.swagger.annotations.ApiModelProperty 4 | 5 | /** 6 | * 複数のアカウントのレスポンス情報。 7 | * 8 | * 複数のアカウントのレスポンス情報には以下の情報が含まれる。 9 | * - count 10 | * アカウントの件数 11 | * - hasMore 12 | * 取得できていないアカウントがあるかどうか 13 | * - data 14 | * アカウントのレスポンス情報([AccountResponse])のリスト 15 | */ 16 | data class AccountResponses( 17 | @ApiModelProperty( 18 | value = "アカウントの件数", required = true, position = 1 19 | ) 20 | val count: Int, 21 | @ApiModelProperty( 22 | value = "取得できていないアカウントがあるかどうか", required = true, position = 2 23 | ) 24 | val hasMore: Boolean, 25 | @ApiModelProperty( 26 | value = "アカウントのレスポンス情報のリスト", required = true, position = 3 27 | ) 28 | val data: List 29 | ) 30 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/controller/resource/AccountUpdateRequest.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.controller.resource 2 | 3 | import io.swagger.annotations.ApiModelProperty 4 | 5 | /** 6 | * アカウント更新時のリクエスト情報。 7 | */ 8 | data class AccountUpdateRequest( 9 | @ApiModelProperty( 10 | value = "アカウントの氏名または会社名", example = "あいうえお", required = false, position = 1 11 | ) 12 | val name: String?, 13 | @ApiModelProperty( 14 | value = "アカウントの発音", example = "アイウエオ", required = false, position = 2 15 | ) 16 | val namePronunciation: String?, 17 | @ApiModelProperty( 18 | value = "アカウントのメールアドレス", example = "example@example.com", required = false, position = 3 19 | ) 20 | val email: String?, 21 | @ApiModelProperty( 22 | value = "アカウントのパスワード", required = false, position = 4 23 | ) 24 | val password: String? 25 | ) 26 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/gateway/db/AccountExposedRepository.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.gateway.db 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Account 4 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountId 5 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountNotFoundException 6 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountUpdateFailedException 7 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Email 8 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Name 9 | import htnk128.kotlin.ddd.sample.account.domain.model.account.NamePronunciation 10 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Password 11 | import htnk128.kotlin.ddd.sample.account.domain.repository.AccountRepository 12 | import htnk128.kotlin.ddd.sample.shared.adapter.gateway.db.ExposedTable 13 | import java.time.Instant 14 | import org.jetbrains.exposed.sql.Column 15 | import org.jetbrains.exposed.sql.ResultRow 16 | import org.jetbrains.exposed.sql.insert 17 | import org.jetbrains.exposed.sql.select 18 | import org.jetbrains.exposed.sql.selectAll 19 | import org.jetbrains.exposed.sql.update 20 | import org.springframework.stereotype.Repository 21 | 22 | @Repository 23 | class AccountExposedRepository : AccountRepository { 24 | 25 | override fun find(accountId: AccountId, lock: Boolean): Account = 26 | AccountTable.select { AccountTable.accountId eq accountId.id() } 27 | .run { if (lock) this.forUpdate() else this } 28 | .firstOrNull() 29 | ?.rowToModel() 30 | ?: throw AccountNotFoundException(accountId) 31 | 32 | override fun findAll(limit: Int, offset: Int): List = 33 | AccountTable.selectAll() 34 | .orderBy(AccountTable.createdAt) 35 | .limit(limit, offset = offset * limit) 36 | .map { it.rowToModel() } 37 | 38 | override fun count(): Int = 39 | AccountTable.selectAll() 40 | .count() 41 | 42 | override fun add(account: Account) { 43 | AccountTable.insert { 44 | it[accountId] = account.accountId.id() 45 | it[name] = account.name.value() 46 | it[namePronunciation] = account.namePronunciation.value() 47 | it[email] = account.email.value() 48 | it[password] = account.password.value() 49 | it[createdAt] = account.createdAt 50 | it[deletedAt] = account.deletedAt 51 | it[updatedAt] = account.updatedAt 52 | } 53 | } 54 | 55 | override fun set(account: Account) { 56 | AccountTable.update({ AccountTable.accountId eq account.accountId.id() }) { 57 | it[name] = account.name.value() 58 | it[namePronunciation] = account.namePronunciation.value() 59 | it[email] = account.email.value() 60 | it[password] = account.password.value() 61 | it[updatedAt] = account.updatedAt 62 | } 63 | .takeIf { it > 0 } 64 | ?: throw AccountUpdateFailedException(account.accountId) 65 | } 66 | 67 | override fun remove(account: Account) { 68 | AccountTable.update({ AccountTable.accountId eq account.accountId.id() }) { 69 | it[deletedAt] = account.deletedAt 70 | it[updatedAt] = account.updatedAt 71 | } 72 | .takeIf { it > 0 } 73 | ?: throw AccountUpdateFailedException(account.accountId) 74 | } 75 | 76 | private fun ResultRow.rowToModel(): Account = 77 | Account( 78 | AccountId.valueOf(this[AccountTable.accountId]), 79 | Name.valueOf(this[AccountTable.name]), 80 | NamePronunciation.valueOf(this[AccountTable.namePronunciation]), 81 | Email.valueOf(this[AccountTable.email]), 82 | Password.from(this[AccountTable.password]), 83 | this[AccountTable.createdAt], 84 | this[AccountTable.deletedAt], 85 | this[AccountTable.updatedAt] 86 | ) 87 | } 88 | 89 | private object AccountTable : ExposedTable("account") { 90 | 91 | val accountId: Column = varchar("account_id", length = 64).primaryKey() 92 | val name: Column = varchar("name", length = 100) 93 | val namePronunciation: Column = varchar("name_pronunciation", length = 100) 94 | val email: Column = varchar("email", length = 100) 95 | val password: Column = varchar("password", length = 64) 96 | val createdAt: Column = instant("created_at") 97 | val deletedAt: Column = instant("deleted_at").nullable() 98 | val updatedAt: Column = instant("updated_at") 99 | } 100 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/gateway/messaging/AccountEventSpringPublisher.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.gateway.messaging 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountEvent 4 | import htnk128.kotlin.ddd.sample.ddd.core.domain.DomainEventPublisher 5 | import org.springframework.context.ApplicationEventPublisher 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class AccountEventSpringPublisher( 10 | private val eventPublisher: ApplicationEventPublisher 11 | ) : DomainEventPublisher> { 12 | 13 | override fun publish(domainEvent: AccountEvent<*>) { 14 | eventPublisher.publishEvent(domainEvent) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/gateway/messaging/AccountEventSpringSubscriber.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.gateway.messaging 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountEvent 4 | import htnk128.kotlin.ddd.sample.ddd.core.domain.DomainEventSubscriber 5 | import org.springframework.context.event.EventListener 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class AccountEventSpringSubscriber : DomainEventSubscriber> { 10 | 11 | @EventListener 12 | override fun handleEvent(domainEvent: AccountEvent<*>) { 13 | println("type=${domainEvent.type}, account=${domainEvent.account}, occurredOn=${domainEvent.occurredOn}") 14 | // 何もしない。キューにエンキューする、メールを送る、REST APIを叩く、どっかに通知を送るなどが考えられる 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/gateway/rest/AddressBookRestService.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.gateway.rest 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountId 4 | import htnk128.kotlin.ddd.sample.account.domain.model.addressbook.AccountAddress 5 | import htnk128.kotlin.ddd.sample.account.domain.model.addressbook.AccountAddressId 6 | import htnk128.kotlin.ddd.sample.account.domain.model.addressbook.AddressBook 7 | import htnk128.kotlin.ddd.sample.account.domain.model.addressbook.AddressBookService 8 | import java.time.Instant 9 | import org.springframework.beans.factory.annotation.Value 10 | import org.springframework.stereotype.Component 11 | import org.springframework.web.reactive.function.client.WebClient 12 | import reactor.core.publisher.Flux 13 | import reactor.core.publisher.Mono 14 | 15 | @Component 16 | class AddressBookRestService( 17 | private val addressClient: AddressClient 18 | ) : AddressBookService { 19 | 20 | override fun find(accountId: AccountId): Mono = 21 | addressClient.findAll(accountId) 22 | .collectList() 23 | .map { AddressBook(it) } 24 | 25 | override fun remove(accountAddressId: AccountAddressId): Mono = 26 | addressClient.delete(accountAddressId) 27 | } 28 | 29 | @Component 30 | class AddressClient( 31 | @Value("\${api.address.url:http://localhost:8081/addresses}") 32 | private val addressUrl: String 33 | ) { 34 | 35 | fun findAll(accountId: AccountId): Flux = 36 | WebClient 37 | .builder() 38 | .build() 39 | .get() 40 | .uri("$addressUrl?ownerId=$accountId") 41 | .retrieve() 42 | .bodyToMono(AddressResponses::class.java) 43 | .flatMapIterable { it.data } 44 | .map { it.responseToModel() } 45 | 46 | fun delete(accountAddressId: AccountAddressId): Mono = 47 | WebClient 48 | .builder() 49 | .build() 50 | .delete() 51 | .uri("$addressUrl/$accountAddressId") 52 | .retrieve() 53 | .bodyToMono(AddressResponse::class.java) 54 | .map { } 55 | 56 | private data class AddressResponses( 57 | val data: List 58 | ) 59 | 60 | private data class AddressResponse( 61 | val addressId: String, 62 | val deletedAt: Long? 63 | ) { 64 | 65 | fun responseToModel(): AccountAddress = 66 | AccountAddress( 67 | AccountAddressId.valueOf(addressId), 68 | deletedAt?.let { Instant.ofEpochMilli(it) } 69 | ) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/adapter/presenter/AccountPresenter.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.adapter.presenter 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Account 4 | import htnk128.kotlin.ddd.sample.account.usecase.outputport.AccountUseCase 5 | import htnk128.kotlin.ddd.sample.account.usecase.outputport.dto.AccountDTO 6 | import htnk128.kotlin.ddd.sample.shared.usecase.outputport.dto.PaginationDTO 7 | import org.springframework.stereotype.Component 8 | 9 | @Component 10 | class AccountPresenter : AccountUseCase { 11 | 12 | override fun toDTO(account: Account): AccountDTO { 13 | return AccountDTO( 14 | account.accountId.id(), 15 | account.name.value(), 16 | account.namePronunciation.value(), 17 | account.email.value(), 18 | account.password.format(), 19 | account.createdAt.toEpochMilli(), 20 | account.deletedAt?.toEpochMilli(), 21 | account.updatedAt.toEpochMilli() 22 | ) 23 | } 24 | 25 | override fun toDTO(accounts: List, count: Int, limit: Int, offset: Int): PaginationDTO { 26 | return PaginationDTO( 27 | count, 28 | limit, 29 | offset, 30 | accounts.map { toDTO(it) } 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/Account.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.Entity 4 | import java.time.Instant 5 | 6 | /** 7 | * アカウントを表現する。 8 | * 9 | * 氏名または会社名、発音などの情報を指定して作成することが可能である。 10 | * また、アカウントの作成後は更新、削除が可能である。 11 | */ 12 | class Account( 13 | val accountId: AccountId, 14 | val name: Name, 15 | val namePronunciation: NamePronunciation, 16 | val email: Email, 17 | val password: Password, 18 | val createdAt: Instant, 19 | val deletedAt: Instant? = null, 20 | val updatedAt: Instant 21 | ) : Entity { 22 | 23 | private val events = mutableListOf>() 24 | 25 | /** 26 | * このアカウントが削除されている場合に`true`を返す。 27 | */ 28 | val isDeleted: Boolean = deletedAt != null 29 | 30 | /** 31 | * アカウントを更新する。 32 | * 33 | * 氏名または会社名、発音、メールアドレス、パスワードを更新可能で、すべて任意指定が可能であり指定しなかった場合は現在の値のままとなる。 34 | * 更新後にイベント([AccountUpdated])を生成する。 35 | * 36 | * また、このアカウントが削除されている場合には例外となる。 37 | * 38 | * @return 更新されたアカウント 39 | * @throws AccountInvalidDataStateException 40 | */ 41 | fun update( 42 | name: Name?, 43 | namePronunciation: NamePronunciation?, 44 | email: Email?, 45 | password: Password? 46 | ): Account { 47 | if (isDeleted) throw AccountInvalidDataStateException( 48 | "Account has been deleted." 49 | ) 50 | 51 | return Account( 52 | accountId, 53 | name = name ?: this.name, 54 | namePronunciation = namePronunciation ?: this.namePronunciation, 55 | email = email ?: this.email, 56 | password = password ?: this.password, 57 | createdAt = this.createdAt, 58 | updatedAt = Instant.now() 59 | ) 60 | .addEvent(AccountEvent.Type.UPDATED, events.toList()) 61 | } 62 | 63 | /** 64 | * アカウントを削除する。 65 | * 66 | * 削除日時([deletedAt])に現在日付が設定されことによって論理削除状態となる。 67 | * 更新後にイベント([AccountDeleted])を生成する。 68 | * 69 | * また、このアカウントが削除済みの場合にはそのままこのアカウントが返却される。 70 | * 71 | * @return 削除されたアカウント 72 | */ 73 | fun delete(): Account = if (isDeleted) this else with(Instant.now()) { 74 | Account( 75 | accountId, 76 | name = name, 77 | namePronunciation = namePronunciation, 78 | email = email, 79 | password = password, 80 | createdAt = createdAt, 81 | deletedAt = this, 82 | updatedAt = this 83 | ) 84 | .addEvent(AccountEvent.Type.DELETED, events.toList()) 85 | } 86 | 87 | /** 88 | * 発生したイベントを返す。 89 | * 90 | * @return 発生したイベントのリスト 91 | */ 92 | fun occurredEvents(): List> = events.toList() 93 | 94 | private fun addEvent(type: AccountEvent.Type, events: List> = emptyList()): Account = this 95 | .also { 96 | this.events += events 97 | this.events += when (type) { 98 | AccountEvent.Type.CREATED -> AccountCreated(this) 99 | AccountEvent.Type.UPDATED -> AccountUpdated(this) 100 | AccountEvent.Type.DELETED -> AccountDeleted(this) 101 | } 102 | } 103 | 104 | override fun equals(other: Any?): Boolean { 105 | if (this === other) return true 106 | if (javaClass != other?.javaClass) return false 107 | other as Account 108 | return sameIdentityAs(other) 109 | } 110 | 111 | override fun hashCode(): Int = accountId.hashCode() 112 | 113 | override fun sameIdentityAs(other: Account): Boolean = accountId == other.accountId 114 | 115 | override fun toString(): String { 116 | return buildString { 117 | append("accountId=$accountId, ") 118 | append("name=$name, ") 119 | append("namePronunciation=$namePronunciation, ") 120 | append("email=$email, ") 121 | append("password=$password, ") 122 | append("createdAt=$createdAt, ") 123 | append("deletedAt=$deletedAt, ") 124 | append("updatedAt=$updatedAt") 125 | } 126 | } 127 | 128 | companion object { 129 | 130 | /** 131 | * アカウントを作成する。 132 | * 133 | * 名前、発音、メールアドレス、パスワードすべての項目が必須指定となる。 134 | * 135 | * また、作成後にイベント([AccountCreated])を生成する。 136 | * 137 | * @return 作成されたアカウント 138 | */ 139 | fun create( 140 | accountId: AccountId, 141 | name: Name, 142 | namePronunciation: NamePronunciation, 143 | email: Email, 144 | password: Password 145 | ): Account = with(Instant.now()) { 146 | Account( 147 | accountId, 148 | name, 149 | namePronunciation, 150 | email, 151 | password, 152 | createdAt = this, 153 | updatedAt = this 154 | ) 155 | } 156 | .addEvent(AccountEvent.Type.CREATED) 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/AccountEvent.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.DomainEvent 4 | import htnk128.kotlin.ddd.sample.ddd.core.domain.ValueObject 5 | import java.time.Instant 6 | 7 | /** 8 | * アカウントのイベントを表現する。 9 | * 10 | * @param T [AccountEvent] 11 | */ 12 | sealed class AccountEvent> : DomainEvent { 13 | 14 | abstract val type: Type 15 | 16 | abstract val account: Account 17 | 18 | override val occurredOn: Instant = Instant.now() 19 | 20 | override fun equals(other: Any?): Boolean { 21 | if (this === other) return true 22 | if (javaClass != other?.javaClass) return false 23 | @Suppress("UNCHECKED_CAST") 24 | other as T 25 | return sameEventAs(other) 26 | } 27 | 28 | override fun hashCode(): Int { 29 | var result = type.hashCode() 30 | result = 31 * result + occurredOn.hashCode() 31 | return result 32 | } 33 | 34 | override fun sameEventAs(other: T): Boolean { 35 | if (type != other.type) return false 36 | if (occurredOn != other.occurredOn) return false 37 | return true 38 | } 39 | 40 | enum class Type(private val value: String) : 41 | ValueObject { 42 | CREATED("account.created"), 43 | UPDATED("account.updated"), 44 | DELETED("account.deleted"); 45 | 46 | override fun sameValueAs(other: Type): Boolean = value == other.value 47 | } 48 | } 49 | 50 | /** 51 | * アカウントの作成イベントを表現する。 52 | */ 53 | class AccountCreated(override val account: Account) : AccountEvent() { 54 | 55 | override val type: Type = Type.CREATED 56 | } 57 | 58 | /** 59 | * アカウントの更新イベントを表現する。 60 | */ 61 | class AccountUpdated(override val account: Account) : AccountEvent() { 62 | 63 | override val type: Type = Type.UPDATED 64 | } 65 | 66 | /** 67 | * アカウントの削除イベントを表現する。 68 | */ 69 | class AccountDeleted(override val account: Account) : AccountEvent() { 70 | 71 | override val type: Type = Type.DELETED 72 | } 73 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/AccountId.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeIdentity 4 | import java.util.UUID 5 | 6 | /** 7 | * アカウントのIDを表現する。 8 | * 9 | * 64桁までの一意な文字列をもつ。 10 | */ 11 | class AccountId private constructor(id: String) : SomeIdentity(id) { 12 | 13 | companion object { 14 | 15 | /** 16 | * [UUID]を用いてアカウントのIDを生成する。 17 | * 18 | * @return 生成した値を持つアカウントのID 19 | */ 20 | fun generate(): AccountId = AccountId("AC_${UUID.randomUUID()}") 21 | 22 | /** 23 | * [id]に指定された値をアカウントのIDに変換する。 24 | * 25 | * 値には、64桁までの一意な文字列を指定することが可能で、 26 | * 指定可能な値は、英数字、ハイフン、アンダースコアとなる。 27 | * この条件に違反した値を指定した場合には例外となる。 28 | * 29 | * @throws AccountInvalidRequestException 条件に違反した値を指定した場合 30 | * @return 指定された値を持つアカウントのID 31 | */ 32 | fun valueOf(id: String): AccountId = id 33 | .takeIf { LENGTH_RANGE.contains(it.length) && PATTERN.matches(it) } 34 | ?.let { AccountId(it) } 35 | ?: throw AccountInvalidRequestException( 36 | "Account id must be 64 characters or less and alphanumeric, hyphen, underscore." 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/AccountInvalidDataStateException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | /** 4 | * アカウントのドメインモデルが無効なデータ状態にある場合に発生する例外。 5 | */ 6 | class AccountInvalidDataStateException( 7 | override val message: String, 8 | cause: Throwable? = null 9 | ) : RuntimeException(message, cause) { 10 | 11 | val type: String = "invalid_data_state" 12 | } 13 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/AccountInvalidRequestException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | /** 4 | * 無効なリクエストを受けてアカウントのドメインモデルへの変換に失敗した場合に発生する例外。 5 | */ 6 | class AccountInvalidRequestException( 7 | override val message: String, 8 | cause: Throwable? = null 9 | ) : RuntimeException(message, cause) { 10 | 11 | val type: String = "invalid_request_error" 12 | } 13 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/AccountNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | /** 4 | * アカウントのドメインモデルが存在しない場合に発生する例外。 5 | */ 6 | class AccountNotFoundException( 7 | accountId: AccountId, 8 | override val message: String = "Account not found. (accountId=$accountId)", 9 | cause: Throwable? = null 10 | ) : RuntimeException(message, cause) { 11 | 12 | val type: String = "not_found_error" 13 | } 14 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/AccountUpdateFailedException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | /** 4 | * アカウントのドメインモデルの更新に失敗した場合に発生する例外。 5 | */ 6 | class AccountUpdateFailedException( 7 | accountId: AccountId, 8 | override val message: String = "Account update failure. (accountId=$accountId)", 9 | cause: Throwable? = null 10 | ) : RuntimeException(message, cause) { 11 | 12 | val type: String = "update_failure_error" 13 | } 14 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/Email.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * アカウントのメールアドレスを表現する。 7 | * 8 | * 100桁までの文字列をもつ。 9 | */ 10 | class Email private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..100) 15 | 16 | /** 17 | * [value]に指定された値をアカウントのメールアドレスに変換する。 18 | * 19 | * 値には、100桁までの文字列を指定することが可能である。 20 | * 21 | * @throws AccountInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つアカウントのメールアドレス 23 | */ 24 | fun valueOf(value: String): Email = value 25 | .takeIf { LENGTH_RANGE.contains(it.length) } 26 | ?.let { Email(it) } 27 | ?: throw AccountInvalidRequestException("Email must be 100 characters or less.") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/Name.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * アカウントの氏名または会社名を表現する。 7 | * 8 | * 100桁までの文字列をもつ。 9 | */ 10 | class Name private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..100) 15 | 16 | /** 17 | * [value]に指定された値をアカウントの氏名または会社名に変換する。 18 | * 19 | * 値には、100桁までの文字列を指定することが可能である。 20 | * 21 | * @throws AccountInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つアカウントの氏名または会社名 23 | */ 24 | fun valueOf(value: String): Name = value 25 | .takeIf { LENGTH_RANGE.contains(it.length) } 26 | ?.let { Name(it) } 27 | ?: throw AccountInvalidRequestException("Name must be 100 characters or less.") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/NamePronunciation.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * アカウントの発音を表現する。 7 | * 8 | * 100桁までの文字列をもつ。 9 | */ 10 | class NamePronunciation private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..100) 15 | 16 | /** 17 | * [value]に指定された値をアカウントの発音に変換する。 18 | * 19 | * 値には、100桁までの文字列を指定することが可能である。 20 | * 21 | * @throws AccountInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つアカウントの発音 23 | */ 24 | fun valueOf(value: String): NamePronunciation = value 25 | .takeIf { LENGTH_RANGE.contains(it.length) } 26 | ?.let { NamePronunciation(it) } 27 | ?: throw AccountInvalidRequestException("Name pronunciation must be 100 characters or less.") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/Password.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | import java.security.MessageDigest 5 | import javax.crypto.SecretKeyFactory 6 | import javax.crypto.spec.PBEKeySpec 7 | 8 | /** 9 | * アカウントのパスワードを表現する。 10 | * 11 | * 必ず64文字の文字列をもつ。 12 | */ 13 | class Password private constructor(value: String) : SomeValueObject(value) { 14 | 15 | /** 16 | * アカウントを外部向けのフォーマットに変換する。 17 | * 必ず"*****"を返す。 18 | */ 19 | fun format(): String = "*****" 20 | 21 | companion object { 22 | 23 | private const val ITERATION_COUNT = 100 24 | private const val KEY_LENGTH = 256 25 | 26 | private val LENGTH_RANGE = (6..100) 27 | 28 | /** 29 | * [value]に指定された値をアカウントのパスワードに変換する。 30 | * 31 | * 値には、6〜100桁までの文字列を指定することが可能である。 32 | * 33 | * アカウントのID([AccountId])をソルトとして用いてハッシュ化する。 34 | * 35 | * SHA-256を用いているため、文字列変換後は64文字となる。 36 | * 37 | * @throws AccountInvalidRequestException 条件に違反した値を指定した場合 38 | * @return 指定された値を持つアカウントのパスワード 39 | */ 40 | fun valueOf(value: String, accountId: AccountId): Password { 41 | return value 42 | .takeIf { LENGTH_RANGE.contains(it.length) } 43 | ?.let { 44 | val secret = it.toCharArray() 45 | val salt = MessageDigest.getInstance("SHA-256") 46 | .apply { update(accountId.id().toByteArray()) } 47 | .digest() 48 | 49 | SecretKeyFactory 50 | .getInstance("PBKDF2WithHmacSHA256") 51 | .generateSecret(PBEKeySpec(secret, salt, ITERATION_COUNT, KEY_LENGTH)) 52 | .encoded 53 | .joinToString("") { b -> String.format("%02x", b.toInt() and 255) } 54 | } 55 | ?.let { Password(it) } 56 | ?: throw AccountInvalidRequestException("Password must be between 6 and 100 characters.") 57 | } 58 | 59 | /** 60 | * 指定された値をアカウントのパスワードに変換する。 61 | */ 62 | fun from(value: String): Password = Password(value) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/addressbook/AccountAddress.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.addressbook 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.ValueObject 4 | import java.time.Instant 5 | 6 | /** 7 | * アカウントの住所を表現する。 8 | */ 9 | class AccountAddress( 10 | val accountAddressId: AccountAddressId, 11 | private val deletedAt: Instant? 12 | ) : ValueObject { 13 | 14 | /** 15 | * アカウントの住所が有効な場合に`true`を返す。 16 | * 17 | * 有効とは[deletedAt]が`null`の場合である。 18 | */ 19 | val isAvailable: Boolean = deletedAt == null 20 | 21 | override fun equals(other: Any?): Boolean { 22 | if (this === other) return true 23 | if (javaClass != other?.javaClass) return false 24 | other as AccountAddress 25 | return sameValueAs(other) 26 | } 27 | 28 | override fun hashCode(): Int { 29 | var result = accountAddressId.hashCode() 30 | result = 31 * result + deletedAt.hashCode() 31 | return result 32 | } 33 | 34 | override fun sameValueAs(other: AccountAddress): Boolean { 35 | if (accountAddressId != other.accountAddressId) return false 36 | if (deletedAt != other.deletedAt) return false 37 | return true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/addressbook/AccountAddressId.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.addressbook 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeIdentity 4 | 5 | /** 6 | * アカウントの住所のIDを表現する。 7 | * 8 | * 64桁までの一意な文字列をもつ。 9 | */ 10 | class AccountAddressId private constructor(id: String) : SomeIdentity(id) { 11 | 12 | companion object { 13 | 14 | /** 15 | * [id]に指定された値をアカウントの住所のIDに変換する。 16 | * 17 | * 値には、64桁までの一意な文字列を指定することが可能で、 18 | * 指定可能な値は、英数字、ハイフン、アンダースコアとなる。 19 | * この条件に違反した値を指定した場合には例外となる。 20 | * 21 | * @throws AddressBookInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つアカウントの住所のID 23 | */ 24 | fun valueOf(id: String): AccountAddressId = id 25 | .takeIf { LENGTH_RANGE.contains(it.length) && PATTERN.matches(it) } 26 | ?.let { AccountAddressId(it) } 27 | ?: throw AddressBookInvalidRequestException( 28 | "Account address id must be 64 characters or less and alphanumeric, hyphen, underscore." 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/addressbook/AddressBook.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.addressbook 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.ValueObject 4 | 5 | /** 6 | * アカウントの住所録を表現する。 7 | */ 8 | class AddressBook( 9 | private val allAccountAddresses: List 10 | ) : ValueObject { 11 | 12 | /** 13 | * 有効なアカウントの住所一覧。 14 | * 15 | * 有効とはアカウントの住所が有効([AccountAddress.isAvailable]=`true`)なものである。 16 | */ 17 | val availableAccountAddresses = allAccountAddresses 18 | .filter { it.isAvailable } 19 | 20 | override fun equals(other: Any?): Boolean { 21 | if (this === other) return true 22 | if (javaClass != other?.javaClass) return false 23 | other as AddressBook 24 | return sameValueAs(other) 25 | } 26 | 27 | override fun hashCode(): Int = allAccountAddresses.hashCode() 28 | 29 | override fun sameValueAs(other: AddressBook): Boolean = allAccountAddresses == other.allAccountAddresses 30 | } 31 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/addressbook/AddressBookInvalidRequestException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.addressbook 2 | 3 | /** 4 | * 無効なリクエストを受けてアカウントの住所録のドメインモデルへの変換に失敗した場合に発生する例外。 5 | */ 6 | class AddressBookInvalidRequestException( 7 | override val message: String, 8 | cause: Throwable? = null 9 | ) : RuntimeException(message, cause) { 10 | 11 | val type: String = "invalid_request_error" 12 | } 13 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/addressbook/AddressBookService.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.addressbook 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountId 4 | import reactor.core.publisher.Mono 5 | 6 | /** 7 | * アカウントの住所録を操作するドメインサービス。 8 | */ 9 | interface AddressBookService { 10 | 11 | fun find(accountId: AccountId): Mono 12 | 13 | fun remove(accountAddressId: AccountAddressId): Mono 14 | } 15 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/domain/repository/AccountRepository.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.repository 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Account 4 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountId 5 | 6 | /** 7 | * アカウントを操作するためのリポジトリを表現する。 8 | */ 9 | interface AccountRepository { 10 | 11 | fun find(accountId: AccountId, lock: Boolean = false): Account 12 | 13 | fun findAll(limit: Int, offset: Int): List 14 | 15 | fun count(): Int 16 | 17 | fun add(account: Account) 18 | 19 | fun set(account: Account) 20 | 21 | fun remove(account: Account) 22 | 23 | fun nextAccountId(): AccountId = AccountId.generate() 24 | } 25 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/external/spring/Application.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.external.spring 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan 5 | import org.springframework.boot.runApplication 6 | 7 | @SpringBootApplication(scanBasePackages = ["htnk128.kotlin.ddd.sample.account"]) 8 | @ConfigurationPropertiesScan(basePackages = ["htnk128.kotlin.ddd.sample.account"]) 9 | class Application 10 | 11 | fun main(args: Array) { 12 | runApplication(*args) 13 | } 14 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/external/spring/configuration/ApplicationConfiguration.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.external.spring.configuration 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 6 | import com.zaxxer.hikari.HikariDataSource 7 | import io.netty.channel.ChannelOption 8 | import javax.sql.DataSource 9 | import org.jetbrains.exposed.spring.SpringTransactionManager 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.context.annotation.Configuration 12 | import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor 13 | import org.springframework.http.client.reactive.ReactorClientHttpConnector 14 | import org.springframework.http.client.reactive.ReactorResourceFactory 15 | import org.springframework.transaction.annotation.EnableTransactionManagement 16 | import org.springframework.web.reactive.function.client.WebClient 17 | import reactor.netty.http.client.HttpClient 18 | import springfox.documentation.builders.ApiInfoBuilder 19 | import springfox.documentation.builders.RequestHandlerSelectors 20 | import springfox.documentation.service.ApiInfo 21 | import springfox.documentation.service.Contact 22 | import springfox.documentation.spi.DocumentationType 23 | import springfox.documentation.spring.web.plugins.Docket 24 | 25 | private typealias PlatformDataSource = HikariDataSource 26 | 27 | @Configuration 28 | @EnableTransactionManagement 29 | class ExposedConfiguration(val dataSource: DataSource) { 30 | 31 | @Bean 32 | fun transactionManager(dataSource: PlatformDataSource): SpringTransactionManager = 33 | SpringTransactionManager(dataSource) 34 | 35 | @Bean 36 | fun persistenceExceptionTranslationPostProcessor(): PersistenceExceptionTranslationPostProcessor = 37 | PersistenceExceptionTranslationPostProcessor() 38 | } 39 | 40 | @Configuration 41 | class JacksonConfiguration { 42 | 43 | @Bean 44 | fun objectMapper(): ObjectMapper = jacksonObjectMapper() 45 | .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 46 | } 47 | 48 | @Configuration 49 | class SpringfoxConfiguration { 50 | 51 | @Bean 52 | fun customDocket(): Docket = Docket(DocumentationType.SWAGGER_2) 53 | .select() 54 | .apis(RequestHandlerSelectors.basePackage("htnk128.kotlin.ddd.sample.account.external.spring.rest")) 55 | .build() 56 | .useDefaultResponseMessages(false) 57 | .apiInfo(apiInfo()) 58 | 59 | private fun apiInfo(): ApiInfo = ApiInfoBuilder() 60 | .title("Account APIs") 61 | .description("API specifications for account") 62 | .contact(Contact("htnk128", "https://github.com/htnk128", "hiroaki.tanaka128@gmail.com")) 63 | .version("1.0.0") 64 | .build() 65 | } 66 | 67 | @Configuration 68 | class WebClientConfiguration { 69 | 70 | @Bean 71 | fun reactorResourceFactory() = ReactorResourceFactory() 72 | .apply { 73 | isUseGlobalResources = false 74 | } 75 | 76 | @Bean 77 | fun webClient(): WebClient { 78 | val mapper: (HttpClient) -> HttpClient = { hc -> 79 | hc.tcpConfiguration { tc -> 80 | tc.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, TCP_CONNECT_TIMEOUT_MILLIS) 81 | } 82 | } 83 | val connector = ReactorClientHttpConnector(reactorResourceFactory(), mapper) 84 | return WebClient.builder().clientConnector(connector).build() 85 | } 86 | 87 | private companion object { 88 | 89 | const val TCP_CONNECT_TIMEOUT_MILLIS = 10000 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/external/spring/rest/AccountRestController.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.external.spring.rest 2 | 3 | import htnk128.kotlin.ddd.sample.account.adapter.controller.AccountController 4 | import htnk128.kotlin.ddd.sample.account.adapter.controller.resource.AccountCreateRequest 5 | import htnk128.kotlin.ddd.sample.account.adapter.controller.resource.AccountFindAllRequest 6 | import htnk128.kotlin.ddd.sample.account.adapter.controller.resource.AccountResponse 7 | import htnk128.kotlin.ddd.sample.account.adapter.controller.resource.AccountResponses 8 | import htnk128.kotlin.ddd.sample.account.adapter.controller.resource.AccountUpdateRequest 9 | import htnk128.kotlin.ddd.sample.shared.adapter.controller.resource.ErrorResponse 10 | import io.swagger.annotations.Api 11 | import io.swagger.annotations.ApiOperation 12 | import io.swagger.annotations.ApiParam 13 | import io.swagger.annotations.ApiResponse 14 | import io.swagger.annotations.ApiResponses 15 | import org.springframework.http.HttpStatus 16 | import org.springframework.http.MediaType 17 | import org.springframework.web.bind.annotation.DeleteMapping 18 | import org.springframework.web.bind.annotation.GetMapping 19 | import org.springframework.web.bind.annotation.ModelAttribute 20 | import org.springframework.web.bind.annotation.PathVariable 21 | import org.springframework.web.bind.annotation.PostMapping 22 | import org.springframework.web.bind.annotation.PutMapping 23 | import org.springframework.web.bind.annotation.RequestBody 24 | import org.springframework.web.bind.annotation.RequestMapping 25 | import org.springframework.web.bind.annotation.ResponseStatus 26 | import org.springframework.web.bind.annotation.RestController 27 | import reactor.core.publisher.Mono 28 | 29 | @Api("アカウントを管理するAPI", tags = ["Accounts"]) 30 | @RestController 31 | @RequestMapping("/accounts") 32 | class AccountRestController( 33 | private val accountController: AccountController 34 | ) { 35 | 36 | @ApiResponses( 37 | value = [ 38 | (ApiResponse(code = 200, message = "OK", response = AccountResponse::class)), 39 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 40 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 41 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 42 | ] 43 | ) 44 | @ApiOperation("アカウントを取得する") 45 | @GetMapping("/{accountId}") 46 | fun find( 47 | @ApiParam(value = "アカウントのID", required = true, example = "AC_c5fb2cec-a77c-4886-b997-ffc2ef060e78") 48 | @PathVariable accountId: String 49 | ): Mono { 50 | return accountController.find(accountId) 51 | } 52 | 53 | @ApiResponses( 54 | value = [ 55 | (ApiResponse(code = 200, message = "OK", response = AccountResponses::class)), 56 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 57 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 58 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 59 | ] 60 | ) 61 | @ApiOperation("すべてのアカウントを取得する") 62 | @GetMapping("") 63 | fun findAll( 64 | @ModelAttribute request: AccountFindAllRequest 65 | ): Mono { 66 | return accountController.findAll(request.limit, request.offset) 67 | } 68 | 69 | @ApiResponses( 70 | value = [ 71 | (ApiResponse(code = 201, message = "Created", response = AccountResponse::class)), 72 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 73 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 74 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 75 | ] 76 | ) 77 | @ApiOperation("アカウントを作成する") 78 | @PostMapping("", consumes = [MediaType.APPLICATION_JSON_VALUE]) 79 | @ResponseStatus(HttpStatus.CREATED) 80 | fun create( 81 | @RequestBody request: AccountCreateRequest 82 | ): Mono { 83 | return accountController.create( 84 | request.name, 85 | request.namePronunciation, 86 | request.email, 87 | request.password 88 | ) 89 | } 90 | 91 | @ApiResponses( 92 | value = [ 93 | (ApiResponse(code = 200, message = "OK", response = AccountResponse::class)), 94 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 95 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 96 | (ApiResponse(code = 409, message = "Conflict", response = ErrorResponse::class)), 97 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 98 | ] 99 | ) 100 | @ApiOperation("アカウントを更新する") 101 | @PutMapping("/{accountId}", consumes = [MediaType.APPLICATION_JSON_VALUE]) 102 | fun update( 103 | @ApiParam(value = "アカウントのID", required = true, example = "AC_c5fb2cec-a77c-4886-b997-ffc2ef060e78") 104 | @PathVariable accountId: String, 105 | @RequestBody request: AccountUpdateRequest 106 | ): Mono { 107 | return accountController.update( 108 | accountId, 109 | request.name, 110 | request.namePronunciation, 111 | request.email, 112 | request.password 113 | ) 114 | } 115 | 116 | @ApiResponses( 117 | value = [ 118 | (ApiResponse(code = 200, message = "OK", response = AccountResponse::class)), 119 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 120 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 121 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 122 | ] 123 | ) 124 | @ApiOperation("アカウントを削除する") 125 | @DeleteMapping("/{accountId}") 126 | fun delete( 127 | @ApiParam(value = "アカウントのID", required = true, example = "AC_c5fb2cec-a77c-4886-b997-ffc2ef060e78") 128 | @PathVariable accountId: String 129 | ): Mono { 130 | return accountController.delete(accountId) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/usecase/inputport/AccountUseCase.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.usecase.inputport 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Account 4 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.CreateAccountCommand 5 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.DeleteAccountCommand 6 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.FindAccountCommand 7 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.FindAllAccountCommand 8 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.UpdateAccountCommand 9 | import reactor.core.publisher.Mono 10 | 11 | interface AccountUseCase { 12 | 13 | fun find(command: FindAccountCommand): Mono 14 | 15 | fun findAll(command: FindAllAccountCommand): Mono>> 16 | 17 | fun create(command: CreateAccountCommand): Mono 18 | 19 | fun update(command: UpdateAccountCommand): Mono 20 | 21 | fun delete(command: DeleteAccountCommand): Mono 22 | } 23 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/usecase/inputport/command/CreateAccountCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.usecase.inputport.command 2 | 3 | /** 4 | * アカウントを作成する際のコマンド情報。 5 | */ 6 | data class CreateAccountCommand( 7 | val name: String, 8 | val namePronunciation: String, 9 | val email: String, 10 | val password: String 11 | ) 12 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/usecase/inputport/command/DeleteAccountCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.usecase.inputport.command 2 | 3 | /** 4 | * アカウントを削除する際のコマンド情報。 5 | */ 6 | data class DeleteAccountCommand( 7 | val accountId: String 8 | ) 9 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/usecase/inputport/command/FindAccountCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.usecase.inputport.command 2 | 3 | /** 4 | * アカウントを取得する際のコマンド情報。 5 | */ 6 | data class FindAccountCommand( 7 | val accountId: String 8 | ) 9 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/usecase/inputport/command/FindAllAccountCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.usecase.inputport.command 2 | 3 | /** 4 | * すべてのアカウントを取得する際のコマンド情報。 5 | */ 6 | data class FindAllAccountCommand( 7 | val limit: Int, 8 | val offset: Int 9 | ) 10 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/usecase/inputport/command/UpdateAccountCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.usecase.inputport.command 2 | 3 | /** 4 | * アカウントを更新する際のコマンド情報。 5 | */ 6 | data class UpdateAccountCommand( 7 | val accountId: String, 8 | val name: String?, 9 | val namePronunciation: String?, 10 | val email: String?, 11 | val password: String? 12 | ) 13 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/usecase/interactor/AccountInteractor.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.usecase.interactor 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Account 4 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountEvent 5 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountId 6 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountInvalidDataStateException 7 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountInvalidRequestException 8 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountNotFoundException 9 | import htnk128.kotlin.ddd.sample.account.domain.model.account.AccountUpdateFailedException 10 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Email 11 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Name 12 | import htnk128.kotlin.ddd.sample.account.domain.model.account.NamePronunciation 13 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Password 14 | import htnk128.kotlin.ddd.sample.account.domain.model.addressbook.AddressBookInvalidRequestException 15 | import htnk128.kotlin.ddd.sample.account.domain.model.addressbook.AddressBookService 16 | import htnk128.kotlin.ddd.sample.account.domain.repository.AccountRepository 17 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.AccountUseCase 18 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.CreateAccountCommand 19 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.DeleteAccountCommand 20 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.FindAccountCommand 21 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.FindAllAccountCommand 22 | import htnk128.kotlin.ddd.sample.account.usecase.inputport.command.UpdateAccountCommand 23 | import htnk128.kotlin.ddd.sample.ddd.core.domain.DomainEventPublisher 24 | import htnk128.kotlin.ddd.sample.shared.usecase.ApplicationException 25 | import org.springframework.stereotype.Service 26 | import org.springframework.transaction.annotation.Transactional 27 | import reactor.core.publisher.Flux 28 | import reactor.core.publisher.Mono 29 | import reactor.core.publisher.toMono 30 | 31 | /** 32 | * アカウント([Account])ドメインの操作を提供するアプリケーションサービス。 33 | */ 34 | @Service 35 | class AccountInteractor( 36 | private val accountRepository: AccountRepository, 37 | private val addressBookService: AddressBookService, 38 | private val domainEventPublisher: DomainEventPublisher> 39 | ) : AccountUseCase { 40 | 41 | @Transactional(readOnly = true) 42 | override fun find(command: FindAccountCommand): Mono = runCatching { 43 | val accountId = AccountId.valueOf(command.accountId) 44 | 45 | Mono.just(accountRepository.find(accountId)) 46 | .onErrorResume { Mono.error(it.error()) } 47 | } 48 | .getOrElse { Mono.error(it.error()) } 49 | 50 | @Transactional(readOnly = true) 51 | override fun findAll(command: FindAllAccountCommand): Mono>> = runCatching { 52 | Mono.just(accountRepository.count() to accountRepository.findAll(command.limit, command.offset)) 53 | } 54 | .getOrElse { Mono.error(it.error()) } 55 | 56 | @Transactional(timeout = TRANSACTIONAL_TIMEOUT_SECONDS, rollbackFor = [Exception::class]) 57 | override fun create(command: CreateAccountCommand): Mono = runCatching { 58 | val accountId = accountRepository.nextAccountId() 59 | val name = Name.valueOf(command.name) 60 | val namePronunciation = NamePronunciation.valueOf(command.namePronunciation) 61 | val email = Email.valueOf(command.email) 62 | val password = Password.valueOf(command.password, accountId) 63 | 64 | val created = Account 65 | .create(accountId, name, namePronunciation, email, password) 66 | .also { accountRepository.add(it) } 67 | 68 | Mono.just(created) 69 | .also { created.publish() } 70 | .onErrorResume { Mono.error(it.error()) } 71 | } 72 | .getOrElse { Mono.error(it.error()) } 73 | 74 | @Transactional(timeout = TRANSACTIONAL_TIMEOUT_SECONDS, rollbackFor = [Exception::class]) 75 | override fun update(command: UpdateAccountCommand): Mono = runCatching { 76 | val accountId = AccountId.valueOf(command.accountId) 77 | val name = command.name?.let { Name.valueOf(it) } 78 | val namePronunciation = command.namePronunciation?.let { NamePronunciation.valueOf(it) } 79 | val email = command.email?.let { Email.valueOf(it) } 80 | val password = command.password?.let { Password.valueOf(it, accountId) } 81 | 82 | val updated = accountRepository 83 | .find(accountId, lock = true) 84 | .update(name, namePronunciation, email, password) 85 | .also { accountRepository.set(it) } 86 | 87 | Mono.just(updated) 88 | .also { updated.publish() } 89 | .onErrorResume { Mono.error(it.error()) } 90 | } 91 | .getOrElse { Mono.error(it.error()) } 92 | 93 | @Transactional(timeout = TRANSACTIONAL_TIMEOUT_SECONDS, rollbackFor = [Exception::class]) 94 | override fun delete(command: DeleteAccountCommand): Mono = runCatching { 95 | val accountId = AccountId.valueOf(command.accountId) 96 | 97 | val deleted = accountRepository 98 | .find(accountId, lock = true) 99 | .delete() 100 | .also { accountRepository.remove(it) } 101 | 102 | addressBookService.find(accountId) 103 | .flux() 104 | .flatMap { Flux.fromIterable(it.availableAccountAddresses) } 105 | .flatMap { addressBookService.remove(it.accountAddressId) } 106 | .toMono() 107 | .map { deleted } 108 | .also { deleted.publish() } 109 | .onErrorResume { Mono.error(it.error()) } 110 | } 111 | .getOrElse { Mono.error(it.error()) } 112 | 113 | private fun Account.publish() { 114 | occurredEvents().forEach { domainEventPublisher.publish(it) } 115 | } 116 | 117 | private fun Throwable.error(): Throwable = 118 | when (this) { 119 | is AccountInvalidRequestException -> ApplicationException(type, 400, message, this) 120 | is AddressBookInvalidRequestException -> ApplicationException(type, 400, message, this) 121 | is AccountNotFoundException -> ApplicationException(type, 404, message, this) 122 | is AccountInvalidDataStateException -> ApplicationException(type, 409, message, this) 123 | is AccountUpdateFailedException -> ApplicationException(type, 500, message, this) 124 | else -> ApplicationException(message, this) 125 | } 126 | 127 | private companion object { 128 | 129 | const val TRANSACTIONAL_TIMEOUT_SECONDS: Int = 10 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/usecase/outputport/AccountUseCase.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.usecase.outputport 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Account 4 | import htnk128.kotlin.ddd.sample.account.usecase.outputport.dto.AccountDTO 5 | import htnk128.kotlin.ddd.sample.shared.usecase.outputport.dto.PaginationDTO 6 | 7 | interface AccountUseCase { 8 | 9 | fun toDTO(account: Account): AccountDTO 10 | 11 | fun toDTO(accounts: List, count: Int, limit: Int, offset: Int): PaginationDTO 12 | } 13 | -------------------------------------------------------------------------------- /account/src/main/kotlin/htnk128/kotlin/ddd/sample/account/usecase/outputport/dto/AccountDTO.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.usecase.outputport.dto 2 | 3 | import htnk128.kotlin.ddd.sample.account.domain.model.account.Account 4 | 5 | /** 6 | * アカウント([Account])のDTO。 7 | */ 8 | data class AccountDTO( 9 | val accountId: String, 10 | val name: String, 11 | val namePronunciation: String, 12 | val email: String, 13 | val password: String, 14 | val createdAt: Long, 15 | val deletedAt: Long?, 16 | val updatedAt: Long 17 | ) 18 | -------------------------------------------------------------------------------- /account/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | main: 3 | lazy-initialization: false 4 | datasource: 5 | url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE 6 | username: sa 7 | password: password 8 | driver-class-name: org.h2.Driver 9 | flyway: 10 | enabled: true 11 | schemas: PUBLIC 12 | exposed: 13 | generate-ddl: false 14 | server: 15 | port: 8080 16 | shutdown: graceful 17 | logging: 18 | level: 19 | htnk128.kotlin.ddd.sample.account: INFO 20 | Exposed: INFO 21 | api: 22 | address: 23 | url: http://localhost:8081/addresses 24 | -------------------------------------------------------------------------------- /account/src/main/resources/db/migration/V1__account.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE account ( 2 | account_id varchar(64) PRIMARY KEY 3 | ,name varchar(100) NOT NULL 4 | ,name_pronunciation varchar(100) NOT NULL 5 | ,email varchar(100) NOT NULL 6 | ,password varchar(64) NOT NULL 7 | ,created_at DATETIME(3) NOT NULL 8 | ,deleted_at DATETIME(3) NULL 9 | ,updated_at DATETIME(3) NOT NULL 10 | ); 11 | -------------------------------------------------------------------------------- /account/src/test/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/AccountIdSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class AccountIdSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(64)), 14 | row("a_b-c-d-e"), 15 | row("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 16 | ) { value -> 17 | AccountId.valueOf(value).id() shouldBe value 18 | } 19 | } 20 | 21 | "不正な値の場合例外となること" { 22 | forall( 23 | row(""), 24 | row("a".repeat(65)), 25 | row("あ") 26 | ) { value -> 27 | shouldThrow { 28 | AccountId.valueOf(value) 29 | } 30 | } 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /account/src/test/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/AccountSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import io.kotlintest.shouldBe 4 | import io.kotlintest.shouldThrow 5 | import io.kotlintest.specs.StringSpec 6 | import java.time.Instant 7 | 8 | class AccountSpec : StringSpec({ 9 | 10 | "アカウントが作成されること" { 11 | val now = Instant.now() 12 | val accountId = AccountId.generate() 13 | val name = Name.valueOf("あいうえお") 14 | val namePronunciation = NamePronunciation.valueOf("アイウエオ") 15 | val email = Email.valueOf("example@example.com") 16 | val password = Password.valueOf("a".repeat(100), accountId) 17 | 18 | val account = Account.create( 19 | accountId, 20 | name, 21 | namePronunciation, 22 | email, 23 | password 24 | ) 25 | 26 | account.accountId shouldBe accountId 27 | account.name shouldBe name 28 | account.namePronunciation shouldBe namePronunciation 29 | account.email shouldBe email 30 | account.password shouldBe password 31 | (account.createdAt >= now) shouldBe true 32 | (account.updatedAt >= now) shouldBe true 33 | val events = account.occurredEvents() 34 | 35 | events.size shouldBe 1 36 | events[0] 37 | .also { 38 | it.type shouldBe AccountEvent.Type.CREATED 39 | it.account.accountId shouldBe accountId 40 | it.account.name shouldBe name 41 | it.account.namePronunciation shouldBe namePronunciation 42 | it.account.email shouldBe email 43 | it.account.password shouldBe password 44 | (it.account.createdAt >= now) shouldBe true 45 | (it.account.updatedAt >= now) shouldBe true 46 | } 47 | } 48 | 49 | "アカウントが更新されること" { 50 | val now = Instant.now() 51 | val accountId = AccountId.generate() 52 | val name2 = Name.valueOf("あいうえおa") 53 | val namePronunciation2 = NamePronunciation.valueOf("アイウエオb") 54 | val email2 = Email.valueOf("example@example.comc") 55 | val password2 = Password.valueOf("b".repeat(100), accountId) 56 | 57 | val created = Account.create( 58 | accountId, 59 | Name.valueOf("あいうえお"), 60 | NamePronunciation.valueOf("アイウエオ"), 61 | Email.valueOf("example@example.com"), 62 | Password.valueOf("a".repeat(100), accountId) 63 | ) 64 | 65 | val updated = created.update( 66 | name2, 67 | namePronunciation2, 68 | email2, 69 | password2 70 | ) 71 | 72 | updated.accountId shouldBe created.accountId 73 | updated.name shouldBe name2 74 | updated.namePronunciation shouldBe namePronunciation2 75 | updated.email shouldBe email2 76 | updated.password shouldBe password2 77 | (updated.createdAt >= now) shouldBe true 78 | (updated.updatedAt >= now) shouldBe true 79 | val events = updated.occurredEvents() 80 | 81 | events.size shouldBe 2 82 | events[1] 83 | .also { 84 | it.type shouldBe AccountEvent.Type.UPDATED 85 | it.account.accountId shouldBe created.accountId 86 | it.account.name shouldBe name2 87 | it.account.namePronunciation shouldBe namePronunciation2 88 | it.account.email shouldBe email2 89 | it.account.password shouldBe password2 90 | (it.account.createdAt >= now) shouldBe true 91 | (it.account.updatedAt >= now) shouldBe true 92 | } 93 | } 94 | 95 | "任意項目を指定しなかった場合のアカウントの更新は既存値であること" { 96 | val now = Instant.now() 97 | val accountId = AccountId.generate() 98 | 99 | val created = Account.create( 100 | accountId, 101 | Name.valueOf("あいうえお"), 102 | NamePronunciation.valueOf("アイウエオ"), 103 | Email.valueOf("example@example.com"), 104 | Password.valueOf("a".repeat(100), accountId) 105 | ) 106 | 107 | val updated = created.update( 108 | null, 109 | null, 110 | null, 111 | null 112 | ) 113 | 114 | updated.accountId shouldBe created.accountId 115 | updated.name shouldBe created.name 116 | updated.namePronunciation shouldBe created.namePronunciation 117 | updated.email shouldBe created.email 118 | updated.password shouldBe created.password 119 | (updated.createdAt >= now) shouldBe true 120 | (updated.updatedAt >= now) shouldBe true 121 | val events = updated.occurredEvents() 122 | 123 | events.size shouldBe 2 124 | events[1] 125 | .also { 126 | it.type shouldBe AccountEvent.Type.UPDATED 127 | it.account.accountId shouldBe created.accountId 128 | it.account.name shouldBe created.name 129 | it.account.namePronunciation shouldBe created.namePronunciation 130 | it.account.email shouldBe created.email 131 | it.account.password shouldBe created.password 132 | (it.account.createdAt >= now) shouldBe true 133 | (it.account.updatedAt >= now) shouldBe true 134 | } 135 | } 136 | 137 | "このアカウントが削除されている場合には例外となること" { 138 | val accountId = AccountId.generate() 139 | val deleted = Account.create( 140 | accountId, 141 | Name.valueOf("あいうえお"), 142 | NamePronunciation.valueOf("アイウエオ"), 143 | Email.valueOf("example@example.com"), 144 | Password.valueOf("a".repeat(100), accountId) 145 | ) 146 | .delete() 147 | 148 | shouldThrow { 149 | deleted.update( 150 | Name.valueOf("あいうえおa"), 151 | NamePronunciation.valueOf("アイウエオb"), 152 | Email.valueOf("example@example.comc"), 153 | Password.valueOf("b".repeat(100), accountId) 154 | ) 155 | } 156 | } 157 | 158 | "アカウントが削除されること" { 159 | val now = Instant.now() 160 | val accountId = AccountId.generate() 161 | 162 | val created = Account.create( 163 | accountId, 164 | Name.valueOf("あいうえお"), 165 | NamePronunciation.valueOf("アイウエオ"), 166 | Email.valueOf("example@example.com"), 167 | Password.valueOf("a".repeat(100), accountId) 168 | ) 169 | 170 | val deleted = created.delete() 171 | 172 | deleted.accountId shouldBe created.accountId 173 | deleted.name shouldBe created.name 174 | deleted.namePronunciation shouldBe created.namePronunciation 175 | deleted.email shouldBe created.email 176 | deleted.password shouldBe created.password 177 | (deleted.createdAt >= now) shouldBe true 178 | (deleted.updatedAt >= now) shouldBe true 179 | deleted.isDeleted shouldBe true 180 | val events = deleted.occurredEvents() 181 | 182 | events.size shouldBe 2 183 | events[1] 184 | .also { 185 | it.type shouldBe AccountEvent.Type.DELETED 186 | it.account.accountId shouldBe created.accountId 187 | it.account.name shouldBe created.name 188 | it.account.namePronunciation shouldBe created.namePronunciation 189 | it.account.email shouldBe created.email 190 | it.account.password shouldBe created.password 191 | (it.account.createdAt >= now) shouldBe true 192 | (it.account.updatedAt >= now) shouldBe true 193 | } 194 | } 195 | 196 | "このアカウントが削除済みの場合にはそのままこのアカウントが返却されること" { 197 | val accountId = AccountId.generate() 198 | val deleted = Account.create( 199 | accountId, 200 | Name.valueOf("あいうえお"), 201 | NamePronunciation.valueOf("アイウエオ"), 202 | Email.valueOf("example@example.com"), 203 | Password.valueOf("a".repeat(100), accountId) 204 | ) 205 | .delete() 206 | 207 | val deleted2 = deleted.delete() 208 | 209 | deleted2.accountId shouldBe deleted.accountId 210 | deleted2.name shouldBe deleted.name 211 | deleted2.namePronunciation shouldBe deleted.namePronunciation 212 | deleted2.email shouldBe deleted.email 213 | deleted2.password shouldBe deleted.password 214 | deleted2.createdAt shouldBe deleted.createdAt 215 | deleted2.updatedAt shouldBe deleted.updatedAt 216 | deleted2.isDeleted shouldBe true 217 | deleted2.occurredEvents().size shouldBe 2 218 | } 219 | 220 | "属性が異なっても一意な識別子が一緒であれば等価となる" { 221 | val accountId = AccountId.generate() 222 | val account1 = Account.create( 223 | accountId, 224 | Name.valueOf("あいうえお"), 225 | NamePronunciation.valueOf("アイウエオ"), 226 | Email.valueOf("example@example.com"), 227 | Password.valueOf("a".repeat(100), accountId) 228 | ) 229 | val account2 = Account.create( 230 | accountId, 231 | Name.valueOf("あいうえお1"), 232 | NamePronunciation.valueOf("アイウエオ2"), 233 | Email.valueOf("example@example.com3"), 234 | Password.valueOf("b".repeat(100), accountId) 235 | ) 236 | 237 | (account1 == account2) shouldBe true 238 | (account1.sameIdentityAs(account2)) shouldBe true 239 | } 240 | 241 | "属性が同一でも一意な識別子が異なれば等価とならない" { 242 | val accountId1 = AccountId.generate() 243 | val accountId2 = AccountId.generate() 244 | val password = Password.valueOf("a".repeat(100), accountId1) 245 | val account1 = Account.create( 246 | accountId1, 247 | Name.valueOf("あいうえお"), 248 | NamePronunciation.valueOf("アイウエオ"), 249 | Email.valueOf("example@example.com"), 250 | password 251 | ) 252 | val account2 = Account.create( 253 | accountId2, 254 | Name.valueOf("あいうえお"), 255 | NamePronunciation.valueOf("アイウエオ"), 256 | Email.valueOf("example@example.com"), 257 | password 258 | ) 259 | 260 | (account1 == account2) shouldBe false 261 | (account1.sameIdentityAs(account2)) shouldBe false 262 | } 263 | }) 264 | -------------------------------------------------------------------------------- /account/src/test/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/EmailSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class EmailSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(100)) 14 | ) { value -> 15 | Email.valueOf(value).value() shouldBe value 16 | } 17 | } 18 | 19 | "不正な値の場合例外となること" { 20 | forall( 21 | row(""), 22 | row("a".repeat(101)) 23 | ) { value -> 24 | shouldThrow { 25 | Email.valueOf(value) 26 | } 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /account/src/test/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/NamePronunciationSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class NamePronunciationSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(100)) 14 | ) { value -> 15 | NamePronunciation.valueOf(value).value() shouldBe value 16 | } 17 | } 18 | 19 | "不正な値の場合例外となること" { 20 | forall( 21 | row(""), 22 | row("a".repeat(101)) 23 | ) { value -> 24 | shouldThrow { 25 | NamePronunciation.valueOf(value) 26 | } 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /account/src/test/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/NameSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class NameSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(100)) 14 | ) { value -> 15 | Name.valueOf(value).value() shouldBe value 16 | } 17 | } 18 | 19 | "不正な値の場合例外となること" { 20 | forall( 21 | row(""), 22 | row("a".repeat(101)) 23 | ) { value -> 24 | shouldThrow { 25 | Name.valueOf(value) 26 | } 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /account/src/test/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/account/PasswordSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.account 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldNotThrow 6 | import io.kotlintest.shouldThrow 7 | import io.kotlintest.specs.StringSpec 8 | import io.kotlintest.tables.row 9 | 10 | class PasswordSpec : StringSpec({ 11 | 12 | val accountId = AccountId.generate() 13 | 14 | "正しい値の場合インスタンスを生成できる" { 15 | val rows = (6..100).map { row("a".repeat(it)) } + 16 | (6..100).map { row("1".repeat(it)) } + 17 | (6..100).map { row("あ".repeat(it)) } 18 | 19 | forall(*rows.toTypedArray()) { value -> 20 | Password.valueOf(value, accountId).value().length shouldBe 64 21 | } 22 | } 23 | 24 | "インスタンスを生成できる" { 25 | shouldNotThrow { 26 | Password.from("dummy") 27 | } 28 | } 29 | 30 | "不正な値の場合例外となること" { 31 | forall( 32 | row(""), 33 | row("a".repeat(5)), 34 | row("a".repeat(101)) 35 | ) { value -> 36 | shouldThrow { 37 | Password.valueOf(value, accountId) 38 | } 39 | } 40 | } 41 | 42 | "アカウントを外部向けのフォーマットに変換は必ず同じ値を返すこと" { 43 | Password.valueOf("a".repeat(100), accountId).format() shouldBe "*****" 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /account/src/test/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/addressbook/AccountAddressIdSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.addressbook 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class AccountAddressIdSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(64)), 14 | row("a_b-c-d-e"), 15 | row("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 16 | ) { value -> 17 | AccountAddressId.valueOf(value).id() shouldBe value 18 | } 19 | } 20 | 21 | "不正な値の場合例外となること" { 22 | forall( 23 | row(""), 24 | row("a".repeat(65)), 25 | row("あ") 26 | ) { value -> 27 | shouldThrow { 28 | AccountAddressId.valueOf(value) 29 | } 30 | } 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /account/src/test/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/addressbook/AccountAddressSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.addressbook 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldNotBe 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | import java.time.Instant 9 | 10 | class AccountAddressSpec : StringSpec({ 11 | 12 | "アカウントの住所が有効な場合の判定が想定通りであること" { 13 | forall( 14 | row(Instant.now(), false), 15 | row(null, true) 16 | ) { deletedAt, expected -> 17 | AccountAddress( 18 | AccountAddressId.valueOf("address01"), 19 | deletedAt 20 | ).isAvailable shouldBe expected 21 | } 22 | } 23 | 24 | "同じ値を持つ場合は等価となる" { 25 | val accountAddressId = AccountAddressId.valueOf("address01") 26 | val deletedAt = Instant.now() 27 | val data1 = AccountAddress( 28 | accountAddressId, 29 | deletedAt 30 | ) 31 | val data2 = AccountAddress( 32 | accountAddressId, 33 | deletedAt 34 | ) 35 | 36 | data1 shouldBe data2 37 | } 38 | 39 | "同じ値でない場合は等価とならない" { 40 | val deletedAt = Instant.now() 41 | val data1 = AccountAddress( 42 | AccountAddressId.valueOf("address01"), 43 | deletedAt 44 | ) 45 | val data2 = AccountAddress( 46 | AccountAddressId.valueOf("address02"), 47 | deletedAt 48 | ) 49 | 50 | data1 shouldNotBe data2 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /account/src/test/kotlin/htnk128/kotlin/ddd/sample/account/domain/model/addressbook/AddressBookSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.account.domain.model.addressbook 2 | 3 | import io.kotlintest.shouldBe 4 | import io.kotlintest.specs.StringSpec 5 | import java.time.Instant 6 | 7 | class AddressBookSpec : StringSpec({ 8 | 9 | "有効なアカウントの住所一覧が取得できる" { 10 | val accountAddressId = AccountAddressId.valueOf("address01") 11 | 12 | val data1 = AccountAddress(accountAddressId, deletedAt = null) 13 | val data2 = AccountAddress(accountAddressId, deletedAt = Instant.now()) 14 | val data3 = AccountAddress(accountAddressId, deletedAt = null) 15 | 16 | val actual = AddressBook(listOf(data1, data2, data3)).availableAccountAddresses 17 | 18 | actual.contains(data1) shouldBe true 19 | actual.contains(data2) shouldBe false 20 | actual.contains(data3) shouldBe true 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /account/src/test/kotlin/io/kotlintest/provided/ProjectConfig.kt: -------------------------------------------------------------------------------- 1 | package io.kotlintest.provided 2 | 3 | import io.kotlintest.AbstractProjectConfig 4 | import io.kotlintest.extensions.ProjectLevelExtension 5 | import io.kotlintest.spring.SpringAutowireConstructorExtension 6 | 7 | class ProjectConfig : AbstractProjectConfig() { 8 | 9 | override fun extensions(): List = listOf(SpringAutowireConstructorExtension) 10 | } 11 | -------------------------------------------------------------------------------- /address/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | springPlugins() 3 | } 4 | 5 | apply { 6 | spring() 7 | } 8 | 9 | dependencies { 10 | sharedDependency() 11 | dddCoreDependency() 12 | springDependencies() 13 | jacksonDependencies() 14 | sqlDependencies() 15 | flywayDependencies() 16 | springfoxDependencies() 17 | loggingDependency() 18 | } 19 | 20 | kover { 21 | verify { 22 | rule { 23 | name = "Minimal line coverage rate in percents" 24 | bound { 25 | minValue = 28 // TODO 80%くらいにはしたい 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/controller/AddressController.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.controller 2 | 3 | import htnk128.kotlin.ddd.sample.address.adapter.controller.resource.AddressResponse 4 | import htnk128.kotlin.ddd.sample.address.adapter.controller.resource.AddressResponses 5 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.CreateAddressCommand 6 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.DeleteAddressCommand 7 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.FindAddressCommand 8 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.FindAllAddressCommand 9 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.UpdateAddressCommand 10 | import htnk128.kotlin.ddd.sample.address.usecase.outputport.dto.AddressDTO 11 | import org.springframework.stereotype.Component 12 | import reactor.core.publisher.Mono 13 | import java.util.stream.Collectors 14 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.AddressUseCase as InAddressUseCase 15 | import htnk128.kotlin.ddd.sample.address.usecase.outputport.AddressUseCase as OutAddressUseCase 16 | 17 | @Component 18 | class AddressController( 19 | private val inAddressUseCase: InAddressUseCase, 20 | private val outAddressPresenter: OutAddressUseCase 21 | ) { 22 | 23 | fun find( 24 | addressId: String 25 | ): Mono { 26 | val command = FindAddressCommand(addressId) 27 | 28 | return inAddressUseCase.find(command) 29 | .map { outAddressPresenter.toDTO(it) } 30 | .map { it.toResponse() } 31 | } 32 | 33 | fun findAll( 34 | ownerId: String 35 | ): Mono { 36 | val command = FindAllAddressCommand(ownerId) 37 | 38 | return inAddressUseCase.findAll(command) 39 | .map { outAddressPresenter.toDTO(it) } 40 | .map { it.toResponse() } 41 | .collect(Collectors.toList()) 42 | .map { AddressResponses(it) } 43 | } 44 | 45 | fun create( 46 | ownerId: String, 47 | fullName: String, 48 | zipCode: String, 49 | stateOrRegion: String, 50 | line1: String, 51 | line2: String?, 52 | phoneNumber: String 53 | ): Mono { 54 | val command = CreateAddressCommand( 55 | ownerId, 56 | fullName, 57 | zipCode, 58 | stateOrRegion, 59 | line1, 60 | line2, 61 | phoneNumber 62 | ) 63 | 64 | return inAddressUseCase.create(command) 65 | .map { outAddressPresenter.toDTO(it) } 66 | .map { it.toResponse() } 67 | } 68 | 69 | fun update( 70 | addressId: String, 71 | fullName: String?, 72 | zipCode: String?, 73 | stateOrRegion: String?, 74 | line1: String?, 75 | line2: String?, 76 | phoneNumber: String? 77 | ): Mono { 78 | val command = UpdateAddressCommand( 79 | addressId, 80 | fullName, 81 | zipCode, 82 | stateOrRegion, 83 | line1, 84 | line2, 85 | phoneNumber 86 | ) 87 | 88 | return inAddressUseCase.update(command) 89 | .map { outAddressPresenter.toDTO(it) } 90 | .map { it.toResponse() } 91 | } 92 | 93 | fun delete( 94 | addressId: String 95 | ): Mono { 96 | val command = DeleteAddressCommand(addressId) 97 | 98 | return inAddressUseCase.delete(command) 99 | .map { outAddressPresenter.toDTO(it) } 100 | .map { it.toResponse() } 101 | } 102 | 103 | private fun AddressDTO.toResponse() = 104 | AddressResponse.from(this) 105 | } 106 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/controller/ErrorAdvice.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.controller 2 | 3 | import htnk128.kotlin.ddd.sample.shared.adapter.controller.resource.ErrorResponse 4 | import htnk128.kotlin.ddd.sample.shared.usecase.ApplicationException 5 | import mu.KLogging 6 | import org.springframework.http.HttpStatus 7 | import org.springframework.http.ResponseEntity 8 | import org.springframework.web.bind.annotation.ControllerAdvice 9 | import org.springframework.web.bind.annotation.ExceptionHandler 10 | 11 | // TODO external.spring.restに置きたいので、ApplicationExceptionの配置を考える 12 | @ControllerAdvice 13 | class ErrorAdvice { 14 | 15 | @ExceptionHandler(ApplicationException::class) 16 | fun handleApplicationException(exception: ApplicationException): ResponseEntity { 17 | logger.error(exception) { "type=${exception.type}, status=${exception.status}, message=${exception.message}" } 18 | 19 | return ResponseEntity 20 | .status(exception.status) 21 | .body(errorResponse(exception.type, exception.status, exception.message)) 22 | } 23 | 24 | @ExceptionHandler(Exception::class) 25 | fun handleException(exception: Exception): ResponseEntity { 26 | val type = "server_error" 27 | val status = HttpStatus.INTERNAL_SERVER_ERROR.value() 28 | val message = "internal server error." 29 | 30 | logger.error(exception) { "type=$type, status=$status, message=$message" } 31 | 32 | return ResponseEntity 33 | .status(status) 34 | .body(errorResponse(type, status, message)) 35 | } 36 | 37 | private fun errorResponse(type: String, code: Int, message: String): ErrorResponse = 38 | ErrorResponse.from(type, code, message) 39 | 40 | companion object : KLogging() 41 | } 42 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/controller/resource/AddressCreateRequest.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.controller.resource 2 | 3 | import io.swagger.annotations.ApiModelProperty 4 | 5 | /** 6 | * 住所作成時のリクエスト情報。 7 | */ 8 | data class AddressCreateRequest( 9 | @ApiModelProperty( 10 | value = "住所の持ち主のID", example = "AC_c5fb2cec-a77c-4886-b997-ffc2ef060e78", required = true, position = 1 11 | ) 12 | val ownerId: String, 13 | @ApiModelProperty( 14 | value = "住所の氏名または会社名", example = "あいうえお", required = true, position = 2 15 | ) 16 | val fullName: String, 17 | @ApiModelProperty( 18 | value = "住所の郵便番号", example = "1234567", required = true, position = 3 19 | ) 20 | val zipCode: String, 21 | @ApiModelProperty( 22 | value = "住所の都道府県", example = "東京都", required = true, position = 4 23 | ) 24 | val stateOrRegion: String, 25 | @ApiModelProperty( 26 | value = "住所の住所欄1", example = "かきくけこ", required = true, position = 5 27 | ) 28 | val line1: String, 29 | @ApiModelProperty( 30 | value = "住所の住所欄2", example = "さしすせそ", required = false, position = 6 31 | ) 32 | val line2: String?, 33 | @ApiModelProperty( 34 | value = "住所の電話番号", example = "00000000000", required = true, position = 7 35 | ) 36 | val phoneNumber: String 37 | ) 38 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/controller/resource/AddressFindAllRequest.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.controller.resource 2 | 3 | import io.swagger.annotations.ApiModelProperty 4 | 5 | /** 6 | * アカウントのすべての住所取得時のリクエスト情報。 7 | */ 8 | data class AddressFindAllRequest( 9 | @ApiModelProperty( 10 | value = "住所の持ち主のID", example = "AC_c5fb2cec-a77c-4886-b997-ffc2ef060e78", required = true, position = 1 11 | ) 12 | val ownerId: String 13 | ) 14 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/controller/resource/AddressResponse.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.controller.resource 2 | 3 | import htnk128.kotlin.ddd.sample.address.usecase.outputport.dto.AddressDTO 4 | import io.swagger.annotations.ApiModelProperty 5 | 6 | /** 7 | * 住所のレスポンス情報。 8 | */ 9 | data class AddressResponse( 10 | @ApiModelProperty( 11 | value = "住所のID", example = "ADDR_c5fb2cec-a77c-4886-b997-ffc2ef060e78", required = true, position = 1 12 | ) 13 | val addressId: String, 14 | @ApiModelProperty( 15 | value = "住所の持ち主のID", example = "AC_c5fb2cec-a77c-4886-b997-ffc2ef060e78", required = true, position = 2 16 | ) 17 | val ownerId: String, 18 | @ApiModelProperty( 19 | value = "住所の氏名または会社名", example = "あいうえお", required = true, position = 3 20 | ) 21 | val fullName: String, 22 | @ApiModelProperty( 23 | value = "住所の郵便番号", example = "1234567", required = true, position = 4 24 | ) 25 | val zipCode: String, 26 | @ApiModelProperty( 27 | value = "住所の都道府県", example = "東京都", required = true, position = 5 28 | ) 29 | val stateOrRegion: String, 30 | @ApiModelProperty( 31 | value = "住所の住所欄1", example = "かきくけこ", required = true, position = 6 32 | ) 33 | val line1: String, 34 | @ApiModelProperty( 35 | value = "住所の住所欄2", example = "さしすせそ", required = false, position = 7 36 | ) 37 | val line2: String?, 38 | @ApiModelProperty( 39 | value = "住所の電話番号", example = "00000000000", required = true, position = 8 40 | ) 41 | val phoneNumber: String, 42 | @ApiModelProperty( 43 | value = "住所の作成日時", example = "1576120910973", required = true, position = 9 44 | ) 45 | val createdAt: Long, 46 | @ApiModelProperty( 47 | value = "住所の削除日時", example = "1576120910973", required = false, position = 10 48 | ) 49 | val deletedAt: Long?, 50 | @ApiModelProperty( 51 | value = "住所の更新日時", example = "1576120910973", required = true, position = 11 52 | ) 53 | val updatedAt: Long 54 | ) { 55 | 56 | companion object { 57 | 58 | fun from(dto: AddressDTO): AddressResponse = 59 | AddressResponse( 60 | dto.addressId, 61 | dto.ownerId, 62 | dto.fullName, 63 | dto.zipCode, 64 | dto.stateOrRegion, 65 | dto.line1, 66 | dto.line2, 67 | dto.phoneNumber, 68 | dto.createdAt, 69 | dto.deletedAt, 70 | dto.updatedAt 71 | ) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/controller/resource/AddressResponses.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.controller.resource 2 | 3 | import io.swagger.annotations.ApiModelProperty 4 | 5 | /** 6 | * 複数の住所のレスポンス情報。 7 | * 8 | * 複数の住所のレスポンス情報には"data"というキーで住所のレスポンス情報([AddressResponse])が含まれる。 9 | */ 10 | data class AddressResponses( 11 | @ApiModelProperty( 12 | value = "住所のレスポンス情報のリスト", required = true, position = 1 13 | ) 14 | val data: List 15 | ) 16 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/controller/resource/AddressUpdateRequest.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.controller.resource 2 | 3 | import io.swagger.annotations.ApiModelProperty 4 | 5 | /** 6 | * 住所更新時のリクエスト情報。 7 | */ 8 | data class AddressUpdateRequest( 9 | @ApiModelProperty( 10 | value = "住所の氏名または会社名", example = "あいうえお", required = false, position = 1 11 | ) 12 | val fullName: String?, 13 | @ApiModelProperty( 14 | value = "住所の郵便番号", example = "1234567", required = false, position = 2 15 | ) 16 | val zipCode: String?, 17 | @ApiModelProperty( 18 | value = "住所の都道府県", example = "東京都", required = false, position = 3 19 | ) 20 | val stateOrRegion: String?, 21 | @ApiModelProperty( 22 | value = "住所の住所欄1", example = "かきくけこ", required = false, position = 4 23 | ) 24 | val line1: String?, 25 | @ApiModelProperty( 26 | value = "住所の住所欄2", example = "さしすせそ", required = false, position = 5 27 | ) 28 | val line2: String?, 29 | @ApiModelProperty( 30 | value = "住所の電話番号", example = "00000000000", required = false, position = 6 31 | ) 32 | val phoneNumber: String? 33 | ) 34 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/gateway/db/AddressExposedRepository.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.gateway.db 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Address 4 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressId 5 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressNotFoundException 6 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressUpdateFailedException 7 | import htnk128.kotlin.ddd.sample.address.domain.model.address.FullName 8 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Line1 9 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Line2 10 | import htnk128.kotlin.ddd.sample.address.domain.model.address.PhoneNumber 11 | import htnk128.kotlin.ddd.sample.address.domain.model.address.StateOrRegion 12 | import htnk128.kotlin.ddd.sample.address.domain.model.address.ZipCode 13 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerId 14 | import htnk128.kotlin.ddd.sample.address.domain.repository.AddressRepository 15 | import htnk128.kotlin.ddd.sample.shared.adapter.gateway.db.ExposedTable 16 | import java.time.Instant 17 | import org.jetbrains.exposed.sql.Column 18 | import org.jetbrains.exposed.sql.ResultRow 19 | import org.jetbrains.exposed.sql.insert 20 | import org.jetbrains.exposed.sql.select 21 | import org.jetbrains.exposed.sql.update 22 | import org.springframework.stereotype.Repository 23 | 24 | @Repository 25 | class AddressExposedRepository : AddressRepository { 26 | 27 | override fun find(addressId: AddressId, lock: Boolean): Address = 28 | AddressTable.select { AddressTable.addressId eq addressId.id() } 29 | .run { if (lock) this.forUpdate() else this } 30 | .firstOrNull() 31 | ?.rowToModel() 32 | ?: throw AddressNotFoundException(addressId) 33 | 34 | override fun findAll(ownerId: OwnerId): List
= 35 | AddressTable.select { AddressTable.ownerId eq ownerId.id() } 36 | .map { it.rowToModel() } 37 | 38 | override fun add(address: Address) { 39 | AddressTable.insert { 40 | it[addressId] = address.addressId.id() 41 | it[ownerId] = address.ownerId.id() 42 | it[fullName] = address.fullName.value() 43 | it[zipCode] = address.zipCode.value() 44 | it[stateOrRegion] = address.stateOrRegion.value() 45 | it[line1] = address.line1.value() 46 | it[line2] = address.line2?.value() 47 | it[phoneNumber] = address.phoneNumber.value() 48 | it[createdAt] = address.createdAt 49 | it[deletedAt] = address.deletedAt 50 | it[updatedAt] = address.updatedAt 51 | } 52 | } 53 | 54 | override fun set(address: Address) { 55 | AddressTable.update({ AddressTable.addressId eq address.addressId.id() }) { 56 | it[fullName] = address.fullName.value() 57 | it[zipCode] = address.zipCode.value() 58 | it[stateOrRegion] = address.stateOrRegion.value() 59 | it[line1] = address.line1.value() 60 | it[line2] = address.line2?.value() 61 | it[phoneNumber] = address.phoneNumber.value() 62 | it[updatedAt] = address.updatedAt 63 | } 64 | .takeIf { it > 0 } 65 | ?: throw AddressUpdateFailedException(address.addressId) 66 | } 67 | 68 | override fun remove(address: Address) { 69 | AddressTable.update({ AddressTable.addressId eq address.addressId.id() }) { 70 | it[deletedAt] = address.deletedAt 71 | it[updatedAt] = address.updatedAt 72 | } 73 | .takeIf { it > 0 } 74 | ?: throw AddressUpdateFailedException(address.addressId) 75 | } 76 | 77 | private fun ResultRow.rowToModel() = 78 | Address( 79 | AddressId.valueOf(this[AddressTable.addressId]), 80 | OwnerId.valueOf(this[AddressTable.ownerId]), 81 | FullName.valueOf(this[AddressTable.fullName]), 82 | ZipCode.valueOf(this[AddressTable.zipCode]), 83 | StateOrRegion.valueOf(this[AddressTable.stateOrRegion]), 84 | Line1.valueOf(this[AddressTable.line1]), 85 | this[AddressTable.line2]?.let { Line2.valueOf(it) }, 86 | PhoneNumber.valueOf(this[AddressTable.phoneNumber]), 87 | this[AddressTable.createdAt], 88 | this[AddressTable.deletedAt], 89 | this[AddressTable.updatedAt] 90 | ) 91 | } 92 | 93 | private object AddressTable : ExposedTable
("address") { 94 | 95 | val addressId: Column = varchar("address_id", length = 64).primaryKey() 96 | val ownerId: Column = varchar("owner_id", length = 64) 97 | val fullName: Column = varchar("full_name", length = 100) 98 | val zipCode: Column = varchar("zip_code", length = 50) 99 | val stateOrRegion: Column = varchar("state_or_region", length = 100) 100 | val line1: Column = varchar("line1", length = 100) 101 | val line2: Column = varchar("line2", length = 100).nullable() 102 | val phoneNumber: Column = varchar("phone_number", length = 50) 103 | val createdAt: Column = instant("created_at") 104 | val deletedAt: Column = instant("deleted_at").nullable() 105 | val updatedAt: Column = instant("updated_at") 106 | } 107 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/gateway/messaging/AddressEventSpringPublisher.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.gateway.messaging 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressEvent 4 | import htnk128.kotlin.ddd.sample.ddd.core.domain.DomainEventPublisher 5 | import org.springframework.context.ApplicationEventPublisher 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class AddressEventSpringPublisher( 10 | private val eventPublisher: ApplicationEventPublisher 11 | ) : DomainEventPublisher> { 12 | 13 | override fun publish(domainEvent: AddressEvent<*>) { 14 | eventPublisher.publishEvent(domainEvent) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/gateway/messaging/AddressEventSpringSubscriber.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.gateway.messaging 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressEvent 4 | import htnk128.kotlin.ddd.sample.ddd.core.domain.DomainEventSubscriber 5 | import org.springframework.context.event.EventListener 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class AddressEventSpringSubscriber : DomainEventSubscriber> { 10 | 11 | @EventListener 12 | override fun handleEvent(domainEvent: AddressEvent<*>) { 13 | println("type=${domainEvent.type}, address=${domainEvent.address}, occurredOn=${domainEvent.occurredOn}") 14 | // 何もしない。キューにエンキューする、メールを送る、REST APIを叩く、どっかに通知を送るなどが考えられる 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/gateway/rest/OwnerRestService.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.gateway.rest 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.Owner 4 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerId 5 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerNotFoundException 6 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerService 7 | import java.time.Instant 8 | import org.springframework.beans.factory.annotation.Value 9 | import org.springframework.stereotype.Component 10 | import org.springframework.web.reactive.function.client.WebClient 11 | import reactor.core.publisher.Mono 12 | 13 | @Component 14 | class OwnerRestService( 15 | private val accountClient: AccountClient 16 | ) : OwnerService { 17 | 18 | override fun find(ownerId: OwnerId): Mono = 19 | accountClient.find(ownerId) 20 | } 21 | 22 | @Component 23 | class AccountClient( 24 | @Value("\${api.account.url:http://localhost:8080/accounts}") 25 | private val accountUrl: String 26 | ) { 27 | 28 | fun find(ownerId: OwnerId): Mono = 29 | WebClient 30 | .builder() 31 | .build() 32 | .get() 33 | .uri("$accountUrl/$ownerId") 34 | .retrieve() 35 | .bodyToMono(AccountResponse::class.java) 36 | .map { it.responseToModel() } 37 | .onErrorResume { throw OwnerNotFoundException(ownerId, cause = it) } 38 | 39 | private data class AccountResponse( 40 | val accountId: String, 41 | val deletedAt: Long? 42 | ) { 43 | 44 | fun responseToModel(): Owner = 45 | Owner( 46 | OwnerId.valueOf(accountId), 47 | deletedAt?.let { Instant.ofEpochMilli(it) } 48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/adapter/presenter/AddressPresenter.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.adapter.presenter 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Address 4 | import htnk128.kotlin.ddd.sample.address.usecase.outputport.AddressUseCase 5 | import htnk128.kotlin.ddd.sample.address.usecase.outputport.dto.AddressDTO 6 | import org.springframework.stereotype.Component 7 | 8 | @Component 9 | class AddressPresenter : AddressUseCase { 10 | 11 | override fun toDTO(address: Address): AddressDTO { 12 | return AddressDTO( 13 | address.addressId.id(), 14 | address.ownerId.id(), 15 | address.fullName.value(), 16 | address.zipCode.value(), 17 | address.stateOrRegion.value(), 18 | address.line1.value(), 19 | address.line2?.value(), 20 | address.phoneNumber.value(), 21 | address.createdAt.toEpochMilli(), 22 | address.deletedAt?.toEpochMilli(), 23 | address.updatedAt.toEpochMilli() 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/Address.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerId 4 | import htnk128.kotlin.ddd.sample.ddd.core.domain.Entity 5 | import java.time.Instant 6 | 7 | /** 8 | * 住所を表現する。 9 | * 10 | * 氏名または会社名、郵便番号などの情報を指定して作成することが可能である。 11 | * また、住所の作成後は更新、削除が可能である。 12 | */ 13 | class Address( 14 | val addressId: AddressId, 15 | val ownerId: OwnerId, 16 | val fullName: FullName, 17 | val zipCode: ZipCode, 18 | val stateOrRegion: StateOrRegion, 19 | val line1: Line1, 20 | val line2: Line2?, 21 | val phoneNumber: PhoneNumber, 22 | val createdAt: Instant, 23 | val deletedAt: Instant? = null, 24 | val updatedAt: Instant 25 | ) : Entity
{ 26 | 27 | private val events = mutableListOf>() 28 | 29 | /** 30 | * この住所が削除されている場合に`true`を返す。 31 | */ 32 | val isDeleted: Boolean = deletedAt != null 33 | 34 | /** 35 | * 住所を更新する。 36 | * 37 | * 氏名または会社名、郵便番号、都道府県、住所欄1、住所欄2、電話番号を更新可能で、すべて任意指定が可能であり指定しなかった場合は現在の値のままとなる。 38 | * ただし、住所欄2は`null`での更新が可能となる。 39 | * 更新後にイベント([AddressUpdated])を生成する。 40 | * 41 | * また、この住所が削除されている場合には例外となる。 42 | * 43 | * @return 更新された住所 44 | * @throws AddressInvalidDataStateException 45 | */ 46 | fun update( 47 | fullName: FullName?, 48 | zipCode: ZipCode?, 49 | stateOrRegion: StateOrRegion?, 50 | line1: Line1?, 51 | line2: Line2?, 52 | phoneNumber: PhoneNumber? 53 | ): Address { 54 | if (isDeleted) throw AddressInvalidDataStateException("Address has been deleted.") 55 | 56 | return Address( 57 | addressId, 58 | ownerId, 59 | fullName = fullName ?: this.fullName, 60 | zipCode = zipCode ?: this.zipCode, 61 | stateOrRegion = stateOrRegion ?: this.stateOrRegion, 62 | line1 = line1 ?: this.line1, 63 | line2 = line2, 64 | phoneNumber = phoneNumber ?: this.phoneNumber, 65 | createdAt = this.createdAt, 66 | updatedAt = Instant.now() 67 | ) 68 | .addEvent(AddressEvent.Type.UPDATED, events.toList()) 69 | } 70 | 71 | /** 72 | * 住所を削除する。 73 | * 74 | * 削除日時([deletedAt])に現在日付が設定されことによって論理削除状態となる。 75 | * 更新後にイベント([AddressDeleted])を生成する。 76 | * 77 | * また、このアカウントが削除済みの場合にはそのままこのアカウントが返却される。 78 | * 79 | * @return 削除された住所 80 | */ 81 | fun delete(): Address = if (isDeleted) this else with(Instant.now()) { 82 | Address( 83 | addressId, 84 | ownerId, 85 | fullName = fullName, 86 | zipCode = zipCode, 87 | stateOrRegion = stateOrRegion, 88 | line1 = line1, 89 | line2 = line2, 90 | phoneNumber = phoneNumber, 91 | createdAt = createdAt, 92 | deletedAt = this, 93 | updatedAt = this 94 | ) 95 | .addEvent(AddressEvent.Type.DELETED, events.toList()) 96 | } 97 | 98 | /** 99 | * 発生したイベントを返す。 100 | * 101 | * @return 発生したイベントのリスト 102 | */ 103 | fun occurredEvents(): List> = events.toList() 104 | 105 | private fun addEvent(type: AddressEvent.Type, events: List> = emptyList()): Address = this 106 | .also { 107 | this.events += events 108 | this.events += when (type) { 109 | AddressEvent.Type.CREATED -> AddressCreated(this) 110 | AddressEvent.Type.UPDATED -> AddressUpdated(this) 111 | AddressEvent.Type.DELETED -> AddressDeleted(this) 112 | } 113 | } 114 | 115 | override fun equals(other: Any?): Boolean { 116 | if (this === other) return true 117 | if (javaClass != other?.javaClass) return false 118 | other as Address 119 | return sameIdentityAs(other) 120 | } 121 | 122 | override fun hashCode(): Int = addressId.hashCode() 123 | 124 | override fun sameIdentityAs(other: Address): Boolean = addressId == other.addressId 125 | 126 | override fun toString(): String { 127 | return buildString { 128 | append("addressId=$addressId, ") 129 | append("ownerId=$ownerId, ") 130 | append("fullName=$fullName, ") 131 | append("zipCode=$zipCode, ") 132 | append("stateOrRegion=$stateOrRegion, ") 133 | append("line1=$line1, ") 134 | append("line2=$line2, ") 135 | append("phoneNumber=$phoneNumber, ") 136 | append("createdAt=$createdAt, ") 137 | append("deletedAt=$deletedAt, ") 138 | append("updatedAt=$updatedAt") 139 | } 140 | } 141 | 142 | companion object { 143 | 144 | /** 145 | * 住所を作成する。 146 | * 147 | * 住所欄2を除くすべての項目(アカウントのID、氏名または会社名、郵便番号、都道府県、住所欄1、電話番号)が必須指定となる。 148 | * 149 | * また、作成後にイベント([AddressCreated])を生成する。 150 | * 151 | * @return 作成された住所 152 | */ 153 | fun create( 154 | addressId: AddressId, 155 | ownerId: OwnerId, 156 | fullName: FullName, 157 | zipCode: ZipCode, 158 | stateOrRegion: StateOrRegion, 159 | line1: Line1, 160 | line2: Line2?, 161 | phoneNumber: PhoneNumber 162 | ): Address = with(Instant.now()) { 163 | Address( 164 | addressId, 165 | ownerId, 166 | fullName, 167 | zipCode, 168 | stateOrRegion, 169 | line1, 170 | line2, 171 | phoneNumber, 172 | createdAt = this, 173 | updatedAt = this 174 | ) 175 | } 176 | .addEvent(AddressEvent.Type.CREATED) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/AddressEvent.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.DomainEvent 4 | import htnk128.kotlin.ddd.sample.ddd.core.domain.ValueObject 5 | import java.time.Instant 6 | 7 | /** 8 | * 住所のイベントを表現する。 9 | * 10 | * @param T [AddressEvent] 11 | */ 12 | sealed class AddressEvent> : DomainEvent { 13 | 14 | abstract val type: Type 15 | 16 | abstract val address: Address 17 | 18 | override val occurredOn: Instant = Instant.now() 19 | 20 | override fun equals(other: Any?): Boolean { 21 | if (this === other) return true 22 | if (javaClass != other?.javaClass) return false 23 | @Suppress("UNCHECKED_CAST") 24 | other as T 25 | return sameEventAs(other) 26 | } 27 | 28 | override fun hashCode(): Int { 29 | var result = type.hashCode() 30 | result = 31 * result + occurredOn.hashCode() 31 | return result 32 | } 33 | 34 | override fun sameEventAs(other: T): Boolean { 35 | if (type != other.type) return false 36 | if (occurredOn != other.occurredOn) return false 37 | return true 38 | } 39 | 40 | enum class Type(private val value: String) : 41 | ValueObject { 42 | CREATED("address.created"), 43 | UPDATED("address.updated"), 44 | DELETED("address.deleted"); 45 | 46 | override fun sameValueAs(other: Type): Boolean = 47 | value == other.value 48 | } 49 | } 50 | 51 | /** 52 | * 住所の作成イベントを表現する。 53 | */ 54 | class AddressCreated(override val address: Address) : AddressEvent() { 55 | 56 | override val type: Type = Type.CREATED 57 | } 58 | 59 | /** 60 | * 住所の更新イベントを表現する。 61 | */ 62 | class AddressUpdated(override val address: Address) : AddressEvent() { 63 | 64 | override val type: Type = Type.UPDATED 65 | } 66 | 67 | /** 68 | * 住所の削除イベントを表現する。 69 | */ 70 | class AddressDeleted(override val address: Address) : AddressEvent() { 71 | 72 | override val type: Type = Type.DELETED 73 | } 74 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/AddressId.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeIdentity 4 | import java.util.UUID 5 | 6 | /** 7 | * 住所のIDを表現する。 8 | * 9 | * 64桁までの一意な文字列をもつ。 10 | */ 11 | class AddressId private constructor(id: String) : SomeIdentity(id) { 12 | 13 | companion object { 14 | 15 | /** 16 | * [UUID]を用いて住所のIDを生成する。 17 | * 18 | * @return 生成した値を持つ住所のID 19 | */ 20 | fun generate(): AddressId = AddressId("ADDR_${UUID.randomUUID()}") 21 | 22 | /** 23 | * [id]に指定された値を住所のIDに変換する。 24 | * 25 | * 値には、64桁までの一意な文字列を指定することが可能で、 26 | * 指定可能な値は、英数字、ハイフン、アンダースコアとなる。 27 | * この条件に違反した値を指定した場合には例外となる。 28 | * 29 | * @throws AddressInvalidRequestException 条件に違反した値を指定した場合 30 | * @return 指定された値を持つ住所のID 31 | */ 32 | fun valueOf(id: String): AddressId = id 33 | .takeIf { LENGTH_RANGE.contains(it.length) && PATTERN.matches(it) } 34 | ?.let { AddressId(it) } 35 | ?: throw AddressInvalidRequestException( 36 | "Address id must be 64 characters or less and alphanumeric, hyphen, underscore." 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/AddressInvalidDataStateException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | /** 4 | * 住所のドメインが無効なデータ状態にある場合に発生する例外。 5 | */ 6 | class AddressInvalidDataStateException( 7 | override val message: String, 8 | cause: Throwable? = null 9 | ) : RuntimeException(message, cause) { 10 | 11 | val type: String = "invalid_data_state" 12 | } 13 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/AddressInvalidRequestException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | /** 4 | * 無効なリクエストを受けて住所のドメインモデルへの変換に失敗した場合に発生する例外。 5 | */ 6 | class AddressInvalidRequestException( 7 | override val message: String, 8 | cause: Throwable? = null 9 | ) : RuntimeException(message, cause) { 10 | 11 | val type: String = "invalid_request_error" 12 | } 13 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/AddressNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | /** 4 | * 住所のドメインモデルが存在しない場合に発生する例外。 5 | */ 6 | class AddressNotFoundException( 7 | addressId: AddressId, 8 | override val message: String = "Address not found. (addressId=$addressId)", 9 | cause: Throwable? = null 10 | ) : RuntimeException(message, cause) { 11 | 12 | val type: String = "not_found_error" 13 | } 14 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/AddressUpdateFailedException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | /** 4 | * 住所のドメインモデルの更新に失敗した場合に発生する例外。 5 | */ 6 | class AddressUpdateFailedException( 7 | addressId: AddressId, 8 | override val message: String = "Address update failure. (addressId=$addressId)", 9 | cause: Throwable? = null 10 | ) : RuntimeException(message, cause) { 11 | 12 | val type: String = "update_failure_error" 13 | } 14 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/City.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * 住所の市町村を表現する。 7 | * 8 | * 100桁までの文字列をもつ。 9 | */ 10 | class City private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..100) 15 | 16 | /** 17 | * [value]に指定された値を住所の市町村に変換する。 18 | * 19 | * 値には、100桁までの文字列を指定することが可能である。 20 | * 21 | * @throws AddressInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つ住所の市町村 23 | */ 24 | fun valueOf(value: String): City = value 25 | .takeIf { LENGTH_RANGE.contains(it.length) } 26 | ?.let { City(it) } 27 | ?: throw AddressInvalidRequestException("City must be 100 characters or less.") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/FullName.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * 住所の氏名または会社名を表現する。 7 | * 8 | * 100桁までの文字列をもつ。 9 | */ 10 | class FullName private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..100) 15 | 16 | /** 17 | * [value]に指定された値を住所の氏名または会社名に変換する。 18 | * 19 | * 値には、100桁までの文字列を指定することが可能である。 20 | * 21 | * @throws AddressInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つ住所の氏名または会社名 23 | */ 24 | fun valueOf(value: String): FullName = value 25 | .takeIf { LENGTH_RANGE.contains(it.length) } 26 | ?.let { FullName(it) } 27 | ?: throw AddressInvalidRequestException("Full name must be 100 characters or less.") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/Line1.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * 住所の住所欄1を表現する。 7 | * 8 | * 100桁までの文字列をもつ。 9 | */ 10 | class Line1 private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..100) 15 | 16 | /** 17 | * [value]に指定された値を住所の住所欄1に変換する。 18 | * 19 | * 値には、100桁までの文字列を指定することが可能である。 20 | * 21 | * @throws AddressInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つ住所の住所欄1 23 | */ 24 | fun valueOf(value: String): Line1 = value 25 | .takeIf { LENGTH_RANGE.contains(it.length) } 26 | ?.let { Line1(it) } 27 | ?: throw AddressInvalidRequestException("Line1 must be 100 characters or less.") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/Line2.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * 住所の住所欄2を表現する。 7 | * 8 | * 100桁までの文字列をもつ。 9 | */ 10 | class Line2 private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..100) 15 | 16 | /** 17 | * [value]に指定された値を住所の住所欄2に変換する。 18 | * 19 | * 値には、100桁までの文字列を指定することが可能である。 20 | * 21 | * @throws AddressInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つ住所の住所欄2 23 | */ 24 | fun valueOf(value: String): Line2 = value 25 | .takeIf { LENGTH_RANGE.contains(it.length) } 26 | ?.let { Line2(it) } 27 | ?: throw AddressInvalidRequestException("Line2 must be 100 characters or less.") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/PhoneNumber.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * 住所の電話番号を表現する。 7 | * 8 | * 50桁までの文字列をもつ。 9 | */ 10 | class PhoneNumber private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..50) 15 | private val PATTERN = "\\p{Digit}*".toRegex() 16 | 17 | /** 18 | * [value]に指定された値を住所の電話番号に変換する。 19 | * 20 | * 値には、50桁までの文字列を指定することが可能で、指定可能な値は、数字となる。 21 | * この条件に違反した値を指定した場合には例外となる。 22 | * 23 | * @throws AddressInvalidRequestException 条件に違反した値を指定した場合 24 | * @return 指定された値を持つ住所の電話番号 25 | */ 26 | fun valueOf(value: String): PhoneNumber = value 27 | .takeIf { LENGTH_RANGE.contains(it.length) && PATTERN.matches(it) } 28 | ?.let { PhoneNumber(it) } 29 | ?: throw AddressInvalidRequestException("Phone number must be 50 characters or less and numeric.") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/StateOrRegion.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * 住所の都道府県を表現する。 7 | * 8 | * 100桁までの文字列をもつ。 9 | */ 10 | class StateOrRegion private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..100) 15 | 16 | /** 17 | * [value]に指定された値を住所の都道府県に変換する。 18 | * 19 | * 値には、100桁までの文字列を指定することが可能である。 20 | * 21 | * @throws AddressInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つ住所の都道府県 23 | */ 24 | fun valueOf(value: String): StateOrRegion = value 25 | .takeIf { LENGTH_RANGE.contains(it.length) } 26 | ?.let { StateOrRegion(it) } 27 | ?: throw AddressInvalidRequestException("State or region must be 100 characters or less.") 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/ZipCode.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeValueObject 4 | 5 | /** 6 | * 住所の郵便番号を表現する。 7 | * 8 | * 50桁までの文字列をもつ。 9 | */ 10 | class ZipCode private constructor(value: String) : SomeValueObject(value) { 11 | 12 | companion object { 13 | 14 | private val LENGTH_RANGE = (1..50) 15 | private val PATTERN = "[\\p{Alnum}]*".toRegex() 16 | 17 | /** 18 | * [value]に指定された値を住所の電話番号に変換する。 19 | * 20 | * 値には、50桁までの文字列を指定することが可能で、指定可能な値は、英数字となる。 21 | * この条件に違反した値を指定した場合には例外となる。 22 | * 23 | * @throws AddressInvalidRequestException 条件に違反した値を指定した場合 24 | * @return 指定された値を持つ住所の郵便番号 25 | */ 26 | fun valueOf(value: String): ZipCode = value 27 | .takeIf { LENGTH_RANGE.contains(it.length) && PATTERN.matches(it) } 28 | ?.let { ZipCode(it) } 29 | ?: throw AddressInvalidRequestException( 30 | "Zip code must be 50 characters or less and alphanumeric." 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/owner/Owner.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.owner 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.ValueObject 4 | import java.time.Instant 5 | 6 | /** 7 | * 住所の持ち主を表現する。 8 | */ 9 | class Owner( 10 | val ownerId: OwnerId, 11 | private val deletedAt: Instant? 12 | ) : ValueObject { 13 | 14 | /** 15 | * この住所の持ち主が有効な場合に`true`を返す。 16 | * 17 | * 有効とは[deletedAt]が`null`の場合である。 18 | */ 19 | val isAvailable: Boolean = deletedAt == null 20 | 21 | override fun equals(other: Any?): Boolean { 22 | if (this === other) return true 23 | if (javaClass != other?.javaClass) return false 24 | other as Owner 25 | return sameValueAs(other) 26 | } 27 | 28 | override fun hashCode(): Int { 29 | var result = ownerId.hashCode() 30 | result = 31 * result + deletedAt.hashCode() 31 | return result 32 | } 33 | 34 | override fun sameValueAs(other: Owner): Boolean { 35 | if (ownerId != other.ownerId) return false 36 | if (deletedAt != other.deletedAt) return false 37 | return true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/owner/OwnerId.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.owner 2 | 3 | import htnk128.kotlin.ddd.sample.ddd.core.domain.SomeIdentity 4 | 5 | /** 6 | * 住所の持ち主のIDを表現する。 7 | * 8 | * 64桁までの一意な文字列をもつ。 9 | */ 10 | class OwnerId private constructor(id: String) : SomeIdentity(id) { 11 | 12 | companion object { 13 | 14 | /** 15 | * [id]に指定された値を住所の持ち主のIDに変換する。 16 | * 17 | * 値には、64桁までの一意な文字列を指定することが可能で、 18 | * 指定可能な値は、英数字、ハイフン、アンダースコアとなる。 19 | * この条件に違反した値を指定した場合には例外となる。 20 | * 21 | * @throws OwnerInvalidRequestException 条件に違反した値を指定した場合 22 | * @return 指定された値を持つ住所の持ち主のID 23 | */ 24 | fun valueOf(id: String): OwnerId = id 25 | .takeIf { LENGTH_RANGE.contains(it.length) && PATTERN.matches(it) } 26 | ?.let { OwnerId(it) } 27 | ?: throw OwnerInvalidRequestException( 28 | "Owner id must be 64 characters or less and alphanumeric, hyphen, underscore." 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/owner/OwnerInvalidRequestException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.owner 2 | 3 | /** 4 | * 無効なリクエストを受けて住所の持ち主のドメインモデルへの変換に失敗した場合に発生する例外。 5 | */ 6 | class OwnerInvalidRequestException( 7 | override val message: String, 8 | cause: Throwable? = null 9 | ) : RuntimeException(message, cause) { 10 | 11 | val type: String = "invalid_request_error" 12 | } 13 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/owner/OwnerNotFoundException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.owner 2 | 3 | /** 4 | * 住所の持ち主のドメインモデルが存在しない場合に発生する例外。 5 | */ 6 | class OwnerNotFoundException( 7 | ownerId: OwnerId, 8 | override val message: String = "Address owner not found. (ownerId=$ownerId)", 9 | cause: Throwable? = null 10 | ) : RuntimeException(message, cause) { 11 | 12 | val type: String = "not_found_error" 13 | } 14 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/owner/OwnerService.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.owner 2 | 3 | import reactor.core.publisher.Mono 4 | 5 | /** 6 | * 住所の持ち主([Owner])ドメインの操作を提供するドメインサービス。 7 | */ 8 | interface OwnerService { 9 | 10 | fun find(ownerId: OwnerId): Mono 11 | } 12 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/domain/repository/AddressRepository.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.repository 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Address 4 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressId 5 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerId 6 | 7 | /** 8 | * 住所を操作するためのリポジトリを表現する。 9 | */ 10 | interface AddressRepository { 11 | 12 | fun find(addressId: AddressId, lock: Boolean = false): Address 13 | 14 | fun findAll(ownerId: OwnerId): List
15 | 16 | fun add(address: Address) 17 | 18 | fun set(address: Address) 19 | 20 | fun remove(address: Address) 21 | 22 | fun nextAddressId(): AddressId = AddressId.generate() 23 | } 24 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/external/spring/Application.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.external.spring 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.context.properties.ConfigurationPropertiesScan 5 | import org.springframework.boot.runApplication 6 | 7 | @SpringBootApplication(scanBasePackages = ["htnk128.kotlin.ddd.sample.address"]) 8 | @ConfigurationPropertiesScan(basePackages = ["htnk128.kotlin.ddd.sample.address"]) 9 | class Application 10 | 11 | fun main(args: Array) { 12 | runApplication(*args) 13 | } 14 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/external/spring/configuration/ApplicationConfiguration.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.external.spring.configuration 2 | 3 | import com.fasterxml.jackson.databind.DeserializationFeature 4 | import com.fasterxml.jackson.databind.ObjectMapper 5 | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 6 | import com.zaxxer.hikari.HikariDataSource 7 | import io.netty.channel.ChannelOption 8 | import javax.sql.DataSource 9 | import org.jetbrains.exposed.spring.SpringTransactionManager 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.context.annotation.Configuration 12 | import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor 13 | import org.springframework.http.client.reactive.ReactorClientHttpConnector 14 | import org.springframework.http.client.reactive.ReactorResourceFactory 15 | import org.springframework.transaction.annotation.EnableTransactionManagement 16 | import org.springframework.web.reactive.function.client.WebClient 17 | import reactor.netty.http.client.HttpClient 18 | import springfox.documentation.builders.ApiInfoBuilder 19 | import springfox.documentation.builders.RequestHandlerSelectors 20 | import springfox.documentation.service.ApiInfo 21 | import springfox.documentation.service.Contact 22 | import springfox.documentation.spi.DocumentationType 23 | import springfox.documentation.spring.web.plugins.Docket 24 | 25 | private typealias PlatformDataSource = HikariDataSource 26 | 27 | @Configuration 28 | @EnableTransactionManagement 29 | class ExposedConfiguration(val dataSource: DataSource) { 30 | 31 | @Bean 32 | fun transactionManager(dataSource: PlatformDataSource): SpringTransactionManager = 33 | SpringTransactionManager(dataSource) 34 | 35 | @Bean 36 | fun persistenceExceptionTranslationPostProcessor(): PersistenceExceptionTranslationPostProcessor = 37 | PersistenceExceptionTranslationPostProcessor() 38 | } 39 | 40 | @Configuration 41 | class JacksonConfiguration { 42 | 43 | @Bean 44 | fun objectMapper(): ObjectMapper = jacksonObjectMapper() 45 | .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 46 | } 47 | 48 | @Configuration 49 | class SpringfoxConfiguration { 50 | 51 | @Bean 52 | fun customDocket(): Docket = Docket(DocumentationType.SWAGGER_2) 53 | .select() 54 | .apis(RequestHandlerSelectors.basePackage("htnk128.kotlin.ddd.sample.address.external.spring.rest")) 55 | .build() 56 | .useDefaultResponseMessages(false) 57 | .apiInfo(apiInfo()) 58 | 59 | private fun apiInfo(): ApiInfo = ApiInfoBuilder() 60 | .title("Address APIs") 61 | .description("API specifications for address") 62 | .contact(Contact("htnk128", "https://github.com/htnk128", "hiroaki.tanaka128@gmail.com")) 63 | .version("1.0.0") 64 | .build() 65 | } 66 | 67 | @Configuration 68 | class WebClientConfiguration { 69 | 70 | @Bean 71 | fun reactorResourceFactory() = ReactorResourceFactory() 72 | .apply { 73 | isUseGlobalResources = false 74 | } 75 | 76 | @Bean 77 | fun webClient(): WebClient { 78 | val mapper: (HttpClient) -> HttpClient = { hc -> 79 | hc.tcpConfiguration { tc -> 80 | tc.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, TCP_CONNECT_TIMEOUT_MILLIS) 81 | } 82 | } 83 | val connector = ReactorClientHttpConnector(reactorResourceFactory(), mapper) 84 | return WebClient.builder().clientConnector(connector).build() 85 | } 86 | 87 | private companion object { 88 | 89 | const val TCP_CONNECT_TIMEOUT_MILLIS = 10000 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/external/spring/rest/AddressRestController.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.external.spring.rest 2 | 3 | import htnk128.kotlin.ddd.sample.address.adapter.controller.AddressController 4 | import htnk128.kotlin.ddd.sample.address.adapter.controller.resource.AddressCreateRequest 5 | import htnk128.kotlin.ddd.sample.address.adapter.controller.resource.AddressFindAllRequest 6 | import htnk128.kotlin.ddd.sample.address.adapter.controller.resource.AddressResponse 7 | import htnk128.kotlin.ddd.sample.address.adapter.controller.resource.AddressResponses 8 | import htnk128.kotlin.ddd.sample.address.adapter.controller.resource.AddressUpdateRequest 9 | import htnk128.kotlin.ddd.sample.shared.adapter.controller.resource.ErrorResponse 10 | import io.swagger.annotations.Api 11 | import io.swagger.annotations.ApiOperation 12 | import io.swagger.annotations.ApiParam 13 | import io.swagger.annotations.ApiResponse 14 | import io.swagger.annotations.ApiResponses 15 | import org.springframework.http.HttpStatus 16 | import org.springframework.http.MediaType 17 | import org.springframework.web.bind.annotation.DeleteMapping 18 | import org.springframework.web.bind.annotation.GetMapping 19 | import org.springframework.web.bind.annotation.ModelAttribute 20 | import org.springframework.web.bind.annotation.PathVariable 21 | import org.springframework.web.bind.annotation.PostMapping 22 | import org.springframework.web.bind.annotation.PutMapping 23 | import org.springframework.web.bind.annotation.RequestBody 24 | import org.springframework.web.bind.annotation.RequestMapping 25 | import org.springframework.web.bind.annotation.ResponseStatus 26 | import org.springframework.web.bind.annotation.RestController 27 | import reactor.core.publisher.Mono 28 | 29 | @Api("住所を管理するAPI", tags = ["Addresses"]) 30 | @RestController 31 | @RequestMapping("/addresses") 32 | class AddressRestController( 33 | private val addressUseCase: AddressController 34 | ) { 35 | 36 | @ApiResponses( 37 | value = [ 38 | (ApiResponse(code = 200, message = "OK", response = AddressResponse::class)), 39 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 40 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 41 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 42 | ] 43 | ) 44 | @ApiOperation("住所を取得する") 45 | @GetMapping("/{addressId}") 46 | fun find( 47 | @ApiParam(value = "住所のID", required = true, example = "ADDR_c5fb2cec-a77c-4886-b997-ffc2ef060e78") 48 | @PathVariable addressId: String 49 | ): Mono { 50 | return addressUseCase.find(addressId) 51 | } 52 | 53 | @ApiResponses( 54 | value = [ 55 | (ApiResponse(code = 200, message = "OK", response = AddressResponses::class)), 56 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 57 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 58 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 59 | ] 60 | ) 61 | @ApiOperation("アカウントのすべての住所を取得する") 62 | @GetMapping("") 63 | fun findAll( 64 | @ModelAttribute request: AddressFindAllRequest 65 | ): Mono { 66 | return addressUseCase.findAll(request.ownerId) 67 | } 68 | 69 | @ApiResponses( 70 | value = [ 71 | (ApiResponse(code = 201, message = "Created", response = AddressResponse::class)), 72 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 73 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 74 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 75 | ] 76 | ) 77 | @ApiOperation("住所を作成する") 78 | @PostMapping("", consumes = [MediaType.APPLICATION_JSON_VALUE]) 79 | @ResponseStatus(HttpStatus.CREATED) 80 | fun create( 81 | @RequestBody request: AddressCreateRequest 82 | ): Mono { 83 | return addressUseCase.create( 84 | request.ownerId, 85 | request.fullName, 86 | request.zipCode, 87 | request.stateOrRegion, 88 | request.line1, 89 | request.line2, 90 | request.phoneNumber 91 | ) 92 | } 93 | 94 | @ApiResponses( 95 | value = [ 96 | (ApiResponse(code = 200, message = "OK", response = AddressResponse::class)), 97 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 98 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 99 | (ApiResponse(code = 409, message = "Conflict", response = ErrorResponse::class)), 100 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 101 | ] 102 | ) 103 | @ApiOperation("住所を更新する") 104 | @PutMapping("/{addressId}", consumes = [MediaType.APPLICATION_JSON_VALUE]) 105 | fun update( 106 | @ApiParam(value = "住所のID", required = true, example = "ADDR_c5fb2cec-a77c-4886-b997-ffc2ef060e78") 107 | @PathVariable addressId: String, 108 | @RequestBody request: AddressUpdateRequest 109 | ): Mono { 110 | return addressUseCase.update( 111 | addressId, 112 | request.fullName, 113 | request.zipCode, 114 | request.stateOrRegion, 115 | request.line1, 116 | request.line2, 117 | request.phoneNumber 118 | ) 119 | } 120 | 121 | @ApiResponses( 122 | value = [ 123 | (ApiResponse(code = 200, message = "OK", response = AddressResponse::class)), 124 | (ApiResponse(code = 400, message = "Bad Request", response = ErrorResponse::class)), 125 | (ApiResponse(code = 404, message = "Not Found", response = ErrorResponse::class)), 126 | (ApiResponse(code = 500, message = "Internal Server Error", response = ErrorResponse::class)) 127 | ] 128 | ) 129 | @ApiOperation("住所を削除する") 130 | @DeleteMapping("/{addressId}") 131 | fun delete( 132 | @ApiParam(value = "住所のID", required = true, example = "ADDR_c5fb2cec-a77c-4886-b997-ffc2ef060e78") 133 | @PathVariable addressId: String 134 | ): Mono { 135 | return addressUseCase.delete(addressId) 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/usecase/inputport/AddressUseCase.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.usecase.inputport 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Address 4 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.CreateAddressCommand 5 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.DeleteAddressCommand 6 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.FindAddressCommand 7 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.FindAllAddressCommand 8 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.UpdateAddressCommand 9 | import reactor.core.publisher.Flux 10 | import reactor.core.publisher.Mono 11 | 12 | interface AddressUseCase { 13 | 14 | fun find(command: FindAddressCommand): Mono
15 | 16 | fun findAll(command: FindAllAddressCommand): Flux
17 | 18 | fun create(command: CreateAddressCommand): Mono
19 | 20 | fun update(command: UpdateAddressCommand): Mono
21 | 22 | fun delete(command: DeleteAddressCommand): Mono
23 | } 24 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/usecase/inputport/command/CreateAddressCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.usecase.inputport.command 2 | 3 | /** 4 | * 住所を作成する際のコマンド情報。 5 | */ 6 | data class CreateAddressCommand( 7 | val ownerId: String, 8 | val fullName: String, 9 | val zipCode: String, 10 | val stateOrRegion: String, 11 | val line1: String, 12 | val line2: String?, 13 | val phoneNumber: String 14 | ) 15 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/usecase/inputport/command/DeleteAddressCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.usecase.inputport.command 2 | 3 | /** 4 | * 住所を削除する際のコマンド情報。 5 | */ 6 | data class DeleteAddressCommand( 7 | val addressId: String 8 | ) 9 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/usecase/inputport/command/FindAddressCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.usecase.inputport.command 2 | 3 | /** 4 | * 住所を取得する際のコマンド情報。 5 | */ 6 | data class FindAddressCommand( 7 | val addressId: String 8 | ) 9 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/usecase/inputport/command/FindAllAddressCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.usecase.inputport.command 2 | 3 | /** 4 | * アカウントのすべての住所を取得する際のコマンド情報。 5 | */ 6 | data class FindAllAddressCommand( 7 | val ownerId: String 8 | ) 9 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/usecase/inputport/command/UpdateAddressCommand.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.usecase.inputport.command 2 | 3 | /** 4 | * 住所を更新する際のコマンド情報。 5 | */ 6 | data class UpdateAddressCommand( 7 | val addressId: String, 8 | val fullName: String?, 9 | val zipCode: String?, 10 | val stateOrRegion: String?, 11 | val line1: String?, 12 | val line2: String?, 13 | val phoneNumber: String? 14 | ) 15 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/usecase/interactor/AddressInteractor.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.usecase.interactor 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Address 4 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressEvent 5 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressId 6 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressInvalidDataStateException 7 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressInvalidRequestException 8 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressNotFoundException 9 | import htnk128.kotlin.ddd.sample.address.domain.model.address.AddressUpdateFailedException 10 | import htnk128.kotlin.ddd.sample.address.domain.model.address.FullName 11 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Line1 12 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Line2 13 | import htnk128.kotlin.ddd.sample.address.domain.model.address.PhoneNumber 14 | import htnk128.kotlin.ddd.sample.address.domain.model.address.StateOrRegion 15 | import htnk128.kotlin.ddd.sample.address.domain.model.address.ZipCode 16 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerId 17 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerInvalidRequestException 18 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerNotFoundException 19 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerService 20 | import htnk128.kotlin.ddd.sample.address.domain.repository.AddressRepository 21 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.AddressUseCase 22 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.CreateAddressCommand 23 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.DeleteAddressCommand 24 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.FindAddressCommand 25 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.FindAllAddressCommand 26 | import htnk128.kotlin.ddd.sample.address.usecase.inputport.command.UpdateAddressCommand 27 | import htnk128.kotlin.ddd.sample.ddd.core.domain.DomainEventPublisher 28 | import htnk128.kotlin.ddd.sample.shared.usecase.ApplicationException 29 | import org.springframework.stereotype.Service 30 | import org.springframework.transaction.annotation.Transactional 31 | import reactor.core.publisher.Flux 32 | import reactor.core.publisher.Mono 33 | 34 | /** 35 | * 住所([Address])ドメインの操作を提供するアプリケーションサービス。 36 | */ 37 | @Service 38 | class AddressInteractor( 39 | private val addressRepository: AddressRepository, 40 | private val ownerService: OwnerService, 41 | private val domainEventPublisher: DomainEventPublisher> 42 | ) : AddressUseCase { 43 | 44 | @Transactional(readOnly = true) 45 | override fun find(command: FindAddressCommand): Mono
= runCatching { 46 | val addressId = AddressId.valueOf(command.addressId) 47 | 48 | Mono.just(addressRepository.find(addressId)) 49 | .onErrorResume { Mono.error(it.error()) } 50 | } 51 | .getOrElse { Mono.error(it.error()) } 52 | 53 | @Transactional(readOnly = true) 54 | override fun findAll(command: FindAllAddressCommand): Flux
= runCatching { 55 | val ownerId = OwnerId.valueOf(command.ownerId) 56 | 57 | Flux.fromIterable(addressRepository.findAll(ownerId)) 58 | .onErrorResume { Flux.error(it.error()) } 59 | } 60 | .getOrElse { Flux.error(it.error()) } 61 | 62 | @Transactional(timeout = TRANSACTIONAL_TIMEOUT_SECONDS, rollbackFor = [Exception::class]) 63 | override fun create(command: CreateAddressCommand): Mono
= runCatching { 64 | val ownerId = OwnerId.valueOf(command.ownerId) 65 | val fullName = FullName.valueOf(command.fullName) 66 | val zipCode = ZipCode.valueOf(command.zipCode) 67 | val stateOrRegion = StateOrRegion.valueOf(command.stateOrRegion) 68 | val line1 = Line1.valueOf(command.line1) 69 | val line2 = command.line2?.let { Line2.valueOf(it) } 70 | val phoneNumber = PhoneNumber.valueOf(command.phoneNumber) 71 | val addressId = addressRepository.nextAddressId() 72 | 73 | val created = Address 74 | .create(addressId, ownerId, fullName, zipCode, stateOrRegion, line1, line2, phoneNumber) 75 | 76 | ownerService.find(ownerId) 77 | .map { if (!it.isAvailable) throw OwnerNotFoundException(it.ownerId) } 78 | .zipWith(Mono.just(addressRepository.add(created))) 79 | .map { created } 80 | .also { created.publish() } 81 | .onErrorResume { Mono.error(it.error()) } 82 | } 83 | .getOrElse { Mono.error(it.error()) } 84 | 85 | @Transactional(timeout = TRANSACTIONAL_TIMEOUT_SECONDS, rollbackFor = [Exception::class]) 86 | override fun update(command: UpdateAddressCommand): Mono
= runCatching { 87 | val addressId = AddressId.valueOf(command.addressId) 88 | val fullName = command.fullName?.let { FullName.valueOf(it) } 89 | val zipCode = command.zipCode?.let { ZipCode.valueOf(it) } 90 | val stateOrRegion = command.stateOrRegion?.let { StateOrRegion.valueOf(it) } 91 | val line1 = command.line1?.let { Line1.valueOf(it) } 92 | val line2 = command.line2?.let { Line2.valueOf(it) } 93 | val phoneNumber = command.phoneNumber?.let { PhoneNumber.valueOf(it) } 94 | 95 | val updated = addressRepository 96 | .find(addressId, lock = true) 97 | .update(fullName, zipCode, stateOrRegion, line1, line2, phoneNumber) 98 | .also { addressRepository.set(it) } 99 | 100 | Mono.just(updated) 101 | .also { updated.publish() } 102 | .onErrorResume { Mono.error(it.error()) } 103 | } 104 | .getOrElse { Mono.error(it.error()) } 105 | 106 | @Transactional(timeout = TRANSACTIONAL_TIMEOUT_SECONDS, rollbackFor = [Exception::class]) 107 | override fun delete(command: DeleteAddressCommand): Mono
= runCatching { 108 | val addressId = AddressId.valueOf(command.addressId) 109 | 110 | val deleted = addressRepository 111 | .find(addressId, lock = true) 112 | .delete() 113 | .also { addressRepository.remove(it) } 114 | 115 | Mono.just(deleted) 116 | .also { deleted.publish() } 117 | .onErrorResume { Mono.error(it.error()) } 118 | } 119 | .getOrElse { Mono.error(it.error()) } 120 | 121 | private fun Address.publish() { 122 | occurredEvents().forEach { domainEventPublisher.publish(it) } 123 | } 124 | 125 | private fun Throwable.error(): Throwable = 126 | when (this) { 127 | is AddressInvalidRequestException -> ApplicationException(type, 400, message, this) 128 | is OwnerInvalidRequestException -> ApplicationException(type, 400, message, this) 129 | is AddressNotFoundException -> ApplicationException(type, 404, message, this) 130 | is OwnerNotFoundException -> ApplicationException(type, 404, message, this) 131 | is AddressInvalidDataStateException -> ApplicationException(type, 409, message, this) 132 | is AddressUpdateFailedException -> ApplicationException(type, 500, message, this) 133 | else -> ApplicationException(message, this) 134 | } 135 | 136 | private companion object { 137 | 138 | const val TRANSACTIONAL_TIMEOUT_SECONDS: Int = 10 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/usecase/outputport/AddressUseCase.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.usecase.outputport 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Address 4 | import htnk128.kotlin.ddd.sample.address.usecase.outputport.dto.AddressDTO 5 | 6 | interface AddressUseCase { 7 | 8 | fun toDTO(address: Address): AddressDTO 9 | } 10 | -------------------------------------------------------------------------------- /address/src/main/kotlin/htnk128/kotlin/ddd/sample/address/usecase/outputport/dto/AddressDTO.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.usecase.outputport.dto 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.address.Address 4 | 5 | /** 6 | * 住所([Address])のDTO。 7 | */ 8 | data class AddressDTO( 9 | val addressId: String, 10 | val ownerId: String, 11 | val fullName: String, 12 | val zipCode: String, 13 | val stateOrRegion: String, 14 | val line1: String, 15 | val line2: String?, 16 | val phoneNumber: String, 17 | val createdAt: Long, 18 | val deletedAt: Long?, 19 | val updatedAt: Long 20 | ) 21 | -------------------------------------------------------------------------------- /address/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | main: 3 | lazy-initialization: false 4 | datasource: 5 | url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE 6 | username: sa 7 | password: password 8 | driver-class-name: org.h2.Driver 9 | flyway: 10 | enabled: true 11 | schemas: PUBLIC 12 | exposed: 13 | generate-ddl: false 14 | server: 15 | port: 8081 16 | shutdown: graceful 17 | logging: 18 | level: 19 | htnk128.kotlin.ddd.sample.address: INFO 20 | Exposed: INFO 21 | api: 22 | account: 23 | url: http://localhost:8080/accounts 24 | -------------------------------------------------------------------------------- /address/src/main/resources/db/migration/V1__address.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE address ( 2 | address_id varchar(64) PRIMARY KEY 3 | ,owner_id varchar(64) NOT NULL 4 | ,full_name varchar(100) NOT NULL 5 | ,zip_code varchar(50) NOT NULL 6 | ,state_or_region varchar(100) NOT NULL 7 | ,line1 varchar(100) NOT NULL 8 | ,line2 varchar(100) NULL 9 | ,phone_number varchar(50) NOT NULL 10 | ,created_at DATETIME(3) NOT NULL 11 | ,deleted_at DATETIME(3) NULL 12 | ,updated_at DATETIME(3) NOT NULL 13 | ); 14 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/AddressIdSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class AddressIdSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(64)), 14 | row("a_b-c-d-e"), 15 | row("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 16 | ) { value -> 17 | AddressId.valueOf(value).id() shouldBe value 18 | } 19 | } 20 | 21 | "不正な値の場合例外となること" { 22 | forall( 23 | row(""), 24 | row("a".repeat(65)), 25 | row("あ") 26 | ) { value -> 27 | shouldThrow { 28 | AddressId.valueOf(value) 29 | } 30 | } 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/AddressSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import htnk128.kotlin.ddd.sample.address.domain.model.owner.OwnerId 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import java.time.Instant 8 | 9 | class AddressSpec : StringSpec({ 10 | 11 | "住所が作成されること" { 12 | val now = Instant.now() 13 | val addressId = AddressId.generate() 14 | val ownerId = OwnerId.valueOf("account01") 15 | val fullName = FullName.valueOf("あいうえお") 16 | val zipCode = ZipCode.valueOf("1234567") 17 | val stateOrRegion = StateOrRegion.valueOf("かきくけこ") 18 | val line1 = Line1.valueOf("さしすせそ") 19 | val line2 = Line2.valueOf("たちつてと") 20 | val phoneNumber = PhoneNumber.valueOf("11111111111") 21 | 22 | val address = Address.create( 23 | addressId, 24 | ownerId, 25 | fullName, 26 | zipCode, 27 | stateOrRegion, 28 | line1, 29 | line2, 30 | phoneNumber 31 | ) 32 | 33 | address.addressId shouldBe addressId 34 | address.ownerId shouldBe ownerId 35 | address.fullName shouldBe fullName 36 | address.zipCode shouldBe zipCode 37 | address.stateOrRegion shouldBe stateOrRegion 38 | address.line1 shouldBe line1 39 | address.line2 shouldBe line2 40 | address.phoneNumber shouldBe phoneNumber 41 | (address.createdAt >= now) shouldBe true 42 | (address.updatedAt >= now) shouldBe true 43 | val events = address.occurredEvents() 44 | 45 | events.size shouldBe 1 46 | events[0] 47 | .also { 48 | it.type shouldBe AddressEvent.Type.CREATED 49 | it.address.addressId shouldBe addressId 50 | it.address.ownerId shouldBe ownerId 51 | it.address.fullName shouldBe fullName 52 | it.address.zipCode shouldBe zipCode 53 | it.address.stateOrRegion shouldBe stateOrRegion 54 | it.address.line1 shouldBe line1 55 | it.address.line2 shouldBe line2 56 | (it.address.createdAt >= now) shouldBe true 57 | (it.address.updatedAt >= now) shouldBe true 58 | } 59 | } 60 | 61 | "住所が更新されること" { 62 | val now = Instant.now() 63 | val fullName2 = FullName.valueOf("あいうえおa") 64 | val zipCode2 = ZipCode.valueOf("12345678") 65 | val stateOrRegion2 = StateOrRegion.valueOf("かきくけこb") 66 | val line12 = Line1.valueOf("さしすせそc") 67 | val line22 = Line2.valueOf("たちつてとd") 68 | val phoneNumber2 = PhoneNumber.valueOf("111111111112") 69 | 70 | val created = Address.create( 71 | AddressId.generate(), 72 | OwnerId.valueOf("account01"), 73 | FullName.valueOf("あいうえお"), 74 | ZipCode.valueOf("1234567"), 75 | StateOrRegion.valueOf("かきくけこ"), 76 | Line1.valueOf("さしすせそ"), 77 | Line2.valueOf("たちつてと"), 78 | PhoneNumber.valueOf("11111111111") 79 | ) 80 | 81 | val updated = created.update( 82 | fullName2, 83 | zipCode2, 84 | stateOrRegion2, 85 | line12, 86 | line22, 87 | phoneNumber2 88 | ) 89 | 90 | updated.ownerId shouldBe created.ownerId 91 | updated.fullName shouldBe fullName2 92 | updated.zipCode shouldBe zipCode2 93 | updated.stateOrRegion shouldBe stateOrRegion2 94 | updated.line1 shouldBe line12 95 | updated.line2 shouldBe line22 96 | (updated.createdAt >= now) shouldBe true 97 | (updated.updatedAt >= now) shouldBe true 98 | val events = updated.occurredEvents() 99 | 100 | events.size shouldBe 2 101 | events[1] 102 | .also { 103 | it.type shouldBe AddressEvent.Type.UPDATED 104 | it.address.ownerId shouldBe created.ownerId 105 | it.address.fullName shouldBe fullName2 106 | it.address.zipCode shouldBe zipCode2 107 | it.address.stateOrRegion shouldBe stateOrRegion2 108 | it.address.line1 shouldBe line12 109 | it.address.line2 shouldBe line22 110 | it.address.phoneNumber shouldBe phoneNumber2 111 | (it.address.createdAt >= now) shouldBe true 112 | (it.address.updatedAt >= now) shouldBe true 113 | } 114 | } 115 | 116 | "任意項目を指定しなかった場合の住所の更新は既存値であること" { 117 | val now = Instant.now() 118 | 119 | val created = Address.create( 120 | AddressId.generate(), 121 | OwnerId.valueOf("account01"), 122 | FullName.valueOf("あいうえお"), 123 | ZipCode.valueOf("1234567"), 124 | StateOrRegion.valueOf("かきくけこ"), 125 | Line1.valueOf("さしすせそ"), 126 | Line2.valueOf("たちつてと"), 127 | PhoneNumber.valueOf("11111111111") 128 | ) 129 | 130 | val updated = created.update( 131 | null, 132 | null, 133 | null, 134 | null, 135 | null, 136 | null 137 | ) 138 | 139 | updated.ownerId shouldBe created.ownerId 140 | updated.fullName shouldBe created.fullName 141 | updated.zipCode shouldBe created.zipCode 142 | updated.stateOrRegion shouldBe created.stateOrRegion 143 | updated.line1 shouldBe created.line1 144 | updated.line2 shouldBe null 145 | (updated.createdAt >= now) shouldBe true 146 | (updated.updatedAt >= now) shouldBe true 147 | val events = updated.occurredEvents() 148 | 149 | events.size shouldBe 2 150 | events[1] 151 | .also { 152 | it.type shouldBe AddressEvent.Type.UPDATED 153 | it.address.ownerId shouldBe created.ownerId 154 | it.address.fullName shouldBe created.fullName 155 | it.address.zipCode shouldBe created.zipCode 156 | it.address.stateOrRegion shouldBe created.stateOrRegion 157 | it.address.line1 shouldBe created.line1 158 | it.address.line2 shouldBe null 159 | it.address.phoneNumber shouldBe created.phoneNumber 160 | (it.address.createdAt >= now) shouldBe true 161 | (it.address.updatedAt >= now) shouldBe true 162 | } 163 | } 164 | 165 | "この住所が削除されている場合には例外となること" { 166 | val deleted = Address.create( 167 | AddressId.generate(), 168 | OwnerId.valueOf("account01"), 169 | FullName.valueOf("あいうえお"), 170 | ZipCode.valueOf("1234567"), 171 | StateOrRegion.valueOf("かきくけこ"), 172 | Line1.valueOf("さしすせそ"), 173 | Line2.valueOf("たちつてと"), 174 | PhoneNumber.valueOf("11111111111") 175 | ) 176 | .delete() 177 | 178 | shouldThrow { 179 | deleted.update( 180 | FullName.valueOf("あいうえおa"), 181 | ZipCode.valueOf("12345678"), 182 | StateOrRegion.valueOf("かきくけこb"), 183 | Line1.valueOf("さしすせそc"), 184 | Line2.valueOf("たちつてとd"), 185 | PhoneNumber.valueOf("111111111112") 186 | ) 187 | } 188 | } 189 | 190 | "住所が削除されること" { 191 | val now = Instant.now() 192 | 193 | val created = Address.create( 194 | AddressId.generate(), 195 | OwnerId.valueOf("account01"), 196 | FullName.valueOf("あいうえお"), 197 | ZipCode.valueOf("1234567"), 198 | StateOrRegion.valueOf("かきくけこ"), 199 | Line1.valueOf("さしすせそ"), 200 | Line2.valueOf("たちつてと"), 201 | PhoneNumber.valueOf("11111111111") 202 | ) 203 | 204 | val deleted = created.delete() 205 | 206 | deleted.ownerId shouldBe created.ownerId 207 | deleted.fullName shouldBe created.fullName 208 | deleted.zipCode shouldBe created.zipCode 209 | deleted.stateOrRegion shouldBe created.stateOrRegion 210 | deleted.line1 shouldBe created.line1 211 | deleted.line2 shouldBe created.line2 212 | deleted.isDeleted shouldBe true 213 | (deleted.createdAt >= now) shouldBe true 214 | (deleted.updatedAt >= now) shouldBe true 215 | val events = deleted.occurredEvents() 216 | 217 | events.size shouldBe 2 218 | events[1] 219 | .also { 220 | it.type shouldBe AddressEvent.Type.DELETED 221 | it.address.ownerId shouldBe created.ownerId 222 | it.address.fullName shouldBe created.fullName 223 | it.address.zipCode shouldBe created.zipCode 224 | it.address.stateOrRegion shouldBe created.stateOrRegion 225 | it.address.line1 shouldBe created.line1 226 | it.address.line2 shouldBe created.line2 227 | it.address.phoneNumber shouldBe created.phoneNumber 228 | (it.address.createdAt >= now) shouldBe true 229 | (it.address.updatedAt >= now) shouldBe true 230 | } 231 | } 232 | 233 | "この住所が削除済みの場合にはそのままこのアカウントが返却されること" { 234 | val deleted = Address.create( 235 | AddressId.generate(), 236 | OwnerId.valueOf("account01"), 237 | FullName.valueOf("あいうえお"), 238 | ZipCode.valueOf("1234567"), 239 | StateOrRegion.valueOf("かきくけこ"), 240 | Line1.valueOf("さしすせそ"), 241 | Line2.valueOf("たちつてと"), 242 | PhoneNumber.valueOf("11111111111") 243 | ) 244 | .delete() 245 | 246 | val deleted2 = deleted.delete() 247 | 248 | deleted2.ownerId shouldBe deleted.ownerId 249 | deleted2.fullName shouldBe deleted.fullName 250 | deleted2.zipCode shouldBe deleted.zipCode 251 | deleted2.stateOrRegion shouldBe deleted.stateOrRegion 252 | deleted2.line1 shouldBe deleted.line1 253 | deleted2.line2 shouldBe deleted.line2 254 | deleted2.phoneNumber shouldBe deleted.phoneNumber 255 | deleted2.createdAt shouldBe deleted.createdAt 256 | deleted2.updatedAt shouldBe deleted.updatedAt 257 | deleted2.isDeleted shouldBe true 258 | deleted2.occurredEvents().size shouldBe 2 259 | } 260 | 261 | "属性が異なっても一意な識別子が一緒であれば等価となる" { 262 | val addressId = AddressId.generate() 263 | val address1 = Address.create( 264 | addressId, 265 | OwnerId.valueOf("account01"), 266 | FullName.valueOf("あいうえお"), 267 | ZipCode.valueOf("1234567"), 268 | StateOrRegion.valueOf("かきくけこ"), 269 | Line1.valueOf("さしすせそ"), 270 | Line2.valueOf("たちつてと"), 271 | PhoneNumber.valueOf("11111111111") 272 | ) 273 | val address2 = Address.create( 274 | addressId, 275 | OwnerId.valueOf("account02"), 276 | FullName.valueOf("あいうえお1"), 277 | ZipCode.valueOf("12345678"), 278 | StateOrRegion.valueOf("かきくけこ2"), 279 | Line1.valueOf("さしすせそ3"), 280 | Line2.valueOf("たちつてと4"), 281 | PhoneNumber.valueOf("111111111115") 282 | ) 283 | 284 | (address1 == address2) shouldBe true 285 | (address1.sameIdentityAs(address2)) shouldBe true 286 | } 287 | 288 | "属性が同一でも一意な識別子が異なれば等価とならない" { 289 | val address1 = Address.create( 290 | AddressId.generate(), 291 | OwnerId.valueOf("account01"), 292 | FullName.valueOf("あいうえお"), 293 | ZipCode.valueOf("1234567"), 294 | StateOrRegion.valueOf("かきくけこ"), 295 | Line1.valueOf("さしすせそ"), 296 | Line2.valueOf("たちつてと"), 297 | PhoneNumber.valueOf("11111111111") 298 | ) 299 | val address2 = Address.create( 300 | AddressId.generate(), 301 | OwnerId.valueOf("account01"), 302 | FullName.valueOf("あいうえお"), 303 | ZipCode.valueOf("1234567"), 304 | StateOrRegion.valueOf("かきくけこ"), 305 | Line1.valueOf("さしすせそ"), 306 | Line2.valueOf("たちつてと"), 307 | PhoneNumber.valueOf("11111111111") 308 | ) 309 | 310 | (address1 == address2) shouldBe false 311 | (address1.sameIdentityAs(address2)) shouldBe false 312 | } 313 | }) 314 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/CitySpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class CitySpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(100)) 14 | ) { value -> 15 | City.valueOf(value).value() shouldBe value 16 | } 17 | } 18 | 19 | "不正な値の場合例外となること" { 20 | forall( 21 | row(""), 22 | row("a".repeat(101)) 23 | ) { value -> 24 | shouldThrow { 25 | City.valueOf(value) 26 | } 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/FullNameSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class FullNameSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(100)) 14 | ) { value -> 15 | FullName.valueOf(value).value() shouldBe value 16 | } 17 | } 18 | 19 | "不正な値の場合例外となること" { 20 | forall( 21 | row(""), 22 | row("a".repeat(101)) 23 | ) { value -> 24 | shouldThrow { 25 | FullName.valueOf(value) 26 | } 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/Line1Spec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class Line1Spec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(100)) 14 | ) { value -> 15 | Line1.valueOf(value).value() shouldBe value 16 | } 17 | } 18 | 19 | "不正な値の場合例外となること" { 20 | forall( 21 | row(""), 22 | row("a".repeat(101)) 23 | ) { value -> 24 | shouldThrow { 25 | Line1.valueOf(value) 26 | } 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/Line2Spec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class Line2Spec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(100)) 14 | ) { value -> 15 | Line2.valueOf(value).value() shouldBe value 16 | } 17 | } 18 | 19 | "不正な値の場合例外となること" { 20 | forall( 21 | row(""), 22 | row("a".repeat(101)) 23 | ) { value -> 24 | shouldThrow { 25 | Line2.valueOf(value) 26 | } 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/PhoneNumberSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class PhoneNumberSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("1".repeat(50)), 14 | row("12345667890") 15 | ) { value -> 16 | PhoneNumber.valueOf(value).value() shouldBe value 17 | } 18 | } 19 | 20 | "不正な値の場合例外となること" { 21 | forall( 22 | row(""), 23 | row("1".repeat(51)), 24 | row("あ") 25 | ) { value -> 26 | shouldThrow { 27 | PhoneNumber.valueOf(value) 28 | } 29 | } 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/StateOrRegionSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class StateOrRegionSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(100)) 14 | ) { value -> 15 | StateOrRegion.valueOf(value).value() shouldBe value 16 | } 17 | } 18 | 19 | "不正な値の場合例外となること" { 20 | forall( 21 | row(""), 22 | row("a".repeat(101)) 23 | ) { value -> 24 | shouldThrow { 25 | StateOrRegion.valueOf(value) 26 | } 27 | } 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/address/ZipCodeSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.address 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class ZipCodeSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("1".repeat(50)), 14 | row("12345667890") 15 | ) { value -> 16 | ZipCode.valueOf(value).value() shouldBe value 17 | } 18 | } 19 | 20 | "不正な値の場合例外となること" { 21 | forall( 22 | row(""), 23 | row("1".repeat(51)), 24 | row("あ") 25 | ) { value -> 26 | shouldThrow { 27 | ZipCode.valueOf(value) 28 | } 29 | } 30 | } 31 | }) 32 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/owner/OwnerIdSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.owner 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldThrow 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | 9 | class OwnerIdSpec : StringSpec({ 10 | 11 | "正しい値の場合インスタンスを生成できる" { 12 | forall( 13 | row("a".repeat(64)), 14 | row("a_b-c-d-e"), 15 | row("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 16 | ) { value -> 17 | OwnerId.valueOf(value).id() shouldBe value 18 | } 19 | } 20 | 21 | "不正な値の場合例外となること" { 22 | forall( 23 | row(""), 24 | row("a".repeat(65)), 25 | row("あ") 26 | ) { value -> 27 | shouldThrow { 28 | OwnerId.valueOf(value) 29 | } 30 | } 31 | } 32 | }) 33 | -------------------------------------------------------------------------------- /address/src/test/kotlin/htnk128/kotlin/ddd/sample/address/domain/model/owner/OwnerSpec.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.address.domain.model.owner 2 | 3 | import io.kotlintest.data.forall 4 | import io.kotlintest.shouldBe 5 | import io.kotlintest.shouldNotBe 6 | import io.kotlintest.specs.StringSpec 7 | import io.kotlintest.tables.row 8 | import java.time.Instant 9 | 10 | class OwnerSpec : StringSpec({ 11 | 12 | "住所の持ち主が有効な場合の判定が想定通りであること" { 13 | forall( 14 | row(Instant.now(), false), 15 | row(null, true) 16 | ) { deletedAt, expected -> 17 | Owner( 18 | OwnerId.valueOf("account01"), 19 | deletedAt 20 | ).isAvailable shouldBe expected 21 | } 22 | } 23 | 24 | "同じ値を持つ場合は等価となる" { 25 | val ownerId = OwnerId.valueOf("account01") 26 | val deletedAt = Instant.now() 27 | val data1 = Owner( 28 | ownerId, 29 | deletedAt 30 | ) 31 | val data2 = Owner( 32 | ownerId, 33 | deletedAt 34 | ) 35 | 36 | data1 shouldBe data2 37 | } 38 | 39 | "同じ値でない場合は等価とならない" { 40 | val deletedAt = Instant.now() 41 | val data1 = Owner( 42 | OwnerId.valueOf("account01"), 43 | deletedAt 44 | ) 45 | val data2 = Owner( 46 | OwnerId.valueOf("account02"), 47 | deletedAt 48 | ) 49 | 50 | data1 shouldNotBe data2 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /address/src/test/kotlin/io/kotlintest/provided/ProjectConfig.kt: -------------------------------------------------------------------------------- 1 | package io.kotlintest.provided 2 | 3 | import io.kotlintest.AbstractProjectConfig 4 | import io.kotlintest.extensions.ProjectLevelExtension 5 | import io.kotlintest.spring.SpringAutowireConstructorExtension 6 | 7 | class ProjectConfig : AbstractProjectConfig() { 8 | 9 | override fun extensions(): List = listOf(SpringAutowireConstructorExtension) 10 | } 11 | -------------------------------------------------------------------------------- /build.gradle.kts: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | import org.jlleitschuh.gradle.ktlint.reporter.ReporterType 3 | 4 | plugins { 5 | allModulePlugins() 6 | } 7 | 8 | tasks.getByName("jar") { 9 | enabled = false 10 | } 11 | 12 | allprojects { 13 | apply { 14 | allModule() 15 | } 16 | 17 | repositories { 18 | allModuleRepositories() 19 | } 20 | 21 | dependencies { 22 | allModuleDependencies() 23 | } 24 | 25 | ktlint { 26 | version.set(Versions.ktlintCore) 27 | debug.set(false) 28 | verbose.set(false) 29 | android.set(false) 30 | outputToConsole.set(true) 31 | ignoreFailures.set(false) 32 | reporters { 33 | reporter(ReporterType.PLAIN) 34 | reporter(ReporterType.CHECKSTYLE) 35 | 36 | customReporters { 37 | register("html") { 38 | fileExtension = "html" 39 | dependency = "me.cassiano:ktlint-html-reporter:0.2.3" 40 | } 41 | } 42 | } 43 | filter { 44 | include("**/kotlin/**") 45 | } 46 | } 47 | 48 | tasks { 49 | withType { 50 | dependsOn(listOf(ktlintKotlinScriptFormat, ktlintFormat)) 51 | 52 | kotlinOptions { 53 | freeCompilerArgs = listOf("-Xjsr305=strict") 54 | jvmTarget = Versions.java.toString() 55 | } 56 | } 57 | 58 | withType { 59 | testLogging { 60 | events("skipped", "failed") 61 | setExceptionFormat("full") 62 | } 63 | useJUnitPlatform() 64 | } 65 | } 66 | 67 | group = "htnk128" 68 | version = "1.0.0" 69 | java.sourceCompatibility = Versions.java 70 | java.targetCompatibility = Versions.java 71 | } 72 | -------------------------------------------------------------------------------- /buildSrc/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | `kotlin-dsl` 3 | } 4 | 5 | kotlinDslPluginOptions { 6 | experimentalWarning.set(false) 7 | } 8 | 9 | repositories { 10 | jcenter() 11 | } 12 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Apply.kt: -------------------------------------------------------------------------------- 1 | import org.gradle.api.plugins.ObjectConfigurationAction 2 | 3 | fun ObjectConfigurationAction.allModule() { 4 | plugin("kotlin") 5 | plugin("idea") 6 | plugin("org.jlleitschuh.gradle.ktlint") 7 | plugin("org.jetbrains.kotlinx.kover") 8 | } 9 | 10 | fun ObjectConfigurationAction.spring() { 11 | plugin("io.spring.dependency-management") 12 | } 13 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Dependencies.kt: -------------------------------------------------------------------------------- 1 | import org.gradle.api.artifacts.dsl.DependencyHandler 2 | import org.gradle.kotlin.dsl.kotlin 3 | import org.gradle.kotlin.dsl.project 4 | 5 | fun DependencyHandler.implementation(dependency: Any) { 6 | add("implementation", dependency) 7 | } 8 | 9 | fun DependencyHandler.testImplementation(dependency: Any) { 10 | add("testImplementation", dependency) 11 | } 12 | 13 | fun DependencyHandler.runtimeOnly(dependency: Any) { 14 | add("runtimeOnly", dependency) 15 | } 16 | 17 | fun DependencyHandler.testRuntimeOnly(dependency: Any) { 18 | add("testRuntimeOnly", dependency) 19 | } 20 | 21 | fun DependencyHandler.allModuleDependencies() { 22 | // Kotlin 23 | implementation(kotlin("reflect")) 24 | implementation(kotlin("stdlib-jdk8")) 25 | // Test 26 | testImplementation("org.junit.jupiter:junit-jupiter-api:${Versions.junit}") 27 | testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:${Versions.junit}") 28 | testImplementation("io.kotlintest:kotlintest-runner-junit5:${Versions.kotlintest}") 29 | testImplementation("io.kotlintest:kotlintest-extensions-spring:${Versions.kotlintest}") 30 | testImplementation("org.mockito.kotlin:mockito-kotlin:${Versions.mockitoKotlin}") 31 | } 32 | 33 | fun DependencyHandler.springDependencies() { 34 | implementation("org.springframework.boot:spring-boot-starter-webflux") 35 | implementation("org.springframework.boot:spring-boot-starter-jdbc") 36 | implementation("org.springframework.boot:spring-boot-starter-actuator") 37 | 38 | runtimeOnly("org.springframework.boot:spring-boot-devtools") 39 | 40 | testImplementation("org.springframework.boot:spring-boot-starter-test") 41 | } 42 | 43 | fun DependencyHandler.jacksonDependencies() { 44 | implementation("com.fasterxml.jackson.module:jackson-modules-java8:${Versions.jackson}") 45 | implementation("com.fasterxml.jackson.module:jackson-module-kotlin:${Versions.jackson}") 46 | implementation("com.fasterxml.jackson.module:jackson-module-parameter-names:${Versions.jackson}") 47 | implementation("com.fasterxml.jackson.datatype:jackson-datatype-jdk8:${Versions.jackson}") 48 | implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:${Versions.jackson}") 49 | } 50 | 51 | fun DependencyHandler.sqlDependencies() { 52 | implementation("org.jetbrains.exposed:exposed-core:${Versions.exposed}") 53 | implementation("org.jetbrains.exposed:exposed-jdbc:${Versions.exposed}") 54 | implementation("org.jetbrains.exposed:exposed-jodatime:${Versions.exposed}") 55 | implementation("org.jetbrains.exposed:spring-transaction:${Versions.exposed}") 56 | runtimeOnly("com.h2database:h2:${Versions.h2}") 57 | } 58 | 59 | fun DependencyHandler.flywayDependencies() { 60 | implementation("org.flywaydb:flyway-core:${Versions.flyway}") 61 | } 62 | 63 | fun DependencyHandler.springfoxDependencies() { 64 | implementation("io.springfox:springfox-boot-starter:${Versions.springfoxVersion}") 65 | } 66 | 67 | fun DependencyHandler.loggingDependency() { 68 | implementation("io.github.microutils:kotlin-logging:${Versions.kotlinLogging}") 69 | } 70 | 71 | fun DependencyHandler.sharedDependency() { 72 | implementation(project(":shared")) 73 | } 74 | 75 | fun DependencyHandler.dddCoreDependency() { 76 | implementation(project(":ddd-core")) 77 | } 78 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Plugins.kt: -------------------------------------------------------------------------------- 1 | import org.gradle.kotlin.dsl.kotlin 2 | import org.gradle.kotlin.dsl.version 3 | import org.gradle.plugin.use.PluginDependenciesSpec 4 | 5 | fun PluginDependenciesSpec.allModulePlugins() { 6 | kotlin("jvm") version Versions.kotlin 7 | id("org.jlleitschuh.gradle.ktlint") version Versions.ktlint 8 | id("org.jetbrains.kotlinx.kover") version Versions.kover 9 | } 10 | 11 | fun PluginDependenciesSpec.springPlugins() { 12 | id("org.springframework.boot") version Versions.springBoot 13 | kotlin("plugin.spring") version Versions.kotlin 14 | } 15 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Repositories.kt: -------------------------------------------------------------------------------- 1 | import org.gradle.api.artifacts.dsl.RepositoryHandler 2 | import org.gradle.kotlin.dsl.maven 3 | 4 | fun RepositoryHandler.allModuleRepositories() { 5 | mavenCentral() 6 | maven("https://plugins.gradle.org/m2/") 7 | } 8 | -------------------------------------------------------------------------------- /buildSrc/src/main/kotlin/Versions.kt: -------------------------------------------------------------------------------- 1 | import org.gradle.api.JavaVersion 2 | 3 | object Versions { 4 | 5 | val java: JavaVersion = JavaVersion.VERSION_1_8 6 | const val kotlin: String = "1.5.32" 7 | 8 | const val ktlint: String = "10.0.0" 9 | const val ktlintCore: String = "0.41.0" 10 | 11 | const val kover: String = "0.6.1" 12 | 13 | const val springBoot: String = "2.5.14" 14 | 15 | const val jackson: String = "2.12.7" 16 | const val springfoxVersion: String = "3.0.0" 17 | const val kotlinLogging: String = "1.6.26" 18 | const val exposed: String = "0.19.3" 19 | const val flyway: String = "7.7.3" 20 | const val h2: String = "1.4.199" 21 | 22 | const val junit: String = "5.7.2" 23 | const val kotlintest: String = "3.3.2" 24 | const val mockitoKotlin: String = "4.0.0" 25 | } 26 | -------------------------------------------------------------------------------- /ddd-core/build.gradle.kts: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ddd-core/src/main/kotlin/htnk128/kotlin/ddd/sample/ddd/core/domain/DomainEvent.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.ddd.core.domain 2 | 3 | import java.time.Instant 4 | 5 | /** 6 | * DDDにおけるドメインイベントの概念。 7 | */ 8 | interface DomainEvent> { 9 | 10 | val occurredOn: Instant 11 | 12 | fun sameEventAs(other: T): Boolean 13 | } 14 | -------------------------------------------------------------------------------- /ddd-core/src/main/kotlin/htnk128/kotlin/ddd/sample/ddd/core/domain/DomainEventPublisher.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.ddd.core.domain 2 | 3 | /** 4 | * [DomainEvent]の出版インターフェース。 5 | */ 6 | interface DomainEventPublisher> { 7 | 8 | /** 9 | * 指定されたドメインイベントを出版する。 10 | * 11 | * @param domainEvent ドメインイベント 12 | */ 13 | fun publish(domainEvent: T) 14 | } 15 | -------------------------------------------------------------------------------- /ddd-core/src/main/kotlin/htnk128/kotlin/ddd/sample/ddd/core/domain/DomainEventSubscriber.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.ddd.core.domain 2 | 3 | /** 4 | * [DomainEvent]の購読インターフェース。 5 | */ 6 | interface DomainEventSubscriber> { 7 | 8 | /** 9 | * ドメインイベントを購読しハンドリングする。 10 | * 11 | * @param domainEvent ドメインイベント 12 | */ 13 | fun handleEvent(domainEvent: T) 14 | } 15 | -------------------------------------------------------------------------------- /ddd-core/src/main/kotlin/htnk128/kotlin/ddd/sample/ddd/core/domain/Entity.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.ddd.core.domain 2 | 3 | /** 4 | * DDDにおけるエンティティの概念。 5 | */ 6 | interface Entity { 7 | 8 | fun sameIdentityAs(other: T): Boolean 9 | } 10 | -------------------------------------------------------------------------------- /ddd-core/src/main/kotlin/htnk128/kotlin/ddd/sample/ddd/core/domain/Identity.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.ddd.core.domain 2 | 3 | /** 4 | * 何らかのドメインを識別するIDを表現した値オブジェクトの概念。 5 | */ 6 | interface Identity> : ValueObject { 7 | 8 | fun id(): String 9 | } 10 | -------------------------------------------------------------------------------- /ddd-core/src/main/kotlin/htnk128/kotlin/ddd/sample/ddd/core/domain/SomeIdentity.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.ddd.core.domain 2 | 3 | /** 4 | * 何らかのドメインを識別するIDを表現した値オブジェクト。 5 | * 6 | * @param T 値オブジェクトの型 7 | */ 8 | abstract class SomeIdentity>(private val id: String) : Identity { 9 | 10 | override fun id(): String = id 11 | 12 | override fun equals(other: Any?): Boolean { 13 | if (this === other) return true 14 | if (javaClass != other?.javaClass) return false 15 | @Suppress("UNCHECKED_CAST") 16 | other as T 17 | return sameValueAs(other) 18 | } 19 | 20 | override fun hashCode(): Int = id().hashCode() 21 | 22 | override fun sameValueAs(other: T): Boolean = id() == other.id() 23 | 24 | override fun toString(): String = id() 25 | 26 | protected companion object { 27 | 28 | val LENGTH_RANGE = (1..64) 29 | val PATTERN = "[\\p{Alnum}-_]*".toRegex() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /ddd-core/src/main/kotlin/htnk128/kotlin/ddd/sample/ddd/core/domain/SomeValueObject.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.ddd.core.domain 2 | 3 | /** 4 | * 何らかの型の値を1つ持つ値オブジェクト。 5 | * 6 | * @param T 値オブジェクトの型 7 | * @param V 値オブジェクトが持つ値の型 8 | */ 9 | abstract class SomeValueObject, V : Comparable>(private val value: V) : ValueObject { 10 | 11 | fun value(): V = value 12 | 13 | override fun equals(other: Any?): Boolean { 14 | if (this === other) return true 15 | if (javaClass != other?.javaClass) return false 16 | @Suppress("UNCHECKED_CAST") 17 | other as T 18 | return sameValueAs(other) 19 | } 20 | 21 | override fun hashCode(): Int = value().hashCode() 22 | 23 | override fun sameValueAs(other: T): Boolean = value() == other.value() 24 | 25 | override fun toString(): String = "${value()}" 26 | } 27 | -------------------------------------------------------------------------------- /ddd-core/src/main/kotlin/htnk128/kotlin/ddd/sample/ddd/core/domain/ValueObject.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.ddd.core.domain 2 | 3 | import java.io.Serializable 4 | 5 | /** 6 | * DDDにおける値オブジェクトの概念。 7 | * 8 | * @param T 値オブジェクトの型 9 | */ 10 | interface ValueObject : Serializable { 11 | 12 | fun sameValueAs(other: T): Boolean 13 | } 14 | -------------------------------------------------------------------------------- /docs/account-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htnk128/kotlin-ddd-sample/e8f038c29f08c6914f5c824164a2103f7b518e20/docs/account-use-case.png -------------------------------------------------------------------------------- /docs/account-use-case.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | actor User 4 | 5 | :ユーザ: as User 6 | (アカウントを取得) as (find) 7 | (アカウントをすべて取得) as (findAll) 8 | (アカウントを作成) as (Create) 9 | (アカウントを更新) as (Update) 10 | (アカウントを削除) as (Delete) 11 | 12 | User --> (find) 13 | User --> (findAll) 14 | User --> (Create) 15 | User --> (Update) 16 | User --> (Delete) 17 | 18 | @enduml 19 | -------------------------------------------------------------------------------- /docs/address-use-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htnk128/kotlin-ddd-sample/e8f038c29f08c6914f5c824164a2103f7b518e20/docs/address-use-case.png -------------------------------------------------------------------------------- /docs/address-use-case.puml: -------------------------------------------------------------------------------- 1 | @startuml 2 | 3 | actor User 4 | 5 | :ユーザ: as User 6 | (住所を取得) as (find) 7 | (住所をすべて取得) as (findAll) 8 | (住所を作成) as (Create) 9 | (住所を更新) as (Update) 10 | (住所を削除) as (Delete) 11 | 12 | User --> (find) 13 | User --> (findAll) 14 | User --> (Create) 15 | User --> (Update) 16 | User --> (Delete) 17 | 18 | @enduml 19 | -------------------------------------------------------------------------------- /docs/contextmap.drawio: -------------------------------------------------------------------------------- 1 | 7VjbUtswEP2aPMLYUuwkj5AQYKYd2lJaeBS2YosoViort359pXhlW7GBUKAtU5yHeG+67DkrbdLBw9n6VJJ5+lHElHeQF687eNRByPfCUH8ZzabQhIOgUCSSxeBUKS7ZT2ojQbtgMc0dRyUEV2zuKiORZTRSjo5IKVau20Rwd9Y5SWhDcRkR3tR+Z7FKQet7XmU4oyxJYep+AIZbEk0TKRYZzNdBeLJ9CvOM2LHAP09JLFY1FT7p4KEUQhVvs/WQcpNbm7YibnyPtVy3pJnaK4AS7Pd6Ay9GERn08YGPYGFqY5NBY50bEDOR6a/j7Q6pGcPXUqpmHF45uaX8uEzCUHAhq7BcEamODD47ujHjZgTPysCIQMs0i21ExEmes+hryrLCAGF+IdWC7qhSG5DJQgmtElKlIhEZ4R+EmENUrqSYUrtKDdbx9lNaLPjGdyIyNSYzxg2nv1EZk4yAGmayuaunHxAxOawpAIxTKmZUyY12kJQTxZYu/wjQOCn9ytBPgukpkAcl10NQYFBwOPTcIXRWE6ogqiKEfqkto1JtabIvZbrFVEvCF7D+Yjcie+fSG+TSAIeHXs9DuN8L8cBDfZdZPf8Qh9hHno+6fYz7r8Wzuyvsn11d0xM6GKend8Pz6dmPA7ubGtc6KOTKMEfTMEzMm9Xcyl2NnrPutktOzvUFY4i0Spmil3MSGctK33EuMyvKbllmz3BjisSMRWB4jMC/xQp8TDhLMkNhTQwqjYOmbo13k8B82hgZbh8YsqYvnjIlLZxbUqno+kHWgbUb9A7ds8gfwFm0qq5SZG/StHaLlo5tXHWY9GTaoD1ocxTHkuZ50Vbo3ar9WLP1dOjh5h3groMEqgaQJstMdyFHYJixOOb38dEl4Ysgh3aQQ+GeyAUvANzFly9yPu2tLz6fq3wS+qtlOjrYB7f3cn9euTcY0sKjB0jjUqb7R4u9lTN4n1qPIo2Resu1/kzYPAe24K9XerOJbBZs1b5BVmtA6I3LzbXJ0GFgxZu6bbSG9BXSxkprpmphWrqpWaogI2wcAHb6sNbTNxcLGdHHD7iibXq08WniWcMreACuZ7aEZVXvVrkdothno+1rDBR0dwbqvtrvlNZ0Bw2Wjf7RIt9eA7Co+67yp1R8WeE28y0V321hEHqtgg8bUFz9p1DgljvzhaDQYvVnUlFE1T92+OQX -------------------------------------------------------------------------------- /docs/contextmap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htnk128/kotlin-ddd-sample/e8f038c29f08c6914f5c824164a2103f7b518e20/docs/contextmap.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/htnk128/kotlin-ddd-sample/e8f038c29f08c6914f5c824164a2103f7b518e20/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-7.0.2-bin.zip 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # 4 | # Copyright 2015 the original author or 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 | 19 | ############################################################################## 20 | ## 21 | ## Gradle start up script for UN*X 22 | ## 23 | ############################################################################## 24 | 25 | # Attempt to set APP_HOME 26 | # Resolve links: $0 may be a link 27 | PRG="$0" 28 | # Need this for relative symlinks. 29 | while [ -h "$PRG" ] ; do 30 | ls=`ls -ld "$PRG"` 31 | link=`expr "$ls" : '.*-> \(.*\)$'` 32 | if expr "$link" : '/.*' > /dev/null; then 33 | PRG="$link" 34 | else 35 | PRG=`dirname "$PRG"`"/$link" 36 | fi 37 | done 38 | SAVED="`pwd`" 39 | cd "`dirname \"$PRG\"`/" >/dev/null 40 | APP_HOME="`pwd -P`" 41 | cd "$SAVED" >/dev/null 42 | 43 | APP_NAME="Gradle" 44 | APP_BASE_NAME=`basename "$0"` 45 | 46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 48 | 49 | # Use the maximum available, or set MAX_FD != -1 to use that value. 50 | MAX_FD="maximum" 51 | 52 | warn () { 53 | echo "$*" 54 | } 55 | 56 | die () { 57 | echo 58 | echo "$*" 59 | echo 60 | exit 1 61 | } 62 | 63 | # OS specific support (must be 'true' or 'false'). 64 | cygwin=false 65 | msys=false 66 | darwin=false 67 | nonstop=false 68 | case "`uname`" in 69 | CYGWIN* ) 70 | cygwin=true 71 | ;; 72 | Darwin* ) 73 | darwin=true 74 | ;; 75 | MINGW* ) 76 | msys=true 77 | ;; 78 | NONSTOP* ) 79 | nonstop=true 80 | ;; 81 | esac 82 | 83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 84 | 85 | # Determine the Java command to use to start the JVM. 86 | if [ -n "$JAVA_HOME" ] ; then 87 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 88 | # IBM's JDK on AIX uses strange locations for the executables 89 | JAVACMD="$JAVA_HOME/jre/sh/java" 90 | else 91 | JAVACMD="$JAVA_HOME/bin/java" 92 | fi 93 | if [ ! -x "$JAVACMD" ] ; then 94 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 95 | 96 | Please set the JAVA_HOME variable in your environment to match the 97 | location of your Java installation." 98 | fi 99 | else 100 | JAVACMD="java" 101 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 102 | 103 | Please set the JAVA_HOME variable in your environment to match the 104 | location of your Java installation." 105 | fi 106 | 107 | # Increase the maximum file descriptors if we can. 108 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 109 | MAX_FD_LIMIT=`ulimit -H -n` 110 | if [ $? -eq 0 ] ; then 111 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 112 | MAX_FD="$MAX_FD_LIMIT" 113 | fi 114 | ulimit -n $MAX_FD 115 | if [ $? -ne 0 ] ; then 116 | warn "Could not set maximum file descriptor limit: $MAX_FD" 117 | fi 118 | else 119 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 120 | fi 121 | fi 122 | 123 | # For Darwin, add options to specify how the application appears in the dock 124 | if $darwin; then 125 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 126 | fi 127 | 128 | # For Cygwin or MSYS, switch paths to Windows format before running java 129 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then 130 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 131 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 132 | JAVACMD=`cygpath --unix "$JAVACMD"` 133 | 134 | # We build the pattern for arguments to be converted via cygpath 135 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 136 | SEP="" 137 | for dir in $ROOTDIRSRAW ; do 138 | ROOTDIRS="$ROOTDIRS$SEP$dir" 139 | SEP="|" 140 | done 141 | OURCYGPATTERN="(^($ROOTDIRS))" 142 | # Add a user-defined pattern to the cygpath arguments 143 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 144 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 145 | fi 146 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 147 | i=0 148 | for arg in "$@" ; do 149 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 150 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 151 | 152 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 153 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 154 | else 155 | eval `echo args$i`="\"$arg\"" 156 | fi 157 | i=`expr $i + 1` 158 | done 159 | case $i in 160 | 0) set -- ;; 161 | 1) set -- "$args0" ;; 162 | 2) set -- "$args0" "$args1" ;; 163 | 3) set -- "$args0" "$args1" "$args2" ;; 164 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 165 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 166 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 167 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 168 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 169 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 170 | esac 171 | fi 172 | 173 | # Escape application args 174 | save () { 175 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 176 | echo " " 177 | } 178 | APP_ARGS=`save "$@"` 179 | 180 | # Collect all arguments for the java command, following the shell quoting and substitution rules 181 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 182 | 183 | exec "$JAVACMD" "$@" 184 | -------------------------------------------------------------------------------- /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 | 17 | @if "%DEBUG%" == "" @echo off 18 | @rem ########################################################################## 19 | @rem 20 | @rem Gradle startup script for Windows 21 | @rem 22 | @rem ########################################################################## 23 | 24 | @rem Set local scope for the variables with windows NT shell 25 | if "%OS%"=="Windows_NT" setlocal 26 | 27 | set DIRNAME=%~dp0 28 | if "%DIRNAME%" == "" set DIRNAME=. 29 | set APP_BASE_NAME=%~n0 30 | set APP_HOME=%DIRNAME% 31 | 32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 34 | 35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 37 | 38 | @rem Find java.exe 39 | if defined JAVA_HOME goto findJavaFromJavaHome 40 | 41 | set JAVA_EXE=java.exe 42 | %JAVA_EXE% -version >NUL 2>&1 43 | if "%ERRORLEVEL%" == "0" goto init 44 | 45 | echo. 46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 47 | echo. 48 | echo Please set the JAVA_HOME variable in your environment to match the 49 | echo location of your Java installation. 50 | 51 | goto fail 52 | 53 | :findJavaFromJavaHome 54 | set JAVA_HOME=%JAVA_HOME:"=% 55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 56 | 57 | if exist "%JAVA_EXE%" goto init 58 | 59 | echo. 60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 61 | echo. 62 | echo Please set the JAVA_HOME variable in your environment to match the 63 | echo location of your Java installation. 64 | 65 | goto fail 66 | 67 | :init 68 | @rem Get command-line arguments, handling Windows variants 69 | 70 | if not "%OS%" == "Windows_NT" goto win9xME_args 71 | 72 | :win9xME_args 73 | @rem Slurp the command line arguments. 74 | set CMD_LINE_ARGS= 75 | set _SKIP=2 76 | 77 | :win9xME_args_slurp 78 | if "x%~1" == "x" goto execute 79 | 80 | set CMD_LINE_ARGS=%* 81 | 82 | :execute 83 | @rem Setup the command line 84 | 85 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 86 | 87 | @rem Execute Gradle 88 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 89 | 90 | :end 91 | @rem End local scope for the variables with windows NT shell 92 | if "%ERRORLEVEL%"=="0" goto mainEnd 93 | 94 | :fail 95 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 96 | rem the _cmd.exe /c_ return code! 97 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 98 | exit /b 1 99 | 100 | :mainEnd 101 | if "%OS%"=="Windows_NT" endlocal 102 | 103 | :omega 104 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | } 5 | } 6 | rootProject.name = "kotlin-ddd-sample" 7 | 8 | include(":shared") 9 | include(":ddd-core") 10 | 11 | include(":account") 12 | include(":address") 13 | -------------------------------------------------------------------------------- /shared/build.gradle.kts: -------------------------------------------------------------------------------- 1 | dependencies { 2 | sqlDependencies() 3 | } 4 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/htnk128/kotlin/ddd/sample/shared/adapter/controller/resource/ErrorResponse.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.shared.adapter.controller.resource 2 | 3 | /** 4 | * エラーが発生した場合の最終的なエンドユーザ向けエラーレスポンス情報。 5 | * 6 | * エラーレスポンス情報には"error"というキーでエラーの詳細情報([Error])が含まれる。 7 | */ 8 | data class ErrorResponse( 9 | val error: Error 10 | ) { 11 | /** 12 | * エラーの詳細情報。 13 | * 14 | * 次の情報が含まれる。 15 | * - type 16 | * - エラータイプ 17 | * - status 18 | * - HTTPステータスコード 19 | * - message 20 | * - エラーメッセージ 21 | */ 22 | data class Error( 23 | val type: String, 24 | val status: Int, 25 | val message: String 26 | ) 27 | 28 | companion object { 29 | 30 | fun from(type: String, code: Int, message: String): ErrorResponse = 31 | ErrorResponse( 32 | Error( 33 | type, 34 | code, 35 | message 36 | ) 37 | ) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/htnk128/kotlin/ddd/sample/shared/adapter/gateway/db/ExposedTable.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.shared.adapter.gateway.db 2 | 3 | import java.time.Instant 4 | import org.jetbrains.exposed.sql.Column 5 | import org.jetbrains.exposed.sql.Table 6 | 7 | abstract class ExposedTable(name: String) : Table(name) { 8 | 9 | protected fun instant(name: String): Column = registerColumn(name, 10 | InstantColumnType(true) 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/htnk128/kotlin/ddd/sample/shared/adapter/gateway/db/InstantColumnType.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.shared.adapter.gateway.db 2 | 3 | import java.time.Instant 4 | import org.jetbrains.exposed.sql.ColumnType 5 | import org.jetbrains.exposed.sql.jodatime.DateColumnType as JodaDateColumnType 6 | import org.joda.time.DateTime 7 | 8 | class InstantColumnType(time: Boolean) : ColumnType() { 9 | private val delegate = JodaDateColumnType(time) 10 | 11 | override fun sqlType(): String = delegate.sqlType() 12 | 13 | override fun nonNullValueToString(value: Any): String = when (value) { 14 | is Instant -> delegate.nonNullValueToString(value.toDateTime()) 15 | else -> delegate.nonNullValueToString(value) 16 | } 17 | 18 | override fun valueFromDB(value: Any): Any { 19 | val fromDb = when (value) { 20 | is Instant -> delegate.valueFromDB(value.toDateTime()) 21 | else -> delegate.valueFromDB(value) 22 | } 23 | return when (fromDb) { 24 | is DateTime -> Instant.ofEpochMilli(fromDb.millis) 25 | else -> error("failed to convert value to Instant") 26 | } 27 | } 28 | 29 | override fun notNullValueToDB(value: Any): Any = when (value) { 30 | is Instant -> delegate.notNullValueToDB(value.toDateTime()) 31 | else -> delegate.notNullValueToDB(value) 32 | } 33 | 34 | private fun Instant.toDateTime() = DateTime(this.toEpochMilli()) 35 | } 36 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/htnk128/kotlin/ddd/sample/shared/usecase/ApplicationException.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.shared.usecase 2 | 3 | /** 4 | * アプリケーションレイヤーにおいて問題が生じた場合に発生する例外。 5 | */ 6 | class ApplicationException( 7 | val type: String, 8 | val status: Int, 9 | override val message: String, 10 | override val cause: Throwable? = null 11 | ) : RuntimeException(message, cause) { 12 | 13 | constructor(message: String?, cause: Throwable? = null) : this( 14 | type = "server_error", 15 | status = 500, 16 | message = message ?: "internal server error.", 17 | cause = cause 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /shared/src/main/kotlin/htnk128/kotlin/ddd/sample/shared/usecase/outputport/dto/PaginationDTO.kt: -------------------------------------------------------------------------------- 1 | package htnk128.kotlin.ddd.sample.shared.usecase.outputport.dto 2 | 3 | /** 4 | * 各オブジェクトを一覧取得した際のDTO。 5 | */ 6 | data class PaginationDTO( 7 | val count: Int, 8 | val limit: Int, 9 | val offset: Int, 10 | val data: List 11 | ) { 12 | val hasMore = (count > limit + (limit * offset)) 13 | } 14 | --------------------------------------------------------------------------------