Web Artisan
Beranda

Progress belajar

Modul 73 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 9 · Advanced Scaling

Split ke Microservices
Tanpa Merusak Bisnis

Microservices adalah keputusan organisasi dan operasional, bukan sekadar memecah folder Go menjadi banyak repository.

Bahasa: Go 1.26~75 menit baca
01

Microservices Bukan Tujuan Awal

Mulai dari modular monolith, pecah hanya ketika rasa sakitnya nyata

Di React, kita tidak memecah aplikasi menjadi sepuluh package sebelum tahu state mana yang benar-benar berubah sendiri. Di backend juga begitu.

Pada proyek online shop skincare, modular monolith memberi kita satu deployable Go API dengan modul domain yang jelas: product, cart, checkout, inventory, payment, notification, dan search. Struktur ini sudah cukup kuat selama tim masih kecil, traffic masih bisa ditangani, dan perubahan antar domain masih bisa dikoordinasikan dengan cepat.

microservice

Microservice adalah layanan kecil yang punya boundary bisnis jelas, dapat dideploy secara independen, dan idealnya memiliki data yang ia kuasai sendiri.

🌉Jembatan: dari folder modular ke ownership service

Di Laravel, folder app/Services/PaymentService.php masih berjalan dalam proses aplikasi yang sama. Di microservices, payment punya runtime, deployment, observability, database, retry, dan error boundary sendiri.

Go cocok untuk service kecil karena binary statis, startup cepat, concurrency bawaan, dan standard library HTTP yang kuat. Tetapi kemampuan Go bukan alasan otomatis untuk memecah sistem. Rilis Go 1.26 (Februari 2026) tetap menjaga Go 1 compatibility promise, jadi proyek baru cukup memakai go 1.26 di go.mod sesuai dokumentasi Go terbaru. Sejak Go 1.21, baris go directive boleh berisi versi patch lengkap (mis. go 1.26.0) dan toolchain selektor sudah standar, tetapi untuk proyek skincare kita cukup go 1.26 saja.

🌉Jembatan: dari MonolithFirst Martin Fowler

Martin Fowler menyebut pola ini “MonolithFirst”: bangun monolith dulu, pecah ketika boundary sudah terbukti stabil. Sama seperti React, kita tidak memecah komponen besar menjadi puluhan komponen kecil sebelum tahu bagian mana yang benar-benar berubah sendiri. Boundary yang dipaksakan terlalu dini hampir selalu salah.

Sasaran modul
Bukan: membuat banyak service supaya terlihat enterprise. Ya: tahu kapan modul harus punya lifecycle, data, scaling, dan ownership sendiri.
flowchart LR
  A["Modular monolith rapi"] --> B{"Ada rasa sakit nyata?"}
  B -->|Tidak| C["Tetap monolith, perkuat boundary"]
  B -->|Ya| D["Pilih domain paling independen"]
  D --> E["Definisikan contract"]
  E --> F["Ekstrak bertahap"]
  F --> G["Service mandiri dengan database sendiri"]

Gambar 1. Microservices adalah langkah evolusi, bukan starting template.

02

Kapan Tidak Pakai Microservices

Microservices mahal jika masalahnya belum ada

Microservices sering terlihat seperti arsitektur modern, tetapi biaya koordinasinya langsung terasa sejak service kedua lahir.

Jangan mulai dari microservices ketika tim masih kecil, deploy masih mudah, traffic masih bisa ditangani modular monolith, dan bottleneck utama masih berada di query, caching, indexing, atau desain transaksi. Dari modul sebelumnya, kita sudah punya pprof, Redis cache, full-text search, event-driven worker, dan strategi konsistensi inventory. Semua itu biasanya memberi dampak lebih cepat daripada split prematur.

Modular monolith
  • Satu proses, satu deploy, transaksi database lebih sederhana.
  • Boundary domain tetap dijaga lewat package dan interface Go.
  • Cocok untuk tim kecil dan produk yang masih sering berubah.
Microservices
  • Banyak proses, banyak deploy pipeline, observability wajib matang.
  • Konsistensi data lintas service harus ditangani dengan event, saga, dan idempotency.
  • Cocok ketika ownership, scaling, atau cadence deploy sudah berbeda.
⚠️Jangan split karena bosan dengan monolith

Monolith yang berantakan kalau dipecah tanpa boundary akan menjadi distributed mess: bug yang sama, tapi sekarang tersebar di jaringan.

Team kecil

Satu sampai tiga backend engineer biasanya lebih produktif dengan modular monolith yang ketat.

Traffic masih normal

Jika bottleneck bisa diselesaikan lewat index, cache, pool tuning, dan async worker, split belum perlu.

Domain masih berubah

Boundary yang belum stabil akan membuat contract antar service sering pecah.

distributed monolith

Kumpulan service yang terlihat terpisah, tetapi deploy, database, dan perubahan fitur tetap saling mengunci seperti satu aplikasi besar.

03

Sinyal Modular Monolith Mulai Tidak Cukup

Pecah ketika dependency organisasi dan runtime mulai berbeda

Signal split yang sehat bukan “service ini punya banyak file”, tetapi “service ini punya lifecycle bisnis dan operasional yang berbeda”.

Ada tiga sinyal utama: deployment frequency berbeda, scaling requirement berbeda, dan tim yang berbeda. Contohnya, notification berubah sering karena template email, provider WhatsApp, dan campaign. search butuh index khusus dan mungkin worker sinkronisasi. payment butuh audit, webhook idempotent, dan compliance yang lebih ketat.

Deploy cadence berbeda

Notification bisa deploy beberapa kali sehari, sementara checkout sebaiknya lebih konservatif karena menyentuh uang dan stok.

Scaling berbeda

Search read-heavy, worker notification bursty, checkout write-heavy dan sensitif terhadap transaksi.

Ownership tim berbeda

