Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
110 changes: 110 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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<T, E>` types

## Key Implementation Details

### Error Handling

The domain uses functional error handling via `Result<T, E>` from result4k:
- `withdraw()` returns `Result<BankAccount, NotEnoughFunds>` 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<T, E>` uses `.map()` for success path and `.recover()` for error path
16 changes: 6 additions & 10 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -14,25 +14,21 @@ 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 {
useJUnitPlatform()
}

compileKotlin {
kotlinOptions.jvmTarget = '11'
}

compileTestKotlin {
kotlinOptions.jvmTarget = '11'
kotlin {
jvmToolchain(25)
}
}

wrapper {
gradleVersion = '6.7.1'
gradleVersion = '9.2.1'
distributionType = Wrapper.DistributionType.ALL
}
8 changes: 2 additions & 6 deletions domain/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,9 @@ 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:5.7.0'
testFixturesImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1'
testFixturesImplementation 'com.natpryce:hamkrest:1.8.0.1'
}

compileTestFixturesKotlin {
kotlinOptions.jvmTarget = '11'
}
3 changes: 3 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
kotlin.code.style=official

# Enable configuration cache for faster builds
org.gradle.configuration-cache=true
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion http/build.gradle
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
4 changes: 2 additions & 2 deletions web/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}

Expand Down
20 changes: 10 additions & 10 deletions web/src/test/kotlin/lmirabal/bank/web/BankWebTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
}
Expand Down Expand Up @@ -63,21 +62,22 @@ class BankWebDriver(web: HttpHandler) : Bank {

override fun withdraw(id: BankAccountId, amount: Amount): Result<BankAccount, NotEnoughFunds> {
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 {
Expand Down
12 changes: 3 additions & 9 deletions web/src/test/kotlin/lmirabal/selenium/SeleniumExtensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,10 @@ import org.openqa.selenium.SearchContext
import org.openqa.selenium.WebElement

fun SearchContext.getTableRows(): List<WebElement> {
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<WebElement> =
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
}