Studi Kasus: Microservices

Studi Kasus: Microservices

Bitnesia Sep 13, 2026 10 EN

Aplikasi yang tumbuh dari satu monolith besar cepat atau lambat akan sampai pada titik di mana satu tim tidak lagi bisa deploy fitur kecil tanpa takut merusak bagian lain, dan proses build yang tadinya cepat berubah jadi lambat karena seluruh kode harus dikompilasi ulang meski cuma satu modul yang berubah. Microservices menjawab masalah ini dengan memecah aplikasi jadi kumpulan service kecil yang berdiri sendiri, masing-masing punya tanggung jawab spesifik, basis data sendiri, dan siklus deploy sendiri. Docker jadi fondasi alami untuk pola ini karena setiap service bisa dibungkus jadi image terpisah, dijalankan sebagai container independen, dan dihubungkan lewat network tanpa perlu tahu detail environment tempat service lain berjalan. Bab ini membahas studi kasus containerize sistem microservices sederhana bergaya e-commerce, mulai dari menyusun arsitektur service, mengatur komunikasi antar-service, menjaga konsistensi data lintas database, sampai menerapkan pola scaling saat traffic salah satu service meningkat.

36.1 Arsitektur Service pada Microservices

Studi kasus pada bab ini memakai dua service utama: product-service yang mengelola data produk dan stok, serta order-service yang mengelola data pesanan. Kedua service ini sengaja dibuat sekecil mungkin supaya fokus pembahasan tetap pada pola arsitektur dan komunikasinya, bukan pada kompleksitas logic bisnis.

36.1.1 Prinsip Single Responsibility dan Database per Service

Prinsip inti microservices adalah single responsibility: satu service hanya menangani satu domain bisnis, dan perubahan pada domain itu tidak boleh memaksa service lain ikut di-deploy ulang. Konsekuensi langsung dari prinsip ini adalah pola database per service, tempat setiap service punya database sendiri yang tidak diakses langsung oleh service lain. product-service memegang database productdb, sedangkan order-service memegang database orderdb, dan keduanya tidak pernah melakukan query langsung ke database milik service lain.

Pola ini berbeda jauh dari arsitektur monolith yang biasanya berbagi satu database besar antar-modul. Pemisahan database membuat setiap tim leluasa mengganti skema atau bahkan jenis database tanpa mengganggu service lain, tapi konsekuensinya, sinkronisasi data antar-service tidak bisa lagi mengandalkan JOIN SQL biasa, melainkan lewat pemanggilan API atau pertukaran event antar-service.

36.1.2 Menyusun Struktur Proyek Multi-Service

Susun struktur direktori proyek supaya setiap service punya folder dan Dockerfile sendiri, konsisten dengan prinsip bahwa tiap service adalah unit deploy independen.

microservices-demo/
├── product-service/
│   ├── Dockerfile
│   └── src/
├── order-service/
│   ├── Dockerfile
│   └── src/
├── gateway/
│   └── nginx.conf
└── compose.yaml

product-service dan order-service memakai Dockerfile Node.js sederhana yang identik polanya, cukup beda pada nama entrypoint-nya.

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
EXPOSE 3000
CMD ["node", "src/index.js"]

Definisikan kedua service beserta database masing-masing di compose.yaml. Setiap service diberi network internal sendiri agar hanya bisa diakses lewat gateway di depannya, bukan diekspos langsung ke host.

services:
  product-service:
    build: ./product-service
    environment:
      DATABASE_URL: postgres://product:secret@product-db:5432/productdb
    depends_on:
      - product-db
    networks:
      - backend

  product-db:
    image: postgres:16
    environment:
      POSTGRES_DB: productdb
      POSTGRES_USER: product
      POSTGRES_PASSWORD: secret
    volumes:
      - product-data:/var/lib/postgresql/data
    networks:
      - backend

  order-service:
    build: ./order-service
    environment:
      DATABASE_URL: postgres://order_app:secret@order-db:5432/orderdb
      PRODUCT_SERVICE_URL: http://product-service:3000
    depends_on:
      - order-db
      - product-service
    networks:
      - backend

  order-db:
    image: postgres:16
    environment:
      POSTGRES_DB: orderdb
      POSTGRES_USER: order_app
      POSTGRES_PASSWORD: secret
    volumes:
      - order-data:/var/lib/postgresql/data
    networks:
      - backend

