Mortgage quote and lead-generation platform: a borrower-facing React app, a separate admin app, an Nginx edge, and Spring Boot services — extended with an AI-agent tool layer (MCP), a Kafka event stream, distributed tracing, and a one-command cloud deploy.
- Three-service backend — harbor-api owns loan quotes, auth, borrowers, leads, and admin in-process; pricing-service runs the pricing engine; notification-service delivers SSE updates. Domain-per-service with no cross-service table access.
- AI-agent integration (MCP) — pricing-service exposes its engine to LLM agents as discoverable, typed Model Context Protocol tools via Spring AI (ADR-0051), secured with API-key auth + Bucket4j rate limiting at the agent boundary (ADR-0053). A provider-agnostic client demo drives it with any OpenAI-compatible model (ADR-0054).
- Event streaming (Kafka / Redpanda) — rate-sheet activations fan out over a Kafka event stream to independent, replayable consumer groups (audit + SSE push), complementing RabbitMQ for work queues (ADR-0052).
- Observability — OpenTelemetry → Grafana (Tempo/Loki/Prometheus) with a single otel-lgtm container; one trace spans agent → MCP → pricing → DB, and rate-sheet → Kafka → consumers across services (ADR-0030).
- RabbitMQ async messaging — harbor-api and pricing-service publish to topic exchanges; notification-service consumes and pushes SSE. Built on a broker-agnostic transport abstraction (ADR-0007, ADR-0050).
- Pluggable security — self-issued HMAC JWTs or OIDC (Keycloak), plus service-to-service JWTs validated on issuer/audience/scope/type.
- Cloud deploy kit — full stack on one Oracle Always-Free ARM instance behind an outbound Cloudflare Tunnel (no inbound ports), via a prod compose override + runbook (ADR-0055).
- Tested — JUnit unit tests plus Testcontainers integration tests (real Redpanda + MySQL) across the services, frontend unit tests, and a Playwright E2E suite in CI.
- Decision-driven — 56 ADRs capture the design reasoning behind the system.
Backend: Java 17/21, Spring Boot, Spring AI (MCP), MyBatis · Data: MySQL, Redis · Messaging: RabbitMQ (work queues) + Kafka/Redpanda (event stream), via a broker-agnostic transport · Observability: OpenTelemetry, Grafana (Tempo/Loki/Prometheus) · AI: Model Context Protocol, any OpenAI-compatible LLM (Ollama, OpenAI, …) · Auth: JWT, OIDC/Keycloak, API keys · Frontend: React, Vite · Infra: Docker Compose, Nginx, Cloudflare Tunnel, Oracle Cloud, Jenkins CI
Note: this is a personal portfolio project demonstrating backend and distributed-systems architecture and design reasoning. The full stack runs with a single
docker compose up. All credentials in the repo are clearly-labeled local-development placeholders.
Additional docs:
web: borrower-facing React + Vite app served by Nginxadmin-web: admin React + Vite app served by Nginx at/admin/edge: public Nginx reverse proxy on8088and8443api(harbor-api): public quote orchestration, calculators, metrics aggregation, notification publishing; also owns auth, borrowers, leads, and admin endpoints in-processpricing-service: synchronous HTTP pricing engine with a persisted pricing catalog; exposes MCP tools for AI agents; publishes rate-sheet activations to RabbitMQ and the Kafka event streamnotification-service: quote snapshot delivery and SSE notifications; consumes rate-change events from RabbitMQ or Kafkarabbitmq: work-queue broker; management UI athttp://localhost:15672(guest/guest)redpanda: Kafka-API event stream for rate-change fan-out (audit + SSE consumers)otel-lgtm: Grafana observability stack (Tempo/Loki/Prometheus, OTLP); Grafana UI athttp://localhost:3000mysql: relational persistenceredis: session state, dedupe state, cache support, metrics counters, notification snapshotscloudflared(prod only): outbound Cloudflare Tunnel for the cloud deploylocalstack(optional —integrationprofile): local SQS emulation for the Phase-3 adapter path
flowchart LR
B["Borrower App (web)"] --> E["Nginx Edge"]
A["Admin App (admin-web)"] --> E
AGENT["AI Agent (LLM)"] -->|"MCP: tools/list, tools/call\n(API key + rate limit)"| PRICING
E --> API["harbor-api"]
E --> NOTIFY["notification-service"]
API --> REDIS["Redis"]
API --> MYSQL["mortgage_quote_workflow"]
API -->|"sync HTTP"| PRICING["pricing-service\n(+ MCP tools)"]
API -->|"QUOTE_NOTIFICATION_SNAPSHOT"| MQ["RabbitMQ"]
PRICING --> MYSQLP["mortgage_pricing"]
PRICING --> REDIS
PRICING -->|"RATE_SHEET_ACTIVATED"| MQ
PRICING -->|"pricing.rate-sheet.activated"| KAFKA["Kafka / Redpanda"]
KAFKA -->|"group: rate-change-audit"| PRICING
KAFKA -->|"group: rate-change-sse"| NOTIFY
MQ -->|"work queues"| NOTIFY
NOTIFY --> REDIS
NOTIFY --> E
API -.->|OTLP| OTEL["Grafana / OTel"]
PRICING -.->|OTLP| OTEL
NOTIFY -.->|OTLP| OTEL
- Anonymous user requests a public mortgage quote.
harbor-apideduplicates repeated requests per session and persists quote state.harbor-apicallspricing-servicesynchronously over HTTP;pricing-serviceprices the scenario from its persisted product catalog and returns a result.harbor-apiupdates the quote read model with the pricing result.- If an authenticated user refines the quote,
harbor-apicaptures the lead in-process. harbor-apipublishes a quote notification snapshot to thequote.notification.eventsRabbitMQ exchange (routing keyQUOTE_NOTIFICATION_SNAPSHOT).notification-serviceconsumes from queuequote.notification.snapshot, stores the snapshot in Redis, and pushes an SSE update to the frontend.- When a rate sheet is activated,
pricing-servicepublishes it two ways:RATE_SHEET_ACTIVATEDto RabbitMQ (existing notification flow), and to thepricing.rate-sheet.activatedKafka topic, which fans out to independent consumer groups —rate-change-audit(durable, replayable audit log) andrate-change-sse(SSE broadcast). Cache eviction stays in-process at the write site. admin-webreads aggregated metrics throughharbor-api.- AI agents call
pricing-servicedirectly over MCP: they discover the typed tools (getRateQuote,listLoanPrograms,getLoanProgramDetails), call them through the API-key + rate-limited gateway, and get real quotes from the same engine — no reimplementation, no hallucinated numbers.
Owns:
- public quote creation and retrieval
- quote refinement orchestration
- quote read model in
mortgage_quote_workflow - monthly payment and amortization calculators
- session-aware deduplication
- quote metrics and admin summary aggregation
- notification snapshot publishing
- service-to-service JWT issuance for workers and internal clients
- user registration, login, and access token issuance (auth)
- borrower creation, retrieval, existence checks, and borrower metrics
- lead creation from completed refined quotes and lead metrics
Owns:
- synchronous quote pricing (called by harbor-api over HTTP per ADR-0003)
- pricing catalog persistence in
mortgage_pricing - pricing products, rate sheets, and adjustment rules
- Redis pricing cache
- pricing metrics
- rate-sheet-activated event publishing to RabbitMQ and Kafka
- the
rate-change-auditKafka consumer (durable rate-change audit log) - the MCP server:
@Tool-annotated adapters over the pricing engine, an API-key + Bucket4j rate-limit gateway on/sseand/mcp/**
Owns:
- quote notification event consumption from RabbitMQ
- rate-sheet-activated consumption from RabbitMQ or the
rate-change-sseKafka group (config-selectable) - latest quote snapshot cache in Redis
- quote snapshot fetch endpoint
- quote SSE endpoint used by the frontend
api(harbor-api) →mortgage_quote_workflow(auth, borrower, lead, and quote tables all in one schema owned by harbor-api)pricing-service→mortgage_pricingnotification-service→ Redis only
Each service owns its own database schema — no cross-service table access.
Two brokers, each for its pattern (ADR-0050, ADR-0052): RabbitMQ for point-to-point work queues (via the broker-agnostic transport, ADR-0007); Kafka/Redpanda for the rate-change event stream that needs multiple independent consumers with retention and replay. SQS/LocalStack is the optional Phase-3 adapter path (
integrationprofile). Lead processing is in-process (ADR-0003) — no lead queues.
| Component | Name |
|---|---|
| Exchange | quote.notification.events (topic) |
| Routing key | QUOTE_NOTIFICATION_SNAPSHOT |
| Queue | quote.notification.snapshot |
| Component | Name |
|---|---|
| Exchange | rate-sheet.events (topic) |
| Routing key | RATE_SHEET_ACTIVATED |
| Queue | rate-sheet.activated |
Flow C — Rate-change event stream (pricing-service → Kafka, ADR-0052)
| Component | Name |
|---|---|
| Topic | pricing.rate-sheet.activated (keyed by investorId) |
| Consumer group | rate-change-audit (pricing-service) → durable rate_change_audit table, reads from earliest (replay) |
| Consumer group | rate-change-sse (notification-service) → SSE broadcast, reads from latest |
Audit consumers are idempotent (unique rate_sheet_id), so at-least-once redelivery and offset replay are safe. Cache invalidation is deliberately not a Kafka consumer — it stays synchronous at the write site because the cache is shared Redis.
All asynchronous message payloads carry:
schemaVersionmessageId
Consumers:
- reject unsupported schema versions
- dedupe deliveries on
messageId - publish poison messages to DLQs when processing fails
The SQS adapter and LocalStack container exist behind the integration profile. The queues provisioned by localstack/init/01-create-queues.sh are the Phase-3 targets. Use the dlq-*.sh scripts against that path only.
pricing-service exposes its pricing engine to AI agents over the Model Context Protocol using Spring AI (ADR-0051). Business capabilities are thin @Tool adapters over the existing QuotePricingService — the agent contract is decoupled from internals; no pricing logic is reimplemented.
- Tools:
getRateQuote,listLoanPrograms,getLoanProgramDetails - Transport: SSE over Spring MVC (
GET /sse,POST /mcp/message) - Security (ADR-0053): a scoped filter on
/sse+/mcp/**enforces API-key auth (X-API-Key) then per-key Bucket4j rate limiting — before any request reaches the DB or cache - Config:
HARBOR_MCP_ENABLED,HARBOR_MCP_API_KEYS,HARBOR_MCP_RPM
A provider-agnostic client demo (mcp-client-demo/, ADR-0054) lets any OpenAI-compatible model (Ollama, OpenAI, Groq, …) discover the tools and call them to answer a natural-language mortgage question:
cd mcp-client-demo && cp .env.example .env # set MCP_API_KEY + LLM_* vars
python3 harbor_agent_demo.py "What rate could I get on a $500k home, $100k down, conventional 30-yr in 89101?"Distributed tracing via OpenTelemetry → Grafana (ADR-0030). Services emit vendor-neutral OTLP; the backend is a single grafana/otel-lgtm container (Tempo/Loki/Prometheus). Kafka trace context propagates over message headers, so a rate-sheet activation is one trace from HTTP → JDBC → Kafka → the audit + SSE consumers across services. Grafana UI at http://localhost:3000.
- Config:
OTLP_TRACING_ENDPOINT(defaulthttp://localhost:4318/v1/traces),MANAGEMENT_TRACING_SAMPLING(1.0 dev, lower in prod)
The full stack deploys to one Oracle Cloud Always-Free ARM instance behind an outbound Cloudflare Tunnel — no inbound ports, TLS at Cloudflare (ADR-0055). A docker-compose.prod.yml override adds the tunnel + prod config; secrets come from .env.prod.
docker compose --env-file .env.prod -f docker-compose.yml -f docker-compose.prod.yml up -d --build \
mysql redis rabbitmq redpanda otel-lgtm \
pricing-service notification-service api web admin-web edge-tunnel cloudflaredSee the Oracle Cloud deploy runbook for the step-by-step.
Two modes are supported for protected user-facing endpoints in api:
internal: built-in auth (in harbor-api) issues HMAC-signed JWTsoidc: local Keycloak issues OIDC access tokens validated from a configured JWK set URI
Required OIDC settings:
APP_USER_TOKEN_PROVIDER=oidcAPP_USER_TOKEN_ISSUER=http://keycloak:8080/realms/mortgage-loan-apiAPP_USER_TOKEN_AUDIENCE=mortgage-loan-api-webAPP_USER_TOKEN_JWK_SET_URI=http://keycloak:8080/realms/mortgage-loan-api/protocol/openid-connect/certs
Internal services use service JWTs with issuer, audience, scope, and type validation.
Examples:
harbor-api→pricing-servicevia job token (sync HTTP)harbor-api→notification-servicevia job token (RabbitMQ message header)
The borrower-facing frontend generates and persists a session ID and sends it on quote requests with X-Session-Id.
api uses Redis to:
- track active sessions
- detect in-flight duplicate quote requests
- return the existing
quoteIdwhen a duplicate request is alreadyQUEUEDorPROCESSING - maintain fast quote status snapshots for the async flow
The public app is borrower-first and intentionally hides internal platform views. It focuses on:
- guided public quote request
- borrower quote refinement after sign-in
- my quotes history
- public calculators
- lender and agent match directory
The separate admin app at /admin/ displays:
- borrower totals and credit-score distribution
- quote funnel and quote outcomes
- pricing product distribution
- lead status distribution
- system snapshot metrics
- lender and agent directory management
GET /api/metrics/admin/summary
Admin access requires an ADMIN role token.
This endpoint aggregates:
- quote metrics from
harbor-api - borrower metrics from
harbor-api - pricing catalog metrics from
pricing-service - lead metrics from
harbor-api
docker compose up -d --buildBrings up: rabbitmq, mysql, redis, api, pricing-service, notification-service, admin-web, web, edge.
To watch a message flow end to end, open the RabbitMQ management UI at http://localhost:15672 (guest/guest) and observe queues quote.notification.snapshot and rate-sheet.activated.
docker compose --env-file .env.integration --profile integration up -d --build
# or:
make upStop the stack:
docker compose --env-file .env.integration --profile integration down
# or:
make downThe .env.integration file switches the transport to SQS so api publishes pricing jobs to LocalStack queues for demos and CI.
APP_USER_TOKEN_PROVIDER=oidc \
APP_USER_TOKEN_ISSUER=http://keycloak:8080/realms/mortgage-loan-api \
APP_USER_TOKEN_AUDIENCE=mortgage-loan-api-web \
APP_USER_TOKEN_JWK_SET_URI=http://keycloak:8080/realms/mortgage-loan-api/protocol/openid-connect/certs \
docker compose --profile oidc up -d --build keycloak mysql redis rabbitmq api pricing-service notification-service web admin-web edgeLocal Keycloak URLs:
- issuer:
http://localhost:18080/realms/mortgage-loan-api - admin console:
http://localhost:18080/admin
Imported demo credentials:
- Keycloak console:
admin/admin - admin user:
admin@example.com/StrongPass123! - test user:
testuser/StrongPass123!
docker compose down- borrower app:
http://localhost:8088 - borrower app (TLS):
https://localhost:8443 - admin app (TLS):
https://localhost:8443/admin/ - health:
https://localhost:8443/actuator/health - RabbitMQ management UI:
http://localhost:15672(guest/guest)
If local TLS certs are missing:
./scripts/generate-local-certs.shmvn -Dmaven.repo.local=.m2 test
cd pricing-service && mvn test
cd notification-service && mvn testcd web && npm run test:ci
cd admin-web && npm run test:ciThe Playwright suite lives in e2e.
Install once:
cd e2e
npm install
npx playwright install chromiumRun against the full integration stack:
docker compose --env-file .env.integration --profile integration up -d --build
cd e2e
npm run test
cd ..
docker compose --env-file .env.integration --profile integration downUse the checked-in smoke runner after the stack is up:
./scripts/local-smoke.shOr:
make smokeWhat it verifies:
- borrower quote flow
- borrower auth redirect before personalization
- admin login
- admin workspace access
make up
make smoke
make e2e
make downCovered flows:
- borrower public quote request
- borrower auth redirect before personalization
- direct admin login
- admin workspace access
GitHub Actions (.github/workflows/ci.yml) — triggered manually (Actions tab → "Run workflow", or gh workflow run ci.yml); not run on push/PR by design, to keep CI usage in check. It runs:
- JUnit suites for all three Java services (harbor-api, pricing-service, notification-service)
- frontend unit tests (
test:ci) forwebandadmin-web - frontend production builds
A Jenkinsfile mirrors the above and additionally runs the Playwright E2E suite on the integration-profile Docker stack (kept out of GitHub Actions to avoid a heavy full-stack spin-up on every PR).
Source:
web/
Focus:
- public quote flow
- refined quote flow
- my quotes history
- calculators
- lender and agent match directory
- login/register
- quote status updates through notification SSE or polling fallback
Source:
admin-web/
Focus:
- operational visibility
- borrower, quote, lead, and pricing metrics
- lender and agent directory management
All Java services use:
com.jaycodesx.mortgage
The default scaffold namespace was removed.
src/main/java/com/jaycodesx/mortgage
├─ MortgageApplication.java
├─ auth
│ ├─ controller
│ ├─ dto
│ ├─ model
│ ├─ repository
│ └─ service
├─ borrower
│ ├─ controller
│ ├─ dto
│ ├─ model
│ ├─ repository
│ └─ service
├─ infrastructure
│ ├─ admin
│ ├─ borrower
│ ├─ config
│ ├─ messaging
│ ├─ metrics
│ └─ security
├─ lead
│ ├─ dto
│ ├─ model
│ └─ repository
├─ pricing
│ └─ service
├─ quote
│ ├─ controller
│ ├─ dto
│ ├─ model
│ ├─ repository
│ └─ service
└─ shared
├─ controller
├─ dto
├─ model
├─ repository
└─ service
pricing-service/src/main/java/com/jaycodesx/mortgage
├─ PricingServiceApplication.java
├─ mcp # MCP server: @Tool adapters + API-key/rate-limit gateway
│ ├─ MortgagePricingTools.java
│ ├─ McpToolConfig.java
│ ├─ McpGatewayFilter.java
│ └─ McpSecurityProperties.java
├─ infrastructure
│ ├─ config
│ ├─ messaging
│ │ └─ kafka # rate-change event stream (topic config + properties)
│ ├─ metrics
│ └─ security
├─ pricing
│ ├─ messaging # RateChangeEventPublisher + RateChangeAuditListener
│ ├─ model
│ ├─ repository
│ └─ service
├─ quote
│ ├─ dto
│ ├─ model
│ ├─ repository
│ └─ service
└─ shared
└─ service
notification-service/src/main/java/com/jaycodesx/mortgage
├─ NotificationServiceApplication.java
├─ infrastructure
│ ├─ messaging
│ └─ security
├─ lead
│ └─ dto
├─ notification
│ ├─ controller
│ ├─ dto
│ └─ service
admin-web
├─ src
├─ public
├─ Dockerfile
├─ nginx.conf
└─ package.json
mcp-client-demo/ # provider-agnostic MCP agent client (stdlib Python)
├─ harbor_agent_demo.py
├─ .env.example
└─ README.md
cloudflared/ # Cloudflare Tunnel ingress (config.example.yml; real config gitignored)
docker-compose.prod.yml # prod override: tunnel + MCP/Kafka/tracing env
nginx/default.tunnel.conf # TLS-free routing for the tunnel deploy
.env.prod.example # production secrets template
POST /api/loan-quotes/publicGET /api/loan-quotes/{id}POST /api/loan-quotes/{id}/refineGET /api/loan-quotes/{id}/events
GET /api/loans/mortgage-payment/calculateGET /api/loans/amortization/calculate
POST /api/auth/registerPOST /api/auth/loginPOST /api/auth/refresh
POST /api/borrowersGET /api/borrowers/{id}
GET /api/notifications/quotes/{id}GET /api/notifications/quotes/{id}/events
GET /api/borrower/quotes
GET /api/directory/locationsGET /api/directory/agentsGET /api/directory/lenders
GET /admin/agentsGET /admin/lenders
GET /api/metrics/quotesGET /api/metrics/admin/summaryPOST /api/metrics/quotes/sessions/authenticated
GET /sse— open the MCP session (SSE)POST /mcp/message— JSON-RPC channel (tools/list,tools/call)
mvn -Dmaven.repo.local=.m2 testUnit tests are pure Mockito; integration tests use Testcontainers (real Redpanda + MySQL) to exercise the Kafka producer → topic → audit consumer → DB chain, idempotency against the real unique constraint, and the cross-service SSE consumer's deserialization. On Docker Desktop, the surefire config pins the Docker Engine API version so Testcontainers can discover the daemon.
cd ./web
npm install
npm run buildcd ./admin-web
npm install
npm run buildThe dlq-*.sh scripts operate against the LocalStack SQS path (Phase-3 / integration profile). For the default RabbitMQ stack, use the management UI at http://localhost:15672 or service logs to inspect message flow.
Inspect SQS DLQs (LocalStack / Phase-3):
./scripts/dlq-inspect.sh quote-pricing-results-dlq
./scripts/dlq-inspect.sh quote-notification-events-dlqReplay DLQ messages:
./scripts/dlq-replay.sh quote-pricing-results-dlq quote-pricing-results
./scripts/dlq-replay.sh quote-notification-events-dlq quote-notification-eventsFor a fuller operator workflow, see: