Spring Boot E-Commerce Backend
1. Overview & Executive Summary
Spring Boot E-Commerce Backend adalah REST API monolitik untuk platform e-commerce — mulai dari katalog produk, otentikasi pengguna, lifecycle pemesanan, hingga pembayaran nyata lewat Stripe. Dibangun di atas Java 17 + Spring Boot 3.4, MySQL 8.4, dan Spring Security, proyek ini dikemas penuh dalam Docker Compose dan dijamin oleh pengujian otomatis (JUnit 5 + MockMvc).
Dibandingkan proyek e-commerce tutorial pada umumnya, ada beberapa hal yang membedakan proyek ini:
- Dua skema token dalam satu aplikasi — JWT lokal bertanda tangan HS256 dan JWT Okta RS256 diterima oleh resource server yang sama, melalui satu
JwtDecoderyang fallback secara transparan. - Mock OIDC server untuk development — Okta ditiru oleh server OIDC kecil berbasis Python (HTTPS + JWKS asli) sehingga alur OAuth2 dapat dikembangkan dan diuji sepenuhnya secara offline.
- Webhook Stripe sebagai fallback idempotent — status pembayaran tetap tersinkronisasi ke
PAIDmeskipun konfirmasi sinkron terganggu, dengan verifikasi tanda tangan HMAC untuk keamanan. - Katalog produk read-only via Spring Data REST — tanpa boilerplate controller, dengan metode tulis yang dimatikan demi keamanan.
Artikel ini menguraikan latar belakang masalah, keputusan arsitektur dan teknis, tantangan yang dihadapi beserta solusinya, hingga bagaimana proyek diuji dan dijalankan — ditutup dengan evaluasi jujur atas keterbatasan yang tersisa.
2. Context & The Problem
Setiap sistem e-commerce menghadapi masalah yang sama: memindahkan uang dengan aman dan konsisten. Di balik antarmuka belanja yang sederhana, tersimpan sejumlah tantangan teknis yang saling terkait:
- Data produk dan kategori harus terkelola dengan rapi, konsisten, dan mudah dijelajahi frontend — tapi tidak boleh sembarang bisa diubah dari luar.
- Riwayat pemesanan wajib akurat dan permanen. Harga yang berubah setelah transaksi tidak boleh mengubah histori pembeli.
- Autentikasi seringkali terpecah: sebagian pengguna datang dengan akun lokal, sebagian lagi lewat single sign-on enterprise (Okta). Keduanya harus hidup berdampingan tanpa memaksa dua sistem keamanan yang berbeda.
- Pembayaran adalah titik paling rawan. Konfirmasi bisa "hilang" di tengah jalan — browser ditutup, respons HTTP terputus — sementara uang sudah berpindah. Tanpa mekanisme penyelaras, status order dan status pembayaran bisa tidak sinkron selamanya.
- API harus terdokumentasi dan mudah dikonsumsi frontend apa pun (React, Next.js, mobile), serta bisa diuji tanpa menunggu tim frontend selesai.
Tanpa desain yang matang, celah yang muncul biasanya bersifat kritis: stok yang menipu, histori order yang salah, token yang bocor, atau pembayaran yang tercatat ganda.
3. Goals & Scope
The Goal. Mengembangkan RESTful API yang aman, terdokumentasi lengkap lewat Swagger/OpenAPI, dan siap diintegrasikan dengan aplikasi frontend apa pun. Ruang lingkup fiturnya:
- Autentikasi pengguna lokal berbasis JWT dan single sign-on OAuth2 via Okta.
- Katalog produk dan kategori yang read-only dari sisi klien.
- Lifecycle pemesanan yang utuh: checkout, payment intent, konfirmasi pembayaran, hingga penandaan order
PAID. - Notifikasi asinkron dari Stripe melalui webhook dengan verifikasi tanda tangan.
- Riwayat order per pengguna yang terproteksi.
- Dokumentasi API interaktif (Swagger UI), pengujian otomatis, dan distribusi via Docker Compose.
Non-goals (sengaja di luar lingkup): tidak menangani stok pada saat checkout, tidak ada admin panel, dan tidak ada refresh-token rotation — keputusan ini dibahas jujur pada bagian akhir artikel.
4. High-Level Architecture
Proyek dirancang sebagai REST API monolitik dengan arsitektur berlapis (layered architecture) yang bersih dan mudah dipelihara. Setiap permintaan HTTP masuk dari klien, melewati filter keamanan, kemudian diteruskan ke lapisan controller, diolah di service layer, dan dipersistenkan ke MySQL melalui Spring Data JPA.
- Client. SPA / aplikasi mobile yang memakai REST API dengan token
Bearerpada headerAuthorization. Klien bebas berupa React, Next.js, maupun mobile app. - Presentation layer. Titik masuk HTTP:
AuthController,CheckoutController,WebhookController, ditambah endpoint Spring Data REST untuk katalog produk, kategori, negara, dan state di bawah prefix/api. - Service layer. Logika bisnis dipisahkan menjadi
AuthService(register / login / profil) danCheckoutService(pembuatan order, payment intent, konfirmasi pembayaran, dan update status order). - Data access layer. Interface
JpaRepository(AppUserRepository,OrderRepository,ProductRepository, dll.) dengan derived queries sepertifindByEmaildanfindByOrderTrackingNumber. - JPA entities. 9 entitas relasional yang memetakan tabel database —
AppUser,Customer,Order,OrderItem,Product,ProductCategory,Address,Country, danState. - Data. MySQL sebagai basis data utama; H2 in-memory dipakai pada profil test.
- Cross-cutting concerns. Keamanan (
SecurityFilterChain,JwtService, dualJwtDecoder), DTO, CORS, seeding data, dan konfigurasi OpenAPI/Swagger menyeberang seluruh lapisan. - Integrasi eksternal. Dua layanan pihak ketiga: Okta (identitas OAuth2/OIDC) dan Stripe (pemrosesan pembayaran).
Alur setiap request dijamin konsisten: token divalidasi di filter chain sebelum menyentuh logika bisnis, sehingga trafik yang tidak terautentikasi atau token yang rusak ditolak lebih dulu tanpa membebani service layer.
5. Core Backend Logic & Features
Autentikasi & Profil Pengguna
Sistem autentikasi menangani dua skema identitas sekaligus — pengguna lokal dan pengguna enterprise Okta:
POST /api/auth/register— membuat akun baru. Email dinormalisasi menjadi huruf kecil, dicek keunikannya (existsByEmail), password di-hash dengan BCrypt, lalu langsung mengembalikan JWT (HTTP201 Created).POST /api/auth/login— memvalidasi kredensial (BCryptPasswordEncoder.matches) dan mengembalikan JWT (HTTP200 OK). Kredensial salah dilempar sebagaiBadCredentialsExceptionyang dipetakan menjadi401.GET /api/auth/me— mengembalikan profil pengguna saat ini berdasarkan token Bearer.AuthServiceImpl.findProfilemencari pengguna lokal; jika tidak ada, ia membangun profil fallback dari klaim token dengan penandaprovider: "okta".
Katalog Produk (Read-Only)
Katalog diekspos secara otomatis oleh Spring Data REST di bawah prefix /api: /api/products, /api/product-category, /api/countries, /api/states. MyDataRestConfig mematikan metode PUT, POST, PATCH, dan DELETE untuk entitas katalog dan Order, sehingga data produk hanya bisa dibaca klien dan tidak bisa diubah dari luar. ID setiap entitas sengaja diekspos agar mudah dikonsumsi frontend.
Checkout & Pembayaran Stripe
Alur checkout berjalan dalam tiga tahap sinkron ditambah satu tahap asinkron (webhook):
- Pembuatan order —
POST /api/checkout/purchasemenerimaPurchase(customer, alamat pengiriman & penagihan, order, dan daftar item).CheckoutServiceImpl.placeOrderbertipe@Transactional: membuat UUID tracking number, menyambungkan order items, menyimpan alamat, lalu menautkan order ke customer — customer yang sudah ada dipakai ulang berdasarkan email agar tidak terjadi duplikasi. Responsnya berupaPurchaseResponseberisi tracking number. - Pembuatan PaymentIntent —
POST /api/checkout/payment-intentmembuatPaymentIntentdi Stripe dengan amount, currency,payment_method_types: ["card"],receipt_email, dan metadata berisiorderTrackingNumberagar order dan pembayaran bisa dipertemukan kembali. Klien menerimaclientSecretuntuk me-render Stripe Elements. - Konfirmasi pembayaran —
POST /api/checkout/payment-intent/{id}/confirmmengonfirmasi PaymentIntent denganpaymentMethodId, atau membuat PaymentMethod baru daritokenkartu bila hanya token yang diberikan. Jika status menjadisucceeded, status order langsung diubah menjadiPAID. Kartu ditolak menghasilkan400dengan decline code yang terstruktur. - Webhook Stripe —
POST /api/webhook/stripemenerima event dari Stripe, memverifikasi tanda tangan HMAC (Stripe-Signature) denganWebhook.constructEvent, lalu pada eventpayment_intent.succeededmembacaorderTrackingNumberdari metadata dan menandai order menjadiPAID. Webhook ini adalah idempotent fallback: walaupun konfirmasi sinkron gagal atau terlewat, status pembayaran tetap tersinkronisasi.
Riwayat Order Per Pengguna
GET /api/orders (wajib terautentikasi) memakai derived query findByCustomerEmailOrderByDateCreatedDesc untuk mengembalikan riwayat order milik pengguna yang login, diurutkan dari yang terbaru, dengan dukungan pagination (Pageable).
6. Authentication & Security
Desain keamanan adalah salah satu bagian paling menarik dari proyek ini: satu aplikasi melayani dua skema token sekaligus.
- JWT lokal (HS256). Token diterbitkan oleh
JwtService— implementasi JWT yang ditulis dari nol denganHmacSHA256, berisi klaimsub,email,name,iat, danexp(masa berlaku 1 jam). Verifikasi memakai perbandingan constant-time (MessageDigest.isEqual) untuk mencegah timing attack, dan menolak token yang rusak, tampered, atau kedaluwarsa. - JWT Okta (RS256). Token diterbitkan oleh Okta (atau mock OIDC saat pengembangan).
jwtDecoderdiSecurityConfigurationmencoba decoder lokal HS256 lebih dulu; jika gagal, ia membangunNimbusJwtDecoder.withIssuerLocationsecara lazy — dokumen discovery dan JWKS Okta baru di-fetch saat token Okta pertama kali benar-benar digunakan. - Aturan otorisasi.
SecurityFilterChainmenetapkan/api/orders/**dan/api/auth/mesebagai authenticated, sementara endpoint lain permitAll;oauth2ResourceServer.jwtmengaktifkan validasi token, CORS diaktifkan, dan CSRF dimatikan (API stateless). - CORS. Origin yang diizinkan diambil dari environment variable
ALLOWED_ORIGINSdan diterapkan diMyAppConfigsertaMyDataRestConfig. - Password. Selalu disimpan dalam bentuk
BCryptPasswordEncoder— tidak pernah plaintext. - Respons 401 yang konsisten.
Okta.configureResourceServer401ResponseBodymemastikan klien menerima body 401 yang seragam untuk semua skema autentikasi.
7. Database & Data Modeling
Skema relasional dirancang untuk menjaga integritas transaksi dan memudahkan pelaporan.

Relasi kunci antar entitas:
| Relasi | Kardinalitas | Catatan |
|---|---|---|
| Customer → Order | 1:N | melacak riwayat transaksi pembeli |
| Order → OrderItem | 1:N | snapshot harga/kuantitas saat transaksi |
| Order → Address (shipping) | 1:1 | alamat pengiriman |
| Order → Address (billing) | 1:1 | alamat penagihan |
| Product → ProductCategory | N:1 | kategori produk via category_id |
| Country → State | 1:N | dropdown lokasi untuk alamat |
Keputusan desain penting:
- OrderItem menyimpan snapshot harga.
OrderItemmenyalinunitPrice,imageUrl,quantity, danproductIdpada saat transaksi. Perubahan harga produk di masa depan tidak akan mengubah histori pesanan — prinsip yang sama dengan yang dipegang sistem e-commerce skala besar. - Pemisahan
AppUserdanCustomer. Identitas autentikasi (app_user) sengaja terpisah dari profil pembeli (customer). Ini memungkinkan satu pengguna memakai banyak alamat/customer profile tanpa mengikat domain autentikasi. - Status order eksplisit. Order membawa
status(dariPENDINGmenjadiPAID) plus timestampdateCreated/lastUpdatedyang diisi otomatis oleh@CreationTimestamp/@UpdateTimestamp. - Self-referencing address.
Addressmenautkan balik keOrdervia@PrimaryKeyJoinColumn, menjaga relasi tetap dua arah.
Seed data. DataSeeder menjalankan db/seed-data.sql saat database masih kosong: 8 negara, 40 state, 8 kategori (BOOKS, COFFEE, MOUNTAINEERING, ELECTRONICS, FASHION, SPORTS, HOME & KITCHEN, TOYS & GAMES), dan 122 produk contoh dengan SKU, harga, stok, serta gambar.
8. Tech Stack & Engineering Trade-offs
| Concern | Choice |
|---|---|
| Language / Framework | Java 17 + Spring Boot 3.4.5 |
| Persistence | Spring Data JPA / Hibernate + MySQL 8.4 |
| REST Layer | Spring Data REST + Spring MVC |
| Auth & Security | Spring Security 6 + Okta Spring Boot Starter 3.0.7 |
| Token | JWT HS256 (lokal) + RS256 (Okta, via Nimbus) |
| Payments | stripe-java 29.1.0 + webhook |
| API Documentation | springdoc-openapi 2.8.6 (Swagger UI) |
| Build & Dependency | Maven |
| Testing | JUnit 5 + MockMvc + H2 (profil test) |
| Containerization | Docker multi-stage + Docker Compose |
| CI/CD | GitHub Actions (build + push image) |
Keputusan trade-off:
- Spring Boot + Java dipilih karena ekosistemnya menawarkan manajemen transaksi deklaratif (
@Transactional) dan dukungan keamanan yang matang out-of-the-box — sangat krusial untuk aplikasi yang memindahkan uang lewat order dan pembayaran. - Spring Data REST untuk katalog mengurangi boilerplate controller secara drastis: produk/kategori/negara/state langsung tersedia sebagai REST endpoint dengan pagination bawaan, sementara metode tulis dimatikan demi keamanan.
- JWT stateless memudahkan skalabilitas horizontal karena informasi autentikasi ada di token klien, bukan di sesi memori server.
- Decoder ganda (HS256 + RS256) memungkinkan migrasi bertahap dari identitas lokal ke identitas Okta — dua dunia autentikasi hidup berdampingan dalam satu aplikasi tanpa downtime.
- Mock OIDC untuk development menggantikan tenant Okta asli di mesin lokal, sehingga alur OAuth2 tetap bisa dikembangkan dan diuji tanpa bergantung pada layanan eksternal.
9. Engineering Challenges & Solutions
The Challenge — mengoperasikan dua skema autentikasi sekaligus. Token lokal (HS256) dan token Okta (RS256) harus diterima oleh resource server yang sama, tetapi masing-masing memiliki algoritma tanda tangan dan cara validasi yang berbeda.
The Solution. jwtDecoder mencoba decoder lokal HS256 lebih dulu, lalu fallback ke NimbusJwtDecoder.withIssuerLocation untuk RS256. Dekoder Okta dibuat lazy — discovery document dan JWKS baru diunduh saat benar-benar diperlukan — sehingga aplikasi lokal tidak pernah memanggil Okta saat hanya token lokal yang dipakai. Hasilnya, GET /api/auth/me dan semua endpoint terproteksi menerima kedua jenis token secara transparan.
The Challenge — menjaga integritas order di tengah transaksi yang kompleks. placeOrder menyatukan banyak operasi dalam satu alur: membuat UUID, menyambungkan order items, menyimpan dua alamat, lalu menautkan ke customer. Jika salah satu gagal di tengah jalan, database bisa berada dalam keadaan tidak konsisten.
The Solution. Anotasi @Transactional pada placeOrder memastikan seluruh operasi commit atau rollback bersama-sama — all-or-nothing. Customer yang sudah ada dipakai ulang berdasarkan email, menghindari duplikasi profil pembeli, dan cascade ORM menyimpan grafik order secara atomik.
The Challenge — sinkronisasi status pembayaran yang andal. Konfirmasi pembayaran bisa berhasil di Stripe tetapi respons HTTP hilang di tengah jalan, atau pengguna menutup browser sebelum status kembali.
The Solution. Verifikasi tanda tangan webhook dengan Webhook.constructEvent menjamin event hanya berasal dari Stripe. orderTrackingNumber disematkan di metadata PaymentIntent saat pembuatan, sehingga webhook bisa menemukan order yang tepat dan menandainya PAID tanpa harus menebak-nebak. Webhook bertindak sebagai fallback idempotent: menandai order PAID berkali-kali aman karena operasinya set, bukan increment.
The Challenge — keamanan kredensial dan token. Password dan token adalah target serangan utama pada API publik.
The Solution. Password di-hash dengan BCrypt, token diverifikasi dengan perbandingan constant-time untuk menangkal timing attack, dan token kedaluwarsa atau bertanda tangan salah langsung ditolak. Kredensial salah dipetakan ke respons 401 terstruktur, sementara IllegalArgumentException dari validasi data dipetakan ke 400.
10. API Documentation & Testing
Semua endpoint didokumentasikan otomatis oleh SpringDoc OpenAPI. OpenApiConfig menambahkan skema keamanan bearerAuth (JWT), dan customizer membersihkan tag Swagger agar setiap rute dikelompokkan rapi berdasarkan domain — Authentication, Checkout, Webhook, Order, Product, Country, State, dan lainnya — lengkap dengan deskripsi dalam bahasa Indonesia.
Swagger UI merangkum seluruh rute API yang tersedia lengkap dengan HTTP method dan deskripsi.
Grup Authentication menampilkan endpoint register, login, dan profil, lengkap dengan respons yang bisa diuji langsung dari browser.
Grup Checkout mendokumentasikan alur purchase, pembuatan PaymentIntent, dan konfirmasi pembayaran.
Pengujian otomatis. Proyek memakai JUnit 5 + MockMvc dengan H2 in-memory pada profil test, sehingga mvn test berjalan tanpa layanan eksternal:
JwtServiceTest— round-trip generate/parse token, penolakan token yang di-tamper, kedaluwarsa, dan format rusak.AuthServiceImplTest— unit test register, login, duplikat email, kredensial salah, dan fallback profil Okta.AuthControllerIntegrationTest— integrasi register (201), duplikat email (400), login (200/401),GET /api/auth/me(200/401), dan proteksi/api/orders.CheckoutIntegrationTest— pembuatan order menghasilkan tracking number, webhook dengan tanda tangan salah ditolak (400), dan webhook bertanda tangan valid menandai order menjadiPAID(termasuk simulasi HMAC Stripe secara penuh).
11. Docker & Local Development
Stack lokal berjalan penuh lewat Docker Compose: database MySQL, mock OIDC server, dan aplikasi — semuanya siap dalam satu perintah.
| Service | Image | Host port | Keterangan |
|---|---|---|---|
db | mysql:8.4 | 3308 → 3306 | database ecommerce, healthcheck mysqladmin ping |
oidc | python:3.12-slim | 8085 → 8085 (HTTPS) | mock Okta: OIDC discovery, JWKS, dan penerbitan token RS256 |
app | Spring Boot (multi-stage) | 9898 → 9898 | aplikasi + truststore gabungan (cacerts + cert mock OIDC) |
Mock OIDC. docker/oidc/mock_oidc.py adalah server OIDC kecil yang melayani .well-known/openid-configuration, jwks, endpoint /health, dan convenience endpoint /oauth2/default/issue?email=... yang menerbitkan JWT RS256 bertanda tangan dengan kid mock-key-1. Sertifikat self-signed dibuat oleh scripts/gen-oidc-cert.sh dan diimpor ke combined truststore pada saat build image — dengan begitu aplikasi di dalam container bisa melakukan issuer discovery ke https://oidc:8085 tanpa error SSL.
Dockerfile multi-stage. Stage pertama membangun JAR dengan Maven; stage kedua menjalankan aplikasi di eclipse-temurin:17-jdk-jammy dan mengimpor sertifikat mock OIDC ke truststore sebelum menjalankan java -jar app.jar.
Konfigurasi berbasis environment variable. application.properties hanya berisi placeholder yang diisi dari .env — tidak ada kredensial di kode:
| Variable | Fungsi |
|---|---|
DATABASE_URL, DATABASE_USERNAME, DATABASE_PASSWORD | koneksi MySQL |
OKTA_CLIENT_ID, OKTA_ISSUER | identitas OAuth2 |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | pembayaran & verifikasi webhook |
JWT_SECRET | rahasia penandatanganan JWT lokal |
ALLOWED_ORIGINS | origin CORS yang diizinkan |
CI/CD. .github/workflows/maven.yml menjalankan build pada setiap push/PR ke master: setup JDK 17, mvn clean package, arsip JAR sebagai artifact, lalu build & push image Docker hendrowunga/springboot-images-new ke Docker Hub.
12. Stripe Integration in Action
Berikut bukti integrasi pembayaran yang bekerja di dunia nyata (sandbox Stripe).
Dashboard Stripe menampilkan transaksi yang berhasil diproses: total $85.95 dengan status Succeeded, metode pembayaran Visa berakhir 4242, deskripsi "Hen Store - Purchase", email pelanggan, dan waktu transaksi.
Email tanda terima yang dikirim Stripe otomatis ke pelanggan setelah pembayaran berhasil, berisi jumlah yang dibayar, metode pembayaran, dan ringkasan item.
Alur lengkapnya berjalan mulus dari ujung ke ujung: frontend membuat order → mendapatkan tracking number → membuat PaymentIntent → pengguna membayar di Stripe Elements → Stripe mengirim event payment_intent.succeeded → webhook memverifikasi tanda tangan → status order menjadi PAID → pelanggan menerima email tanda terima.
13. Lessons Learned & Honest Limitations
Setiap keputusan desain di atas membawa kompromi. Bagian ini mengakui secara jujur apa yang tersisa — dan apa yang akan saya kerjakan berbeda jika memulai dari awal.
Apa yang akan saya lakukan berbeda:
- Skema dikelola dengan migrasi, bukan
ddl-auto: update. Saat ini skema database dibentuk otomatis oleh Hibernate. Untuk prototipe ini cepat dan praktis, tetapi di lingkungan produksi rentan terhadap schema drift. Saya akan memindahkan skema ke Flyway migration denganddl-auto: validateagar perubahan skema terversi, dapat di-review, dan fail-fast bila tidak sinkron. - Menurunkan stok secara atomik pada saat checkout.
placeOrdertidak menyentuhunitsInStock— stok hanya menjadi data referensi. Pada versi berikutnya, pengurangan stok akan dilakukan dalam transaksi yang sama dengan optimistic locking (@Version) sehingga dua pembeli tidak bisa membeli produk terakhir secara bersamaan. - Menambahkan rate limiting pada endpoint autentikasi.
/api/auth/*terbuka untuk umum dan belum memiliki perlindungan brute-force. Saya akan menambahkan pembatasan per-IP (misal 10 request/menit) dengan respons HTTP 429. - Refresh token atau blacklist. JWT stateless tidak bisa dicabut sebelum kedaluwarsa. Untuk skenario logout yang lebih tegas, saya akan menambahkan refresh token dengan rotasi atau blacklist bervolume kecil.
Keterbatasan yang disadari:
- Pembayaran hanya mendukung metode
cardpada PaymentIntent — metode lain (misal e-wallet, bank transfer) belum diaktifkan. ddl-auto: updatedan seed SQL bersifat idempotent-by-check: seeder berhenti jika kategori sudah ada, tetapi tidak menangani konflik data yang lebih halus.- Belum ada observability terstruktur (log request traceable, metrik API) — area yang langsung menarik perhatian pada iterasi berikutnya.
Keterbatasan ini bukan kegagalan desain, melainkan batas yang disengaja demi menjaga ruang lingkup tetap terkendali — dan titik awal yang jelas untuk iterasi berikutnya.