networks:
  backend:

volumes:
  product-data:
  order-data:

Perhatikan bahwa tidak ada satu pun port yang dipetakan ke host pada blok ini. Semua akses ke product-service dan order-service lewat satu pintu masuk saja berupa API gateway, bukan lewat pemetaan port langsung ke tiap service. Jalankan docker compose config untuk memvalidasi sintaks sebelum benar-benar menjalankan stack ini.

docker compose config --quiet

Perintah ini tidak menampilkan output apa pun kalau compose.yaml valid, dan akan mencetak pesan error lengkap dengan nomor baris kalau ada kesalahan sintaks YAML atau referensi service yang tidak ditemukan.

36.2 Komunikasi Antar-Service

Begitu logic bisnis tersebar ke banyak service, komunikasi antar-service jadi bagian paling krusial dari keseluruhan sistem. Studi kasus ini memakai dua pola sekaligus: komunikasi sinkron lewat REST API untuk kebutuhan yang butuh jawaban langsung, dan komunikasi asinkron lewat message broker untuk kebutuhan yang boleh diproses belakangan.

36.2.1 Komunikasi Sinkron lewat REST API

order-service perlu memastikan produk yang dipesan benar-benar ada dan stoknya cukup sebelum pesanan dibuat, sehingga ia memanggil product-service secara sinkron lewat REST API tiap kali menerima pesanan baru. Nama service Compose, dalam hal ini product-service, langsung berfungsi sebagai hostname yang bisa di-resolve lewat DNS internal Docker, pola yang sama seperti pembahasan komunikasi antar-container pada bab jaringan.

// order-service/src/index.js (potongan)
const res = await fetch(`${process.env.PRODUCT_SERVICE_URL}/products/${productId}`);
if (!res.ok) {
  throw new Error("Produk tidak ditemukan atau stok tidak mencukupi");
}

Pola komunikasi sinkron seperti ini gampang dipahami karena mirip pemanggilan fungsi biasa, tapi punya risiko nyata di lapangan: kalau product-service lambat merespons atau sedang down, order-service ikut terhambat karena menunggu jawabannya. Developer yang membangun komunikasi sinkron antar-service sebaiknya selalu menambahkan timeout eksplisit pada setiap pemanggilan HTTP, supaya satu service yang bermasalah tidak menyeret service lain ikut macet menunggu tanpa batas waktu.

36.2.2 Komunikasi Asinkron lewat Message Broker

Setelah pesanan berhasil dibuat, order-service perlu memberi tahu bahwa stok produk terkait harus dikurangi. Proses ini tidak butuh jawaban langsung, sehingga cocok memakai pola asinkron lewat message broker. Tambahkan service rabbitmq ke compose.yaml yang sudah dibuat pada sub-bab sebelumnya.

services:
  rabbitmq:
    image: rabbitmq:3.13-management
    environment:
      RABBITMQ_DEFAULT_USER: admin
      RABBITMQ_DEFAULT_PASS: secret
    ports:
      - "15672:15672"
    networks:
      - backend

Port 15672 sengaja dipetakan ke host supaya management UI RabbitMQ bisa diakses lewat browser untuk keperluan monitoring antrean pesan, sementara port 5672 untuk komunikasi AMQP antar-service cukup diakses lewat network internal backend tanpa perlu dipetakan ke host. Variabel RABBITMQ_DEFAULT_USER dan RABBITMQ_DEFAULT_PASS wajib diisi di sini karena user bawaan guest pada image resmi RabbitMQ cuma diizinkan login lewat koneksi loopback langsung; koneksi yang masuk lewat port yang dipetakan Docker dianggap datang dari luar loopback, sesuai catatan resmi image RabbitMQ di Docker Hub, sehingga login guest/guest lewat browser di host akan ditolak kalau kedua variabel ini tidak diisi.

Tambahkan juga RABBITMQ_URL ke service order-service dan product-service yang sudah didefinisikan sebelumnya, supaya keduanya tahu alamat broker yang harus dihubungi.

services:
  order-service:
    environment:
      RABBITMQ_URL: amqp://rabbitmq:5672
    depends_on:
      - rabbitmq

  product-service:
    environment:
      RABBITMQ_URL: amqp://rabbitmq:5672
    depends_on:
      - rabbitmq

