├── .java-version ├── settings.gradle.kts.jinja ├── requirements.txt ├── assets ├── testing-pyramid.jpg └── hexagonal-architecture.png ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── kotlin │ │ └── {{ tld }} │ │ │ └── {{ group_name }} │ │ │ └── {{ project_name }} │ │ │ ├── domain │ │ │ └── model │ │ │ │ ├── DomainError.kt.jinja │ │ │ │ ├── CustomerRepository.kt.jinja │ │ │ │ └── Customer.kt.jinja │ │ │ ├── Application.kt.jinja │ │ │ ├── infrastructure │ │ │ ├── adapters │ │ │ │ ├── outbound │ │ │ │ │ ├── memory │ │ │ │ │ │ └── InMemoryCustomerRepository.kt.jinja │ │ │ │ │ └── postgres │ │ │ │ │ │ └── PostgresCustomerRepository.kt.jinja │ │ │ │ └── inbound │ │ │ │ │ └── http │ │ │ │ │ └── controller │ │ │ │ │ └── SignUpController.kt.jinja │ │ │ └── configuration │ │ │ │ └── ApplicationConfiguration.kt.jinja │ │ │ └── application │ │ │ └── service │ │ │ └── SignUpCustomerService.kt.jinja │ └── resources │ │ ├── db │ │ └── migration │ │ │ └── V1___init.sql │ │ └── application.yaml.jinja └── test │ └── kotlin │ └── {{ tld }} │ └── {{ group_name }} │ └── {{ project_name }} │ ├── infrastructure │ └── adapters │ │ ├── outbound │ │ └── postgres │ │ │ ├── DatabaseContainer.kt.jinja │ │ │ └── PostgresCustomerRepositoryShould.kt.jinja │ │ └── inbound │ │ └── http │ │ └── controller │ │ └── SignUpControllerShould.kt.jinja │ ├── componenttest │ ├── BaseComponentTest.kt.jinja │ └── SignUpCustomerShould.kt.jinja │ ├── architecturetest │ └── HexagonalArchitectureTest.kt.jinja │ ├── domain │ └── model │ │ └── CustomerShould.kt.jinja │ └── application │ └── service │ └── SignUpCustomerServiceShould.kt.jinja ├── runTests.sh ├── docker-compose.yml ├── renovate.json ├── copier.yaml ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── build.yml ├── SECURITY.md ├── gradlew.bat ├── README.md └── gradlew /.java-version: -------------------------------------------------------------------------------- 1 | 21.0.1 2 | -------------------------------------------------------------------------------- /settings.gradle.kts.jinja: -------------------------------------------------------------------------------- 1 | rootProject.name = "{{project_name}}" 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pydantic==2.12.5 2 | copier==9.11.0 3 | pyyaml-include<3 4 | -------------------------------------------------------------------------------- /assets/testing-pyramid.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hyunk3l/hexagonal-architecture-kotlin-template/HEAD/assets/testing-pyramid.jpg -------------------------------------------------------------------------------- /assets/hexagonal-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hyunk3l/hexagonal-architecture-kotlin-template/HEAD/assets/hexagonal-architecture.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Hyunk3l/hexagonal-architecture-kotlin-template/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/domain/model/DomainError.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.domain.model 2 | 3 | data class DomainError(val message: String) 4 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V1___init.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE TABLE IF NOT EXISTS customers ( 3 | id UUID PRIMARY KEY, 4 | full_name TEXT NOT NULL, 5 | email TEXT NOT NULL, 6 | created_at TIMESTAMP NOT NULL 7 | ) 8 | -------------------------------------------------------------------------------- /src/main/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/domain/model/CustomerRepository.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.domain.model 2 | 3 | interface CustomerRepository { 4 | fun save(customer: Customer): Unit 5 | } 6 | -------------------------------------------------------------------------------- /runTests.sh: -------------------------------------------------------------------------------- 1 | 2 | copier copy -f --vcs-ref HEAD . ../hexagonal-architecture-kotlin-template-test && \ 3 | cd ../hexagonal-architecture-kotlin-template-test && \ 4 | ./gradlew clean build --info && \ 5 | cd ../ && \ 6 | rm -r hexagonal-architecture-kotlin-template-test/ 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | 3 | services: 4 | db: 5 | image: "postgres:18.1" 6 | restart: always 7 | ports: 8 | - 5432:5432 9 | environment: 10 | POSTGRES_DB: somedatabasename 11 | POSTGRES_USER: postgres 12 | POSTGRES_PASSWORD: postgres 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "packageRules": [ 7 | { 8 | "automerge": true, 9 | "matchPackageNames": [ 10 | "*" 11 | ] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/Application.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}} 2 | 3 | import org.springframework.boot.autoconfigure.SpringBootApplication 4 | import org.springframework.boot.runApplication 5 | 6 | @SpringBootApplication 7 | class Application 8 | 9 | @Suppress("SpreadOperator") 10 | fun main(args: Array) { 11 | runApplication(*args) 12 | } 13 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml.jinja: -------------------------------------------------------------------------------- 1 | 2 | spring: 3 | application: 4 | name: {{project_name}} 5 | profiles: 6 | active: dev 7 | main: 8 | banner-mode: off 9 | datasource: 10 | url: jdbc:postgresql://localhost:5432/somedatabasename 11 | username: postgres 12 | password: postgres 13 | driverClassName: org.postgresql.Driver 14 | hikari: 15 | connectionTimeout: 30000 16 | idleTimeout: 600000 17 | maxLifetime: 1800000 18 | -------------------------------------------------------------------------------- /copier.yaml: -------------------------------------------------------------------------------- 1 | 2 | tld: 3 | type: str 4 | help: What is the top level domain of your main package? 5 | default: com 6 | 7 | group_name: 8 | type: str 9 | help: What is the name of the main package? 10 | default: fabridinapoli 11 | 12 | project_name: 13 | type: str 14 | help: What is the name of the project? 15 | default: hexagonalarchitecture 16 | 17 | _exclude: 18 | - ".github*" 19 | - "copier.yaml" 20 | - "README.md" 21 | - ".git*" 22 | - "runTests.sh" 23 | - "assets" 24 | - "requirements.txt" 25 | -------------------------------------------------------------------------------- /src/main/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/infrastructure/adapters/outbound/memory/InMemoryCustomerRepository.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.infrastructure.adapters.outbound.memory 2 | 3 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.Customer 4 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.CustomerRepository 5 | 6 | class InMemoryCustomerRepository : CustomerRepository { 7 | 8 | private val customers = mutableListOf() 9 | 10 | override fun save(customer: Customer) { 11 | customers.add(customer) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | HELP.md 2 | .gradle 3 | build/ 4 | !gradle/wrapper/gradle-wrapper.jar 5 | !**/src/main/**/build/ 6 | !**/src/test/**/build/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | bin/ 17 | !**/src/main/**/bin/ 18 | !**/src/test/**/bin/ 19 | 20 | ### IntelliJ IDEA ### 21 | .idea 22 | *.iws 23 | *.iml 24 | *.ipr 25 | out/ 26 | !**/src/main/**/out/ 27 | !**/src/test/**/out/ 28 | 29 | ### NetBeans ### 30 | /nbproject/private/ 31 | /nbbuild/ 32 | /dist/ 33 | /nbdist/ 34 | /.nb-gradle/ 35 | 36 | ### VS Code ### 37 | .vscode/ 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Use this section to tell people about which versions of your project are 6 | currently being supported with security updates. 7 | 8 | | Version | Supported | 9 | | ------- | ------------------ | 10 | | 5.1.x | :white_check_mark: | 11 | | 5.0.x | :x: | 12 | | 4.0.x | :white_check_mark: | 13 | | < 4.0 | :x: | 14 | 15 | ## Reporting a Vulnerability 16 | 17 | Use this section to tell people how to report a vulnerability. 18 | 19 | Tell them where to go, how often they can expect to get an update on a 20 | reported vulnerability, what to expect if the vulnerability is accepted or 21 | declined, etc. 22 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | 2 | name: CI 3 | 4 | on: 5 | push: 6 | branches: [ main ] 7 | pull_request: 8 | branches: [ main ] 9 | 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: write 14 | pull-requests: write 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 21 | 22 | - uses: actions/setup-java@v5 23 | with: 24 | distribution: 'temurin' 25 | java-version: '21' 26 | cache: gradle 27 | 28 | - uses: actions/setup-python@v6 29 | with: 30 | python-version: '3.14.2' 31 | cache: 'pip' 32 | 33 | - name: Install python dependencies (copier) 34 | run: pip install -r requirements.txt 35 | 36 | - name: Run tests 37 | run: ./runTests.sh 38 | -------------------------------------------------------------------------------- /src/main/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/infrastructure/configuration/ApplicationConfiguration.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.infrastructure.configuration 2 | 3 | import {{tld}}.{{group_name}}.{{project_name}}.application.service.SignUpCustomerService 4 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.CustomerRepository 5 | import {{tld}}.{{group_name}}.{{project_name}}.infrastructure.adapters.outbound.postgres.PostgresCustomerRepository 6 | import java.time.Clock 7 | import org.springframework.context.annotation.Bean 8 | import org.springframework.context.annotation.Configuration 9 | import org.springframework.jdbc.core.JdbcTemplate 10 | 11 | @Configuration 12 | class ApplicationConfiguration { 13 | 14 | @Bean 15 | fun customerRepository(jdbcTemplate: JdbcTemplate) = PostgresCustomerRepository(jdbcTemplate, Clock.systemUTC()) 16 | 17 | @Bean 18 | fun signUpCustomerService(repository: CustomerRepository) = SignUpCustomerService(repository) 19 | } 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/main/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/domain/model/Customer.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.domain.model 2 | 3 | import arrow.core.Either 4 | import arrow.core.left 5 | import arrow.core.right 6 | import java.util.UUID 7 | 8 | data class Customer( 9 | val id: CustomerId, 10 | val fullName: FullName, 11 | val email: Email, 12 | ) { 13 | companion object { 14 | fun create( 15 | id: UUID, 16 | name: String, 17 | surname: String, 18 | email: String, 19 | ): Either = when { 20 | name == "" -> DomainError("Name cannot be empty").left() 21 | surname == "" -> DomainError("Surname cannot be empty").left() 22 | email == "" -> DomainError("Email cannot be empty").left() 23 | else -> Customer( 24 | CustomerId(id), 25 | FullName("$name $surname"), 26 | Email(email), 27 | ).right() 28 | } 29 | } 30 | } 31 | 32 | data class CustomerId(val id: UUID) 33 | data class FullName(val fullName: String) 34 | data class Email(val email: String) 35 | -------------------------------------------------------------------------------- /src/main/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/application/service/SignUpCustomerService.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.application.service 2 | 3 | import arrow.core.Either 4 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.Customer 5 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.CustomerRepository 6 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.DomainError 7 | import java.util.UUID 8 | 9 | class SignUpCustomerService(private val repository: CustomerRepository) { 10 | operator fun invoke(request: SignUpCustomerRequest): Either { 11 | return Customer.create( 12 | request.id, 13 | request.name, 14 | request.surname, 15 | request.email, 16 | ) 17 | .map { 18 | repository.save(it) 19 | SignUpCustomerResponse(it.id.id) 20 | } 21 | } 22 | } 23 | 24 | data class SignUpCustomerRequest( 25 | val id: UUID, 26 | val name: String, 27 | val surname: String, 28 | val email: String, 29 | ) 30 | 31 | data class SignUpCustomerResponse(val id: UUID) 32 | -------------------------------------------------------------------------------- /src/test/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/infrastructure/adapters/outbound/postgres/DatabaseContainer.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.infrastructure.adapters.outbound.postgres 2 | 3 | import com.zaxxer.hikari.HikariConfig 4 | import com.zaxxer.hikari.HikariDataSource 5 | import org.flywaydb.core.Flyway 6 | import org.flywaydb.core.api.configuration.FluentConfiguration 7 | import org.testcontainers.containers.PostgreSQLContainer 8 | import org.testcontainers.utility.DockerImageName 9 | 10 | class DatabaseContainer { 11 | 12 | val postgresContainer: PostgreSQLContainer<*> = PostgreSQLContainer(DockerImageName.parse("postgres:16.1")) 13 | .withDatabaseName("somedatabasename") 14 | .withUsername("postgres") 15 | .withPassword("postgres") 16 | .also{ it.start() } 17 | 18 | val dataSource = HikariConfig() 19 | .apply { 20 | jdbcUrl = postgresContainer.jdbcUrl 21 | username = postgresContainer.username 22 | password = postgresContainer.password 23 | driverClassName = postgresContainer.driverClassName 24 | }.let { 25 | HikariDataSource(it) 26 | }.also { 27 | Flyway( 28 | FluentConfiguration().dataSource(it) 29 | ).migrate() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/componenttest/BaseComponentTest.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.componenttest 2 | 3 | import {{tld}}.{{group_name}}.{{project_name}}.Application 4 | import {{tld}}.{{group_name}}.{{project_name}}.infrastructure.adapters.outbound.postgres.DatabaseContainer 5 | import org.junit.jupiter.api.Tag 6 | import org.springframework.boot.test.context.SpringBootTest 7 | import org.springframework.boot.test.web.server.LocalServerPort 8 | import org.springframework.test.context.DynamicPropertyRegistry 9 | import org.springframework.test.context.DynamicPropertySource 10 | import org.testcontainers.junit.jupiter.Testcontainers 11 | 12 | @Suppress("UnnecessaryAbstractClass") 13 | @Tag("component") 14 | @Testcontainers 15 | @SpringBootTest(classes = [Application::class], webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) 16 | abstract class BaseComponentTest { 17 | 18 | @LocalServerPort 19 | val servicePort: Int = 0 20 | 21 | companion object { 22 | 23 | @JvmStatic 24 | @DynamicPropertySource 25 | fun postgresProperties(registry: DynamicPropertyRegistry) { 26 | val postgresContainer = DatabaseContainer().postgresContainer 27 | registry.add("spring.datasource.url", postgresContainer::getJdbcUrl) 28 | registry.add("spring.datasource.username", postgresContainer::getUsername) 29 | registry.add("spring.datasource.password", postgresContainer::getPassword) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/infrastructure/adapters/outbound/postgres/PostgresCustomerRepository.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.infrastructure.adapters.outbound.postgres 2 | 3 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.Customer 4 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.CustomerRepository 5 | import java.time.Clock 6 | import java.time.LocalDateTime 7 | import java.util.UUID 8 | import org.springframework.jdbc.core.JdbcTemplate 9 | 10 | class PostgresCustomerRepository( 11 | private val jdbcTemplate: JdbcTemplate, 12 | private val clock: Clock 13 | ) : CustomerRepository { 14 | 15 | override fun save(customer: Customer) { 16 | val databaseCustomer = customer.toDatabaseEntity() 17 | jdbcTemplate.update( 18 | """ 19 | INSERT INTO customers 20 | ( 21 | id, full_name, email, created_at 22 | ) 23 | VALUES (?, ?, ?, ?) 24 | """.trimIndent(), 25 | databaseCustomer.id, 26 | databaseCustomer.fullName, 27 | databaseCustomer.email, 28 | databaseCustomer.createdAt 29 | ) 30 | } 31 | 32 | private fun Customer.toDatabaseEntity() = DatabaseCustomer( 33 | id = this.id.id, 34 | fullName = this.fullName.fullName, 35 | email = this.email.email, 36 | createdAt = LocalDateTime.now(clock) 37 | ) 38 | } 39 | 40 | data class DatabaseCustomer( 41 | val id: UUID, 42 | val fullName: String, 43 | val email: String, 44 | val createdAt: LocalDateTime 45 | ) 46 | -------------------------------------------------------------------------------- /src/test/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/componenttest/SignUpCustomerShould.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.componenttest 2 | 3 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.Customer 4 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.CustomerId 5 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.Email 6 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.FullName 7 | import io.kotest.assertions.json.shouldEqualJson 8 | import io.kotest.matchers.shouldBe 9 | import io.restassured.RestAssured.given 10 | import java.util.UUID 11 | import org.apache.http.entity.ContentType 12 | import org.junit.jupiter.api.Test 13 | import org.springframework.beans.factory.annotation.Autowired 14 | 15 | private val CUSTOMER_ID = UUID.randomUUID() 16 | 17 | class SignUpCustomerShould : BaseComponentTest() { 18 | 19 | @Test 20 | fun `sign up a customer successfully`() { 21 | val response = given() 22 | .contentType(ContentType.APPLICATION_JSON.toString()) 23 | .port(servicePort) 24 | .and() 25 | .body( 26 | """ 27 | { 28 | "id": "$CUSTOMER_ID", 29 | "name": "Fabrizio", 30 | "surname": "Di Napoli", 31 | "email": "some.email@example.org" 32 | } 33 | """.trimIndent() 34 | ) 35 | .`when`() 36 | .post("/customers") 37 | .then() 38 | .extract() 39 | 40 | response.statusCode() shouldBe 201 41 | response.body().asString() shouldEqualJson """ 42 | { 43 | "id": "$CUSTOMER_ID" 44 | } 45 | """.trimIndent() 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/test/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/architecturetest/HexagonalArchitectureTest.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.architecturetest 2 | 3 | import com.tngtech.archunit.core.importer.ClassFileImporter 4 | import com.tngtech.archunit.core.importer.ImportOption.Predefined.DO_NOT_INCLUDE_TESTS 5 | import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes 6 | import org.junit.jupiter.api.Test 7 | 8 | class HexagonalArchitectureTest { 9 | 10 | private val importedClasses = ClassFileImporter() 11 | .withImportOption(DO_NOT_INCLUDE_TESTS) 12 | .importPackages("{{tld}}.{{group_name}}.{{project_name}}") 13 | 14 | @Test 15 | fun `domain should depend on domain only`() { 16 | classes() 17 | .that() 18 | .resideInAPackage("..domain..") 19 | .should() 20 | .onlyDependOnClassesThat() 21 | .resideInAnyPackage( 22 | "..domain..", 23 | "java..", 24 | "javax..", 25 | "org.jetbrains..", 26 | "kotlin..", 27 | "arrow.." 28 | ) 29 | .check(importedClasses) 30 | } 31 | 32 | @Test 33 | fun `application services should not depend on infrastructure`() { 34 | classes() 35 | .that() 36 | .resideInAPackage("..application..") 37 | .should() 38 | .onlyDependOnClassesThat() 39 | .resideInAnyPackage( 40 | "..domain..", 41 | "..application..", 42 | "java..", 43 | "javax..", 44 | "org.jetbrains..", 45 | "kotlin..", 46 | "arrow.." 47 | ) 48 | .check(importedClasses) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/main/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/infrastructure/adapters/inbound/http/controller/SignUpController.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.infrastructure.adapters.inbound.http.controller 2 | 3 | import {{tld}}.{{group_name}}.{{project_name}}.application.service.SignUpCustomerRequest 4 | import {{tld}}.{{group_name}}.{{project_name}}.application.service.SignUpCustomerService 5 | import java.util.UUID 6 | import org.springframework.beans.factory.annotation.Autowired 7 | import org.springframework.http.HttpStatus 8 | import org.springframework.http.ResponseEntity 9 | import org.springframework.web.bind.annotation.PostMapping 10 | import org.springframework.web.bind.annotation.RequestBody 11 | import org.springframework.web.bind.annotation.RestController 12 | 13 | @RestController 14 | class SignUpController { 15 | 16 | @Autowired 17 | private lateinit var signUpCustomerService: SignUpCustomerService 18 | 19 | @PostMapping("/customers") 20 | fun signUpCustomer(@RequestBody requestBody: HttpSignUpCustomerRequestBody): Any = 21 | requestBody.toServiceRequest() 22 | .let { 23 | signUpCustomerService.invoke(it) 24 | } 25 | .fold( 26 | { 27 | ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build() 28 | }, { 29 | HttpSignUpCustomerResponse(it.id).let { response -> 30 | ResponseEntity.status(HttpStatus.CREATED).body(response) 31 | } 32 | } 33 | ) 34 | 35 | private fun HttpSignUpCustomerRequestBody.toServiceRequest() = SignUpCustomerRequest( 36 | id = this.id, 37 | name = this.name, 38 | surname = this.surname, 39 | email = this.email, 40 | ) 41 | } 42 | 43 | data class HttpSignUpCustomerRequestBody( 44 | val id: UUID, 45 | val name: String, 46 | val surname: String, 47 | val email: String, 48 | ) 49 | 50 | data class HttpSignUpCustomerResponse(val id: UUID) 51 | -------------------------------------------------------------------------------- /src/test/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/domain/model/CustomerShould.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.domain.model 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import io.kotest.matchers.shouldBe 6 | import java.util.UUID 7 | import org.junit.jupiter.api.Test 8 | 9 | private val CUSTOMER_ID = UUID.randomUUID() 10 | 11 | class CustomerShould { 12 | 13 | @Test 14 | fun `create a customer when all the data is valid`() { 15 | val expectedCustomer = Customer( 16 | id = CustomerId(id = CUSTOMER_ID), 17 | fullName = FullName("Fabrizio Di Napoli"), 18 | email = Email("some.email@example.org") 19 | ) 20 | 21 | val customer = Customer.create( 22 | id = CUSTOMER_ID, 23 | name = "Fabrizio", 24 | surname = "Di Napoli", 25 | email = "some.email@example.org" 26 | ) 27 | 28 | customer shouldBe expectedCustomer.right() 29 | } 30 | 31 | @Test 32 | fun `cannot create a customer when name is empty`() { 33 | val customer = Customer.create( 34 | id = CUSTOMER_ID, 35 | name = "", 36 | surname = "Surname", 37 | email = "some.email@example.org" 38 | ) 39 | 40 | customer shouldBe DomainError("Name cannot be empty").left() 41 | } 42 | 43 | @Test 44 | fun `cannot create a customer when surname is empty`() { 45 | val customer = Customer.create( 46 | id = CUSTOMER_ID, 47 | name = "Fabrizio", 48 | surname = "", 49 | email = "some.email@example.org" 50 | ) 51 | 52 | customer shouldBe DomainError("Surname cannot be empty").left() 53 | } 54 | 55 | @Test 56 | fun `cannot create a customer when email is empty`() { 57 | val customer = Customer.create( 58 | id = CUSTOMER_ID, 59 | name = "Fabrizio", 60 | surname = "Di Napoli", 61 | email = "" 62 | ) 63 | 64 | customer shouldBe DomainError("Email cannot be empty").left() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/application/service/SignUpCustomerServiceShould.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.application.service 2 | 3 | import arrow.core.left 4 | import arrow.core.right 5 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.Customer 6 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.CustomerId 7 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.CustomerRepository 8 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.DomainError 9 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.Email 10 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.FullName 11 | import io.kotest.matchers.shouldBe 12 | import io.mockk.every 13 | import io.mockk.just 14 | import io.mockk.mockk 15 | import io.mockk.runs 16 | import io.mockk.verify 17 | import java.util.UUID 18 | import org.junit.jupiter.api.Test 19 | 20 | private val CUSTOMER_ID = UUID.randomUUID() 21 | 22 | class SignUpCustomerServiceShould { 23 | private val repository = mockk() 24 | 25 | @Test 26 | fun `sign up a customer successfully`() { 27 | val customer = Customer( 28 | CustomerId(CUSTOMER_ID), 29 | FullName("Fabrizio Di Napoli"), 30 | Email("some.email@example.org") 31 | ) 32 | val request = SignUpCustomerRequest( 33 | id = CUSTOMER_ID, 34 | name = "Fabrizio", 35 | surname = "Di Napoli", 36 | email = "some.email@example.org", 37 | ) 38 | every { repository.save(customer) } just runs 39 | 40 | val response = SignUpCustomerService(repository).invoke(request) 41 | 42 | response shouldBe SignUpCustomerResponse(id = CUSTOMER_ID).right() 43 | } 44 | 45 | @Test 46 | fun `return an error in case of customer data not passing business rules`() { 47 | val request = SignUpCustomerRequest( 48 | id = CUSTOMER_ID, 49 | name = "Fabrizio", 50 | surname = "", 51 | email = "some.email@example.org", 52 | ) 53 | 54 | val response = SignUpCustomerService(repository).invoke(request) 55 | 56 | response shouldBe DomainError("Surname cannot be empty").left() 57 | verify(exactly = 0) { repository.save(any()) } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/test/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/infrastructure/adapters/outbound/postgres/PostgresCustomerRepositoryShould.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.infrastructure.adapters.outbound.postgres 2 | 3 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.Customer 4 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.CustomerId 5 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.Email 6 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.FullName 7 | import io.kotest.matchers.shouldBe 8 | import java.time.Clock 9 | import java.time.Instant 10 | import java.time.LocalDateTime 11 | import java.time.ZoneId 12 | import java.time.temporal.ChronoUnit 13 | import java.util.UUID 14 | import org.junit.jupiter.api.Tag 15 | import org.junit.jupiter.api.Test 16 | import org.springframework.jdbc.core.JdbcTemplate 17 | import org.springframework.jdbc.core.queryForObject 18 | 19 | private val CUSTOMER_ID = UUID.randomUUID() 20 | 21 | @Tag("integration") 22 | internal class PostgresCustomerRepositoryShould { 23 | 24 | private val databaseContainer = DatabaseContainer() 25 | 26 | private val jdbcTemplate = JdbcTemplate(databaseContainer.dataSource) 27 | 28 | @Test 29 | fun `save customer`() { 30 | val instant = Instant.now().truncatedTo(ChronoUnit.MILLIS) 31 | val clock = Clock.fixed(instant, ZoneId.systemDefault()) 32 | val customer = Customer( 33 | CustomerId(CUSTOMER_ID), 34 | FullName("Fabrizio Di Napoli"), 35 | Email("some.email@example.org") 36 | ) 37 | 38 | PostgresCustomerRepository(jdbcTemplate, clock).save(customer) 39 | 40 | val result = jdbcTemplate.queryForObject("SELECT * FROM customers WHERE id = ? LIMIT 1", CUSTOMER_ID) { rs, _ -> 41 | DatabaseCustomer( 42 | id = UUID.fromString(rs.getString("id")), 43 | fullName = rs.getString("full_name"), 44 | email = rs.getString("email"), 45 | createdAt = rs.getTimestamp("created_at").toLocalDateTime() 46 | ) 47 | } 48 | result shouldBe DatabaseCustomer( 49 | id = customer.id.id, 50 | fullName = customer.fullName.fullName, 51 | email = customer.email.email, 52 | createdAt = LocalDateTime.now(clock) 53 | ) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | 74 | 75 | @rem Execute Gradle 76 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 | 78 | :end 79 | @rem End local scope for the variables with windows NT shell 80 | if %ERRORLEVEL% equ 0 goto mainEnd 81 | 82 | :fail 83 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 | rem the _cmd.exe /c_ return code! 85 | set EXIT_CODE=%ERRORLEVEL% 86 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 | exit /b %EXIT_CODE% 89 | 90 | :mainEnd 91 | if "%OS%"=="Windows_NT" endlocal 92 | 93 | :omega 94 | -------------------------------------------------------------------------------- /src/test/kotlin/{{ tld }}/{{ group_name }}/{{ project_name }}/infrastructure/adapters/inbound/http/controller/SignUpControllerShould.kt.jinja: -------------------------------------------------------------------------------- 1 | package {{tld}}.{{group_name}}.{{project_name}}.infrastructure.adapters.inbound.http.controller 2 | 3 | import arrow.core.Either 4 | import {{tld}}.{{group_name}}.{{project_name}}.application.service.SignUpCustomerRequest 5 | import {{tld}}.{{group_name}}.{{project_name}}.application.service.SignUpCustomerResponse 6 | import {{tld}}.{{group_name}}.{{project_name}}.application.service.SignUpCustomerService 7 | import {{tld}}.{{group_name}}.{{project_name}}.domain.model.DomainError 8 | import com.ninjasquad.springmockk.MockkBean 9 | import io.kotest.assertions.json.shouldEqualJson 10 | import io.mockk.every 11 | import java.util.UUID 12 | import org.junit.jupiter.api.Tag 13 | import org.junit.jupiter.api.Test 14 | import org.springframework.beans.factory.annotation.Autowired 15 | import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest 16 | import org.springframework.http.MediaType 17 | import org.springframework.test.web.reactive.server.WebTestClient 18 | import org.springframework.test.web.reactive.server.expectBody 19 | 20 | private val CUSTOMER_ID = UUID.randomUUID() 21 | 22 | @Tag("integration") 23 | @WebFluxTest(controllers = [SignUpController::class]) 24 | class SignUpControllerShould { 25 | 26 | @Autowired 27 | private lateinit var webTestClient: WebTestClient 28 | 29 | @MockkBean 30 | private lateinit var signUpCustomerService: SignUpCustomerService 31 | 32 | @Test 33 | fun `sign up a customer`() { 34 | val signUpCustomerRequest = SignUpCustomerRequest( 35 | id = CUSTOMER_ID, 36 | name = "Fabrizio", 37 | surname = "Di Napoli", 38 | email = "some.email@example.org", 39 | ) 40 | every { 41 | signUpCustomerService(signUpCustomerRequest) 42 | } returns Either.Right(SignUpCustomerResponse(id = CUSTOMER_ID)) 43 | 44 | val response = webTestClient 45 | .post() 46 | .uri("/customers") 47 | .contentType(MediaType.APPLICATION_JSON) 48 | .bodyValue(""" 49 | { 50 | "id": "$CUSTOMER_ID", 51 | "name": "Fabrizio", 52 | "surname": "Di Napoli", 53 | "email": "some.email@example.org" 54 | } 55 | """.trimIndent()) 56 | .exchange() 57 | 58 | response 59 | .expectStatus() 60 | .isCreated 61 | .expectBody() 62 | .returnResult().responseBody!!.shouldEqualJson(""" 63 | { 64 | "id": "$CUSTOMER_ID" 65 | } 66 | """.trimIndent()) 67 | } 68 | 69 | @Test 70 | fun `return a 500 if any error in the use case`() { 71 | val signUpCustomerRequest = SignUpCustomerRequest( 72 | id = CUSTOMER_ID, 73 | name = "Fabrizio", 74 | surname = "Di Napoli", 75 | email = "some.email@example.org", 76 | ) 77 | every { signUpCustomerService(signUpCustomerRequest) } returns Either.Left(DomainError("Any error")) 78 | 79 | val response = webTestClient 80 | .post() 81 | .uri("/customers") 82 | .contentType(MediaType.APPLICATION_JSON) 83 | .bodyValue(""" 84 | { 85 | "id": "$CUSTOMER_ID", 86 | "name": "Fabrizio", 87 | "surname": "Di Napoli", 88 | "email": "some.email@example.org" 89 | } 90 | """.trimIndent()) 91 | .exchange() 92 | 93 | response 94 | .expectStatus() 95 | .is5xxServerError 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![hexagonal-architecture](./assets/hexagonal-architecture.png) 2 | 3 | ![flujo de trabajo de ejemplo](https://github.com/Hyunk3l/hexagonal-architecture-kotlin-template/actions/workflows/build.yml/badge.svg) 4 | 5 | # Hexagonal Architecture Kotlin Template 6 | 7 | The purpose of this template is to avoid repeating, over and over again, the same basic packages structure, gradle and configurations. 8 | Note: this is just a skeleton with a silly example. Is good to start a project with it, but if you want to see a production ready and fully tested project with a lot of examples, please visit my friend [Albert Llousas Team Management repository](https://github.com/albertllousas/team-management-microservice). 9 | 10 | The current packages structure has been popularized by Vaughn Vernon, the author of [Implementing Domain-Driven Design](https://www.goodreads.com/book/show/15756865-implementing-domain-driven-design) 11 | and [Domain-Driven Design Distilled](https://www.goodreads.com/book/show/28602719-domain-driven-design-distilled) and you can see an example in 12 | his [Github Repository](https://github.com/VaughnVernon/IDDD_Samples/tree/master/iddd_collaboration/src/main/java/com/saasovation/collaboration). 13 | The structure is a mix of [Alistair Cockburn's Ports & Adapters Pattern](https://alistair.cockburn.us/hexagonal-architecture/) and DDD concepts. In fact, if you know the original Hexagonal Architecture article, 14 | you will see that the packages structure is not familiar to you, but you may recognize the concepts embedded (Ports & Adapters). 15 | In the `src` directory you will see the following packages: 16 | ``` 17 | . 18 | ├── application 19 | ├── domain 20 | └── infrastructure 21 | ``` 22 | The `application` package is the place where we will put all the Application Services (aka Use Cases) classes. 23 | 24 | The `domain` package will host all the pure Domain classes, such as Aggregates, Value Objects, Entities, Domain Events, Repositories etc. Usually this package also hosts PORTS (CustomerRepository is an example of a Port). 25 | 26 | The `infrastructure` package instead, is the place were we put all the infrastructure concerns, such as framework, configurations and concrete implementations. You will see an `adapters` package 27 | that contains Inbound and Outbound Adapters, that are concrete implementations of Ports, defined in the `domain` package. 28 | 29 | ### Short introduction to Testing 30 | The silly example provided is, of course, tested (using Outside-In TDD). 31 | This project is following the typical [Testing Pyramid](https://martinfowler.com/bliki/TestPyramid.html), but adapted to (Micro)services. 32 | ![testing-pyramid](./assets/testing-pyramid.jpg) 33 | 34 | You will see that there are three different types of tests and each one of them has a concrete scope. 35 | 36 | - **End-To-End Tests** have been excluded on purpose, since their scope is broader than a single service. 37 | 38 | - **Component Tests**: this is a kind of test that have been popularized in Microservices Patterns (See [Service Component Tests](https://microservices.io/patterns/testing/service-component-test.html)), 39 | but at the end is just another name for the well known Acceptance Tests. The scope is to test the service itself, in isolation, "mocking" external dependencies such as database (using [TestContainers](https://www.testcontainers.org/) for instance) and external services (using [Wiremock](http://wiremock.org/) for instance). 40 | 41 | - **Integration Tests**: this kind of tests are run in isolation, there is no real call to an external service or a real database. 42 | Here we include repositories, clients, controllers, consumers, producers tests etc. We can also include Contract Tests (extending Controller tests, for instance, to check contract with Consumers using some kind of tool like Pact.io). 43 | 44 | - **Unit Tests**: and finally we "unit test" what's the most important part of the service: the Domain. Here we include all the tests for Aggregates, Value Objects, Entities, Domain Events, Application Services (aka Use Cases), Domain Services etc. 45 | 46 | For a full overview, have a look at [my article on Medium](https://fabridinapoli.medium.com/component-vs-integration-vs-unit-tests-in-microservices-architecture-e0aa389f93d1). 47 | 48 | ## How to run 49 | First you will need to install [Copier](https://github.com/copier-org/copier) on your local machine. 50 | This is an open-source tool to copy directories and files from one place to another also using templates. 51 | To install it, run in your console: `pip install copier` 52 | Then to copy this template, run the following command: 53 | ``` 54 | copier https://github.com/Hyunk3l/hexagonal-architecture-kotlin-template.git put-your-directory-here 55 | ``` 56 | you will be asked about a few configurations (name of the package, project etc.). 57 | Once done, open your `put-your-directory-here`. 58 | 59 | ## How to run tests 60 | As you already know, there are three type of tests. You can run them separately or together: 61 | - Component: `./gradlew componentTest` 62 | - Integration: `./gradlew integrationTest` 63 | - Unit: `./gradlew unitTest` 64 | 65 | To run them together: `./gradlew test` 66 | 67 | ## How to run locally 68 | If you really want to run your service locally (why would you? It's fully covered with multiple layers of tests), run the following commands: 69 | 70 | 1. Run containers `docker-compose up` 71 | 2. Run the service `./gradlew bootRun` 72 | 73 | ## Caveats 74 | - Domain is anemic: just a silly example :) 75 | - Not publishing Domain Events: maybe in the future I will. 76 | - In memory repository has not been tested (on purpose). Maybe in the future I'll add a real db repository implementation. 77 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | 118 | 119 | # Determine the Java command to use to start the JVM. 120 | if [ -n "$JAVA_HOME" ] ; then 121 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 122 | # IBM's JDK on AIX uses strange locations for the executables 123 | JAVACMD=$JAVA_HOME/jre/sh/java 124 | else 125 | JAVACMD=$JAVA_HOME/bin/java 126 | fi 127 | if [ ! -x "$JAVACMD" ] ; then 128 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 129 | 130 | Please set the JAVA_HOME variable in your environment to match the 131 | location of your Java installation." 132 | fi 133 | else 134 | JAVACMD=java 135 | if ! command -v java >/dev/null 2>&1 136 | then 137 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 138 | 139 | Please set the JAVA_HOME variable in your environment to match the 140 | location of your Java installation." 141 | fi 142 | fi 143 | 144 | # Increase the maximum file descriptors if we can. 145 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 146 | case $MAX_FD in #( 147 | max*) 148 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 149 | # shellcheck disable=SC2039,SC3045 150 | MAX_FD=$( ulimit -H -n ) || 151 | warn "Could not query maximum file descriptor limit" 152 | esac 153 | case $MAX_FD in #( 154 | '' | soft) :;; #( 155 | *) 156 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 157 | # shellcheck disable=SC2039,SC3045 158 | ulimit -n "$MAX_FD" || 159 | warn "Could not set maximum file descriptor limit to $MAX_FD" 160 | esac 161 | fi 162 | 163 | # Collect all arguments for the java command, stacking in reverse order: 164 | # * args from the command line 165 | # * the main class name 166 | # * -classpath 167 | # * -D...appname settings 168 | # * --module-path (only if needed) 169 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 170 | 171 | # For Cygwin or MSYS, switch paths to Windows format before running java 172 | if "$cygwin" || "$msys" ; then 173 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 174 | 175 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 | 177 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 | for arg do 179 | if 180 | case $arg in #( 181 | -*) false ;; # don't mess with options #( 182 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 | [ -e "$t" ] ;; #( 184 | *) false ;; 185 | esac 186 | then 187 | arg=$( cygpath --path --ignore --mixed "$arg" ) 188 | fi 189 | # Roll the args list around exactly as many times as the number of 190 | # args, so each arg winds up back in the position where it started, but 191 | # possibly modified. 192 | # 193 | # NB: a `for` loop captures its iteration list before it begins, so 194 | # changing the positional parameters here affects neither the number of 195 | # iterations, nor the values presented in `arg`. 196 | shift # remove old arg 197 | set -- "$@" "$arg" # push replacement arg 198 | done 199 | fi 200 | 201 | 202 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 | 205 | # Collect all arguments for the java command: 206 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 | # and any embedded shellness will be escaped. 208 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 | # treated as '${Hostname}' itself on the command line. 210 | 211 | set -- \ 212 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 214 | "$@" 215 | 216 | # Stop when "xargs" is not available. 217 | if ! command -v xargs >/dev/null 2>&1 218 | then 219 | die "xargs is not available" 220 | fi 221 | 222 | # Use "xargs" to parse quoted args. 223 | # 224 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 225 | # 226 | # In Bash we could simply go: 227 | # 228 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 229 | # set -- "${ARGS[@]}" "$@" 230 | # 231 | # but POSIX shell has neither arrays nor command substitution, so instead we 232 | # post-process each arg (as a line of input to sed) to backslash-escape any 233 | # character that might be a shell metacharacter, then use eval to reverse 234 | # that process (while maintaining the separation between arguments), and wrap 235 | # the whole thing up as a single "set" statement. 236 | # 237 | # This will of course break if any of these variables contains a newline or 238 | # an unmatched quote. 239 | # 240 | 241 | eval "set -- $( 242 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 243 | xargs -n1 | 244 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 245 | tr '\n' ' ' 246 | )" '"$@"' 247 | 248 | exec "$JAVACMD" "$@" 249 | --------------------------------------------------------------------------------