Ketika satu tim khusus payment bertanggung jawab penuh atas SLA, audit, dan integrasi gateway, boundary mulai masuk akal.

🌉Jembatan: dari React component ownership

Di React, komponen yang sering berubah sendiri bisa diekstrak agar ownership jelas. Di backend, ekstraksi service baru masuk akal jika tim dan operasinya juga bisa mandiri.

Checklist sinyal split
[ ] Domain punya alasan deploy sendiri. [ ] Domain punya kebutuhan scaling sendiri. [ ] Domain punya owner jelas. [ ] Domain punya data ownership yang bisa dipisah. [ ] Contract input dan output sudah stabil. [ ] Failure domain bisa diisolasi tanpa menghentikan checkout utama.

Untuk online shop skincare, kandidat awal yang paling aman biasanya notification atau search, bukan checkout dan inventory. Alasannya sederhana: kegagalan email atau reindex search bisa diretry, tetapi kegagalan checkout langsung menyentuh order, payment, dan stok.

04

Menentukan Service Boundary

Mulai dari domain yang paling independen

Boundary terbaik mengikuti bahasa bisnis, bukan mengikuti tabel database atau layer teknis.

Service boundary adalah garis kepemilikan. Di modular monolith, garis ini tampak sebagai package dan interface. Di microservices, garis yang sama menjadi repository, pipeline, runtime, database, dashboard, alarm, dan on-call. Karena itu kandidat pertama adalah domain dengan coupling rendah dan dampak kegagalan yang bisa ditoleransi.

Boundary di modular monolith sebelum split
  • cmd/
  • api/
  • main.go komposisi dependency
  • internal/
  • checkout/
  • service.go orchestrate cart, inventory, payment
  • payment/
  • service.go logic payment masih in-process
  • repository.go
  • notification/
  • worker.go kandidat split awal
  • search/
  • indexer.go kandidat split awal
  • inventory/
  • service.go high consistency, jangan buru-buru split
  • go.mod
💡Boundary kandidat pertama

Pilih domain yang bisa gagal secara terpisah dan dipulihkan lewat retry, seperti notification atau search. Jangan mulai dari inventory kecuali tim sudah matang menangani konsistensi terdistribusi.

flowchart TD
  A["Domain online shop skincare"] --> B["Notification"]
  A --> C["Search"]
  A --> D["Payment"]
  A --> E["Inventory"]
  A --> F["Checkout"]
  B --> B1["Low coupling, async friendly"]
  C --> C1["Read model, index bisa dibangun ulang"]
  D --> D1["Audit tinggi, contract harus stabil"]
  E --> E1["Konsistensi stok kritikal"]
  F --> F1["Orchestrator bisnis inti"]

Gambar 2. Boundary yang aman dipilih dari domain yang paling independen lebih dulu.

Boundary buruk
  • user-service, product-service, dan order-service tetap menulis database yang sama.
  • Setiap request checkout melakukan chain HTTP panjang dan gagal jika salah satu service lambat.
  • Service dipisah berdasarkan layer seperti handler, service, repository.
Boundary sehat
  • Notification menerima event OrderPaid dan mengirim email tanpa memblokir checkout.
  • Search membangun read model sendiri dari event produk.
  • Payment punya contract jelas untuk intent, status, callback, dan rekonsiliasi.
05

Database Ownership Setelah Split

Service boleh berbagi fakta bisnis, tetapi tidak boleh saling menulis tabel internal

Perubahan terbesar dari modular monolith ke microservices bukan HTTP, melainkan kepemilikan data.

AWS Prescriptive Guidance dan katalog pola microservices.io (Chris Richardson) sama-sama mendokumentasikan pola Database per Service: setiap service menyimpan dan mengambil data dari data store yang ia kuasai sendiri. Data store itu tidak di-share, perubahan skema satu service tidak memengaruhi yang lain, dan data hanya bisa diakses lewat API service pemiliknya. Dalam praktik proyek skincare, ini berarti payment_service tidak boleh menulis tabel orders milik checkout secara langsung. Ia menerbitkan event, lalu order service memperbarui state order miliknya.

flowchart TD
  subgraph SEBELUM["Sebelum split: satu DB"]
    M1["checkout module"] --> SDB[("shop_db")]
    M2["payment module"] --> SDB
    M3["search module"] --> SDB
    M2 -.JOIN langsung.-> M1
  end
  subgraph SESUDAH["Sesudah split: DB per service"]
    O["order service"] --> ODB[("order_db")]
    P["payment service"] --> PDB[("payment_db")]
    P -->|PaymentSucceeded event| BUS[["event bus / SQS"]]
    BUS --> O
  end

Gambar 3. Sebelum split, modul join langsung di satu database. Sesudah split, setiap service punya database sendiri dan dihubungkan lewat event, bukan join.

🌉Jembatan: dari Eloquent eager loading ke read model

Di Laravel monolith, Order::with('payment') terasa natural karena semua tabel hidup di satu database, satu query SQL menggabung keduanya. Setelah split, tidak ada lagi JOIN lintas database. Padanannya adalah read model: order service menyimpan kolom payment_status miliknya sendiri, diperbarui saat menerima event PaymentSucceeded. Halaman checkout cukup membaca satu tabel lokal, bukan memanggil payment service setiap render.

Konsekuensi wajib: query lintas service

Database per Service punya harga: begitu data terpecah, kamu tidak bisa lagi menulis satu query yang menggabung order dan payment. Halaman AWS yang sama menegaskan bahwa pola ini mewajibkan pola pelengkap untuk query lintas service. Ada dua jalur utama, dan keduanya sah tergantung kebutuhan.

API composition
  • Saat butuh data gabungan, panggil beberapa service lalu gabungkan hasilnya di pemanggil (mis. BFF atau API gateway).
  • Selalu fresh, tidak perlu menyimpan salinan, tetapi latensi menumpuk dan ikut gagal jika satu service down.
  • Cocok untuk query jarang, mis. halaman detail order di admin.