order-service mem-publish event order.created begitu pesanan tersimpan di database.

// order-service/src/index.js (potongan)
await channel.assertExchange("orders", "fanout", { durable: true });
channel.publish("orders", "", Buffer.from(JSON.stringify({ orderId, productId, qty })));

product-service bertindak sebagai consumer yang mendengarkan exchange tersebut, lalu mengurangi stok begitu event diterima.

// product-service/src/index.js (potongan)
const q = await channel.assertQueue("", { exclusive: true });
await channel.bindQueue(q.queue, "orders", "");
channel.consume(q.queue, async (msg) => {
  const { productId, qty } = JSON.parse(msg.content.toString());
  await db.query("UPDATE products SET stock = stock - $1 WHERE id = $2", [qty, productId]);
  channel.ack(msg);
});

Pola fanout exchange ini dipilih supaya event order.created bisa didengarkan lebih dari satu consumer di kemudian hari, misalnya service notifikasi email, tanpa perlu mengubah kode order-service sebagai publisher-nya. Setelah stack berjalan, buka management UI RabbitMQ di http://localhost:15672 dan login memakai kredensial admin/secret yang sudah diatur lewat RABBITMQ_DEFAULT_USER dan RABBITMQ_DEFAULT_PASS, untuk memverifikasi exchange orders sudah terbentuk dan pesan benar-benar mengalir tiap kali ada pesanan baru dibuat.

36.2.3 API Gateway sebagai Pintu Masuk Tunggal

Klien dari luar, baik aplikasi web maupun mobile, seharusnya tidak perlu tahu ada berapa banyak service di baliknya atau di port mana masing-masing berjalan. API gateway menyelesaikan masalah ini dengan menjadi satu pintu masuk yang meneruskan request ke service yang tepat berdasarkan path. Buat gateway/nginx.conf berikut untuk meneruskan request ke product-service dan order-service.

events {}

http {
  resolver 127.0.0.11 valid=10s;

  server {
    listen 80;

    location /products/ {
      set $product_upstream product-service:3000;
      proxy_pass http://$product_upstream;
    }

    location /orders/ {
      set $order_upstream order-service:3000;
      proxy_pass http://$order_upstream;
    }
  }
}

Directive resolver 127.0.0.11 mengarahkan Nginx memakai DNS server bawaan Docker yang selalu tersedia di alamat tersebut pada tiap container, sesuai dokumentasi resmi Docker mengenai embedded DNS server. proxy_pass di sini sengaja ditulis dengan alamat tujuan lewat variable ($product_upstream, $order_upstream) alih-alih blok upstream statis yang lazim dipakai untuk backend tunggal. Nginx cuma meresolusi hostname pada blok upstream statis satu kali saat proses start atau reload, sehingga kalau product-service di-scale jadi beberapa instance belakangan, gateway tidak akan otomatis menyadari instance baru itu tanpa di-reload manual. Memakai variable pada proxy_pass memaksa Nginx meresolusi ulang hostname secara berkala mengikuti masa berlaku cache yang diatur lewat parameter valid pada directive resolver, sesuai dokumentasi resmi Nginx untuk directive tersebut, sehingga instance baru hasil scaling bisa langsung terjangkau tanpa perlu me-restart gateway.

Perlu diperhatikan juga, proxy_pass di atas sengaja tidak diberi path tambahan setelah alamat variable-nya. Dokumentasi resmi Nginx mencatat bahwa penggantian prefix location tidak bisa diandalkan kalau proxy_pass memakai variable, sehingga path asli dari client (misalnya /products/1) diteruskan apa adanya ke backend. Itulah sebabnya route pada product-service dan order-service sejak awal didefinisikan lengkap dengan prefiks /products dan /orders, bukan cuma /:id saja, supaya tetap cocok dengan path yang diteruskan gateway ini.

Tambahkan service gateway ke compose.yaml, dan pindahkan pemetaan port ke sini karena sekarang hanya gateway yang perlu diakses langsung dari host.

services:
  gateway:
    image: nginx:1.27
    ports:
      - "8080:80"
    volumes:
      - ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - product-service
      - order-service
    networks:
      - backend

