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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
ORACLE_PASSWORD=oracle_admin_password_placeholder
SPRING_DATASOURCE_USERNAME=dev_user_placeholder
SPRING_DATASOURCE_PASSWORD=dev_password_placeholder
JWT_SECRET=replace_with_64_plus_character_hmac_secret_placeholder
REDIS_HOST=localhost
REDIS_PORT=6379
RATE_LIMIT_CAPACITY=120
RATE_LIMIT_REFILL_PER_MINUTE=120
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
HELP.md
target/
.env
!.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
Expand Down
131 changes: 83 additions & 48 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# Merchant API

Merchant API is a Spring Boot REST service for managing merchants and their payment transactions. The project is being modernized as a clean, production-minded Java backend portfolio application with simple architecture, explicit configuration, and database migrations.
Merchant API is a Java 21 / Spring Boot 3.5 backend for merchant onboarding and idempotent payment transaction processing. The project demonstrates production-minded REST design, database migrations, stateless authentication, request throttling, keyset pagination, structured errors, and containerized local infrastructure.

## Current Stack
## Stack

- Java 21
- Spring Boot 3.5
- Spring Web
- Spring Web, Validation, Security, Actuator
- Spring Data JPA / Hibernate
- Oracle JDBC for local and production-style runtime
- Flyway for SQL database migrations
- H2 for test execution
- Testcontainers with Oracle XE for integration testing
- Oracle JDBC with Flyway SQL migrations
- Redis-backed request rate limiting
- JWT bearer authentication and merchant API keys
- RFC 7807 `ProblemDetail` errors
- Structured JSON logging with correlation IDs
- Springdoc OpenAPI / Swagger UI
- Docker Compose with Oracle Free
- Maven Wrapper
- Docker Compose for Oracle Free, Redis, and the API
- Testcontainers integration tests
- k6 benchmark script

## API Surface

Expand All @@ -23,50 +25,87 @@ POST /api/v1/merchants
GET /api/v1/merchants/{merchantId}

POST /api/v1/transactions
GET /api/v1/transactions
GET /api/v1/merchants/{merchantId}/transactions
GET /api/v1/transactions?afterId={cursor}&limit={size}
GET /api/v1/merchants/{merchantId}/transactions?afterId={cursor}&limit={size}
```

Transaction creation requires an `Idempotency-Key` header. Reusing a key returns `409 Conflict`.
Transaction creation requires:

```text
X-API-Key: <merchant-api-key>
Idempotency-Key: <unique-request-key>
```

Administrative endpoints require:

```text
Authorization: Bearer <admin-jwt>
```

Swagger UI is available at:

```text
http://localhost:8080/swagger-ui.html
```

## Configuration
## Environment

Runtime configuration is supplied through environment variables. Copy `.env.example` to `.env` for local Docker Compose runs and replace every placeholder with development-only values.

Runtime database settings are externalized through environment variables. Local defaults are provided for convenience.
Required variables:

```text
SPRING_DATASOURCE_URL
ORACLE_PASSWORD
SPRING_DATASOURCE_USERNAME
SPRING_DATASOURCE_PASSWORD
JWT_SECRET
REDIS_HOST
REDIS_PORT
RATE_LIMIT_CAPACITY
RATE_LIMIT_REFILL_PER_MINUTE
```

Default local values:
The default Spring profile intentionally does not provide database or JWT secret fallbacks. Missing runtime secrets fail application startup instead of silently using unsafe credentials.

```text
jdbc:oracle:thin:@localhost:1521:orcl
system
oracle
```
## Data Model

The application uses Flyway migrations from profile-specific folders:
Flyway migrations are split by database profile:

```text
src/main/resources/db/migration/oracle
src/main/resources/db/migration/h2
```

JPA schema generation is disabled for safety. Hibernate validates the schema created by Flyway.
The API stores API keys as SHA-256 hashes in `api_key`; raw API keys are never persisted. Idempotency records are pruned by a scheduled cleanup job after the configured retention window.

## Concurrency

Transaction creation uses database-backed idempotency plus a pessimistic merchant-row lock. This keeps duplicate requests from creating multiple transaction rows and gives future merchant balance/state transitions a clear isolation point.

Ledger reads use keyset pagination through `afterId` and `limit` instead of offset pagination, keeping large-table scans predictable.

The isolation rationale is documented in:

```text
docs/adr/0002-concurrency-and-isolation.md
```

## Health And Telemetry

Health probes:

```text
GET /health/live
GET /health/ready
```

## Profiles
Logs are emitted as JSON and include `correlation_id` when callers pass:

The default profile targets Oracle using externalized datasource settings.
```text
X-Correlation-ID: <request-correlation-id>
```

The `test` profile uses an in-memory H2 database in Oracle compatibility mode so tests can run without a local Oracle instance.
If no correlation id is supplied, the API generates one and returns it in the response header.

## Build And Test

Expand All @@ -76,43 +115,39 @@ The `test` profile uses an in-memory H2 database in Oracle compatibility mode so
.\mvnw.cmd clean verify
```