CQRS / read model
  • Order service membangun read model sendiri (kolom payment_status) dari event yang masuk.
  • Query checkout jadi satu baca lokal cepat, tahan terhadap payment service yang sedang down, tetapi eventual (ada jeda update).
  • Cocok untuk query panas yang dibaca tiap checkout dan riwayat order.

Untuk skincare, status payment di halaman riwayat order dibaca sangat sering, jadi read model (CQRS ringan) lebih tepat daripada memanggil payment service setiap kali. Halaman admin yang jarang dibuka boleh memakai API composition.

Transactional Outbox: mengapa tabel payment_outbox_events ada

Perhatikan tabel payment_outbox_events di skema berikut. Ini adalah pola kanonik bernama Transactional Outbox (microservices.io), dan ia menjawab satu masalah halus yang sering luput: dual-write problem.

dual-write problem

Update database bisnis dan publish event ke broker adalah dua sistem berbeda. Tidak ada transaksi yang bisa membungkus keduanya secara atomik. Jika DB commit lalu publish gagal (atau sebaliknya), state DB dan event jadi tidak sinkron.

Solusinya: jangan publish langsung ke broker di dalam transaksi bisnis. Sebagai gantinya, dalam SATU transaksi database, update tabel payments dan INSERT baris ke payment_outbox_events. Keduanya commit bersama atau gagal bersama, karena berada di database yang sama. Sebuah poller terpisah lalu membaca baris outbox yang published_at-nya masih kosong, mempublikasikannya ke SQS, lalu menandainya terbit. Inilah jembatan ke modul event-driven (R9C04): event yang andal lahir dari outbox, bukan dari publish langsung.

sequenceDiagram
  participant P as Payment Service
  participant DB as payment_db
  participant Poll as Outbox Poller
  participant Q as SQS
  P->>DB: BEGIN
  P->>DB: UPDATE payments SET status = 'succeeded'
  P->>DB: INSERT INTO payment_outbox_events (...)
  P->>DB: COMMIT
  Note over P,DB: satu transaksi, atomik
  Poll->>DB: SELECT * WHERE published_at IS NULL
  Poll->>Q: publish PaymentSucceeded
  Q-->>Poll: ok
  Poll->>DB: UPDATE outbox SET published_at = now()

Gambar 4. Outbox membuat penulisan bisnis dan pencatatan event menjadi satu commit atomik. Publikasi ke broker dilakukan terpisah oleh poller, jadi event tidak pernah hilang meski broker sempat tak terjangkau.

database ownership setelah split
-- order_db, dimiliki order atau checkout service CREATE TABLE orders ( id uuid PRIMARY KEY, customer_id uuid NOT NULL, status text NOT NULL, total_amount bigint NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); -- payment_db, dimiliki payment service CREATE TABLE payments ( id uuid PRIMARY KEY, order_id uuid NOT NULL, gateway text NOT NULL, gateway_reference text NOT NULL, status text NOT NULL, amount bigint NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); -- Transactional Outbox di payment_db, di-INSERT dalam transaksi -- yang sama dengan UPDATE payments, lalu dipublish poller payment. CREATE TABLE payment_outbox_events ( id uuid PRIMARY KEY, event_type text NOT NULL, aggregate_id uuid NOT NULL, payload jsonb NOT NULL, published_at timestamptz, -- NULL = belum dipublish created_at timestamptz NOT NULL DEFAULT now() );

Jangan salah paham: pada fase transisi, dua service kadang masih membaca database lama lewat view atau adapter. Tetapi target akhir tetap satu owner per data. Shared database mungkin dipakai sementara, namun harus dianggap jembatan migrasi, bukan arsitektur permanen.

data ownership

Aturan bahwa satu service menjadi sumber kebenaran untuk data tertentu, termasuk skema, validasi, perubahan state, dan audit trail.

⚠️Jangan biarkan service lain menulis tabelmu

Begitu dua service menulis tabel yang sama, deploy independen hanya ilusi. Perubahan constraint kecil bisa mematahkan service lain tanpa terlihat di compile time.

06

API Contract Sebelum Dipisah

Pisahkan interface dulu, network belakangan

Cara paling aman untuk split di Go adalah membuat seam berbasis interface sebelum ada service baru.

Di Go, interface kecil membuat boundary mudah diuji. Checkout tidak perlu tahu apakah payment masih package lokal, HTTP service, atau worker async. Ia hanya tahu contract: buat payment intent, terima payment ID, dan lanjutkan state order sesuai hasil.

internal/checkout/payment_contract.go
package checkout import ( "context" "errors" "time" ) var ErrPaymentUnavailable = errors.New("payment unavailable") type PaymentClient interface { CreatePaymentIntent(ctx context.Context, req CreatePaymentIntentRequest) (PaymentIntent, error) } type CreatePaymentIntentRequest struct { OrderID string CustomerID string Amount int64 Currency string Description string } type PaymentIntent struct { ID string RedirectURL string ExpiresAt time.Time }
internal/checkout/service.go
package checkout import ( "context" "fmt" ) type OrderRepository interface { SavePendingOrder(ctx context.Context, order Order) error } type Service struct { orders OrderRepository payments PaymentClient } func NewService(orders OrderRepository, payments PaymentClient) *Service { return &Service{orders: orders, payments: payments} } func (s *Service) Checkout(ctx context.Context, req CheckoutRequest) (CheckoutResult, error) { order := NewPendingOrder(req.CustomerID, req.Items) if err := s.orders.SavePendingOrder(ctx, order); err != nil { return CheckoutResult{}, fmt.Errorf("save pending order: %w", err) } intent, err := s.payments.CreatePaymentIntent(ctx, CreatePaymentIntentRequest{ OrderID: order.ID, CustomerID: order.CustomerID, Amount: order.TotalAmount, Currency: "IDR", Description: "Skincare order " + order.ID, }) if err != nil { return CheckoutResult{}, fmt.Errorf("create payment intent: %w", err) } return CheckoutResult{OrderID: order.ID, PaymentURL: intent.RedirectURL}, nil }
POST /internal/v1/payments/intents Contract internal untuk membuat payment intent dari order yang sudah dibuat
POST /v1/webhooks/payments Webhook publik dari payment gateway yang diverifikasi oleh payment service
GET /internal/v1/payments/{paymentID} Query status payment saat rekonsiliasi atau support case
contract create payment intent
{ "order_id": "ord_01JZ9S8Y1J57V4VCR7R0Z2B8K3", "customer_id": "cus_01JZ9S7G9MTS7SPYF9V6ZAYCH2", "amount": 349000, "currency": "IDR", "description": "Skincare order ord_01JZ9S8Y1J57V4VCR7R0Z2B8K3" }
💡Idiom Go untuk boundary