Jalankan seluruh stack, lalu uji gateway lewat curl untuk memastikan routing ke masing-masing service berjalan benar.

docker compose up -d
curl http://localhost:8080/products/1
curl -X POST http://localhost:8080/orders -d '{"productId":1,"qty":2}' -H "Content-Type: application/json"

Kalau gateway mengembalikan 502 Bad Gateway, penyebab paling umum di lapangan adalah service tujuan belum siap menerima koneksi saat Nginx pertama kali resolve DNS-nya, terutama kalau container backend butuh waktu inisialisasi lebih lama dari Nginx. Tambahkan healthcheck pada service backend dan ubah depends_on memakai kondisi service_healthy, sesuai dukungan Compose Specification untuk dependency condition, supaya gateway benar-benar menunggu backend siap sebelum meneruskan traffic.

36.3 Konsistensi Data pada Microservices

Memecah database jadi banyak bagian membuat konsistensi data menjadi tantangan tersendiri, karena operasi yang dulunya satu transaksi database tunggal di monolith sekarang tersebar ke beberapa service dengan database masing-masing yang tidak bisa saling melakukan transaksi ACID lintas batasnya.

36.3.1 Trade-off Strong Consistency dan Eventual Consistency

Monolith dengan satu database biasanya mengandalkan strong consistency, tempat semua perubahan data langsung terlihat konsisten begitu transaksi selesai berkat jaminan ACID. Microservices dengan database per service, seperti pada studi kasus product-service dan order-service di atas, harus menerima kenyataan bahwa data pesanan dan data stok produk untuk sesaat bisa saja tidak sinkron, sebuah kondisi yang disebut eventual consistency. Selisih waktu antara pesanan tercatat dan stok benar-benar berkurang, walau biasanya cuma hitungan milidetik lewat message broker, tetap merupakan jendela waktu tempat data belum sepenuhnya konsisten.

Trade-off ini sengaja diterima demi keuntungan lain: setiap service tetap bisa berjalan independen meski service lainnya sedang bermasalah. Kalau product-service sedang down, order-service pada arsitektur berbasis event tetap bisa menerima pesanan baru dan menunda pengurangan stok sampai product-service kembali online dan memproses antrean pesan yang tertunda di RabbitMQ, alih-alih ikut gagal total seperti yang biasa terjadi pada arsitektur monolith dengan satu database bersama.

36.3.2 Event-Driven Synchronization dan Idempotency

Pola komunikasi asinkron lewat message broker yang sudah dibangun pada sub-bab komunikasi antar-service sebenarnya adalah bentuk paling umum dari event-driven synchronization: perubahan data di satu service memicu event, dan service lain bereaksi terhadap event tersebut untuk menjaga datanya tetap sinkron secara eventual. Satu hal yang sering luput dari perhatian Developer yang baru membangun pola ini adalah kebutuhan idempotency pada sisi consumer: message broker seperti RabbitMQ, tergantung mode acknowledgment yang dipakai, bisa saja mengirim ulang pesan yang sama lebih dari sekali, misalnya saat koneksi consumer terputus sesaat sebelum mengirim ack.

Tanpa penanganan idempotency, event order.created yang diproses dua kali oleh product-service akan mengurangi stok dua kali untuk satu pesanan yang sama, sebuah bug yang sulit dilacak karena tidak muncul error apa pun di log. Tambahkan kolom pencatat event yang sudah diproses pada tabel di product-service untuk mencegah duplikasi ini.

// product-service/src/index.js (potongan)
const alreadyProcessed = await db.query(
  "SELECT 1 FROM processed_events WHERE event_id = $1", [orderId]
);
if (alreadyProcessed.rowCount === 0) {
  await db.query("UPDATE products SET stock = stock - $1 WHERE id = $2", [qty, productId]);
  await db.query("INSERT INTO processed_events (event_id) VALUES ($1)", [orderId]);
}
channel.ack(msg);

Pengalaman di lapangan menunjukkan bug konsistensi data pada sistem microservices hampir selalu berakar dari dua hal: consumer yang tidak idempotent seperti kasus di atas, atau urutan event yang datang tidak sesuai urutan aslinya karena diproses lebih dari satu consumer paralel. Sysadmin/DevOps Engineer yang menangani insiden data tidak sinkron di production sebaiknya mengecek dulu log consumer dan tabel pencatat event semacam ini sebelum mencurigai bug di logic bisnisnya.

