Tampilan
Kenapa penting
Memecah aplikasi menjadi beberapa service tetapi membiarkan semuanya membaca dan menulis tabel yang sama adalah "distributed monolith": deploy terpisah, tetapi tetap saling mengunci lewat skema database. Keuntungan microservices — tim dan service yang bisa berubah sendiri-sendiri — baru terasa ketika setiap service memiliki datanya sendiri. Harganya nyata: tidak ada lagi JOIN lintas domain, tidak ada lagi satu transaksi @Transactional yang mencakup pesanan, stok, dan pembayaran sekaligus. Bab ini membahas cara hidup dengan konsekuensi itu: konsistensi eventual, query lintas service, CQRS, dan kenapa solusi "transaksi terdistribusi" klasik (2PC) biasanya dihindari.
Konsep inti
Database per service vs shared database
Database per service berarti hanya service pemilik yang boleh mengakses datanya secara langsung. Service lain mendapatkan data lewat API atau event. Secara fisik bisa berupa server database berbeda, atau satu server PostgreSQL dengan schema dan user terpisah yang tidak saling punya hak akses — yang penting batas kepemilikannya ditegakkan.
| Aspek | Shared database | Database per service |
|---|---|---|
| Query lintas domain | Mudah (JOIN) | Lewat API composition atau read model |
| Transaksi | ACID lintas tabel | ACID hanya di dalam satu service |
| Mengubah skema | Berisiko merusak service lain | Bebas selama API/event tetap kompatibel |
| Skala & teknologi | Satu database untuk semua beban | Tiap service memilih dan menskalakan sendiri |
| Kegagalan | Database down = semua service down | Gangguan terlokalisasi |
| Kompleksitas | Rendah | Butuh event, konsistensi eventual, observability |
sql
-- Potongan — satu server PostgreSQL, kepemilikan dipisah per schema & role
CREATE SCHEMA orders AUTHORIZATION order_service;
CREATE SCHEMA inventory AUTHORIZATION inventory_service;
REVOKE ALL ON SCHEMA orders FROM inventory_service;Eventual consistency
Begitu data tersebar, perubahan di satu service baru terlihat di service lain setelah event diproses — bisa milidetik, bisa menit saat consumer tertinggal. Ini disebut eventual consistency: kalau tidak ada perubahan baru, semua salinan akhirnya akan sama. Yang perlu kamu rancang adalah apa yang dilihat pengguna di jendela waktu itu. Contohnya, setelah checkout tampilkan "Pesanan diterima, sedang diproses" alih-alih langsung "Stok dikurangi"; atau setelah pengguna mengubah alamat, tampilkan data dari respons write-nya sendiri (read-your-writes) dan bukan dari read model yang mungkin belum menyusul.
Query lintas service: API composition
Cara paling sederhana: satu komponen (API gateway, BFF, atau service agregator) memanggil beberapa service lalu menggabungkan hasilnya di memori.
java
// Potongan — agregator "detail pesanan" memakai RestClient (M11)
public OrderDetailView getOrderDetail(String orderId) {
OrderDto order = orderClient.getOrder(orderId); // service Pesanan
ShipmentDto shipment = shipmentClient.findByOrderId(orderId); // service Pengiriman
return new OrderDetailView(order.id(), order.total(), order.status(),
shipment == null ? "BELUM_DIKIRIM" : shipment.status());
}Cocok untuk query satu entitas dengan sedikit service. Kelemahannya: latensi = penjumlahan (atau maksimum, bila paralel) semua panggilan, availability ikut turun karena satu service gagal membuat seluruh query gagal, dan query daftar dengan filter lintas service ("semua pesanan yang dikirim kurir X bulan ini") cenderung menjadi N+1 panggilan.
Query lintas service: read model dari event
Alternatifnya, service yang butuh query membangun read model sendiri — tabel terdenormalisasi yang diperbarui dari event service lain. Query menjadi satu SELECT lokal, cepat, dan tetap jalan walau service sumber sedang mati. Harganya: data bisa sedikit tertinggal, butuh kode projector, dan read model harus bisa dibangun ulang dari awal (misalnya dengan me-replay topic) bila ada bug.
CQRS
Command Query Responsibility Segregation memisahkan model untuk menulis (command) dari model untuk membaca (query). Sisi write fokus pada aturan bisnis dan invariant — aggregate Order dengan validasi. Sisi read fokus pada bentuk data yang dibutuhkan layar atau laporan, sering kali di penyimpanan yang berbeda (tabel denormalisasi, Elasticsearch, cache). Keduanya dihubungkan oleh event. Simulasi berikut memakai sealed interface dan pattern matching switch untuk memproyeksikan event pesanan menjadi read model ringkasan:
java
import java.math.BigDecimal;
import java.util.ArrayDeque;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Queue;
public class OrderReadModelDemo {
sealed interface OrderEvent permits OrderPlaced, OrderPaid, OrderCancelled {
String orderId();
}
record OrderPlaced(String orderId, String customerId, BigDecimal total) implements OrderEvent {}
record OrderPaid(String orderId) implements OrderEvent {}
record OrderCancelled(String orderId) implements OrderEvent {}
record OrderSummary(String orderId, String customerId, BigDecimal total, String status) {
OrderSummary withStatus(String newStatus) {
return new OrderSummary(orderId, customerId, total, newStatus);
}
}
// "Broker": event menunggu di antrean sampai projector sempat memprosesnya
static final Queue<OrderEvent> topic = new ArrayDeque<>();
// Read model milik service Laporan — tabel terdenormalisasi untuk query cepat
static final Map<String, OrderSummary> readModel = new LinkedHashMap<>();
static void project(OrderEvent event) {
switch (event) {
case OrderPlaced p -> readModel.put(p.orderId(),
new OrderSummary(p.orderId(), p.customerId(), p.total(), "PLACED"));
case OrderPaid p -> readModel.computeIfPresent(p.orderId(), (id, s) -> s.withStatus("PAID"));
case OrderCancelled c -> readModel.computeIfPresent(c.orderId(), (id, s) -> s.withStatus("CANCELLED"));
}
}
static void drain() {
while (!topic.isEmpty()) {
project(topic.poll());
}
}
static BigDecimal paidTotalFor(String customerId) {
return readModel.values().stream()
.filter(s -> s.customerId().equals(customerId) && s.status().equals("PAID"))
.map(OrderSummary::total)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public static void main(String[] args) {
topic.add(new OrderPlaced("ORD-1", "CUST-7", new BigDecimal("250000")));
topic.add(new OrderPlaced("ORD-2", "CUST-7", new BigDecimal("90000")));
topic.add(new OrderPaid("ORD-1"));
System.out.println("Sebelum projector jalan : " + readModel.size() + " baris, total dibayar CUST-7 = "
+ paidTotalFor("CUST-7"));
drain();
System.out.println("Setelah projector jalan : " + readModel.size() + " baris, total dibayar CUST-7 = "
+ paidTotalFor("CUST-7"));
topic.add(new OrderPaid("ORD-2"));
topic.add(new OrderCancelled("ORD-1"));
drain();
readModel.values().forEach(System.out::println);
System.out.println("Total dibayar CUST-7 = " + paidTotalFor("CUST-7"));
}
}Output:
text
Sebelum projector jalan : 0 baris, total dibayar CUST-7 = 0
Setelah projector jalan : 2 baris, total dibayar CUST-7 = 250000
OrderSummary[orderId=ORD-1, customerId=CUST-7, total=250000, status=CANCELLED]
OrderSummary[orderId=ORD-2, customerId=CUST-7, total=90000, status=PAID]
Total dibayar CUST-7 = 90000Baris pertama adalah eventual consistency dalam bentuk paling kecil: write side sudah mencatat pesanan, tetapi read model masih kosong sampai projector berjalan. Karena switch atas sealed interface bersifat exhaustive, menambah tipe event baru tanpa menangani di projector langsung menjadi error kompilasi — pengaman yang berguna.
CQRS tidak wajib dua database
CQRS paling ringan cukup berupa dua kelas terpisah — OrderCommandService dan OrderQueryService — di atas database yang sama, dengan query membaca view atau tabel ringkasan. Pisahkan penyimpanan hanya bila beban baca dan tulis benar-benar berbeda kebutuhannya.
Event sourcing sekilas
Pada event sourcing, sisi write tidak menyimpan state terakhir, melainkan deretan event (OrderPlaced, OrderPaid, ...) dalam event store. State aggregate didapat dengan me-replay event tersebut; untuk performa biasanya disimpan snapshot berkala. Keuntungannya: jejak audit lengkap, bisa menjawab "seperti apa state pada tanggal X", dan read model baru bisa dibangun dari sejarah penuh. Kerugiannya: evolusi skema event jauh lebih sulit (event lama harus tetap bisa dibaca selamanya), query langsung ke state tidak mungkin tanpa read model, dan kurva belajarnya curam. Pakai untuk domain yang memang berbasis riwayat seperti ledger keuangan atau dompet digital, bukan sebagai default.
CAP dan PACELC secara praktis
CAP menyatakan bahwa saat terjadi partisi jaringan, sistem terdistribusi harus memilih antara konsistensi (menolak permintaan agar tidak memberi data usang) atau availability (tetap menjawab walau datanya mungkin usang). Partisi bukan pilihan — jaringan pasti pernah putus — jadi pertanyaannya hanya C atau A. PACELC melengkapi: else, saat jaringan normal pun, kamu memilih antara latency dan consistency.
| Kasus | Pilihan wajar | Alasan |
|---|---|---|
| Saldo dompet, pemotongan stok terakhir | Konsistensi | Lebih baik menolak daripada saldo minus atau oversell |
| Katalog produk, jumlah ulasan | Availability | Data usang beberapa detik tidak merugikan |
| Riwayat pesanan pengguna | Latency | Read model cepat; tertinggal sedikit masih bisa diterima |
Di microservices, pilihan ini dibuat per use case, bukan per sistem.
2PC dan kenapa dihindari
Two-phase commit mengoordinasikan beberapa resource agar commit bersama: coordinator meminta semua peserta prepare (mengunci dan menjamin bisa commit), lalu mengirim commit bila semua setuju. Di atas kertas atomik, tetapi dalam praktik microservices bermasalah: lock ditahan selama menunggu peserta paling lambat, coordinator yang mati di tengah jalan membuat peserta terkatung-katung dengan lock terpegang, dan REST API maupun Kafka tidak berpartisipasi dalam protokol XA. Throughput dan availability turun ke level peserta terlemah.
Alternatif yang dipakai industri adalah saga: rangkaian transaksi lokal di tiap service, dihubungkan event atau perintah, dan setiap langkah punya aksi kompensasi bila langkah berikutnya gagal — misalnya ReleaseStock saat pembayaran ditolak. Detailnya dibahas di bab berikutnya.
Contoh di dunia backend
Tim Customer Service butuh layar "riwayat pelanggan": semua pesanan, status pembayaran, dan status pengiriman, dengan filter tanggal. Datanya tersebar di tiga service. Daripada memanggil tiga API untuk tiap baris, service CS membangun read model sendiri:
sql
-- Potongan — read model milik service Customer Service (PostgreSQL)
CREATE TABLE customer_order_view (
order_id VARCHAR(50) PRIMARY KEY,
customer_id VARCHAR(50) NOT NULL,
total_amount NUMERIC(15, 2) NOT NULL,
payment_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
shipment_status VARCHAR(20) NOT NULL DEFAULT 'NOT_SHIPPED',
placed_at TIMESTAMPTZ NOT NULL,
last_version BIGINT NOT NULL
);
CREATE INDEX idx_cov_customer_placed ON customer_order_view (customer_id, placed_at DESC);java
// Potongan — projector di service Customer Service
@KafkaListener(topics = "shipments.events", groupId = "cs-order-view")
@Transactional
public void onShipmentEvent(ShipmentEventEnvelope envelope) {
if (processedEventRepository.existsById(envelope.eventId())) {
return; // idempoten (bab event-driven)
}
if ("ShipmentDelivered".equals(envelope.eventType())) {
orderViewRepository.updateShipmentStatus(envelope.orderId(), "DELIVERED");
}
processedEventRepository.save(new ProcessedEvent(envelope.eventId()));
}Query layar CS kini satu SELECT ... WHERE customer_id = ? ORDER BY placed_at DESC yang terindeks. Kalau service Pengiriman down, layar tetap tampil dengan status terakhir yang diketahui. Bila suatu hari projector ternyata salah, tabel dikosongkan lalu consumer group di-reset ke offset awal untuk membangun ulang — selama retensi topic mencukupi.
Pertanyaan interview
1. Bagaimana menjalankan query yang butuh data dari beberapa service?
Ada dua pola. API composition: agregator memanggil service terkait dan menggabungkan hasil di memori — sederhana, cocok untuk detail satu entitas, tapi latensi dan availability ikut terpengaruh semua service. Read model/CQRS: service peminta membangun tabel terdenormalisasi dari event — cepat dan tahan gangguan, tapi eventual consistent dan butuh projector. Pilih berdasarkan volume, kebutuhan filter, dan toleransi data tertinggal.
2. Kapan CQRS berlebihan?
Saat model baca dan tulis hampir sama dan bebannya kecil — aplikasi CRUD biasa. CQRS menambah komponen (projector, penyimpanan kedua, pemantauan lag) dan konsistensi eventual. Layak dipakai bila bentuk data untuk layar jauh berbeda dari aggregate, beban baca jauh lebih besar dari tulis, atau query butuh data lintas service.
3. Kenapa tidak memakai transaksi terdistribusi (2PC/XA) saja?
2PC bersifat blocking: peserta menahan lock sampai coordinator memutuskan, sehingga peserta lambat atau coordinator yang mati menahan semuanya. Banyak komponen modern (REST API, broker) tidak mendukung XA, dan availability total menjadi hasil kali availability semua peserta. Saga dengan transaksi lokal dan kompensasi memberi konsistensi eventual tanpa lock lintas service.
Jebakan umum
- "Database per service" tetapi service lain diberi kredensial read-only ke tabel kita "sementara" — kopling skema kembali, dan sementara biasanya menjadi permanen.
- Menampilkan data dari read model tepat setelah pengguna menulis, sehingga pengguna mengira perubahannya hilang — pakai respons write atau beri status "sedang diproses".
- API composition untuk daftar panjang: satu panggilan per baris ke service lain (N+1 lintas jaringan).
- Projector yang tidak idempoten atau tidak bisa di-replay — sekali ada bug, read model tidak bisa diperbaiki.
- Memilih event sourcing untuk semua service karena terdengar canggih, lalu kesulitan saat skema event harus berubah.
- Mencoba mempertahankan transaksi ACID lintas service dengan 2PC atau lock manual, bukan merancang saga.
Ringkasan
- Database per service: hanya pemilik yang mengakses data langsung; yang lain lewat API atau event.
- Konsekuensinya eventual consistency — rancang apa yang dilihat pengguna selama data belum sinkron.
- Query lintas service: API composition untuk kasus sederhana, read model dari event untuk volume dan filter besar.
- CQRS memisahkan model tulis dan baca; event sourcing menyimpan event sebagai sumber kebenaran dan hanya cocok untuk domain berbasis riwayat.
- CAP/PACELC dipilih per use case: konsistensi untuk uang dan stok, availability/latency untuk katalog dan riwayat.
- 2PC bersifat blocking dan rapuh; microservices memakai saga dengan kompensasi.