From b9839705e95bf19fa5784fa1d5c2bde954b7a9a2 Mon Sep 17 00:00:00 2001 From: Luis Mirabal Date: Sat, 20 Dec 2025 21:30:59 +0000 Subject: [PATCH 1/7] build: update Kotlin to 2.3.0, JVM to 25, and Gradle to 9.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update Kotlin from 1.4.10 to 2.3.0 - Update JVM target from 11 to 25 across all modules - Update Gradle wrapper from 6.7.1 to 9.2.1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- build.gradle | 8 ++++---- domain/build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/build.gradle b/build.gradle index c421033..560d25f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.jetbrains.kotlin.jvm' version '1.4.10' apply false + id 'org.jetbrains.kotlin.jvm' version '2.3.0' apply false } group = 'lmirabal' @@ -24,15 +24,15 @@ subprojects { } compileKotlin { - kotlinOptions.jvmTarget = '11' + kotlinOptions.jvmTarget = '25' } compileTestKotlin { - kotlinOptions.jvmTarget = '11' + kotlinOptions.jvmTarget = '25' } } wrapper { - gradleVersion = '6.7.1' + gradleVersion = '9.2.1' distributionType = Wrapper.DistributionType.ALL } \ No newline at end of file diff --git a/domain/build.gradle b/domain/build.gradle index db1570c..acc9fb0 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -11,5 +11,5 @@ dependencies { } compileTestFixturesKotlin { - kotlinOptions.jvmTarget = '11' + kotlinOptions.jvmTarget = '25' } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1f3fdbc..cf64724 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 4ec23799497c18cb5f31503aa789806b24d466c0 Mon Sep 17 00:00:00 2001 From: Luis Mirabal Date: Sat, 20 Dec 2025 21:31:17 +0000 Subject: [PATCH 2/7] ci: update GitHub Actions to use JDK 25 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update JDK version from 11 to 25 - Update setup-java action from v1 to v4 - Add Temurin distribution specification 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/gradle.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 9ec3df7..02a3579 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -15,12 +15,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up JDK 11 - uses: actions/setup-java@v1 - with: - java-version: 11 - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - name: Build with Gradle - run: ./gradlew build + - uses: actions/checkout@v4 + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + java-version: 25 + distribution: 'temurin' + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build From c80a5cd5b05f50e0dea4f3003958c2233c0e326d Mon Sep 17 00:00:00 2001 From: Luis Mirabal Date: Sat, 20 Dec 2025 21:31:42 +0000 Subject: [PATCH 3/7] docs: add CLAUDE.md with project architecture and commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document hexagonal architecture and testing philosophy - Include common build, test, and run commands - List technology stack with updated versions - Explain contract-based testing approach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c68760f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Kotlin demonstration project showcasing how hexagonal architecture enables testing at multiple layers using the same test contract. The application is a simple bank system with account creation, deposits, and withdrawals. + +## Architecture + +The project uses **hexagonal architecture** (ports and adapters) to enforce separation of concerns: + +### Module Structure + +- **domain**: Core business logic with no external dependencies + - `Bank` interface: Primary port (driving) defining all bank operations + - `BankAccountRepository` interface: Secondary port (driven) for data persistence + - `BankLogic`: Implementation of `Bank` that orchestrates business rules + - Uses `testFixtures` to publish `BankContract` - an abstract test suite used across layers + +- **http**: HTTP API adapter + - `BankHttp`: Exposes `Bank` interface via HTTP using http4k + - `BankHttpClient`: Implements `Bank` interface by calling the HTTP API + - Tests extend `BankContract` and test through HTTP endpoints + +- **web**: Web UI adapter + - `BankWeb`: Handlebars-based web interface + - Tests extend `BankContract` and test through Selenium WebDriver + +### Testing Philosophy + +The key architectural benefit demonstrated here is **contract-based testing**: + +1. `BankContract` in `domain/src/testFixtures` defines the complete functional test suite +2. Each layer extends `BankContract` and provides its own `Bank` implementation +3. The same test suite validates behavior at: + - Unit level: `BankLogicTest` tests business logic directly + - HTTP level: `BankHttpTest` tests through the HTTP API + - UI level: `BankWebTest` tests through the web interface + +This ensures the entire system behaves consistently without duplicating test logic. + +## Common Commands + +### Build and Test + +```bash +# Build and test (preferred - runs tests and verification) +./gradlew check + +# Build without tests +./gradlew build + +# Run tests for a specific module +./gradlew :domain:test +./gradlew :http:test +./gradlew :web:test + +# Run a single test class +./gradlew :domain:test --tests "lmirabal.bank.BankLogicTest" +./gradlew :http:test --tests "lmirabal.bank.http.BankHttpTest" +``` + +**Note**: Gradle's incremental build is efficient - running `clean` is rarely needed and slows down builds. Only use it if you encounter caching issues. + +### Run Application + +```bash +# Run the full web application (domain + HTTP + web UI) +./gradlew :web:run +``` + +The application will start on the default http4k port. Access it in a browser to interact with the bank UI. + +## Technology Stack + +- **Language**: Kotlin 2.3.0 +- **Build**: Gradle 9.2.1 +- **Runtime**: JVM 25 +- **Web Framework**: http4k (HTTP API and web templating) +- **Testing**: JUnit 5, Hamkrest (matchers) +- **UI Testing**: Selenium WebDriver via http4k-testing-webdriver +- **Functional Types**: result4k for `Result` types + +## Key Implementation Details + +### Error Handling + +The domain uses functional error handling via `Result` from result4k: +- `withdraw()` returns `Result` instead of throwing exceptions +- This allows errors to be mapped to HTTP status codes (400 for insufficient funds) and UI error pages + +### Amount Representation + +`Amount` is stored in minor units (cents) as `Long` to avoid floating-point precision issues. The web layer converts to/from major units (dollars) for display. + +### Repository Pattern + +The domain defines `BankAccountRepository` as an interface. `InMemoryBankAccountRepository` is the only implementation, but the pattern allows for easy substitution (e.g., database-backed repository). + +### Test Fixtures + +The `domain` module uses Gradle's `java-test-fixtures` plugin to share `BankContract` with other modules. Other modules declare `testImplementation testFixtures(project(':domain'))` to access it. + +## Development Notes + +- When adding new `Bank` operations, update both `Bank` interface and `BankContract` test suite +- Each adapter layer (http, web) should implement the new operation and inherit test coverage automatically +- The codebase intentionally minimizes external dependencies to keep the architecture clear +- Pattern matching on `Result` uses `.map()` for success path and `.recover()` for error path From 35f399aa7a50693917eb68598a232ca29cba0309 Mon Sep 17 00:00:00 2001 From: Luis Mirabal Date: Sat, 20 Dec 2025 21:41:21 +0000 Subject: [PATCH 4/7] build: replace deprecated kotlinOptions with jvmToolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace deprecated kotlinOptions.jvmTarget with kotlin.jvmToolchain(25) - Remove redundant JVM target configuration from domain module - Enable Gradle configuration cache for faster builds This modernizes the Kotlin build configuration to use the recommended Kotlin 2.x JVM toolchain API instead of the deprecated kotlinOptions API. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- build.gradle | 8 ++------ domain/build.gradle | 4 ---- gradle.properties | 3 +++ 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/build.gradle b/build.gradle index 560d25f..bf32d6f 100644 --- a/build.gradle +++ b/build.gradle @@ -23,12 +23,8 @@ subprojects { useJUnitPlatform() } - compileKotlin { - kotlinOptions.jvmTarget = '25' - } - - compileTestKotlin { - kotlinOptions.jvmTarget = '25' + kotlin { + jvmToolchain(25) } } diff --git a/domain/build.gradle b/domain/build.gradle index acc9fb0..ea69d4c 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -8,8 +8,4 @@ dependencies { testFixturesImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' testFixturesImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' testFixturesImplementation 'com.natpryce:hamkrest:1.8.0.1' -} - -compileTestFixturesKotlin { - kotlinOptions.jvmTarget = '25' } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 7fc6f1f..ded86ad 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,4 @@ kotlin.code.style=official + +# Enable configuration cache for faster builds +org.gradle.configuration-cache=true From a7e1ce4f87065e2dcb0b4d60eabbc680e83ba06d Mon Sep 17 00:00:00 2001 From: Luis Mirabal Date: Sat, 20 Dec 2025 21:46:53 +0000 Subject: [PATCH 5/7] build: update JUnit from 5.7.0 to 6.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade JUnit Jupiter from 5.7.0 to 6.0.1 - Update across all modules including test fixtures - All tests passing with JUnit 6 JUnit 6 includes native Kotlin suspend test support, cancellation API, and performance improvements. Requires Java 17+ (we use Java 25). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- build.gradle | 4 ++-- domain/build.gradle | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index bf32d6f..0a8589f 100644 --- a/build.gradle +++ b/build.gradle @@ -14,9 +14,9 @@ subprojects { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' + testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1' testImplementation 'com.natpryce:hamkrest:1.8.0.1' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.0.1' } test { diff --git a/domain/build.gradle b/domain/build.gradle index ea69d4c..d876607 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -6,6 +6,6 @@ dependencies { api 'dev.forkhandles:result4k:1.6.0.0' testFixturesImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' - testFixturesImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.0' + testFixturesImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1' testFixturesImplementation 'com.natpryce:hamkrest:1.8.0.1' } \ No newline at end of file From 4c09496ae9dbe96e93b867a481877d9619955b13 Mon Sep 17 00:00:00 2001 From: Luis Mirabal Date: Sat, 20 Dec 2025 22:00:07 +0000 Subject: [PATCH 6/7] build: update http4k from 3.284.0 to 6.25.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade http4k BOM to 6.25.0.0 (latest release) - Remove getElement/getElements extensions (no longer needed) - Use findElements + isEmpty check for element existence - Replace !! operators with orEmpty() for null-safe attribute access - Update all code to use findElement/findElements directly - All tests passing http4k 6.x includes Kotlin 2.3.0 support and improved WebDriver API where findElement(s) no longer return null. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- http/build.gradle | 2 +- web/build.gradle | 4 ++-- .../kotlin/lmirabal/bank/web/BankWebTest.kt | 20 +++++++++---------- .../lmirabal/selenium/SeleniumExtensions.kt | 12 +++-------- 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/http/build.gradle b/http/build.gradle index 4982909..f5e15f0 100644 --- a/http/build.gradle +++ b/http/build.gradle @@ -1,6 +1,6 @@ dependencies { implementation project(':domain') - implementation platform('org.http4k:http4k-bom:3.284.0') + implementation platform('org.http4k:http4k-bom:6.25.0.0') implementation 'org.http4k:http4k-core' implementation 'org.http4k:http4k-format-jackson' diff --git a/web/build.gradle b/web/build.gradle index facf1ad..17fccb3 100644 --- a/web/build.gradle +++ b/web/build.gradle @@ -5,12 +5,12 @@ plugins { dependencies { implementation project(':domain') implementation project(':http') - implementation platform('org.http4k:http4k-bom:3.284.0') + implementation platform('org.http4k:http4k-bom:6.25.0.0') implementation "org.http4k:http4k-core" implementation "org.http4k:http4k-template-handlebars" testImplementation testFixtures(project(':domain')) - testImplementation platform('org.http4k:http4k-bom:3.284.0') + testImplementation platform('org.http4k:http4k-bom:6.25.0.0') testImplementation 'org.http4k:http4k-testing-webdriver' } diff --git a/web/src/test/kotlin/lmirabal/bank/web/BankWebTest.kt b/web/src/test/kotlin/lmirabal/bank/web/BankWebTest.kt index f8ef7ab..1ca2246 100644 --- a/web/src/test/kotlin/lmirabal/bank/web/BankWebTest.kt +++ b/web/src/test/kotlin/lmirabal/bank/web/BankWebTest.kt @@ -11,7 +11,6 @@ import lmirabal.bank.model.Amount import lmirabal.bank.model.BankAccount import lmirabal.bank.model.BankAccountId import lmirabal.bank.model.NotEnoughFunds -import lmirabal.selenium.getElement import lmirabal.selenium.getTableColumn import lmirabal.selenium.getTableRows import org.http4k.core.HttpHandler @@ -34,7 +33,7 @@ class BankWebDriver(web: HttpHandler) : Bank { override fun createAccount(): BankAccount { driver.navigate().to("/") - driver.getElement(By.id("create-account")).submit() + driver.findElement(By.id("create-account")).submit() return driver.getBankAccounts().last() } @@ -63,21 +62,22 @@ class BankWebDriver(web: HttpHandler) : Bank { override fun withdraw(id: BankAccountId, amount: Amount): Result { submitBalanceChange(type = "withdraw", id, amount) - return if (driver.findElement(By.id("failure")) == null) { - Success(driver.getBankAccounts().first { account -> account.id == id }) - } else { - val balance = driver.getElement(By.id("balance")).getAttribute("content") - val additionalFundsRequired = driver.getElement(By.id("additionalFundsRequired")).getAttribute("content") + val failureElements = driver.findElements(By.id("failure")) + return if (failureElements.isNotEmpty()) { + val balance = driver.findElement(By.id("balance")).getAttribute("content").orEmpty() + val additionalFundsRequired = driver.findElement(By.id("additionalFundsRequired")).getAttribute("content").orEmpty() Failure(NotEnoughFunds(id, balance.toAmount(), additionalFundsRequired.toAmount())) + } else { + Success(driver.getBankAccounts().first { account -> account.id == id }) } } private fun submitBalanceChange(type: String, id: BankAccountId, amount: Amount) { val row = driver.getTableRows().first { row -> row.getBankAccountId() == id } - val form = row.getElement(By.id("$type-form")) - form.getElement(By.id("amount")).sendKeys(amount.format()) - form.getElement(By.id(type)).submit() + val form = row.findElement(By.id("$type-form")) + form.findElement(By.id("amount")).sendKeys(amount.format()) + form.findElement(By.id(type)).submit() } private fun WebElement.getBankAccountId(): BankAccountId { diff --git a/web/src/test/kotlin/lmirabal/selenium/SeleniumExtensions.kt b/web/src/test/kotlin/lmirabal/selenium/SeleniumExtensions.kt index cca4b6f..c6c7b6b 100644 --- a/web/src/test/kotlin/lmirabal/selenium/SeleniumExtensions.kt +++ b/web/src/test/kotlin/lmirabal/selenium/SeleniumExtensions.kt @@ -5,16 +5,10 @@ import org.openqa.selenium.SearchContext import org.openqa.selenium.WebElement fun SearchContext.getTableRows(): List { - val accountsTable = getElement(By.tagName("tbody")) - return accountsTable.getElements(By.cssSelector("tr")) + val accountsTable = findElement(By.tagName("tbody")) + return accountsTable.findElements(By.cssSelector("tr")) } -fun SearchContext.getElement(by: By): WebElement = - findElement(by) ?: throw AssertionError("Could not find element $by") - -fun SearchContext.getElements(by: By): List = - findElements(by) ?: throw AssertionError("Could not find element $by") - fun WebElement.getTableColumn(index: Int): String { - return getElement(By.cssSelector("td:nth-child(${index + 1})")).text + return findElement(By.cssSelector("td:nth-child(${index + 1})")).text } \ No newline at end of file From 79d343cc6f3b1e3625097bb8c567bee1fe8b0df0 Mon Sep 17 00:00:00 2001 From: Luis Mirabal Date: Sat, 20 Dec 2025 22:03:28 +0000 Subject: [PATCH 7/7] build: update result4k from 1.6.0.0 to 2.23.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade result4k to latest version (2.23.0.0) - All tests passing with updated dependency result4k is part of the forkhandles foundational libraries for Kotlin. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- domain/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/build.gradle b/domain/build.gradle index d876607..d7699d5 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -3,7 +3,7 @@ plugins { } dependencies { - api 'dev.forkhandles:result4k:1.6.0.0' + api 'dev.forkhandles:result4k:2.23.0.0' testFixturesImplementation 'org.jetbrains.kotlin:kotlin-test-junit5' testFixturesImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1'