36.4 Pola Scaling pada Microservices

Salah satu keuntungan utama memecah aplikasi jadi microservices adalah kemampuan men-scale tiap service secara independen sesuai bebannya masing-masing, alih-alih harus men-scale seluruh aplikasi monolith sekaligus meski cuma satu bagian kecil yang traffic-nya tinggi.

36.4.1 Scaling Horizontal dengan Docker Compose

Misalkan product-service menerima traffic jauh lebih tinggi dibanding order-service karena banyak pengguna sedang melihat katalog produk. Skalakan service ini secara horizontal lewat flag --scale pada docker compose up, sesuai dokumentasi resmi Compose CLI reference.

docker compose up -d --scale product-service=3

Perintah ini menjalankan tiga instance container product-service sekaligus. Perlu diperhatikan, service yang akan di-scale seperti ini tidak boleh punya pemetaan port host yang bersifat statis (misalnya "3000:3000") karena setiap instance akan berebut port yang sama di host dan menyebabkan error port is already allocated. Itulah sebabnya product-service pada studi kasus ini sejak awal tidak memetakan port apa pun ke host, karena satu-satunya jalan masuk ke sana adalah lewat gateway. Verifikasi jumlah instance yang aktif lewat docker compose ps.

docker compose ps product-service

Output menampilkan tiga baris container dengan nama yang diberi akhiran angka urut (misalnya microservices-demo-product-service-1 sampai -3), menandakan scaling berhasil dijalankan.

36.4.2 Load Balancing di Depan Service yang Di-scale

Tiga instance product-service tidak banyak gunanya kalau traffic cuma diarahkan ke satu instance saja. DNS internal Docker mengembalikan alamat seluruh instance product-service yang sedang berjalan tiap kali ada yang melakukan query ke nama service tersebut, termasuk instance baru hasil scaling. Karena gateway/nginx.conf pada sub-bab komunikasi antar-service sudah dikonfigurasi memakai proxy_pass berbasis variable dengan directive resolver, gateway ini otomatis meresolusi ulang product-service tiap kali masa berlaku cache resolusi (10 detik, sesuai valid=10s) habis, sehingga ketiga instance ini bisa langsung dijangkau tanpa perlu me-restart atau me-reload gateway secara manual.

Verifikasi ini dengan menambahkan endpoint yang mencetak hostname container pada product-service, lalu kirim beberapa request ke gateway dengan jeda lebih dari 10 detik antar-panggilan supaya tiap panggilan memaksa Nginx meresolusi ulang DNS, bukan memakai hasil resolusi yang masih tersimpan di cache.

for i in 1 2 3; do curl -s http://localhost:8080/products/health; echo; sleep 11; done

Kalau hostname pada respons berganti-ganti di antara beberapa panggilan tersebut, gateway sudah berhasil menjangkau lebih dari satu instance. Algoritma persis bagaimana Nginx memilih salah satu alamat hasil resolusi DNS pada tiap siklus merupakan detail implementasi resolver-nya sehingga distribusinya tidak selalu bisa dijamin bergantian sempurna di tiap request tunggal; yang penting untuk dipastikan justru ketiga instance sama-sama menerima traffic dalam rentang waktu yang lebih panjang, bukan cuma satu instance yang bekerja terus-menerus.

Pantau beban tiap instance lewat docker stats untuk memastikan traffic benar-benar terbagi rata dan tidak ada satu instance yang jadi bottleneck karena alasan lain, misalnya koneksi database yang dibatasi terlalu ketat pada sisi product-db.

docker stats --filter "name=product-service"

Perlu dicatat, scaling horizontal semacam ini hanya efektif kalau service yang di-scale bersifat stateless, artinya tidak menyimpan data penting di memori lokal container atau di filesystem container itu sendiri. product-service pada studi kasus ini aman untuk di-scale karena seluruh datanya sudah dititipkan ke product-db yang terpisah, bukan disimpan di dalam container service itu sendiri. Sysadmin/DevOps Engineer yang men-scale service dengan pola serupa di production sebaiknya selalu memastikan syarat stateless ini terpenuhi lebih dulu sebelum menambah jumlah instance.