| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Name | Name | Last commit date | ||
|---|---|---|---|---|
Event-sourced financial ledger implementing CQRS with Kafka Streams. Account state is derived entirely from an immutable Kafka event log; PostgreSQL serves as a rebuildable read model, never the source of truth.
flowchart TD
Client(["Client"])
subgraph app["Spring Boot"]
API["REST API"]
Consumer["Event Consumer"]
Streams["Kafka Streams"]
end
subgraph kafka["Kafka"]
Events[("account.events")]
Alerts[("account.alerts")]
end
PG[("PostgreSQL")]
subgraph obs["Observability"]
Prom["Prometheus"]
Graf["Grafana"]
end
Client -->|HTTP / JWT| API
API -->|write path| Events
Events -->|async| Consumer
Consumer -->|update read model| PG
API -->|read path| PG
Events -->|KTable aggregation| Streams
Streams -->|anomaly routing| Alerts
Prom -.->|scrape| API
Prom -.-> Graf
docker compose up --buildTo run the test suite (requires Docker for Testcontainers):
./mvnw verify| Service | URL |
|---|---|
| API | http://localhost:8080 |
| Prometheus | http://localhost:9090 |
| Grafana | http://localhost:3000 |
Grafana credentials: admin / admin. The LedgeFlow dashboard loads automatically.
# Register and get a JWT
TOKEN=$(curl -s -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret"}' | jq -r '.token')
# Create an account
ACCOUNT=$(curl -s -X POST http://localhost:8080/accounts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"ownerId":"00000000-0000-0000-0000-000000000001","currency":"EUR"}')
ID=$(echo $ACCOUNT | grep -o '"id":"[^"]*"' | cut -d'"' -f4)
# Deposit
curl -s -X POST http://localhost:8080/accounts/$ID/deposit \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"amount":100.00,"currency":"EUR"}'
# Check balance (give the event consumer a moment to process)
curl -s http://localhost:8080/accounts/$ID \
-H "Authorization: Bearer $TOKEN"
# Rebuild the entire read model from the Kafka event log (admin only)
ADMIN_TOKEN=$(curl -s -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r '.token')
curl -s -X POST http://localhost:8080/admin/rebuild \
-H "Authorization: Bearer $ADMIN_TOKEN"Write-path consistency boundary
The write path (deposit, withdraw, transfer) reads the current balance from the PostgreSQL read model, validates it in memory, then publishes an event to Kafka. The Kafka Streams KTable is the authoritative balance, but it is not queried on the write path.
This creates a TOCTOU race whose exploitable window is the entire produce-to-projection latency (the time between publishing an event and the consumer committing the resulting balance update to PostgreSQL), typically tens to hundreds of milliseconds. Within that window, any number of requests fired sequentially, not just concurrently, can all pass the same stale balance check and publish events, driving the account arbitrarily negative without requiring two requests to overlap in time.
withdraw() and transfer() include a partial mitigation: they bump the @Version column on the account row before publishing, so two truly concurrent requests (arriving at the same instant) serialise at the database commit; the second writer receives HTTP 409 and can retry. This does not close the sequential window described above. Full prevention requires atomic command-write and event-publication, which needs either an outbox pattern or Kafka transactions.
In production this would be fully resolved by one of:
The current approach limits the race window and surfaces conflicts as retryable 409 responses rather than silent overwrites.
Per-account authorization
The API authenticates all requests and enforces role-based access (ADMIN for the rebuild endpoint), but does not verify that the authenticated principal owns the account being acted on. Any authenticated user can read or mutate any account by UUID. This is a deliberate scope boundary; production enforcement would derive ownerId from the JWT subject and add an ownership check in the service layer.
Event consumer error handling
If the consumer cannot find an account row, it throws a RuntimeException so the @KafkaListener error handler retries rather than silently skipping the event. A DefaultErrorHandler is configured with a fixed back-off (3 retries, 1 s apart); after exhausting retries the record is forwarded to account.events.DLT via a DeadLetterPublishingRecoverer, keeping the main partition moving and making failures operationally visible.
Large-transaction alert threshold
The Kafka Streams topology flags any transaction exceeding 10,000 as a large-transaction alert, regardless of currency. The threshold is a fixed nominal value; in a multi-currency system a proper implementation would normalise amounts to a base currency before comparing. This is documented as a known simplification.
PostgreSQL as rebuildable read model
PostgreSQL holds no state that cannot be reconstructed by replaying Kafka from offset 0. The POST /admin/rebuild endpoint demonstrates this: it drops all read-model rows, then replays the full event log. Kafka is the source of truth; PostgreSQL is a queryable cache. The one exception is account creation: createAccount writes directly to PostgreSQL before publishing the event, so the row exists immediately on the normal path; on rebuild the AccountCreated event recreates it.
MIT License. Author: Simon D. · github.com/simddev Contact: simon.d.dev@proton.me
| Back | FazBrowse Home | New Git URL |