Terima interface di constructor dan kembalikan struct konkret. Handler dan service mudah dites, sementara implementasi payment bisa diganti dari lokal ke HTTP tanpa mengubah checkout.

Versioning contract: tambah, jangan ubah

Contract antar service akan berubah seiring waktu, dan di sinilah banyak migrasi tersandung. Aturan praktisnya: perubahan yang aman adalah aditif (additive only). Menambah field opsional, menambah endpoint, atau menambah nilai enum baru biasanya backward compatible. Mengubah tipe field, mengganti nama, atau menghapus field adalah breaking change yang akan mematahkan consumer lama.

Breaking change
  • Mengganti amount dari integer menjadi objek { value, currency }.
  • Menghapus field redirect_url karena dianggap usang.
  • Membuat field opsional menjadi wajib (required) tanpa default.
Backward compatible
  • Menambah field opsional expires_at yang diabaikan consumer lama.
  • Menambah endpoint /internal/v2/payments/intents di samping v1.
  • Menjaga v1 tetap hidup sampai semua consumer pindah, baru hapus.

Untuk perubahan yang benar-benar breaking, pakai versi path eksplisit (/internal/v1/... lalu /internal/v2/...) dan jalankan keduanya berdampingan selama masa transisi. Sebelum menghapus v1, pastikan observability menunjukkan tidak ada lagi traffic yang memakainya. Pendekatan consumer-driven contract (consumer menulis ekspektasi, provider memverifikasinya di CI) membuat breaking change terdeteksi sebelum deploy, bukan saat production rusak.

🌉Jembatan: dari OpenAPI/tRPC ke .proto

Di frontend kamu sudah terbiasa contract-first: OpenAPI, GraphQL schema, atau tRPC menghasilkan typed client sehingga perubahan API ketahuan saat compile. Antar service Go, padanannya adalah gRPC dengan file .proto sebagai single source of truth: tipe request, response, dan service di-generate untuk server dan client. Disiplinnya sama, hanya formatnya yang beda.

07

Async Communication antar Service

Event untuk side effect, bukan chain HTTP panjang

Microservices sehat berkomunikasi lewat event untuk side effect yang tidak perlu memblokir request utama, dan lewat panggilan sinkron hanya saat hasilnya harus diketahui sekarang juga.

Ada dua gaya komunikasi, dan memilih yang tepat lebih penting daripada teknologinya. Sinkron (request lalu tunggu balasan) cocok ketika pemanggil butuh hasil segera, mis. checkout butuh payment_url sebelum bisa menjawab pelanggan. Asinkron (kirim event, lupakan) cocok untuk side effect yang boleh selesai belakangan, mis. email, sinkronisasi search index, analytics, audit, dan shipping notification.

Sinkron: gRPC vs REST

Untuk panggilan sinkron antar service Go, ada dua pilihan idiomatik. Modul ini memakai REST/JSON karena hanya butuh net/http standard library dan paling mudah didebug. Tetapi banyak tim memilih gRPC saat lalu lintas antar service tinggi. Pilih berdasarkan kebutuhan, bukan tren.

REST / JSON (net/http)
  • Cukup standard library, mudah dipanggil curl, mudah didebug dengan mata.
  • Contract longgar (JSON), mudah dipakai dari frontend dan tool eksternal.
  • Cocok untuk webhook publik, internal API ringan, dan tim kecil.
gRPC (HTTP/2 + protobuf)
  • Contract-first lewat .proto, typed client dan server di-generate, payload binary lebih ringkas.
  • HTTP/2 multiplexing dan streaming, latensi internal lebih kecil saat traffic padat.
  • Cocok untuk jalur internal panas antar banyak service, bukan untuk endpoint publik browser.

AWS Prescriptive Guidance menempatkan event-driven architecture sebagai pola umum untuk konsistensi lintas microservices, sementara Amazon SQS menyediakan queue, visibility timeout, dan dead-letter queue (DLQ) untuk retry yang terkontrol. Keputusan sync atau async bisa diringkas jadi satu pertanyaan: butuh hasilnya sekarang?

flowchart TD
  A["Antar service perlu bicara"] --> B{"Butuh hasil segera?"}
  B -->|Ya, hasil dipakai di response| C{"Traffic internal padat?"}
  C -->|Ya| D["Sinkron: gRPC"]
  C -->|Tidak| E["Sinkron: REST / JSON"]
  B -->|Tidak, side effect eventual| F["Asinkron: event ke SQS"]
  F --> G["Consumer idempotent + DLQ"]

Gambar 5. Keputusan komunikasi: sinkron saat hasil dipakai langsung, asinkron saat side effect boleh menyusul.

