Tampilan
Kenapa penting
Hampir tidak ada perusahaan yang memulai microservices dari nol. Yang lebih sering kamu temui adalah monolith yang sudah jalan bertahun-tahun, lalu satu bagian — biasanya pembayaran, karena butuh skala, audit, dan siklus rilis sendiri — perlu dipisahkan tanpa menghentikan bisnis. Proyek mini ini merangkai semua bab M13: bounded context, kontrak REST dan event, database per service, event-driven, dan saga. Hasilnya adalah kerangka yang bisa kamu jelaskan di interview sebagai "saya pernah memecah monolith secara bertahap", lengkap dengan alasan di balik setiap keputusan.
Konsep inti
Gambaran target
Monolith shop saat ini menangani pesanan dan pembayaran dalam satu database. Target akhir proyek:
| Komponen | Tanggung jawab | Data milik sendiri |
|---|---|---|
order-service | membuat pesanan, status pesanan, saga | orders, outbox |
payment-service | menagih, refund, riwayat transaksi | payments, processed_events |
| Kafka | topic order-events, payment-events | — |
Alur bahagia: klien POST /orders → order-service menyimpan pesanan PENDING_PAYMENT + event OrderPlaced ke outbox → relay menerbitkan ke order-events → payment-service menagih dan menerbitkan PaymentCompleted (atau PaymentFailed) → order-service mengubah status menjadi PAID atau menjalankan kompensasi CANCELLED.
Langkah 1 — strangler fig
Jangan memindahkan semuanya sekaligus. Pola strangler fig memasang titik routing di depan monolith:
- Buat
payment-serviceyang menyediakan perilaku sama dengan modul pembayaran lama. - Di monolith, ganti pemanggilan langsung ke modul pembayaran dengan interface
PaymentGateway; implementasi lama dan klien HTTP ke service baru sama-sama memenuhinya. - Alihkan lalu lintas bertahap (flag konfigurasi, misalnya 10% → 50% → 100%), pantau error rate dan selisih hasil.
- Setelah stabil, ganti panggilan sinkron dengan event, lalu hapus kode dan tabel pembayaran dari monolith.
Rollback di setiap tahap cukup mengubah flag — inilah keunggulan utama dibanding rewrite besar.
Langkah 2 — bounded context dan kontrak
Bagi bahasa domain: di konteks Order, "pembayaran" hanyalah status; di konteks Payment, ada transaksi, metode bayar, dan refund. Kontrak antar-service ditulis eksplisit dan diberi versi.
json
{
"eventId": "7d3f0f8e-2c41-4f43-9a52-0b7c1f0e9a11",
"eventType": "OrderPlaced",
"version": 1,
"occurredAt": "2026-10-23T07:15:00Z",
"orderId": "ORD-1001",
"customerId": "CUS-42",
"amount": 150000,
"currency": "IDR"
}PaymentCompleted membawa eventId, orderId, paymentId, dan amount. REST tetap dipakai untuk query sinkron, misalnya GET /payments?orderId=ORD-1001 untuk halaman detail pesanan. Aturan kontrak: menambah field boleh, mengganti nama atau menghapus field adalah breaking change dan butuh versi baru.
Langkah 3 — database terpisah dan outbox
Menyimpan pesanan lalu langsung send ke Kafka mengandung dual write problem: database commit sementara broker gagal (atau sebaliknya). Solusinya tabel outbox di database order-service, ditulis dalam transaksi yang sama.
sql
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL UNIQUE,
event_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX idx_outbox_unpublished ON outbox (id) WHERE published_at IS NULL;java
// Potongan — order-service (Spring Boot 4, Spring Data JPA, Jackson 3)
@Service
class PlaceOrderService {
private final OrderRepository orders;
private final OutboxRepository outbox;
private final JsonMapper jsonMapper; // tools.jackson.databind.json.JsonMapper
PlaceOrderService(OrderRepository orders, OutboxRepository outbox, JsonMapper jsonMapper) {
this.orders = orders;
this.outbox = outbox;
this.jsonMapper = jsonMapper;
}
@Transactional
public Order place(PlaceOrderRequest request) {
Order order = orders.save(Order.pendingPayment(request.customerId(), request.amount()));
var event = new OrderPlaced(UUID.randomUUID(), order.getId(), order.getCustomerId(), order.getAmount());
outbox.save(OutboxMessage.of(event.eventId(), "OrderPlaced", order.getId(),
jsonMapper.writeValueAsString(event)));
return order; // pesanan dan event commit bersama, atau tidak sama sekali
}
}Relay sederhana adalah job terjadwal yang membaca baris belum terkirim dan menerbitkannya:
java
// Potongan — relay outbox di order-service
@Component
class OutboxRelay {
private final OutboxRepository outbox;
private final KafkaTemplate<String, String> kafka;
OutboxRelay(OutboxRepository outbox, KafkaTemplate<String, String> kafka) {
this.outbox = outbox;
this.kafka = kafka;
}
@Scheduled(fixedDelay = 1000)
@Transactional
public void publishPending() {
for (OutboxMessage msg : outbox.findTop100ByPublishedAtIsNullOrderByIdAsc()) {
kafka.send("order-events", msg.getAggregateId(), msg.getPayload()).join(); // key = orderId menjaga urutan per pesanan
msg.markPublished();
}
}
}Relay di produksi
Untuk volume besar, tim biasanya memakai Debezium (change data capture) yang membaca WAL PostgreSQL dan menerbitkan isi outbox ke Kafka, sehingga tidak perlu polling. Polling seperti di atas cukup untuk proyek ini.
Langkah 4 — consumer idempoten dan saga sederhana
Payment-service mendengarkan order-events. Karena outbox menjamin at-least-once, consumer mencatat eventId yang sudah diproses di tabel processed_events (primary key event_id) dalam transaksi yang sama dengan penagihan.
java
// Potongan — payment-service
@Component
class OrderEventsListener {
private final ChargeService chargeService;
OrderEventsListener(ChargeService chargeService) {
this.chargeService = chargeService;
}
@KafkaListener(topics = "order-events", groupId = "payment-service")
public void onMessage(String payload) {
chargeService.handleOrderPlaced(payload); // @Transactional: cek processed_events, tagih, tulis outbox PaymentCompleted/PaymentFailed
}
}Saga di proyek ini memakai koreografi dua langkah: PaymentCompleted → order-service set PAID; PaymentFailed → kompensasi: order-service set CANCELLED dan melepas semantic lock PENDING_PAYMENT. Bila nanti stok dan pengiriman ikut dipecah, pertimbangkan pindah ke orkestrator seperti di bab sebelumnya.
Agar mekanisme outbox + consumer idempoten terasa nyata, berikut simulasi Java murni: relay crash sebelum menandai baris, lalu mengirim ulang setelah restart.
java
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class OutboxDemo {
record OutboxRow(long id, String eventType, String orderId, boolean published) {
OutboxRow markPublished() { return new OutboxRow(id, eventType, orderId, true); }
}
// "Database" order-service: tabel orders + outbox ditulis dalam satu transaksi lokal.
static final List<String> orders = new ArrayList<>();
static final List<OutboxRow> outbox = new ArrayList<>();
static void placeOrder(String orderId) {
orders.add(orderId);
outbox.add(new OutboxRow(outbox.size() + 1, "OrderPlaced", orderId, false));
}
// Relay membaca baris yang belum terkirim lalu "publish" ke broker.
static List<OutboxRow> relay(boolean crashBeforeMark) {
List<OutboxRow> sent = new ArrayList<>();
for (int i = 0; i < outbox.size(); i++) {
OutboxRow row = outbox.get(i);
if (row.published()) continue;
sent.add(row);
if (!crashBeforeMark) outbox.set(i, row.markPublished());
}
return sent;
}
// payment-service: consumer idempoten berdasarkan id event.
static final Set<Long> processed = new HashSet<>();
static void consume(OutboxRow event) {
if (!processed.add(event.id())) {
System.out.println("payment: event " + event.id() + " duplikat, diabaikan");
return;
}
System.out.println("payment: tagih pesanan " + event.orderId());
}
public static void main(String[] args) {
placeOrder("ORD-1");
placeOrder("ORD-2");
System.out.println("Relay putaran 1 (crash sebelum menandai published)");
relay(true).forEach(OutboxDemo::consume);
System.out.println("Relay putaran 2 (setelah restart)");
relay(false).forEach(OutboxDemo::consume);
System.out.println("Relay putaran 3");
System.out.println("terkirim: " + relay(false).size());
}
}text
Relay putaran 1 (crash sebelum menandai published)
payment: tagih pesanan ORD-1
payment: tagih pesanan ORD-2
Relay putaran 2 (setelah restart)
payment: event 1 duplikat, diabaikan
payment: event 2 duplikat, diabaikan
Relay putaran 3
terkirim: 0Contoh di dunia backend
compose.yaml untuk lingkungan lokal
Dua service, Kafka mode KRaft (tanpa ZooKeeper), dan dua PostgreSQL — satu per service. Kredensial di sini hanya untuk lokal.
yaml
services:
kafka:
image: apache/kafka:4.1.0
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
order-db:
image: postgres:17
environment:
POSTGRES_DB: orders
POSTGRES_USER: order
POSTGRES_PASSWORD: order-local
healthcheck:
test: ["CMD-SHELL", "pg_isready -U order -d orders"]
interval: 5s
retries: 10
payment-db:
image: postgres:17
environment:
POSTGRES_DB: payments
POSTGRES_USER: payment
POSTGRES_PASSWORD: payment-local
healthcheck:
test: ["CMD-SHELL", "pg_isready -U payment -d payments"]
interval: 5s
retries: 10
order-service:
build: ./order-service
ports: ["8081:8080"]
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://order-db:5432/orders
SPRING_DATASOURCE_USERNAME: order
SPRING_DATASOURCE_PASSWORD: order-local
SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
depends_on:
order-db: { condition: service_healthy }
kafka: { condition: service_started }
payment-service:
build: ./payment-service
ports: ["8082:8080"]
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://payment-db:5432/payments
SPRING_DATASOURCE_USERNAME: payment
SPRING_DATASOURCE_PASSWORD: payment-local
SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
depends_on:
payment-db: { condition: service_healthy }
kafka: { condition: service_started }Skema tiap database dikelola Flyway di masing-masing service (db/migration/V1__orders.sql, V1__payments.sql), bukan oleh service lain.
Test kontrak dan smoke test end-to-end
Test kontrak paling sederhana: simpan contoh event resmi sebagai fixture bersama (contracts/order-placed.v1.json), lalu producer memastikan event yang ia serialisasi memuat semua field kontrak, dan consumer memastikan ia bisa membaca fixture tersebut.
java
// Potongan — test di order-service (JUnit Jupiter + AssertJ, Jackson 3)
@Test
void orderPlacedMemenuhiKontrakV1() throws Exception {
JsonMapper mapper = JsonMapper.builder().build();
JsonNode contract = mapper.readTree(Files.readString(Path.of("../contracts/order-placed.v1.json")));
JsonNode actual = mapper.readTree(mapper.writeValueAsString(
new OrderPlaced(UUID.randomUUID(), "ORD-1", "CUS-1", 150_000)));
contract.propertyNames().forEach(field -> assertThat(actual.has(field))
.as("field kontrak '%s' wajib ada", field).isTrue());
}Untuk alur penuh, jalankan stack lalu periksa hasilnya dari luar seperti klien sungguhan:
bash
docker compose up -d --build
curl -s -X POST localhost:8081/orders \
-H 'Content-Type: application/json' \
-d '{"customerId":"CUS-42","amount":150000}'
# tunggu beberapa detik, lalu cek status pesanan dan pembayaran
curl -s localhost:8081/orders/ORD-1001
curl -s 'localhost:8082/payments?orderId=ORD-1001'Ulangi dengan nominal yang sengaja ditolak payment-service (misalnya di atas limit) untuk memastikan kompensasi mengubah pesanan menjadi CANCELLED. Setelah lancar, pindahkan skenario ini ke test otomatis dengan Testcontainers di modul berikutnya.
Checklist proyek
| Area | Cek |
|---|---|
| Batas | Tidak ada service yang membaca tabel milik service lain |
| Kontrak | Event punya eventId, eventType, version; fixture kontrak ada di repo |
| Keandalan | Pesanan + event ditulis dalam satu transaksi (outbox) |
| Idempotensi | Consumer mengabaikan eventId yang sudah diproses |
| Saga | PaymentFailed memicu kompensasi CANCELLED |
| Observability | orderId muncul di log kedua service |
| Rollback | Flag strangler bisa mengembalikan pembayaran ke monolith |
Latihan kode untuk proyek ini menyusul di folder exercises/.
Pertanyaan interview
1. Bagaimana kamu memindahkan modul dari monolith tanpa downtime?
Saya memakai strangler fig: abstraksikan modul di balik interface, buat service baru dengan perilaku sama, lalu alihkan lalu lintas bertahap lewat flag atau routing sambil membandingkan hasil dan error rate. Data dimigrasikan dengan sinkronisasi sementara, dan kode lama baru dihapus setelah service baru menangani 100% lalu lintas cukup lama tanpa masalah.
2. Apa itu dual write problem dan bagaimana outbox menyelesaikannya?
Dual write terjadi saat satu operasi harus menulis ke dua sistem (database dan broker) yang tidak bisa di-commit atomik, sehingga salah satunya bisa gagal sendirian. Outbox menulis event ke tabel di database yang sama dalam satu transaksi lokal, lalu proses terpisah menerbitkannya ke broker. Hasilnya at-least-once, sehingga consumer harus idempoten.
3. Bagaimana menjaga kontrak event tetap kompatibel saat producer berubah?
Perubahan dibuat aditif: menambah field opsional boleh, tetapi mengganti nama, mengubah tipe, atau menghapus field dianggap breaking. Event diberi version, dan test kontrak di pipeline producer memastikan field yang diandalkan consumer tetap ada. Untuk skala lebih besar, tim memakai schema registry atau consumer-driven contract testing.
Jebakan umum
- Memecah monolith sekaligus dalam satu rilis besar — rollback hampir mustahil saat ada masalah.
- Membiarkan service baru tetap membaca tabel monolith "sementara", lalu sementara itu menjadi permanen.
- Memanggil
kafkaTemplate.sendlangsung setelahsavetanpa outbox, sehingga event bisa hilang saat broker bermasalah. - Consumer tidak idempoten, sehingga event yang dikirim ulang relay menagih pelanggan dua kali.
- Mengirim event tanpa key
orderId, sehingga event satu pesanan bisa diproses di luar urutan. - Menulis kredensial produksi di
compose.yamlatauapplication.yamlyang di-commit ke repo.
Ringkasan
- Pisahkan layanan secara bertahap dengan strangler fig: interface, service baru, routing bertahap, lalu hapus kode lama.
- Tetapkan bounded context dan kontrak eksplisit — REST untuk query, event berversi (
OrderPlaced,PaymentCompleted) untuk perubahan state. - Setiap service memiliki database sendiri; event diterbitkan andal lewat transactional outbox.
- Outbox berarti at-least-once — consumer wajib idempoten dengan mencatat
eventId. - Saga sederhana dengan kompensasi
CANCELLED,compose.yaml, test kontrak, dan smoke test end-to-end membuat proyek siap dikembangkan ke Spring Cloud di modul berikutnya.