Spring Boot 3 / Java 21 reference service that builds a Customer 360 view on the fly by fanning out to SAP S/4HANA Business Partner OData endpoints and merging the result with locally stored CRM annotations (tags + notes) from a configurable SQL backend (Postgres or MySQL 8). Used inside the Keploy project as the canonical regression fixture for the SAP fan-out path and the v3 HTTPS + Postgres/MySQL parsers.
This is a small "Customer 360" aggregator, the kind of service an internal
CRM dashboard team would ship on SAP BTP. When a user hits
GET /api/v1/customers/{id}/360, the service fans out: one synchronous
SAP OData call for the BusinessPartner master record, then two more
parallel SAP OData calls (addresses + roles), in parallel with two
Postgres queries (tags + notes). The five results are merged into a single
JSON response.
The real-world analog is an in-house CRM dashboard that needs a unified customer view by calling the system-of-record (SAP) plus a local CRM annotations DB (Postgres), without any surface area that hides how those downstream calls behave on the wire.
flowchart LR
Client[Client<br/>curl / browser] --> Ctrl[Customer360Controller]
Ctrl --> Agg[Customer360AggregatorService]
Agg -->|sync| SapPartner[SAP OData<br/>A_BusinessPartner]
Agg -->|async| SapAddr[SAP OData<br/>to_BusinessPartnerAddress]
Agg -->|async| SapRole[SAP OData<br/>to_BusinessPartnerRole]
Agg -->|async| DbTags[(SQL<br/>customer_tag)]
Agg -->|async| DbNotes[(SQL<br/>customer_note)]
SapPartner -.->|HTTP/1.1 + TLS<br/>keep-alive| SAPAPI[SAP Sandbox]
SapAddr -.-> SAPAPI
SapRole -.-> SAPAPI
DbTags -.->|JDBC| DB[(Postgres 16 | MySQL 8)]
DbNotes -.-> DB
A few things to notice about this shape:
- The synchronous SAP
A_BusinessPartnerfetch runs first and acts as the existence check — if it fails the whole request short-circuits. - The four remaining calls (two SAP nav collections + two Postgres
queries) run in parallel via
CompletableFuture.allOfdispatched on the dedicatedsapCallExecutorthread pool. - All three SAP calls hit the same host (same SNI) and share a
connection-pooled Apache
HttpComponents5client with keep-alive; the two Postgres calls share a HikariCP pool. - The five results are merged into a single JSON envelope and returned to the caller in one trip — one inbound request, five concurrent backend conversations, one response.
The service is deliberately structured to exercise the trickiest parts of Keploy's interception layer in a single flow.
- Parallel outbound TLS — every
/360request opens 3 concurrent HTTPS connections to the SAP sandbox plus 2 concurrent TLS-enabled Postgres queries, giving Keploy a dense concurrency pattern to capture and replay. - Chunked HTTP/1.1 + keep-alive reuse — SAP's sandbox returns chunked responses over a reused keep-alive connection, so the recorded mocks preserve the same wire shape your service sees in production.
- Schema diversity in a single repo — GET / POST / DELETE verbs, JSON
request bodies, a custom
X-Correlation-Idheader, actuator health probes, both chunked and Content-Length responses, and the OpenAPI/v3/api-docscatalog endpoint. - Stateful local DB — Flyway-migrated schema behind a HikariCP connection pool, which exercises the v3 Postgres parser's prepared-statement cache handling and pool-reuse semantics.
- Captures live production-shape traffic, including the concurrent SAP fan-out, without mocks.
- Replays the exact same multi-TLS concurrency pattern inside CI, so regressions in the real HTTP/Postgres stack are caught before release.
- Auto-detects non-deterministic fields (timestamps, correlation IDs) and marks them as noise.
- In-cluster mode spins up an ephemeral replica and runs the test set automatically on every new pod version — no manual test writing.
- No code changes to the Spring Boot app — Keploy sits in the network path via eBPF.
- Java 21 + Maven 3.9+
- Docker (Postgres 16 or MySQL 8 is brought up as a sidecar via
docker compose) - A Keploy binary if you want to record / replay (any v3.3.x or newer is fine)
- An SAP API sandbox key — grab one for free from the SAP Business Accelerator Hub: api.sap.com/api/API_BUSINESS_PARTNER. Click Show API Key once signed in.
cd sap-demo-java
# 1. Bring up the default datasource (Postgres 16) in the background
# (or use ./deploy_kind.sh for k8s)
docker compose up -d postgres
# 2. Point the app at the SAP sandbox
export SAP_API_KEY=<your-sandbox-key>
export SAP_SANDBOX_BASE_URL=https://sandbox.api.sap.com/s4hanacloud
# 3. Build and run
mvn spring-boot:runThe service listens on :8080. Smoke-test it:
curl -s http://localhost:8080/actuator/health | jq .
curl -s http://localhost:8080/api/v1/customers/202/360 | jq .To run against MySQL 8 instead (same commands, different profile):
SPRING_PROFILES_ACTIVE=mysql docker compose --profile mysql up -d mysql customer360See Database backends below for details.
The local store (customer_tag, customer_note, audit_event) runs on
either Postgres 16 (default) or MySQL 8. Selection is a runtime
decision — no rebuild, no code change, just a Spring profile toggle.
| Aspect | Postgres (default) | MySQL 8 |
|---|---|---|
| Spring profile | postgres |
mysql |
| Driver | org.postgresql.Driver |
com.mysql.cj.jdbc.Driver |
| Hibernate dialect | PostgreSQLDialect |
MySQLDialect |
| Flyway location | classpath:db/migration/postgres |
classpath:db/migration/mysql |
| Profile YAML | src/main/resources/application-postgres.yml |
src/main/resources/application-mysql.yml |
| Default JDBC URL | jdbc:postgresql://localhost:5432/customer360 |
jdbc:mysql://localhost:3306/customer360?... |
| Docker compose service | postgres (no profile tag — starts by default) |
mysql (compose profile mysql, opt-in) |
| k8s manifests | k8s/postgres.yaml + k8s/configmap.yaml |
k8s/mysql.yaml + k8s/configmap-mysql.yaml |
Local (Docker Compose):
# Default — Postgres
docker compose up -d postgres customer360
# MySQL
SPRING_PROFILES_ACTIVE=mysql \
docker compose --profile mysql up -d mysql customer360Kubernetes (deploy_kind.sh):
# Default — Postgres
./deploy_kind.sh apply
# MySQL
DB_BACKEND=mysql ./deploy_kind.sh applyThe profile override env vars are standard Spring Boot — you can also run the fat jar directly:
SPRING_PROFILES_ACTIVE=mysql \
SPRING_DATASOURCE_URL=jdbc:mysql://localhost:3306/customer360?useSSL=false\&allowPublicKeyRetrieval=true\&serverTimezone=UTC \
java -jar target/customer360.jarThe Flyway migrations in src/main/resources/db/migration/postgres/ and
.../mysql/ are functionally identical schemas with dialect-specific
DDL (BIGSERIAL vs BIGINT AUTO_INCREMENT, TIMESTAMPTZ vs
TIMESTAMP, NOW() vs CURRENT_TIMESTAMP, etc.). Hibernate's
jdbc.time_zone=UTC keeps write timestamps identical across the two
backends.
Run the service under keploy record, exercise it with run_flow.sh
(which fires 20 distinct request shapes covering every endpoint and
verb), then replay:
# terminal 1 — record
keploy record -c "java -jar target/customer360.jar"
# terminal 2 — drive traffic
bash run_flow.sh
# Ctrl+C the record command. Testcases land under ./keploy/
# then replay:
keploy test -c "java -jar target/customer360.jar"The same flow runs in-cluster through the Keploy k8s-proxy. Deploy the app to kind:
./deploy_kind.sh
kubectl -n sap-demo annotate deploy/customer360 keploy.io/record=enabled
# start recording
curl -k -X POST https://<k8s-proxy-svc>:8080/record/start \
-H "Authorization: Bearer $KEPLOY_SHARED_TOKEN_OVERRIDE" \
-d '{"namespace":"sap-demo","deployment":"customer360"}'
# drive traffic (e.g. run_flow.sh against the NodePort / Ingress host)
./run_flow.sh
# stop recording — auto-replay then fires on a standalone pod
curl -k -X POST https://<k8s-proxy-svc>:8080/record/stop \
-d '{"record_id":"sap-demo-customer360"}'Replay results land in the enterprise dashboard at app.keploy.io.
| Method | Path | Purpose | Downstream |
|---|---|---|---|
| GET | /actuator/health |
Liveness / readiness probe | none |
| GET | /api/v1/customers/count |
KPI tile — total partner count | Postgres only |
| GET | /api/v1/customers/{id} |
Business partner detail | SAP only |
| GET | /api/v1/customers/{id}/tags |
Customer tags | Postgres only |
| GET | /api/v1/customers/{id}/360 |
Full aggregation | SAP × 3 + Postgres × 2 parallel |
| POST | /api/v1/customers/{id}/tags |
Add a tag | Postgres only |
| POST | /api/v1/customers/{id}/notes |
Add a note | Postgres only |
| DELETE | /api/v1/customers/{id}/tags/{tag} |
Remove a tag | Postgres only |
| GET | /v3/api-docs |
OpenAPI catalog | none |
keploy.yml marks three fields as global noise so replays stay
deterministic across runs:
header.X-Correlation-Id— generated per-request byCorrelationIdFilter; it's intentionally unique per call, so it can never match on replay.body.timestamp/body.installedOn/body.id— server-generated values on write paths (tag / note rows). The semantic content is stable; the numeric/temporal surface is not.ETagon SAP responses (andDateheaders) — SAP regenerates these on every fetch, independent of the underlying record state.
If your team adds more generated fields, extend test.globalNoise.global
in keploy.yml.
Classic Spring Boot layering, with one custom wrinkle for the fan-out:
- Controller —
web/Customer360Controller.java(+CustomerController,TagController,NoteController,AuditController). RFC 7807 problem responses come fromweb/GlobalExceptionHandler. - Aggregator —
service/Customer360AggregatorService.java. Builds threeCompletableFutures for the SAP calls and two more for the Postgres queries, all dispatched on a dedicatedsapCallExecutorthread pool, then joins them viaCompletableFuture.allOf. Partial-failure policy: the SAP partner fetch is mandatory; everything else degrades gracefully. - SAP client —
sap/SapBusinessPartnerClient.java. SpringRestTemplatebacked by the ApacheHttpComponents5client factory (keep-alive + transparent gzip handling, which the JDK default doesn't offer). Retries + circuit breaker via Resilience4j (sapApiinstance inapplication.yml). - Persistence —
repository/CustomerTagRepository.javaandCustomerNoteRepository.java(Spring Data JPA), plusAuditEventRepository. Schema is Flyway-migrated; each supported backend has its own migration tree (src/main/resources/db/migration/postgres/V1__init_schema.sqland.../mysql/V1__init_schema.sql). Pool is HikariCP withmaximum-pool-size=10. - Correlation — inbound
CorrelationIdFilterseeds the MDC; outboundCorrelationIdInterceptorpropagates the ID on every SAP call.
502 SAP upstream erroron/360. CheckSAP_API_KEY; the SAP sandbox also rate-limits at roughly 120 requests/minute. The built-in Resilience4j circuit breaker will open if you punch through that.- Tests drift on
X-Correlation-Id. ConfigureX-Correlation-Idas noise inkeploy.ymlunderglobalNoise.header.X-Correlation-Id. Keploy respects case-insensitive header matching, so you can use any casing. ImagePullBackOff/ErrImageNeverPullin kind. You forgot tokind load docker-image customer360:local— run./deploy_kind.sh build.- Liveness probe flaps at startup. The 40 s
startupProbegrace is usually enough for the JVM; raisefailureThresholdink8s/deployment.yamlif your host is slow.
| Path | Purpose |
|---|---|
pom.xml |
Spring Boot 3, Java 21, Resilience4j, Flyway, HikariCP, SpringDoc, Postgres + MySQL drivers |
src/main/java/com/keploy/sapdemo/customer360/... |
Application source (see Architecture above) |
src/main/resources/application.yml |
Externalised config (shared) |
src/main/resources/application-postgres.yml |
Postgres-specific datasource + Flyway location |
src/main/resources/application-mysql.yml |
MySQL-specific datasource + Flyway location |
src/main/resources/db/migration/postgres/V1__init_schema.sql |
Flyway schema (Postgres dialect) |
src/main/resources/db/migration/mysql/V1__init_schema.sql |
Flyway schema (MySQL 8 dialect) |
docker-compose.yml |
Local Postgres 16 sidecar (default) + MySQL 8 sidecar (compose profile mysql) |
Dockerfile |
Multi-stage, non-root Spring Boot layered image |
k8s/*.yaml |
Namespace / ConfigMap(s) / Secret / Deployment / Service / Ingress / postgres.yaml / mysql.yaml |
deploy_kind.sh |
One-shot kind cluster + build + load + apply |
run_flow.sh |
20-request exerciser used during keploy record |
demo_script.sh |
Record / replay / offline-test harness |
simulate_fiori_flow.sh |
Narrated Fiori-style flow for two-terminal demos |
keploy.yml |
Recorded-mock metadata + global noise rules |