internal/platform/events/publisher.go
package events import ( "context" "encoding/json" "time" ) type Publisher interface { Publish(ctx context.Context, event Event) error } type Event struct { ID string `json:"id"` Type string `json:"type"` AggregateID string `json:"aggregate_id"` CorrelationID string `json:"correlation_id"` OccurredAt time.Time `json:"occurred_at"` Payload json.RawMessage `json:"payload"` }
internal/payment/events.go
package payment import ( "context" "encoding/json" "fmt" "time" "github.com/kamu/skincare-backend/internal/platform/events" ) type EventPublisher interface { Publish(ctx context.Context, event events.Event) error } type PaymentSucceededPayload struct { PaymentID string `json:"payment_id"` OrderID string `json:"order_id"` Amount int64 `json:"amount"` } // Publisher menyuntik clock dan generator ID agar event bisa dites // secara deterministik, konsisten dengan gaya "terima interface". type Publisher struct { events EventPublisher now func() time.Time newID func() string } func NewPublisher(events EventPublisher, now func() time.Time, newID func() string) *Publisher { return &Publisher{events: events, now: now, newID: newID} } func (p *Publisher) PublishPaymentSucceeded(ctx context.Context, payload PaymentSucceededPayload, correlationID string) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal payment succeeded payload: %w", err) } event := events.Event{ ID: p.newID(), Type: "PaymentSucceeded", AggregateID: payload.PaymentID, CorrelationID: correlationID, OccurredAt: p.now().UTC(), Payload: body, } if err := p.events.Publish(ctx, event); err != nil { return fmt.Errorf("publish payment succeeded: %w", err) } return nil }
sequenceDiagram
  participant C as Customer
  participant O as Order Service
  participant P as Payment Service
  participant Q as SQS
  participant N as Notification Worker
  participant S as Search Worker
  C->>O: POST /v1/checkout
  O->>P: Create payment intent
  P-->>O: payment_url
  O-->>C: 201 Created
  P->>Q: PaymentSucceeded event
  Q->>N: send receipt email
  Q->>S: update searchable order projection

Gambar 6. Request checkout tetap pendek, side effect berjalan lewat event dan worker.

🌉Jembatan: dari Laravel Queue & Events

Di Laravel, event(new OrderPaid($order)) memanggil Listener secara in-process dan sinkron secara default, sementara dispatch(new SendReceiptJob) melempar Job ke Redis atau SQS untuk diproses worker. Antar service, kita hanya punya jenis kedua: event domain melintasi broker. Karena itu konsep yang sudah kamu kenal dari Laravel Queue, retry, backoff, dan ShouldBeUnique, langsung relevan. Bedanya, di Laravel event in-process selalu jalan sekali; event terdistribusi butuh outbox saat mengirim dan idempotency saat menerima.

⚠️At-least-once berarti handler harus idempotent

Queue dapat mengirim pesan lagi jika consumer gagal menghapus pesan sebelum visibility timeout habis. Consumer event wajib aman dipanggil berulang, sama seperti Job Laravel yang bisa ter-retry.

Idempotency store: cara konkret, bukan sekadar imbauan

”Handler harus idempotent” mudah diucapkan, susah dipraktikkan kalau tidak tahu caranya. Pola paling sederhana: simpan dedup key (di sini event.ID) di tabel khusus dengan constraint UNIQUE. Sebelum memproses, coba klaim key itu. Jika sudah ada, event sudah diproses dan kita lewati dengan aman.

dedup di order_db, dimiliki order service
CREATE TABLE processed_events ( event_id uuid PRIMARY KEY, -- = Event.ID, unik per event handler text NOT NULL, processed_at timestamptz NOT NULL DEFAULT now() );
internal/order/idempotency.go
package order import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) // ErrAlreadyProcessed dikembalikan saat event dengan ID sama sudah pernah diproses. var ErrAlreadyProcessed = errors.New("event already processed") // claimEvent mencoba menandai event sebagai diproses. INSERT gagal karena // pelanggaran UNIQUE berarti event duplikat, jadi handler boleh berhenti aman. func claimEvent(ctx context.Context, db *pgxpool.Pool, eventID, handler string) error { _, err := db.Exec(ctx, `INSERT INTO processed_events (event_id, handler) VALUES ($1, $2)`, eventID, handler, ) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation return ErrAlreadyProcessed } return fmt.Errorf("claim event %s: %w", eventID, err) } return nil } func HandlePaymentSucceeded(ctx context.Context, db *pgxpool.Pool, ev Event) error { if err := claimEvent(ctx, db, ev.ID, "order.payment_succeeded"); err != nil { if errors.Is(err, ErrAlreadyProcessed) { return nil // sudah pernah, abaikan tanpa error } return err } // aman: jalankan efek bisnis tepat sekali, mis. tandai order paid. return markOrderPaid(ctx, db, ev.AggregateID) }
💡Dedup key harus stabil per event logis

Pakai ID event yang sama setiap kali pesan dikirim ulang (di sini Event.ID), bukan UUID acak yang dibuat per percobaan. Kunci yang berubah tiap retry membuat dedup tidak berfungsi: setiap kiriman dianggap event baru.

08

Strangler Fig: Ekstrak Payment Service

Pisahkan satu domain per waktu, bukan big bang rewrite

Strangler fig pattern (istilah Martin Fowler) memindahkan fungsi lama ke service baru secara bertahap sambil aplikasi tetap melayani pelanggan, seperti pohon ara yang perlahan menyelimuti inangnya.

Kita akan memakai payment sebagai contoh karena ia punya boundary bisnis yang jelas, tetapi tetap perlu disiplin tinggi: uang, webhook, idempotency, dan audit. Pada fase awal, checkout tetap berada di modular monolith. Kita hanya menaruh seam interface, lalu mengarahkan implementasi payment dari package lokal ke HTTP client, kemudian memindahkan database payment, lalu memindahkan webhook. Perhatikan kolom traffic: service baru mulai dari nol persen lalu naik bertahap (canary), bukan langsung menerima seluruh beban.

