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.
Split ke Microservices
Tanpa Merusak Bisnis
Microservices adalah keputusan organisasi dan operasional, bukan sekadar memecah folder Go menjadi banyak repository.
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 adalah layanan kecil yang punya boundary bisnis jelas, dapat dideploy secara independen, dan idealnya memiliki data yang ia kuasai sendiri.
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.
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 modulBukan: 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.
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.
- 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.
- 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.
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.
Kumpulan service yang terlihat terpisah, tetapi deploy, database, dan perubahan fitur tetap saling mengunci seperti satu aplikasi besar.
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.
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.
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.
- 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
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.
user-service,product-service, danorder-servicetetap 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.
- Notification menerima event
OrderPaiddan mengirim email tanpa memblokir checkout. - Search membangun read model sendiri dari event produk.
- Payment punya contract jelas untuk intent, status, callback, dan rekonsiliasi.
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
endGambar 3. Sebelum split, modul join langsung di satu database. Sesudah split, setiap service punya database sendiri dan dihubungkan lewat event, bukan join.
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.
- 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.
- 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.
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.
Aturan bahwa satu service menjadi sumber kebenaran untuk data tertentu, termasuk skema, validasi, perubahan state, dan audit trail.
Begitu dua service menulis tabel yang sama, deploy independen hanya ilusi. Perubahan constraint kecil bisa mematahkan service lain tanpa terlihat di compile time.
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.gopackage 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.gopackage 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 }
/internal/v1/payments/intents Contract internal untuk membuat payment intent dari order yang sudah dibuat /v1/webhooks/payments Webhook publik dari payment gateway yang diverifikasi oleh payment service /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" }
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.
- Mengganti
amountdari integer menjadi objek{ value, currency }. - Menghapus field
redirect_urlkarena dianggap usang. - Membuat field opsional menjadi wajib (required) tanpa default.
- Menambah field opsional
expires_atyang diabaikan consumer lama. - Menambah endpoint
/internal/v2/payments/intentsdi 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.
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.
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.
- 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.
- 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.gopackage 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.gopackage 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.
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.
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 serviceCREATE 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.gopackage 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) }
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.
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.
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.
Buat PaymentClient interface di package checkout, lalu implementasikan adapter lokal yang memanggil package payment lama.
Tulis request dan response JSON internal, status code, error code, idempotency key, dan correlation ID.
Payment service berjalan di ECS sendiri, tetapi checkout belum diarahkan ke sana.
Alihkan sebagian order internal ke payment service, monitor latency, error, webhook, dan rekonsiliasi.
Payment service mulai menulis payment_db, lalu menerbitkan event untuk order service.
Setelah semua traffic stabil dan audit lengkap, hapus adapter lokal dan tabel lama yang sudah tidak dipakai.
internal/checkout/payment_http_client.gopackage 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.gopackage 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 } }
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.
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.
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.
- 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
Tambahkan PaymentClient dan struct request response seperti contoh sebelumnya.
Adapter ini memanggil service payment lama di proses yang sama, sehingga belum ada network risk.
Adapter HTTP mengikuti contract yang sama, sehingga checkout tidak perlu berubah saat traffic dialihkan.
Gunakan env PAYMENT_MODE=local untuk dev dan PAYMENT_MODE=http untuk canary staging.
Mock PaymentClient untuk memastikan checkout tidak bergantung pada implementasi payment.
internal/checkout/payment_local_client.gopackage 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.gopackage 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") }
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.gopackage 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") } }
Terminalgo test ./internal/checkout PAYMENT_MODE=local go run ./cmd/api PAYMENT_MODE=http PAYMENT_SERVICE_URL=http://localhost:9090 go run ./cmd/api
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.
Begitu seam tersedia dan test checkout tetap hijau, ekstraksi payment menjadi perubahan konfigurasi dan deployment, bukan rewrite checkout.
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.
Jangan berharap transaksi SQL lintas service seperti transaksi satu database. Untuk flow panjang, gunakan event, saga, kompensasi, dan rekonsiliasi.
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.
- 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.
- 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.
- Service baru langsung membaca tabel
ordersdari 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.
- Payment service punya
payment_db, lalu publishPaymentSucceeded. - Order service memproses event secara idempotent dan bisa retry.
- Semua event membawa ID, aggregate ID, occurred at, payload, dan correlation ID.
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
notificationmenjadi kandidat split awal karena email, WhatsApp, dan campaign bisa diretry tanpa memblokir checkout.searchbisa menjadi service berikutnya karena index dapat dibangun ulang dari event produk dan order.paymentboleh diekstrak setelah contract stabil, webhook idempotent, audit event lengkap, dan ownership data jelas.checkoutdaninventorytetap 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.
Sumber primer pola: Martin Fowler - MonolithFirst, Martin Fowler - StranglerFigApplication, dan katalog microservices.io: Database per Service, Transactional Outbox, Saga, serta Messaging. Dokumentasi platform: Go 1.26 release notes, Go Modules Reference, AWS database-per-service pattern, AWS saga pattern, AWS microservices integration guidance, dan Amazon SQS visibility timeout.
Progress disimpan lokal di browser ini.