Spring Boot E-Commerce Backend

15 min read
JavaSpring BootSpring SecurityMySQLDockerStripeJWTOAuth2Swagger

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 JwtDecoder yang 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 PAID meskipun 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.

Arsitektur berlapis Spring Boot E-Commerce

  • Client. SPA / aplikasi mobile yang memakai REST API dengan token Bearer pada header Authorization. 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) dan CheckoutService (pembuatan order, payment intent, konfirmasi pembayaran, dan update status order).
  • Data access layer. Interface JpaRepository (AppUserRepository, OrderRepository, ProductRepository, dll.) dengan derived queries seperti findByEmail dan findByOrderTrackingNumber.
  • JPA entities. 9 entitas relasional yang memetakan tabel database — AppUser, Customer, Order, OrderItem, Product, ProductCategory, Address, Country, dan State.
  • Data. MySQL sebagai basis data utama; H2 in-memory dipakai pada profil test.
  • Cross-cutting concerns. Keamanan (SecurityFilterChain, JwtService, dual JwtDecoder), 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 (HTTP 201 Created).
  • POST /api/auth/login — memvalidasi kredensial (BCryptPasswordEncoder.matches) dan mengembalikan JWT (HTTP 200 OK). Kredensial salah dilempar sebagai BadCredentialsException yang dipetakan menjadi 401.
  • GET /api/auth/me — mengembalikan profil pengguna saat ini berdasarkan token Bearer. AuthServiceImpl.findProfile mencari pengguna lokal; jika tidak ada, ia membangun profil fallback dari klaim token dengan penanda provider: "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):

  1. Pembuatan orderPOST /api/checkout/purchase menerima Purchase (customer, alamat pengiriman & penagihan, order, dan daftar item). CheckoutServiceImpl.placeOrder bertipe @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 berupa PurchaseResponse berisi tracking number.
  2. Pembuatan PaymentIntentPOST /api/checkout/payment-intent membuat PaymentIntent di Stripe dengan amount, currency, payment_method_types: ["card"], receipt_email, dan metadata berisi orderTrackingNumber agar order dan pembayaran bisa dipertemukan kembali. Klien menerima clientSecret untuk me-render Stripe Elements.
  3. Konfirmasi pembayaranPOST /api/checkout/payment-intent/{id}/confirm mengonfirmasi PaymentIntent dengan paymentMethodId, atau membuat PaymentMethod baru dari token kartu bila hanya token yang diberikan. Jika status menjadi succeeded, status order langsung diubah menjadi PAID. Kartu ditolak menghasilkan 400 dengan decline code yang terstruktur.
  4. Webhook StripePOST /api/webhook/stripe menerima event dari Stripe, memverifikasi tanda tangan HMAC (Stripe-Signature) dengan Webhook.constructEvent, lalu pada event payment_intent.succeeded membaca orderTrackingNumber dari metadata dan menandai order menjadi PAID. 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.

Alur autentikasi Okta / OAuth2

  • JWT lokal (HS256). Token diterbitkan oleh JwtService — implementasi JWT yang ditulis dari nol dengan HmacSHA256, berisi klaim sub, email, name, iat, dan exp (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). jwtDecoder di SecurityConfiguration mencoba decoder lokal HS256 lebih dulu; jika gagal, ia membangun NimbusJwtDecoder.withIssuerLocation secara lazy — dokumen discovery dan JWKS Okta baru di-fetch saat token Okta pertama kali benar-benar digunakan.
  • Aturan otorisasi. SecurityFilterChain menetapkan /api/orders/** dan /api/auth/me sebagai authenticated, sementara endpoint lain permitAll; oauth2ResourceServer.jwt mengaktifkan validasi token, CORS diaktifkan, dan CSRF dimatikan (API stateless).
  • CORS. Origin yang diizinkan diambil dari environment variable ALLOWED_ORIGINS dan diterapkan di MyAppConfig serta MyDataRestConfig.
  • Password. Selalu disimpan dalam bentuk BCryptPasswordEncoder — tidak pernah plaintext.
  • Respons 401 yang konsisten. Okta.configureResourceServer401ResponseBody memastikan klien menerima body 401 yang seragam untuk semua skema autentikasi.

7. Database & Data Modeling

Skema relasional dirancang untuk menjaga integritas transaksi dan memudahkan pelaporan.

ERD Spring Boot E-Commerce

Relasi kunci antar entitas:

RelasiKardinalitasCatatan
Customer → Order1:Nmelacak riwayat transaksi pembeli
Order → OrderItem1:Nsnapshot harga/kuantitas saat transaksi
Order → Address (shipping)1:1alamat pengiriman
Order → Address (billing)1:1alamat penagihan
Product → ProductCategoryN:1kategori produk via category_id
Country → State1:Ndropdown lokasi untuk alamat

Keputusan desain penting:

  • OrderItem menyimpan snapshot harga. OrderItem menyalin unitPrice, imageUrl, quantity, dan productId pada 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 AppUser dan Customer. 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 (dari PENDING menjadi PAID) plus timestamp dateCreated/lastUpdated yang diisi otomatis oleh @CreationTimestamp/@UpdateTimestamp.
  • Self-referencing address. Address menautkan balik ke Order via @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

ConcernChoice
Language / FrameworkJava 17 + Spring Boot 3.4.5
PersistenceSpring Data JPA / Hibernate + MySQL 8.4
REST LayerSpring Data REST + Spring MVC
Auth & SecuritySpring Security 6 + Okta Spring Boot Starter 3.0.7
TokenJWT HS256 (lokal) + RS256 (Okta, via Nimbus)
Paymentsstripe-java 29.1.0 + webhook
API Documentationspringdoc-openapi 2.8.6 (Swagger UI)
Build & DependencyMaven
TestingJUnit 5 + MockMvc + H2 (profil test)
ContainerizationDocker multi-stage + Docker Compose
CI/CDGitHub 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 — daftar endpoint Swagger UI merangkum seluruh rute API yang tersedia lengkap dengan HTTP method dan deskripsi.

Swagger UI — grup Authentication Grup Authentication menampilkan endpoint register, login, dan profil, lengkap dengan respons yang bisa diuji langsung dari browser.

Swagger UI — grup Checkout 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 menjadi PAID (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.

Arsitektur Docker Compose

ServiceImageHost portKeterangan
dbmysql:8.43308 → 3306database ecommerce, healthcheck mysqladmin ping
oidcpython:3.12-slim8085 → 8085 (HTTPS)mock Okta: OIDC discovery, JWKS, dan penerbitan token RS256
appSpring Boot (multi-stage)9898 → 9898aplikasi + 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:

VariableFungsi
DATABASE_URL, DATABASE_USERNAME, DATABASE_PASSWORDkoneksi MySQL
OKTA_CLIENT_ID, OKTA_ISSUERidentitas OAuth2
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRETpembayaran & verifikasi webhook
JWT_SECRETrahasia penandatanganan JWT lokal
ALLOWED_ORIGINSorigin 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).

Stripe Dashboard — daftar transaksi 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.

Stripe — email tanda terima pembayaran 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 dengan ddl-auto: validate agar perubahan skema terversi, dapat di-review, dan fail-fast bila tidak sinkron.
  • Menurunkan stok secara atomik pada saat checkout. placeOrder tidak menyentuh unitsInStock — 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 card pada PaymentIntent — metode lain (misal e-wallet, bank transfer) belum diaktifkan.
  • ddl-auto: update dan 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.

← Back to portfolio