flowchart LR
  A["Fase 0<br/>monolith<br/>0% ke service"] --> B["Fase 1<br/>interface PaymentClient<br/>0%"]
  B --> C["Fase 2<br/>local adapter<br/>0%"]
  C --> D["Fase 3<br/>service di internal API<br/>5% canary"]
  D --> E["Fase 4<br/>webhook ke service<br/>50%"]
  E --> F["Fase 5<br/>payment_db dipindah<br/>100%"]
  F --> G["Fase 6<br/>monolith konsumsi event<br/>100%, adapter lama dihapus"]

Gambar 7. Ekstraksi payment dilakukan bertahap dengan canary: porsi traffic ke service baru naik perlahan dari 5 persen ke 100 persen, sehingga masalah tertangkap saat dampaknya masih kecil.

🌉Jembatan: dari feature flag dan % rollout di frontend

Di frontend kamu sudah terbiasa melepas fitur secara bertahap: feature flag, A/B test, atau rollout 5 persen lalu 100 persen. Strangler fig adalah pola yang sama di sisi backend. PAYMENT_MODE=local|http adalah flag-nya, dan persentase traffic ke payment service adalah rollout-nya. Konsep canary di sini bukan hal baru, hanya pindah lapisan.

Pasang seam di monolith

Buat PaymentClient interface di package checkout, lalu implementasikan adapter lokal yang memanggil package payment lama.

Stabilkan contract

Tulis request dan response JSON internal, status code, error code, idempotency key, dan correlation ID.

Deploy service baru tanpa traffic

Payment service berjalan di ECS sendiri, tetapi checkout belum diarahkan ke sana.

Canary traffic kecil

Alihkan sebagian order internal ke payment service, monitor latency, error, webhook, dan rekonsiliasi.

Pindahkan ownership data

Payment service mulai menulis payment_db, lalu menerbitkan event untuk order service.

Hapus kode lama

Setelah semua traffic stabil dan audit lengkap, hapus adapter lokal dan tabel lama yang sudah tidak dipakai.