`clean test` runs the fast unit and WebMvc slice tests. `clean verify` also runs the Failsafe integration-test phase, including a Testcontainers-backed Oracle XE integration test. Docker must be running locally.
`clean test` runs unit, WebMvc, security, and rate-limit tests against H2. `clean verify` also runs Failsafe integration tests with Testcontainers-backed Oracle XE and Redis when Docker is available. Local runs skip the container tests if Docker is unreachable; CI runners provide Docker, so the real Oracle/Redis integration path is exercised on push and pull request builds.

## Docker Compose

Start the API with Oracle Free:
Create `.env` from `.env.example`, then start the stack:

```powershell
docker compose up --build
```

The API is exposed on `localhost:8080`, and Oracle is exposed on `localhost:1521`.

## CI

GitHub Actions runs on every push and pull request to `main`:

```text
.github/workflows/ci.yml
```

The workflow uses Java 21, Maven dependency caching, Docker-enabled Ubuntu runners, a pre-pulled Oracle XE test image, and `./mvnw --batch-mode clean verify`.
The API is exposed on `localhost:8080`, Oracle on `localhost:1521`, and Redis on `localhost:6379`.

## Local Run
## Benchmarks

Set database credentials if your Oracle setup differs from the defaults:
Install k6, then run:

```powershell
$env:SPRING_DATASOURCE_URL="jdbc:oracle:thin:@localhost:1521:orcl"
$env:SPRING_DATASOURCE_USERNAME="system"
$env:SPRING_DATASOURCE_PASSWORD="oracle"
.\mvnw.cmd spring-boot:run
k6 run benchmarks/transaction-create.k6.js `
-e BASE_URL=http://localhost:8080 `
-e MERCHANT_ID=1 `
-e MERCHANT_API_KEY=<merchant-api-key> `
-e RPS=25 `
-e DURATION=1m
```

## Architecture Decisions
The script reports p50, p95, p99 latency and request failure rate. Tune `RPS`, `VUS`, `MAX_VUS`, and `DURATION` to explore throughput limits.

## CI

Architecture rationale is documented in:
GitHub Actions runs on every push and pull request to `main`:

```text
docs/adr/0001-architecture-and-modernization.md
.github/workflows/ci.yml
```

The workflow uses Java 21, Maven dependency caching, Docker-enabled Ubuntu runners, a pre-pulled Oracle XE test image, and `./mvnw --batch-mode clean verify`.
47 changes: 47 additions & 0 deletions benchmarks/transaction-create.k6.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import http from 'k6/http';
import { check } from 'k6';
import exec from 'k6/execution';

