Tampilan
Kenapa penting
Desain di bab sebelumnya baru bernilai kalau bisa berjalan. Di tahap implementasi, kebanyakan proyek microservices pertama gagal bukan karena kode bisnisnya sulit, tapi karena hal-hal di sekelilingnya: tiap service punya format error berbeda, log tidak bisa dikorelasikan, event hilang saat broker restart, dan test hanya lulus di laptop pembuatnya. Bab ini menyusun cara kerja yang mencegah semua itu sejak service pertama — struktur repo, template bersama, urutan pengerjaan, outbox dan saga checkout, serta strategi test. Hasilnya adalah capstone yang bisa kamu tunjukkan di portofolio dan kamu jelaskan keputusannya di interview.
Konsep inti
Struktur repo: monorepo multi-module
Ada dua pilihan umum. Repo terpisah per service memberi isolasi penuh: tiap tim punya siklus rilis, akses, dan pipeline sendiri. Harganya: perubahan kontrak lintas service butuh beberapa PR, versi dependency mudah melenceng, dan template bersama harus dipublikasikan sebagai artifact.
Capstone ini memakai monorepo multi-module Maven. Alasannya: kamu satu orang (atau tim kecil), dan yang paling sering berubah di awal justru kontrak antar service. Satu PR bisa mengubah skema event, producer, dan consumer sekaligus. Yang penting: monorepo bukan satu deployable — tiap module tetap menghasilkan image dan pipeline sendiri.
text
shop/
├── pom.xml # parent: BOM Spring Boot & Spring Cloud, plugin
├── platform/
│ └── service-starter/ # template bersama (auto-configuration)
├── contracts/ # skema event (record) + OpenAPI
├── services/
│ ├── catalog-service/
│ ├── order-service/
│ ├── payment-service/ # pembayaran palsu (simulasi PSP)
│ ├── inventory-service/
│ ├── notification-service/
│ └── gateway/
├── deploy/ # manifest/Helm (bab berikutnya)
└── e2e/ # test end-to-end kecilxml
<!-- Potongan pom.xml parent -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
</parent>
<modules>
<module>platform/service-starter</module>
<module>contracts</module>
<module>services/catalog-service</module>
<module>services/order-service</module>
<!-- ... -->
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.1.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>Aturan dependensi
services/* boleh bergantung pada platform/ dan contracts/, tapi tidak boleh saling bergantung. Kalau order-service meng-import class dari inventory-service, batas service sudah bocor.
Template service bersama
service-starter adalah module kecil yang dipakai semua service. Isinya hal yang harus seragam:
| Aspek | Isi template |
|---|---|
| Logging | log JSON terstruktur, traceId di tiap baris |
| Tracing | Micrometer Tracing + exporter OTLP ke Tempo |
| Error | @RestControllerAdvice yang menghasilkan ProblemDetail |
| Security | resource server JWT, endpoint actuator/health terbuka |
| Operasional | Actuator: health, prometheus; probe liveness/readiness aktif |
java
// Potongan — platform/service-starter
@RestControllerAdvice
public class GlobalProblemHandler {
@ExceptionHandler(NotFoundException.class)
ProblemDetail notFound(NotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Resource tidak ditemukan");
pd.setType(URI.create("https://shop.example/problems/not-found"));
return pd;
}
@ExceptionHandler(BusinessRuleException.class)
ProblemDetail businessRule(BusinessRuleException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setTitle("Aturan bisnis dilanggar");
pd.setProperty("code", ex.code());
return pd;
}
}yaml
# Potongan — application.yaml default dari starter
management:
endpoints:
web:
exposure:
include: health, prometheus, info
endpoint:
health:
probes:
enabled: true
tracing:
sampling:
probability: 1.0 # turunkan di production
logging:
structured:
format:
console: ecs
spring:
mvc:
problemdetails:
enabled: trueStarter didaftarkan lewat file META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, sehingga service cukup menambah satu dependency.
Urutan pengerjaan
Urutan dipilih supaya tiap langkah menambah satu risiko baru saja:
- catalog-service — CRUD sederhana, PostgreSQL + Flyway. Di sini template bersama diuji pertama kali.
- order-service — membuat pesanan, tabel outbox, publish
OrderCreated. - payment-service (palsu) — mengonsumsi event, mensimulasikan PSP; bisa diatur gagal lewat konfigurasi agar kompensasi bisa diuji.
- inventory-service — reservasi stok dengan optimistic locking; publish
StockReserved/StockRejected. - notification-service — consumer murni (log/email palsu); membuktikan event bisa dipakai pihak baru tanpa mengubah producer.
- gateway — Spring Cloud Gateway: routing, validasi JWT, rate limit. Dibuat terakhir karena rute baru stabil setelah semua API ada.
Outbox: menghindari dual write
Menyimpan pesanan lalu memanggil broker di method yang sama adalah dual write: salah satu bisa sukses sementara yang lain gagal. Outbox menyimpan event di tabel outbox dalam transaksi yang sama dengan perubahan bisnis; relay terpisah membaca dan mengirimnya ke Kafka.
sql
-- V2__outbox.sql (order-service)
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;java
// Potongan — order-service
@Service
public class CheckoutService {
private final OrderRepository orders;
private final OutboxRepository outbox;
private final JsonMapper json; // Jackson 3: tools.jackson.databind.json.JsonMapper
@Transactional
public Order checkout(CheckoutCommand cmd) {
Order order = orders.save(Order.pending(cmd.customerId(), cmd.items()));
var event = new OrderCreated(UUID.randomUUID(), order.getId(), order.total(), cmd.items());
outbox.save(OutboxEntry.of(order.getId(), "OrderCreated", json.writeValueAsString(event)));
return order; // satu commit: pesanan + event
}
}
@Component
class OutboxRelay {
private final OutboxRepository outbox;
private final KafkaTemplate<String, String> kafka;
@Scheduled(fixedDelay = 500)
@Transactional
void publish() {
for (OutboxEntry e : outbox.findTop100ByPublishedAtIsNullOrderByCreatedAt()) {
kafka.send("shop.orders", e.getAggregateId(), e.getPayload()).join(); // tunggu ack
e.markPublished();
}
}
}Relay bisa mengirim ulang event yang sudah terkirim (crash setelah send, sebelum commit). Itu sebabnya outbox = at-least-once, dan consumer harus idempoten. Bila kamu menjalankan lebih dari satu replika, tambahkan SELECT ... FOR UPDATE SKIP LOCKED atau jalankan relay hanya di satu instance. Alternatif tanpa polling adalah CDC (Debezium) yang membaca WAL PostgreSQL.
Saga checkout (koreografi)
| Langkah | Service | Event masuk | Aksi | Event keluar |
|---|---|---|---|---|
| 1 | order | (HTTP checkout) | simpan PENDING | OrderCreated |
| 2 | inventory | OrderCreated | reservasi stok | StockReserved / StockRejected |
| 3 | payment | StockReserved | tagih (palsu) | PaymentSucceeded / PaymentFailed |
| 4 | inventory | PaymentFailed | kompensasi: lepas stok | StockReleased |
| 5 | order | hasil akhir | CONFIRMED / CANCELLED | OrderConfirmed / OrderCancelled |
| 6 | notification | Order* | kirim notifikasi | — |
java
// Potongan — consumer idempoten di order-service
@KafkaListener(topics = "shop.payments", groupId = "order-service")
@Transactional
public void onPayment(PaymentResult event) {
if (!processed.markIfNew(event.eventId())) {
return; // duplikat: sudah pernah diproses
}
Order order = orders.findById(event.orderId()).orElseThrow();
if (event.success()) {
order.confirm();
} else {
order.cancel(event.reason());
}
}markIfNew melakukan insert ke tabel processed_event dengan event_id sebagai primary key; kalau insert melanggar constraint, event dianggap duplikat. Karena insert dan perubahan pesanan satu transaksi, keduanya commit atau rollback bersama.
Test di tiap level
| Level | Alat | Contoh di capstone |
|---|---|---|
| Unit | JUnit 6, AssertJ, Mockito | Order.confirm() menolak pesanan yang sudah batal |
| Integrasi | Testcontainers (PostgreSQL, Kafka) | checkout menulis baris outbox; relay mengirim ke Kafka |
| Kontrak | Spring Cloud Contract / skema di contracts/ | consumer payment cocok dengan event order |
| E2E kecil | Docker Compose + satu skenario | checkout sukses dan checkout gagal-bayar lewat gateway |
java
// Potongan — test integrasi order-service
@SpringBootTest
@Testcontainers
class CheckoutIT {
@Container @ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
@Container @ServiceConnection
static KafkaContainer kafka = new KafkaContainer("apache/kafka:4.0.0");
@Autowired CheckoutService checkout;
@Autowired OutboxRepository outbox;
@Test
void checkoutMenulisPesananDanOutboxDalamSatuTransaksi() {
Order order = checkout.checkout(new CheckoutCommand("cust-1", List.of(new Item("SKU-1", 2))));
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
assertThat(outbox.findAll())
.anySatisfy(e -> assertThat(e.getAggregateId()).isEqualTo(order.getId()));
}
}Testcontainers 2.0
Di Testcontainers 2.x, class container berada di package per modul, misalnya org.testcontainers.postgresql.PostgreSQLContainer dan org.testcontainers.kafka.KafkaContainer, dan tidak lagi generik. Cek dokumentasi modul yang kamu pakai bila import dari versi 1.x tidak ditemukan.
Piramidanya tetap: banyak unit test, cukup test integrasi untuk tiap adapter (repository, consumer, producer), sedikit kontrak, dan hanya dua atau tiga skenario E2E. E2E lambat dan rapuh; jangan memindahkan logika bisnis ke sana.
Definisi selesai per service
Sebuah service dianggap selesai bila:
- Semua endpoint & event punya kontrak di
contracts/dan test kontrak lulus. - Migrasi Flyway berjalan dari database kosong; tidak ada
ddl-auto: update. - Error mengikuti
ProblemDetail; tidak ada stack trace di response. - Log JSON memuat
traceId; trace terlihat utuh dari gateway sampai consumer. /actuator/health/readinesshanyaUPbila database siap.- Consumer idempoten (ada test event duplikat).
READMEservice: cara menjalankan, variabel environment, topik yang dipakai.
Contoh di dunia backend
Inventaris adalah titik rawan saat flash sale: dua checkout bersamaan bisa mengambil stok terakhir yang sama. inventory-service memakai optimistic locking dan menerbitkan hasilnya lewat outbox, sehingga saga tetap konsisten meski ada retry.
java
// Potongan — inventory-service
@Entity
class StockItem {
@Id String sku;
int available;
@Version long version;
void reserve(int qty) {
if (qty > available) {
throw new BusinessRuleException("STOCK_INSUFFICIENT", "Stok " + sku + " tidak cukup");
}
available -= qty;
}
}
@Service
class ReservationService {
@Transactional
@Retryable(includes = OptimisticLockingFailureException.class, maxRetries = 3) // Spring Framework 7: org.springframework.resilience.annotation
public void onOrderCreated(OrderCreated event) {
if (!processed.markIfNew(event.eventId())) return;
try {
event.items().forEach(i -> stock.findById(i.sku()).orElseThrow().reserve(i.qty()));
outbox.save(OutboxEntry.of(event.orderId(), "StockReserved", toJson(event)));
} catch (BusinessRuleException ex) {
// aturan bisnis gagal: catat penolakan, bukan exception ke Kafka
outbox.save(OutboxEntry.of(event.orderId(), "StockRejected", rejectJson(event, ex)));
}
}
}Perhatikan: kegagalan bisnis (stok habis) diubah menjadi event StockRejected, sedangkan kegagalan teknis (konflik versi) di-retry. Mencampur keduanya akan membuat event stok habis terus di-retry tanpa akhir.
Pertanyaan interview
1. Kenapa tidak cukup memakai @Transactional untuk menyimpan ke database dan mengirim ke Kafka?
Transaksi Spring mengikat satu resource, yaitu koneksi database. Broker tidak ikut commit atau rollback, sehingga salah satu bisa sukses sementara yang lain gagal. Outbox menjadikan pengiriman event bagian dari transaksi database, lalu relay mengirimnya dengan jaminan at-least-once.
2. Apa yang kamu masukkan ke template service bersama, dan apa yang tidak?
Masuk: hal lintas fungsi yang harus seragam — format log, tracing, ProblemDetail, konfigurasi security dasar, Actuator. Tidak masuk: logika bisnis atau model domain, karena itu menciptakan coupling antar service dan memaksa semua service rilis bersamaan saat starter berubah.
3. Bagaimana kamu menguji saga yang melibatkan empat service?
Sebagian besar lewat test per service: tiap consumer diuji dengan Testcontainers terhadap event masukan dan event keluaran yang diharapkan, termasuk duplikat dan kegagalan. Kontrak memastikan bentuk event cocok. Lalu satu atau dua E2E membuktikan jalur sukses dan jalur kompensasi secara utuh.
Jebakan umum
- Memanggil
kafkaTemplate.sendlangsung di dalam transaksi bisnis — dual write, event bisa hilang atau terkirim untuk data yang di-rollback. - Consumer tidak idempoten, padahal outbox dan Kafka memberi at-least-once.
- Memasukkan model domain ke module bersama sehingga semua service ter-coupling.
- Me-retry kegagalan bisnis (stok habis) seperti kegagalan teknis — loop tanpa akhir.
- Test integrasi bergantung pada database lokal, bukan Testcontainers.
- Membangun gateway paling awal, lalu terus mengubah rute saat API service berubah.
Ringkasan
- Monorepo multi-module memudahkan perubahan kontrak lintas service, tapi tiap service tetap image dan pipeline sendiri.
- Template bersama menyeragamkan logging, tracing,
ProblemDetail, security, dan Actuator — tanpa logika bisnis. - Kerjakan berurutan: katalog → pesanan → pembayaran palsu → inventaris → notifikasi → gateway.
- Outbox menghindari dual write; consumer wajib idempoten karena jaminannya at-least-once.
- Uji di tiap level dan tetapkan definisi selesai yang sama untuk semua service.