internal/checkout/payment_http_client.go
package checkout import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" ) type HTTPPaymentClient struct { baseURL string client *http.Client } // NewHTTPPaymentClient menerima *http.Client dari luar agar satu client // dengan Transport yang sudah dituning bisa di-reuse lintas pemanggil. func NewHTTPPaymentClient(baseURL string, client *http.Client) *HTTPPaymentClient { return &HTTPPaymentClient{baseURL: baseURL, client: client} } func (c *HTTPPaymentClient) CreatePaymentIntent(ctx context.Context, req CreatePaymentIntentRequest) (PaymentIntent, error) { body, err := json.Marshal(req) if err != nil { return PaymentIntent{}, fmt.Errorf("marshal request: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/internal/v1/payments/intents", bytes.NewReader(body)) if err != nil { return PaymentIntent{}, fmt.Errorf("build request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") // Idempotency-Key memakai OrderID: stabil per order, sama untuk setiap // retry network, jadi payment service aman mendeduplikasi. httpReq.Header.Set("Idempotency-Key", req.OrderID) resp, err := c.client.Do(httpReq) if err != nil { return PaymentIntent{}, fmt.Errorf("call payment service: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { // Habiskan body sebelum close agar koneksi bisa di-reuse keep-alive. io.Copy(io.Discard, resp.Body) return PaymentIntent{}, fmt.Errorf("payment service returned status %d", resp.StatusCode) } var intent PaymentIntent if err := json.NewDecoder(resp.Body).Decode(&intent); err != nil { return PaymentIntent{}, fmt.Errorf("decode payment response: %w", err) } return intent, nil }
cmd/api/http_client.go
package main import ( "net" "net/http" "time" ) // newServiceHTTPClient dibuat sekali saat startup dan dibagi ke semua client // antar service. Transport kustom mengatur pool koneksi keep-alive. func newServiceHTTPClient() *http.Client { transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, // default 2 terlalu kecil untuk client panas IdleConnTimeout: 90 * time.Second, DialContext: (&net.Dialer{ Timeout: 2 * time.Second, }).DialContext, } return &http.Client{ Transport: transport, Timeout: 3 * time.Second, // batas atas keras } }
📝Timeout: Client.Timeout vs context deadline

HTTP client antar service wajib punya batas waktu. Karena kita memakai NewRequestWithContext, ada dua batas yang berlaku: Client.Timeout sebagai pagar keras dan deadline pada ctx dari pemanggil. Yang lebih cepat habis yang menang. Idiom Go menghormati deadline caller, jadi untuk timeout per-request yang berbeda, set context.WithTimeout di pemanggil, bukan mengubah field Client.Timeout global.

⚠️Jangan bikin *http.Client baru tiap panggilan

Membuat &http.Client{} baru di setiap request membuang Transport beserta pool koneksi keep-alive-nya, jadi setiap panggilan membuka koneksi TCP baru. Buat satu client dengan Transport yang dituning saat startup, lalu bagikan. Default MaxIdleConnsPerHost hanya 2, sering terlalu kecil untuk client yang sering dipakai.

09

Hands-on: Siapkan Seam untuk Payment

Latihan ringan sebelum benar-benar memecah service

Latihan ini belum membuat repository baru. Tujuannya menyiapkan boundary supaya migrasi berikutnya aman.

File latihan
  • internal/
  • checkout/
  • service.go
  • payment_contract.go
  • payment_local_client.go
  • payment_http_client.go
  • payment/
  • service.go
  • handler.go
  • repository.go
  • cmd/
  • api/
  • main.go
Buat contract di checkout

Tambahkan PaymentClient dan struct request response seperti contoh sebelumnya.

Buat local adapter

Adapter ini memanggil service payment lama di proses yang sama, sehingga belum ada network risk.

Buat HTTP adapter

Adapter HTTP mengikuti contract yang sama, sehingga checkout tidak perlu berubah saat traffic dialihkan.

Pilih adapter dari config

Gunakan env PAYMENT_MODE=local untuk dev dan PAYMENT_MODE=http untuk canary staging.

Tulis test service checkout

Mock PaymentClient untuk memastikan checkout tidak bergantung pada implementasi payment.

internal/checkout/payment_local_client.go
package checkout import ( "context" "fmt" "github.com/kamu/skincare-backend/internal/payment" ) type LocalPaymentClient struct { service *payment.Service } func NewLocalPaymentClient(service *payment.Service) *LocalPaymentClient { return &LocalPaymentClient{service: service} } func (c *LocalPaymentClient) CreatePaymentIntent(ctx context.Context, req CreatePaymentIntentRequest) (PaymentIntent, error) { intent, err := c.service.CreateIntent(ctx, payment.CreateIntentRequest{ OrderID: req.OrderID, CustomerID: req.CustomerID, Amount: req.Amount, Currency: req.Currency, Description: req.Description, }) if err != nil { return PaymentIntent{}, fmt.Errorf("create local payment intent: %w", err) } return PaymentIntent{ ID: intent.ID, RedirectURL: intent.RedirectURL, ExpiresAt: intent.ExpiresAt, }, nil }
cmd/api/main.go
package main import ( "log" "net/http" "github.com/kamu/skincare-backend/internal/checkout" "github.com/kamu/skincare-backend/internal/payment" ) // Config dibaca sekali saat startup (12-factor), bukan os.Getenv tersebar. type Config struct { PaymentMode string // "local" atau "http" PaymentServiceURL string } func buildPaymentClient(cfg Config, paymentService *payment.Service, httpClient *http.Client) checkout.PaymentClient { if cfg.PaymentMode == "http" { return checkout.NewHTTPPaymentClient(cfg.PaymentServiceURL, httpClient) } return checkout.NewLocalPaymentClient(paymentService) } func main() { cfg := loadConfig() // baca env sekali, validasi di sini httpClient := newServiceHTTPClient() // dibagi ke semua client antar service paymentService := payment.NewService() paymentClient := buildPaymentClient(cfg, paymentService, httpClient) checkoutService := checkout.NewService(newOrderRepository(), paymentClient) _ = checkoutService log.Println("api started") }
📝Stub dari modul sebelumnya

payment.NewService(), newOrderRepository(), loadConfig(), dan NewPendingOrder() didefinisikan di modul-modul sebelumnya (domain, repository, config). Di sini kita hanya merakitnya. Kalau ingin menjalankan latihan secara terisolasi, beri stub minimal yang mengembalikan nilai dummy agar go build lolos sebelum mengisi logika asli.

internal/checkout/service_test.go
package checkout import ( "context" "testing" "time" ) type fakePaymentClient struct { intent PaymentIntent err error } // Pointer receiver, konsisten dengan LocalPaymentClient dan HTTPPaymentClient. func (f *fakePaymentClient) CreatePaymentIntent(ctx context.Context, req CreatePaymentIntentRequest) (PaymentIntent, error) { return f.intent, f.err } func TestCheckoutCreatesPaymentIntent(t *testing.T) { payments := &fakePaymentClient{ intent: PaymentIntent{ ID: "pay_123", RedirectURL: "https://pay.example.test/redirect", ExpiresAt: time.Now().Add(30 * time.Minute), }, } service := NewService(&fakeOrderRepository{}, payments) result, err := service.Checkout(context.Background(), CheckoutRequest{ CustomerID: "cus_123", Items: []CheckoutItem{ {ProductID: "prd_serum", Quantity: 1}, }, }) if err != nil { t.Fatalf("checkout failed: %v", err) } if result.PaymentURL == "" { t.Fatal("expected payment URL") } }
Terminal
go test ./internal/checkout PAYMENT_MODE=local go run ./cmd/api PAYMENT_MODE=http PAYMENT_SERVICE_URL=http://localhost:9090 go run ./cmd/api
⚠️Value vs pointer receiver: pilih satu gaya

Datang dari JS, mudah lupa bahwa method dengan pointer receiver (func (c *T)) hanya memenuhi interface lewat *T, bukan T. Di modul ini semua adapter konsisten pakai pointer receiver, jadi selalu serahkan &fakePaymentClient{}, bukan nilainya. Mencampur value dan pointer receiver pada tipe yang sama adalah sumber bug “does not implement interface” yang membingungkan.

💡Latihan ini kecil tapi strategis

Begitu seam tersedia dan test checkout tetap hijau, ekstraksi payment menjadi perubahan konfigurasi dan deployment, bukan rewrite checkout.

10

Jebakan Umum Saat Split

Masalah microservices biasanya muncul dari boundary yang salah, bukan dari Go

Microservices menambah failure mode baru: network, retry, duplikasi event, observability, dan konsistensi yang tidak instan.

Shared database permanen

Service terlihat terpisah, tetapi migration tetap saling mengunci. Jadikan shared database hanya fase transisi.

Chain HTTP terlalu panjang

Checkout memanggil payment, lalu inventory, lalu shipping, lalu notification secara sinkron. Satu service lambat membuat semua lambat.

Event tidak idempotent

Consumer mengirim email dua kali atau mengubah stok dua kali karena menganggap event hanya datang sekali.

Contract tidak versioned

Perubahan field JSON mematahkan consumer lama. Pakai backward-compatible change dan observability sebelum menghapus field.

Tidak ada correlation ID

Satu checkout tersebar ke beberapa log service tanpa benang merah, debugging production menjadi lambat.

Observability terlambat

Service baru tanpa log JSON, metrics, tracing, DLQ alarm, dan dashboard akan sulit dioperasikan.

⚠️Distributed transaction bukan default

Jangan berharap transaksi SQL lintas service seperti transaksi satu database. Untuk flow panjang, gunakan event, saga, kompensasi, dan rekonsiliasi.

🌉Jembatan: dari await tx.commit() ke saga

Di Node dengan satu database, kamu terbiasa await tx.commit() membungkus banyak operasi jadi satu unit yang gagal bersama. Lintas service, tidak ada commit global itu: reserve stock dan charge payment terjadi di dua database berbeda. Maka rollback diganti kompensasi, yaitu aksi penyeimbang yang membatalkan langkah sebelumnya (mis. release stock setelah charge gagal). Inilah inti pola Saga.

Saga: rollback yang dibuat manual

Saga (didokumentasikan AWS dan microservices.io) adalah rangkaian transaksi lokal di banyak service. Setiap langkah punya aksi kompensasi yang menjalankan kebalikannya bila ada langkah berikutnya gagal. Ada dua gaya koordinasi.

Choreography
  • Tidak ada pusat. Tiap service bereaksi terhadap event dan memancarkan event berikutnya.
  • Longgar dan tahan banting, tetapi alur sulit dilihat utuh saat sistem bertambah besar.
  • Cocok untuk saga pendek dengan dua sampai tiga langkah.
Orchestration
  • Ada orchestrator (mis. order service) yang memerintah tiap langkah dan memicu kompensasi saat gagal.
  • Alur terpusat, mudah dilacak dan diaudit, tetapi orchestrator jadi titik logika penting.
  • Cocok untuk saga panjang yang butuh visibilitas dan kontrol.

Untuk checkout skincare, ambil flow konkret: reserve stock, lalu charge payment. Jika payment gagal atau timeout, stok yang sudah direservasi harus dilepas lagi (kompensasi), dan order ditandai canceled. Diagram state berikut menggambarkan saga orchestration sederhana ini.

stateDiagram-v2
  [*] --> StockReserved: reserve stock
  StockReserved --> PaymentCharged: charge payment ok
  StockReserved --> Compensating: payment failed / timeout
  Compensating --> Canceled: release stock + cancel order
  PaymentCharged --> Completed: PaymentSucceeded event
  Completed --> [*]
  Canceled --> [*]

Gambar 8. Saga order skincare: kegagalan payment memicu kompensasi (release stock) yang mengembalikan sistem ke keadaan konsisten, bukan menggantung setengah jadi.

flowchart TD
  A["POST /v1/checkout"] --> B["Order created"]
  B --> C["Payment intent created"]
  C --> D{"Payment result"}
  D -->|Succeeded| E["PaymentSucceeded event"]
  D -->|Failed or timeout| F["PaymentFailed event"]
  E --> G["Order marked paid"]
  E --> H["Receipt email queued"]
  F --> I["Release stock, order canceled"]

Gambar 9. Flow lintas service membutuhkan state machine dengan jalur kompensasi, bukan transaksi tunggal yang tersembunyi.

Kesalahan umum
  • Service baru langsung membaca tabel orders dari database monolith.
  • Webhook payment memanggil order service sinkron dan gagal jika order service sedang deploy.
  • Event tidak punya ID unik, correlation ID, dan schema yang jelas.
Versi aman
  • Payment service punya payment_db, lalu publish PaymentSucceeded.
  • Order service memproses event secara idempotent dan bisa retry.
  • Semua event membawa ID, aggregate ID, occurred at, payload, dan correlation ID.
11

Ringkasan & Poin Penting

Microservices adalah alat untuk mengelola perbedaan ownership, scaling, dan deployment, bukan hadiah akhir setelah monolith terasa tidak keren.

Yang Wajib Menempel

  • Tetap pakai modular monolith ketika tim kecil, traffic masih terkendali, dan masalah bisa selesai lewat profiling, cache, index, queue, atau tuning database (MonolithFirst).
  • Signal split yang sehat adalah deployment frequency berbeda, scaling requirement berbeda, dan tim yang benar-benar berbeda.
  • Service boundary mengikuti domain bisnis. Notification dan search biasanya lebih aman dipisah lebih dulu daripada checkout dan inventory.
  • Database per Service mewajibkan pola pelengkap untuk query lintas service: API composition untuk query jarang, read model (CQRS ringan) untuk query panas seperti status payment di riwayat order.
  • Definisikan API contract dan interface Go sebelum memindahkan runtime. Versioning aman bersifat aditif; breaking change pakai jalur /v1 ke /v2 berdampingan.
  • Pilih komunikasi sadar: sinkron (gRPC atau REST) saat hasil dipakai langsung, asinkron (event ke SQS) saat side effect boleh menyusul.
  • Transactional Outbox menyelesaikan dual-write problem: tulis bisnis dan catat event dalam satu commit, poller publish belakangan. Consumer wajib idempotent lewat dedup key yang stabil.
  • Saga mengganti transaksi lintas service: tiap langkah punya kompensasi (mis. release stock saat payment gagal). Strangler fig dengan canary memindahkan satu domain per waktu tanpa big bang rewrite.

Pemetaan ke proyek skincare

  • notification menjadi kandidat split awal karena email, WhatsApp, dan campaign bisa diretry tanpa memblokir checkout.
  • search bisa menjadi service berikutnya karena index dapat dibangun ulang dari event produk dan order.
  • payment boleh diekstrak setelah contract stabil, webhook idempotent, audit event lengkap, dan ownership data jelas.
  • checkout dan inventory tetap dijaga ketat karena menyentuh uang, stok, dan konsistensi order.

Langkah berikutnya

Modul berikutnya di jalur advanced bisa memperdalam reliability pattern: circuit breaker, timeout budget, retry policy, idempotency store, tracing lintas service, dan saga untuk order lifecycle yang lebih panjang.

Progress disimpan lokal di browser ini.