Tampilan
Kenapa penting
Tujuh bab terakhir membahas potongan-potongan sistem terdistribusi satu per satu. Di pekerjaan nyata, potongan itu tidak pernah berdiri sendiri: gateway memvalidasi token, service membaca konfigurasi terpusat, panggilan antar service dibungkus timeout dan circuit breaker, dan satu request harus bisa dilacak dari ujung ke ujung. Bug paling menyebalkan justru muncul di sambungannya — issuer JWT yang tidak cocok, header trace yang hilang, service yang start sebelum dependency-nya siap. Proyek mini ini memaksamu merakit semuanya sekaligus, sehingga kamu mengalami sendiri masalah integrasi tersebut di laptop, bukan pertama kali di production.
Konsep inti
Gambaran sistem
Domainnya toko daring kecil. Klien hanya bicara dengan gateway; semua service lain tidak diekspos ke luar.
text
klien ──► gateway :8080 ──► catalog-service :8081 ──► postgres (catalog)
│ ├─► order-service :8082 ──► postgres (orders)
│ │ │ ├─► catalog-service (cek harga & stok)
│ │ │ └─► payment-service (buat pembayaran)
│ └─► payment-service :8083 ──► postgres (payments)
│
keycloak :8180 (JWT) config-server :8888 jaeger :16686 (UI) / :4318 (OTLP)| Komponen | Tanggung jawab | Bab acuan |
|---|---|---|
| gateway | routing /api/**, validasi JWT, rate limit | API Gateway, Security |
| catalog-service | produk, harga, stok | — |
| order-service | membuat pesanan, orkestrasi panggilan ke catalog & payment | Resilience |
| payment-service | membuat dan menyelesaikan pembayaran | Contract Testing |
| config-server | konfigurasi dari repo Git | Konfigurasi Terpusat |
| keycloak | identity provider, realm toko | Security |
| jaeger | menerima dan menampilkan trace | Distributed Tracing |
Setiap service punya database sendiri — tidak ada service yang membaca tabel milik service lain.
Gateway: routing dan validasi JWT
Gateway memakai spring-cloud-starter-gateway-server-webflux dan bertindak sebagai resource server: token yang tidak valid ditolak sebelum menyentuh service internal. Header Authorization diteruskan apa adanya ke service tujuan.
yaml
# config-repo/gateway.yml — Potongan
spring:
cloud:
gateway:
server:
webflux:
routes:
- id: catalog
uri: http://catalog-service:8081
predicates:
- Path=/api/catalog/**
filters:
- StripPrefix=2
- id: orders
uri: http://order-service:8082
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8180/realms/toko
jwk-set-uri: http://keycloak:8080/realms/toko/protocol/openid-connect/certsPerhatikan pasangan issuer-uri dan jwk-set-uri. Token yang kamu ambil dari laptop berisi iss = http://localhost:8180/realms/toko, sedangkan dari dalam jaringan Compose Keycloak dicapai lewat keycloak:8080. Dengan jwk-set-uri internal, kunci publik diambil lewat jaringan Docker, sementara claim iss tetap divalidasi terhadap alamat publik. Di production, satu hostname publik untuk Keycloak menghindari masalah ini sepenuhnya.
Service: JWT lagi, bukan percaya gateway
Tiap service juga resource server dengan konfigurasi JWT yang sama. Alasannya defense in depth: bila suatu saat ada jalur yang melewati gateway (port tak sengaja terbuka, service lain memanggil langsung), service tetap menolak request tanpa token sah.
java
// Potongan — order-service
@Configuration
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers(HttpMethod.POST, "/orders").hasAuthority("SCOPE_orders.write")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}Saat order-service memanggil payment-service atas nama user, ia meneruskan token yang sama (token relay) atau memakai token client credentials miliknya sendiri — pilih salah satu dan tulis alasannya di README proyek.
Konfigurasi terpusat
Semua service memuat konfigurasi dari config-server dengan spring.config.import=configserver:http://config-server:8888. Repo konfigurasi (config-repo/) berisi application.yml untuk nilai bersama (tracing, actuator) dan satu file per service. Secret seperti password database tidak ditaruh di repo — disuntikkan lewat environment variable di compose.yaml.
Resilience antar service
order-service adalah titik paling rawan karena memanggil dua service lain. Terapkan timeout, circuit breaker, dan fallback dari bab Resilience Terdistribusi:
java
// Potongan — order-service
@Service
class PaymentGatewayClient {
private final RestClient paymentClient;
PaymentGatewayClient(RestClient.Builder builder) {
// Builder dari Spring sudah ter-instrument: header traceparent ikut terkirim
this.paymentClient = builder.baseUrl("http://payment-service:8083").build();
}
@CircuitBreaker(name = "payment", fallbackMethod = "paymentPending")
PaymentResult create(PaymentRequest request) {
return paymentClient.post().uri("/payments")
.body(request)
.retrieve()
.body(PaymentResult.class);
}
PaymentResult paymentPending(PaymentRequest request, Throwable cause) {
return PaymentResult.pending(request.orderId());
}
}yaml
# config-repo/order-service.yml — Potongan
resilience4j:
circuitbreaker:
instances:
payment:
sliding-window-size: 10
failure-rate-threshold: 50
wait-duration-in-open-state: 10s
timelimiter:
instances:
payment:
timeout-duration: 2sFallback mengembalikan status PENDING, bukan PAID. Pesanan disimpan sebagai PAYMENT_PENDING dan diproses ulang belakangan. Pastikan juga timeout panggilan ke catalog lebih pendek daripada timeout gateway, agar gateway tidak memutus request yang sebenarnya masih bisa diselesaikan.
Tracing end-to-end
Nilai tracing bersama ditaruh di config-repo/application.yml supaya semua service seragam:
yaml
# config-repo/application.yml — Potongan
management:
tracing:
sampling:
probability: 1.0 # 100% hanya untuk lokal
opentelemetry:
tracing:
export:
otlp:
endpoint: http://jaeger:4318/v1/traces
logging:
pattern:
correlation: "[${spring.application.name:},%X{traceId:-},%X{spanId:-}] "Syaratnya: setiap client HTTP dibuat dari builder yang disediakan Spring (RestClient.Builder, WebClient.Builder), sehingga header traceparent ikut terkirim. Satu POST /orders harus menghasilkan satu trace berisi span gateway, order-service, catalog-service, dan payment-service.
Contract test order ↔ payment
Integrasi yang paling sering berubah adalah order ↔ payment, jadi pasangan inilah yang dijaga kontrak:
- Repo payment-service:
src/test/resources/contracts/order/berisi kontrak sukses (201, statusPENDING) dan penolakan (422,failureReason), plus kontrak eventpayment.completedbila kamu memakai Kafka. - Build payment-service menjalankan test hasil generate dan menerbitkan stub JAR ke repo Maven lokal (
./mvnw install). - order-service menguji
PaymentGatewayClientdengan@AutoConfigureStubRunner(ids = "id.jalurjava:payment-service:+:stubs:8090", stubsMode = StubRunnerProperties.StubsMode.LOCAL).
compose.yaml lengkap
yaml
# compose.yaml — jalankan dari root proyek: docker compose up --build
services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: toko
POSTGRES_PASSWORD: ${DB_PASSWORD:-toko-lokal}
volumes:
- ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # membuat db catalog, orders, payments
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U toko"]
interval: 5s
retries: 10
keycloak:
image: quay.io/keycloak/keycloak:26.4
command: start-dev --import-realm
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD:-admin-lokal}
KC_HOSTNAME: http://localhost:8180
KC_HEALTH_ENABLED: "true"
volumes:
- ./infra/keycloak/toko-realm.json:/opt/keycloak/data/import/toko-realm.json:ro
ports:
- "8180:8080"
jaeger:
image: jaegertracing/jaeger:2.11.0
ports:
- "16686:16686" # UI
- "4318:4318" # OTLP HTTP
config-server:
build: ./config-server
volumes:
- ./config-repo:/config-repo:ro
environment:
SPRING_PROFILES_ACTIVE: native
SPRING_CLOUD_CONFIG_SERVER_NATIVE_SEARCH_LOCATIONS: file:/config-repo
healthcheck:
test: ["CMD", "curl", "-fs", "http://localhost:8888/actuator/health"]
interval: 5s
retries: 20
catalog-service:
build: ./catalog-service
environment: &service-env
SPRING_CONFIG_IMPORT: configserver:http://config-server:8888
SPRING_DATASOURCE_PASSWORD: ${DB_PASSWORD:-toko-lokal}
depends_on: &service-deps
config-server: { condition: service_healthy }
postgres: { condition: service_healthy }
order-service:
build: ./order-service
environment: *service-env
depends_on: *service-deps
payment-service:
build: ./payment-service
environment: *service-env
depends_on: *service-deps
gateway:
build: ./gateway
environment:
SPRING_CONFIG_IMPORT: configserver:http://config-server:8888
ports:
- "8080:8080" # satu-satunya service aplikasi yang diekspos
depends_on:
config-server: { condition: service_healthy }
keycloak: { condition: service_started }
volumes:
pgdata:Beberapa keputusan yang disengaja: hanya gateway, Keycloak, dan Jaeger yang membuka port ke host; satu instance PostgreSQL dengan tiga database terpisah cukup untuk lokal (di production tiap service punya instance/skema sendiri); config-server memakai profil native yang membaca folder lokal agar tidak butuh remote Git saat belajar. Image config-server harus memuat curl untuk healthcheck, atau ganti healthcheck dengan perintah lain yang tersedia di image-mu.
Contoh di dunia backend
Skenario uji manual yang meniru alur checkout:
bash
# 1. Ambil token user (client "toko-cli" dengan direct access grant, hanya untuk lokal)
TOKEN=$(curl -s -X POST http://localhost:8180/realms/toko/protocol/openid-connect/token \
-d grant_type=password -d client_id=toko-cli -d username=budi -d password=budi-lokal \
| jq -r .access_token)
# 2. Lihat katalog lewat gateway
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/catalog/products | jq
# 3. Buat pesanan
curl -s -X POST http://localhost:8080/api/orders \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"items":[{"sku":"KOPI-250G","qty":2}]}' | jq
# 4. Simulasikan payment-service mati, lalu ulangi langkah 3
docker compose stop payment-serviceSetelah langkah 4, respons harus tetap cepat dengan status PAYMENT_PENDING. Buka http://localhost:16686, cari trace POST /orders, dan pastikan span ke payment-service tercatat sebagai error sementara span lain normal. Ambil traceId dari Jaeger, lalu cari di log order-service dengan docker compose logs order-service | grep <traceId> — korelasi log dan trace inilah yang akan kamu pakai saat menangani insiden sungguhan.
Checklist proyek
- [ ]
docker compose up --buildmenjalankan semua komponen tanpa restart berulang. - [ ] Request tanpa token ke
/api/ordersditolak gateway dengan401; request langsung ke port service (dari dalam jaringan) tanpa token juga401. - [ ] User tanpa scope
orders.writemendapat403saatPOST /orders. - [ ] Mengubah nilai di
config-repo/lalu restart service mengubah perilakunya tanpa rebuild image. - [ ] Mematikan payment-service menghasilkan
PAYMENT_PENDINGdalam ≤ 2 detik, dan circuit breaker terlihatOPENdi/actuator/healthatau metrik. - [ ] Satu
POST /ordersmenghasilkan satu trace berisi empat service di Jaeger, dantraceId-nya bisa ditemukan di log. - [ ] Contract test payment-service lulus dan test
PaymentGatewayClientdi order-service lulus memakai stub. - [ ] Tidak ada password atau client secret yang di-commit; semua lewat variabel environment dengan default khusus lokal.
Latihan menyusul
Kerangka proyek (folder service, config-repo/, realm Keycloak, dan test awal) akan tersedia di exercises/ dalam pembaruan berikutnya. Untuk sekarang, susun proyeknya sendiri mengikuti bab ini.
Pertanyaan interview
1. Kalau gateway sudah memvalidasi JWT, kenapa service internal masih memvalidasinya lagi?
Karena jaringan internal tidak otomatis tepercaya. Port yang salah terbuka, service lain yang memanggil langsung, atau penyerang yang sudah masuk ke satu container bisa melewati gateway. Validasi di tiap service (zero trust) memastikan setiap request membawa identitas yang sah, dan service juga butuh claim token untuk otorisasi yang spesifik domain.
2. Bagaimana kamu memastikan satu request bisa dilacak lintas semua service?
Semua service mengekspor span ke backend tracing yang sama, context propagation memakai standar W3C traceparent, dan semua client HTTP/messaging dibuat lewat komponen yang ter-instrument. Lalu traceId dimasukkan ke pola log sehingga log dan trace bisa dikorelasikan.
3. Apa yang terjadi pada pesanan bila payment-service down, dan bagaimana sistem pulih?
Circuit breaker membuka setelah ambang kegagalan tercapai, sehingga order-service berhenti membuang waktu memanggil service yang mati. Fallback menyimpan pesanan sebagai PAYMENT_PENDING dan klien diberi tahu. Setelah payment-service pulih, circuit breaker masuk HALF_OPEN, panggilan percobaan berhasil, dan job/retry memproses pesanan yang tertunda secara idempoten.
Jebakan umum
issuer-uridi service berbeda dengan claimissdi token (hostnamelocalhostvs nama container) — semua request jadi401.- Membuat
RestClient/WebClientdengannewatau builder statis — header trace tidak terkirim dan trace terputus menjadi beberapa bagian. - Mengandalkan urutan penulisan di
compose.yamluntuk urutan start — pakaihealthcheck+depends_on: condition: service_healthy. - Fallback yang "berbohong", misalnya menganggap pesanan sudah dibayar saat payment-service mati.
- Mengekspos port semua service ke host, sehingga gateway bisa dilewati begitu saja.
- Menyimpan password database atau client secret di
config-repo/yang ikut di-commit.
Ringkasan
- Proyek mini menggabungkan gateway, tiga service, Keycloak, config-server, dan Jaeger dalam satu
compose.yaml. - JWT divalidasi di gateway dan di tiap service; perhatikan kecocokan issuer antara alamat publik dan jaringan internal.
- order-service membungkus panggilan ke catalog dan payment dengan timeout, circuit breaker, dan fallback yang jujur.
- Tracing end-to-end bergantung pada client yang ter-instrument dan
traceIddi pola log. - Contract test menjaga integrasi order ↔ payment tanpa harus menjalankan seluruh sistem.