05 · Arsitektur Teknis
Bayangkan sebuah gudang pusat dengan banyak loket. Gudangnya satu: semua data produk, stok, harga, dan transaksi tersimpan di satu tempat yang tertib. Di depannya ada satu meja pelayanan yang melayani semua loket — staf internal, pembeli, vendor, dan aplikasi HP staf gudang. Loketnya bertambah seiring waktu, tapi meja pelayanan dan gudangnya tidak pernah dibangun ulang. Itu sebabnya menambah loket baru cepat, dan kenapa angka yang dilihat semua orang selalu sama.
Monorepo (pnpm + Turborepo): satu API Express 5 (TypeScript) berdiri sendiri
(apps/api — satu proses, satu port, folder + route per modul) melayani tiga
aplikasi web per audiens plus satu aplikasi React Native, ditambah satu
worker Go (apps/worker) untuk kerja CPU-berat — impor Excel,
ekspor pembukuan, render PDF, foto produk — lewat antrean job berbasis Postgres, sehingga
operasi panjang tidak pernah memblokir jalur request. Semua berbagi packages/db
(schema Drizzle), packages/core (aturan domain + otorisasi), dan
packages/contracts (skema Zod → OpenAPI → klien bertipe keempat aplikasi;
→ JSON Schema → validasi payload di Go). Satu PostgreSQL.
Antar modul memanggil lewat packages/core in-process — tidak pernah lewat
HTTP — sehingga checkout menulis sales_orders dan stock_movements
dalam satu transaksi atomik. Kontrak URL {modul}/api/v1/* identik di sisi publik dan
upstream (tanpa rewrite), sehingga klien yang digenerate tidak pernah berbohong.
Deploy: VPS + Docker Compose, edge Caddy, SeaweedFS (container) untuk artefak file.
Schema database penuh untuk semua modul sejak migrasi pertama.
principals, external_accounts, account_grants) berasal dari
adr-003-identitas-dan-pemberian-akses.md.
Tim: 2 web developer + 1 mobile developer (React Native).
satu API Express 5 + satu worker Go melayani lima klien — tiga web, dua HP · garis putus-putus = belum dibangun, tempatnya sudah disediakan
Ctrl/Cmd + scroll untuk zoom. Drag untuk geser. Klik dua kali untuk fit.
konsisten dengan konvensi mount /<modul>/api/v1 yang sudah dipakai EDUNUSA
api.kmtxpumit.my.id/vendor/api/v1/listings (domain sementara — final menunggu nama PT resmi) di sisi publik, dan
/vendor/api/v1/listings juga di sisi upstream. Tanpa rewrite.
Empat hal yang dibeli keputusan sederhana ini: nomor versi terlihat oleh klien
(aplikasi mobile terpasang tidak bisa dipaksa update, jadi v1 harus tetap hidup saat
v2 lahir); klien yang digenerate dari OpenAPI tidak pernah berbohong soal path;
developer mobile bisa menembak proses API langsung di localhost tanpa gateway; dan gateway bisa
disisipkan kapan saja tanpa satu pun URL berubah.
Channel (web/mobile) TIDAK masuk path. Aturannya: route mengikuti upstream, bukan
channel — selama upstream cuma satu, prefix apa pun di depannya hanyalah alias. Perbedaan
web dan mobile ditangani lewat kredensial (cookie vs Bearer), header X-Client, dan
kebijakan per consumer di edge. Ada alasan khusus proyek ini:
sales_orders.channel sudah berarti channel bisnis
(internal | marketplace); kalau URL juga punya "channel", cepat atau lambat seseorang
menurunkan kolom itu dari prefix URL — dan pesanan marketplace dari HP tercatat dengan channel
salah. Itu bug pembukuan lintas dua badan hukum, bukan bug tampilan.
Mengapa dipecah per audiens, bukan per modul? Tool internal, etalase publik, dan portal vendor punya kebutuhan keamanan, model login, dan irama rilis yang berbeda total. Sementara modul bisnis cukup jadi bagian di dalam aplikasi yang tepat, karena semuanya membaca data inti yang sama.
Satu penggabungan bersifat wajib, dan buktinya ada di kriteria selesai kita sendiri:
"pencarian part bisa difilter berdasarkan unit milik pelanggan." Satu layar itu butuh katalog
+ cross-reference (SPAREPART) dan customer_units + sesi login (CUSTOMER)
sekaligus. Batas modul bisnis tidak memotong bersih di lapisan UI — jadi SPAREPART dan CUSTOMER
harus satu aplikasi.
Kepemilikan diatur lewat CODEOWNERS, bukan lewat URL. Tiap aplikasi punya pemilik;
dan yang terpenting, packages/contracts dimiliki bertiga — perubahan kontrak API
tidak bisa di-merge tanpa persetujuan developer mobile. Itu perlindungan yang sebenarnya ia butuhkan:
hak veto atas hal yang bisa merusak aplikasi di tangan pengguna.
master dibagi bersama — transaksi selalu membawa entity_id — stok adalah ledger append-only
Tiga keputusan penting dalam bahasa sehari-hari:
stock_movements append-only, ditegakkan engine: REVOKE UPDATE, DELETE pada app role. Koreksi lewat reversing entry, bukan UPDATE. Stok = agregat ledger; reorder point & fast/slow/dead stock = query.client_ref unique + tabel idempotency_keys yang menyimpan RESPONS. Retry setelah sukses harus replay 200, bukan 409.created_by + device_id. DoD menuntut "alasan DAN jejak" — ledger dua badan hukum butuh tahu siapa.entity_id (KMT non-PPN / PUM PPN); master data tanpa entity_id. Scoping-nya dipaksakan di lapisan akses data lewat packages/core, bukan per handler.principals → users (staf, kredensial) dan external_accounts (pembeli/vendor/mitra, Google atau magic link). Staf gudang sengaja TIDAK memakai Google — alur Google butuh jaringan, dan gudang tidak selalu punya sinyal.account_grants, bukan kolom di akun. Ia dibaca authorize() per permintaan, tidak pernah dibekukan ke dalam token — itulah yang membuat revoked_at menggigit di request berikutnya. Grant buta-entitas: yang dibawa scope_ref (atas nama perusahaan mana), bukan entity_id.grant.create hanya admin; grant.revoke boleh admin atau sales. Asimetri ini mengikuti asimetri biayanya — salah beri tidak bisa ditarik setelah datanya dilihat, salah cabut bisa diperbaiki dalam semenit. Konsekuensinya kolom revoked_by: begitu pencabut tidak lagi bisa disimpulkan sebagai admin, hanya kolom itu yang menjawabnya.name + city, dan tiga pagar tetap berdiri: wajib sudah masuk, batas laju per akun, setiap akses dicatat.sessions + refresh_tokens + devices, semuanya menunjuk principal_id. Cookie (web), Bearer (mobile), Google, dan magic link menghasilkan satu objek Principal kanonik; cek otorisasi hanya membaca Principal.supplier_prices menyimpan histori harga per supplier per produk, multi-currency (IDR/USD/RMB) — fondasi fitur "vendor darurat" Level 3.Ctrl/Cmd + scroll untuk zoom. Drag untuk geser. Klik dua kali untuk fit.
tabel modul CUSTOMER · VENDOR · MEKANIK · RENTAL — schema dibuat sekarang, frontend menyusul sesuai prioritas
Diagram berikut melengkapi ERD inti di atas. Tabel-tabel ini dibuat di migrasi awal
supaya tidak ada perombakan struktur di tengah jalan: modul prioritas (SPAREPART, CUSTOMER,
VENDOR, INTERNAL) langsung memakainya, sedangkan tabel mekanik dan rental menunggu frontend-nya
tanpa mengganggu apa pun. Titik sambungnya ke ERD inti: customers, products,
dan entities.
Ctrl/Cmd + scroll untuk zoom. Drag untuk geser. Klik dua kali untuk fit.
packages/core in-process, bukan HTTP. (ADR-002) Kerja CPU-berat di apps/worker Go via antrean job Postgres — worker adalah otot, bukan otak: tanpa aturan bisnis, tanpa tulis tabel bisnis langsung.{modul}/api/v1/* identik publik dan upstream, tanpa rewrite.public; jangan pernah pisah schema per entitas KMT/PUM.packages/core, dipanggil di lapisan akses data.