export const options = {
scenarios: {
transaction_create: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.RPS || 25),
timeUnit: '1s',
duration: __ENV.DURATION || '1m',
preAllocatedVUs: Number(__ENV.VUS || 25),
maxVUs: Number(__ENV.MAX_VUS || 100),
},
},
thresholds: {
http_req_duration: ['p(50)<100', 'p(95)<300', 'p(99)<750'],
http_req_failed: ['rate<0.01'],
},
};

const baseUrl = __ENV.BASE_URL || 'http://localhost:8080';
const apiKey = __ENV.MERCHANT_API_KEY;
const merchantId = Number(__ENV.MERCHANT_ID || 1);

export default function () {
const idempotencyKey = `bench-${exec.scenario.iterationInTest}`;
const payload = JSON.stringify({
transactionAmount: '12.34',
merchantId,
transactionType: 'SALE',
connectionMode: 'Live',
});

const response = http.post(`${baseUrl}/api/v1/transactions`, payload, {
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
'Idempotency-Key': idempotencyKey,
'X-Correlation-ID': idempotencyKey,
},
});

check(response, {
'created': (res) => res.status === 201,
});
}
26 changes: 21 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ services:
image: gvenzl/oracle-free:23-slim
container_name: merchant-db
environment:
ORACLE_PASSWORD: oracle
APP_USER: merchant_api
APP_USER_PASSWORD: merchant_api
ORACLE_PASSWORD: ${ORACLE_PASSWORD:?Set ORACLE_PASSWORD in .env}
APP_USER: ${SPRING_DATASOURCE_USERNAME:?Set SPRING_DATASOURCE_USERNAME in .env}
APP_USER_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?Set SPRING_DATASOURCE_PASSWORD in .env}
ports:
- "1521:1521"
volumes:
Expand All @@ -16,16 +16,32 @@ services:
timeout: 5s
retries: 10

merchant-redis:
image: redis:7-alpine
container_name: merchant-redis
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10

merchant-api:
build: .
container_name: merchant-api
depends_on:
merchant-db:
condition: service_healthy
merchant-redis:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:oracle:thin:@merchant-db:1521/FREEPDB1
SPRING_DATASOURCE_USERNAME: merchant_api
SPRING_DATASOURCE_PASSWORD: merchant_api
SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME:?Set SPRING_DATASOURCE_USERNAME in .env}
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD:?Set SPRING_DATASOURCE_PASSWORD in .env}
REDIS_HOST: merchant-redis
REDIS_PORT: 6379
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env}
ports:
- "8080:8080"

Expand Down
36 changes: 36 additions & 0 deletions docs/adr/0002-concurrency-and-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ADR 0002: Concurrency and Transaction Isolation

## Status

Accepted

## Context

Transaction creation must be safe under concurrent duplicate submissions and future merchant balance/state transitions. The API already uses database-backed idempotency keys, but idempotency alone does not protect shared merchant state if later business rules deduct balances, reserve funds, or transition account status.

## Decision

The service uses the database default `READ COMMITTED` isolation level and applies explicit pessimistic row locks to the merchant row during transaction creation.

The transaction flow is:

1. Validate the idempotency key.
2. Acquire a `PESSIMISTIC_WRITE` lock on the merchant row.
3. Insert the idempotency record using its primary-key uniqueness guarantee.
4. Insert the transaction row.
5. Link the idempotency record to the created transaction.

## Rationale

`READ COMMITTED` keeps throughput high for ledger reads and independent merchants. The explicit row lock serializes only state transitions for the affected merchant, avoiding the broader contention and retry behavior of `SERIALIZABLE`.

This keeps the lock scope small and predictable:

- Ledger reads remain non-blocking.
- Concurrent writes for different merchants proceed independently.
- Concurrent duplicate requests for the same idempotency key are rejected by the database primary key.
- Future merchant balance deductions can safely occur while holding the same merchant-row lock.

## Consequences

Application code must acquire the merchant lock before adding new transaction-side effects that depend on merchant state. Long external calls must not run while holding the transaction open.
Loading