A backend-only car-sharing platform built as a demonstration of spec-driven development — a methodology for building production-quality software from a formal specification using AI-assisted tooling.
- What the app is
- How it was built
- Architecture
- Data
- External services
- Infrastructure
- Observability
- Build, run, and test
- Troubleshooting
- Known gaps
Car Sharing is an MVP backend platform that enables owners to lend cars to borrowers. It exposes REST endpoints for user management, car registration, and car booking, and handles payment processing through an external Payment Service Provider (PSP). There is no frontend; the system is exercised via HTTP through the Gateway. The scope is intentionally narrow: the goal is a clean, verifiable backend that demonstrates the methodology used to build it.
This project was built following a spec-driven development process designed to produce a formal specification before any code is written, then use AI-assisted tooling to implement it phase by phase.
- Draft an informal app description (
app-definition.md). - Feed it to Gemini to generate a Technical Specification Document (TSD).
- Refine the TSD iteratively with Gemini; rerun from scratch to eliminate bias.
- Ask Claude Code to format the final TSD as
SPEC.md— the single source of truth. - Scaffold a Java/Spring multi-module project and run
/initin Claude Code. - Install supporting tooling: Context7 for live docs lookup; custom skills for TDD, hexagonal architecture, Effective Java, clean code, and ADR authoring; a
DocsExplorersubagent. - Configure
CLAUDE.mdto bind Claude Code to the spec and tooling conventions. - Plan the implementation as phased GitHub issues, stress-tested with the
grill-meskill. - Implement each phase with TDD; produce ADRs and service READMEs along the way.
Full step-by-step process: .claude/specs/other/spec-driven-dev.md.
Six Spring Boot services communicate through a Gateway. Reads are synchronous REST; writes propagate asynchronously via RabbitMQ. The Booking service owns a Saga orchestrator that drives the booking lifecycle from PENDING to ACTIVE or CANCELLED.
graph LR
Client([Client])
subgraph k8s[Kubernetes cluster]
GW["Gateway :8080"]
UM["User Management :8081"]
CR["Car Registry :8082"]
CB["Car Booking :8083"]
PAY["Payments :8084"]
PSP["PSP Stub :8085"]
MQ[(RabbitMQ)]
end
Client -->|HTTP| GW
GW -->|REST| UM & CR & CB
UM -->|UserCreated, UserBankAccountChanged| MQ
CR -->|CarRegistered| MQ
CB -->|BookingPaymentRequested, BorrowerFlaggedAsDebtor| MQ
PAY -->|PaymentProcessed| MQ
MQ -->|BorrowerFlaggedAsDebtor| UM
MQ -->|CarRegistered, UserCreated, BorrowerFlaggedAsDebtor, PaymentProcessed| CB
MQ -->|UserCreated, UserBankAccountChanged, BookingPaymentRequested| PAY
PAY -->|HTTP| PSP
| Service | Port | Responsibility |
|---|---|---|
| Gateway | 8080 | Single inbound entry point; routes all HTTP |
| User Management | 8081 | Admin CRUD for users; tracks debtor status |
| Car Registry | 8082 | Car registration; publishes car events |
| Car Booking | 8083 | Booking lifecycle + Saga orchestrator; CQRS read model for available cars |
| Payments | 8084 | Account management; charges bookings via PSP |
| PSP Stub | 8085 | Local simulator for the external Payment Service Provider |
Owned by Car Booking:
POST /bookings
→ booking saved as PENDING
→ BookingPaymentRequested published to RabbitMQ
→ Payments charges the PSP
→ PaymentProcessed(SUCCESS | FAILED) published
→ booking updated to ACTIVE or CANCELLED
Every 15 min (scheduler):
→ overdue ACTIVE bookings found (end date passed, car not yet returned)
→ BorrowerFlaggedAsDebtor published per borrower not already a debtor
- Hexagonal architecture per service — domain logic is isolated from infrastructure via ports and adapters.
- CQRS read model in Car Booking:
GET /carsqueries a local copy of cars synced from Registry via RabbitMQ, avoiding cross-service joins. - Pessimistic locking on car rows prevents double-booking under concurrent requests.
Each service owns a dedicated SQLite database; no cross-schema joins are permitted.
| Service | Database | Tables |
|---|---|---|
| User Management | user-management.db |
users (id, username, name, surname, bank_account, is_debtor) |
| Car Registry | car-registry.db |
cars (id, owner_id, type, registration_number) |
| Car Booking | car-booking.db |
users (id, is_debtor), cars (id, type), bookings (id, car_id, borrower_id, start_date, end_date, status) |
| Payments | payments.db |
accounts (id, user_id, bank_account), transactions (id, booking_id, borrower_id, amount, status) |
Booking status values: PENDING / ACTIVE / RETURNED / CANCELLED.
Transaction status values: SUCCESS / FAILED.
The Payments service calls a Payment Service Provider (PSP) over HTTP to charge borrowers. The fee is 10 EUR × number of booked days.
| Endpoint | 200 OK |
409 Conflict |
|---|---|---|
POST /process |
Payment accepted | Insufficient funds |
GET /balances |
All account balances | — |
In production this would be a real banking API. Locally, psp-stub simulates it: a Spring Boot service that holds in-memory account balances seeded at startup and decrements them on each successful charge. No code inside Payments distinguishes between the stub and a real PSP. See ADR-002 for the full rationale.
The platform runs on a local Kubernetes cluster (tested with Colima + k3s). Each service has a manifest under services/<name>/infra/. Shared cluster resources live under infra/:
| Manifest | Purpose |
|---|---|
infra/namespace.yaml |
car-sharing namespace |
infra/configmap.yaml |
Shared config (RabbitMQ URL, OTLP endpoint) |
infra/rabbitmq.yaml |
RabbitMQ broker |
infra/openobserve.yaml |
OpenObserve observability backend |
infra/openobserve-alert-setup.yaml |
Alert rules provisioned via Kubernetes Job on startup |
Each service emits three signals, all ingested by OpenObserve running at port 5080:
- Traces — zero-code OTel Java agent (OTLP/gRPC). Spans cover inbound HTTP requests, RabbitMQ consumer handlers, and Saga steps.
- Logs — structured JSON via SLF4J/Logback.
INFOfor state transitions;WARNfor RabbitMQ redeliveries (see ADR-001);ERRORfor unhandled failures. - Metrics — Micrometer gauges exported via OTLP. Custom metrics defined per service (see each service's
README.md); platform-level metrics includebookings.active.current(Car Booking) andusers.debtors.current(User Management).
OpenObserve is deployed automatically by scripts/deploy.sh. Once the cluster is up, import the pre-built dashboard:
./scripts/provision-dashboards.shThis loads dashboards/car-sharing.json into OpenObserve and overwrites any existing version.
- Java 21
- Docker with buildx support
kubectlpointed at a local cluster (Colima + k3s recommended)jq(required byprovision-dashboards.sh)
./gradlew build # compile + test all modules
./gradlew clean build # clean build
./gradlew test --tests "com.example.cs.SomeTest" # single class
./gradlew test --tests "com.example.cs.SomeTest.method" # single methodBefore the first deploy, create a .env file from the provided example and fill in real SMTP credentials:
cp .env.example .env
# edit .env — set ZO_SMTP_HOST, ZO_SMTP_USER_NAME, ZO_SMTP_PASSWORD, ZO_SMTP_FROM_EMAILdeploy.sh reads this file to create the openobserve-smtp Kubernetes Secret. OpenObserve uses that Secret when the openobserve-alert-setup Job runs: it provisions an email notification destination and wires it to the high-debtor-ratio alert. Without the Secret, the Job will create the alert but email delivery will silently fail. The .env file is listed in .gitignore and must never be committed.
colima start --kubernetes # start local cluster
./scripts/build-images.sh # build Docker images and load into local daemon
./scripts/deploy.sh # apply manifests, restart pods (reads .env)
./scripts/provision-dashboards.sh # import OpenObserve dashboardServices inside the cluster are not exposed externally. Use kubectl port-forward to reach them from localhost. Each command blocks the terminal — run each in a separate shell or background it.
OpenObserve (dashboards, logs, traces, metrics, alerts):
kubectl port-forward svc/openobserve 5080:5080 -n car-sharingThen open http://localhost:5080 in a browser.
Gateway (all API operations — see each service's README.md for endpoint details):
kubectl port-forward svc/gateway 8080:8080 -n car-sharingAll HTTP requests go to http://localhost:8080.
Each service stores its SQLite database at /app/data/<service>.db inside the pod. To open an interactive SQLite shell:
# Car Booking
kubectl exec -it deployment/car-booking -n car-sharing -- \
sh -c 'apk add --no-cache sqlite 2>/dev/null; sqlite3 /app/data/car-booking.db'
# User Management
kubectl exec -it deployment/user-management -n car-sharing -- \
sh -c 'apk add --no-cache sqlite 2>/dev/null; sqlite3 /app/data/user-management.db'
# Car Registry
kubectl exec -it deployment/car-registry -n car-sharing -- \
sh -c 'apk add --no-cache sqlite 2>/dev/null; sqlite3 /app/data/car-registry.db'
# Payments
kubectl exec -it deployment/payments -n car-sharing -- \
sh -c 'apk add --no-cache sqlite 2>/dev/null; sqlite3 /app/data/payments.db'./scripts/teardown.sh # destroys the namespace and all PVC data — irreversiblekubectl: connection refused / dial tcp 127.0.0.1:XXXXX: connect: connection refused
The Kubernetes API server is unreachable. Either Colima is not running or the kubeconfig is stale.
colima status # check whether the VM is up
colima start --kubernetes # start it if not running
colima kubernetes reset # re-merge kubeconfig if Colima is running but kubectl still fails
kubectl cluster-info # verify connectivity before retrying deploy.shThe following issues and improvements (sourced from .claude/specs/SPEC.md §7 and open GitHub issues) should be addressed before tackling any post-MVP work:
-
Security — full OWASP Top 10 audit (#14) — a full audit identified multiple findings across Critical, High, and Medium severity. See the issue for the complete list and recommended remediation order.
-
Saga integration test (#12) — no test exercises the full Saga round-trip across Car Booking and Payments. A dedicated
integration-testsGradle module using Testcontainers (RabbitMQ), in-memory SQLite, WireMock (PSP stub), and Awaitility is needed to cover the happy path (ACTIVE) and failed-payment path (CANCELLED). -
Empty projections on first deployment — the Car Booking service's local
carsandusersprojections start empty on first deployment or after a DB wipe;GET /carsreturns no results until events flow in naturally. Sync-on-startup or event sourcing would fix this but were deferred (see ADR-001). -
Eventual consistency on car availability — propagation delay between a car being registered or returned and it appearing in
GET /carsdue to RabbitMQ async delivery. Possible mitigations: optimistic UI, backend double-checks, or message versioning. -
No dead-letter queue (DLQ) — failed sync events between Car Registry and Car Booking are silently dropped, and unprocessed Saga messages have no fallback handling. A DLQ strategy should be defined (see ADR-003).
-
Dual-write risk in the Saga —
CreateBookingHandler(save booking + publishBookingPaymentRequested) andProcessPaymentHandler(save transaction + publishPaymentProcessed) both perform a DB write followed by a RabbitMQ publish without atomicity. If the publish fails after a successful write, the event is lost and the Saga stalls. The Outbox pattern resolves this (see ADR-004). -
PSP mock lacks realism — the stub has no configurable latency or error profiles. Defining explicit timeout and failure scenarios would make payment testing more representative.