A Spring Boot backend for an online store — user accounts, a product/category catalog, order management, and checkout through two payment providers: Stripe and bKash. It's a feature-for-feature port of an earlier Django REST version, built around a few deliberate design choices (a Strategy pattern for payments, DFS + Redis caching for category recommendations, server-side-only totals) that are explained below, along with the actual problems I ran into while building it and how I solved them.
A small plain HTML/CSS/JS frontend is included and deployed at: https://ecommerce-payment-theta.vercel.app/
- Users: register/login with JWT, unique email, view your own orders and payments.
- Products & Categories: full CRUD for admins, a hierarchical category tree, and a "related products" recommendation endpoint.
- Orders: multi-item orders with server-computed totals, and race-condition-safe stock handling — two people can't oversell the last item in stock.
- Payments: checkout through Stripe or bKash, with webhook/callback handling that updates order status automatically once a payment actually completes.
Feature-based package layout — each domain (users, products, orders, payments)
is self-contained with its own entities, repositories, services, controllers, DTOs,
and tests.
src/main/java/com/nafis/ecommerce/
├── config/ SecurityConfig, CorsConfig, RedisCacheConfig, OpenApiConfig,
│ JpaAuditingConfig, StripeConfig, RestClientConfig
├── common/ BaseEntity (UUID id, timestamps), ApiError, GlobalExceptionHandler
├── users/ Custom User (email login), Spring Security + JWT, profile
├── products/ Product, Category, DFS recommendations + Redis caching
├── orders/ Order, OrderItem, deterministic totals, safe stock reduction
└── payments/ Payment, Strategy pattern, Stripe + bKash integrations
src/main/resources/
├── application.yml
└── db/migration/ V1__init.sql (schema), V2__seed.sql (admin + sample products)
frontend/ Plain HTML/CSS/JS client (deployed on Vercel)
docs/ Diagrams, payment flow docs, Postman collection
Flyway owns the schema and runs the migrations on startup; Hibernate is set to
validate only. Redis backs the category-tree cache and the bKash access token.
Every table besides users extends a shared BaseEntity — UUID primary key,
created_at, updated_at via JPA auditing. users keeps an integer primary key to
match the original Django schema. Category is self-referencing (parent), which is
what makes the category tree hierarchical. Payment stores provider, a unique
transaction_id, status, and the full raw_response from whichever provider
handled it, so nothing about a payment's history is ever lost. All money is
BigDecimal (numeric(12,2)), never a floating-point type.
Stripe — the backend opens a PaymentIntent, the card is confirmed entirely on the frontend (Stripe Elements, card data never touches this server), and a signed webhook tells the backend the result:
sequenceDiagram
participant Backend
participant Stripe
Backend->>Stripe: Create a PaymentIntent
Stripe-->>Backend: client_secret + intent id
Backend->>Backend: Save payment (Pending)
Stripe->>Backend: "payment_intent.succeeded" ✅
Backend->>Backend: Verify the signature is really from Stripe
Backend->>Backend: Reduce stock (locked rows)
Backend->>Backend: Mark payment Success, order Paid
bKash — no client SDK; the browser is handed off to bKash's own hosted page, and bKash redirects back once the user approves or declines:
sequenceDiagram
participant Backend
participant bKash
Backend->>bKash: Get access token (cached in Redis)
bKash-->>Backend: Token
Backend->>bKash: Create a payment
bKash-->>Backend: Payment link + ID
Backend->>Backend: Save payment (Pending)
bKash->>Backend: User approved/declined it
alt approved
Backend->>bKash: Execute the payment
bKash-->>Backend: Completed ✅
Backend->>Backend: Reduce stock
Backend->>Backend: Mark payment Success, order Paid
else declined
Backend->>Backend: Mark payment Failed, order Cancelled
end
In both cases, stock is only reduced after a payment actually succeeds — never at order creation — so an abandoned or failed checkout never touches inventory.
Strategy pattern for payments (payments/strategy/) — PaymentService injects
Map<String, PaymentStrategy>, which Spring populates keyed by bean name
({"stripe": StripeStrategy, "bkash": BkashStrategy} — the Java equivalent of the
Django dict). It picks a provider by a simple string lookup, and every strategy
implements the same three-method interface (initiate, confirm, verify). Nothing
in OrderService or the controllers ever branches on which provider is being used.
Adding a third provider later — say, Nagad — means writing one new @Component class
and nothing else; zero changes to existing order or checkout logic. Each provider's
SDK is also quarantined in a single thin client class (StripeApiClient,
BkashApiClient), so the strategies stay easy to unit-test.
DFS + Redis caching for categories (products/CategoryService) — the category
tree is a self-referencing hierarchy, so building it wants a traversal. It's done with
an explicit Deque used as a stack, not recursion — it reads more clearly and can't
blow the call stack on a deep tree. Since that tree rarely changes but gets read
constantly (every product page hits it), the serialized tree is @Cacheable in Redis
with a 24h TTL and only @CacheEvict-ed when a category is actually
created/updated/deleted — so most requests never touch the database for this at all.
Deterministic, server-side totals (orders/OrderService) — calculateTotals
always recomputes quantity × price from the database, never trusts a total the client
sends (the order DTO is even configured to reject a request body that contains a
total). Combined with a pessimistic row lock when checking/reducing stock —
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") UUID id);the JPA equivalent of Django's select_for_update() — two concurrent checkouts on
the last unit of stock can't both succeed. Every product in the order is locked in id
order (deadlock avoidance), and the reduction runs in its own REQUIRES_NEW
transaction so that if one line comes up short, the rollback stays contained and the
shared failPayment helper can still cleanly mark the payment failed and the order
cancelled. One checkout wins; the other gets a clean "insufficient stock" error
instead of silently overselling.
Stripe (test mode):
- Create a free Stripe account, grab your secret key (
sk_test_...) from Developers → API keys. - Add a webhook destination pointing at
<your-public-url>/api/payments/webhook/stripe/(this needs a publicly reachable URL — see the ngrok section below), listening forpayment_intent.succeededandpayment_intent.payment_failed. Copy its signing secret (whsec_...). - Put both into
.envasSTRIPE_SECRET_KEYandSTRIPE_WEBHOOK_SECRET. - To push a test PaymentIntent to "succeeded" without a frontend, the Stripe CLI
works well:
stripe listen --forward-to localhost:8080/api/payments/webhook/stripe/in one terminal, thenstripe payment_intents confirm pi_... --payment-method=pm_card_visa.
bKash (sandbox):
- Get sandbox credentials from bKash's developer portal —
BKASH_APP_KEY,BKASH_APP_SECRET,BKASH_USERNAME,BKASH_PASSWORD. BKASH_BASE_URLmust be the API domain (https://tokenized.sandbox.bka.sh/v1.2.0-beta), not the customer-facing checkout domain — mixing these up is a very easy first mistake (see below). There's a comment saying exactly this above the property inapplication.yml.- Checkout returns a
bkashURL— redirect the whole browser tab there; bKash redirects back to your callback URL once the user approves/declines on their own hosted page.
Every credential is a placeholder in .env.example. Nothing real is committed.
This is the part I actually learned the most from — a payment integration touching two external sandboxes, Docker, and Windows networking surfaces a lot of small, sharp edges that don't show up in a tutorial.
Environment variables don't reload themselves. Editing .env while the backend is
already running does nothing — Spring reads it once, at startup, and Docker bakes it
into the container when the container is created. I hit this repeatedly early on:
I'd fix a key, restart, and the old value would still be in effect. The deeper trap
turned out to be docker compose restart vs docker compose up -d --force-recreate — a plain restart just restarts the process inside the same
container, whose environment was already fixed when it was first created via
env_file. Only a full recreate (or up -d --build) actually re-reads .env. Cost
me a fair bit of "why isn't my fix working" time until I found the distinction.
A genuinely confusing one: an orphaned backend process. After fixing an env value,
requests still failed with the old error — even though a fresh start confirmed the
app had the right key. Turned out an earlier ./mvnw spring-boot:run hadn't actually
died; a stale java process was still bound to port 8080 in the background with the
old environment, while the terminal I was watching was a completely different, unused
process. Found it by checking which PID was actually bound to the port
(Get-NetTCPConnection -LocalPort 8080) rather than trusting which terminal I assumed
was serving requests.
Windows had silently reserved my dev port. At one point Docker refused to bind
port 8080 at all — not because anything else was using it, but because Windows'
Hyper-V/WSL2 networking had claimed that exact port range for its own internal use.
Restarting the winnat service (the thing that manages those reservations) freed it
immediately, without needing a full reboot.
bKash: wrong base URL, then a duplicated env key, then flaky sandbox behavior.
First mistake was pointing BKASH_BASE_URL at the customer-facing checkout domain
instead of the actual API domain — got a misleading "invalid credentials" error that
had nothing to do with the credentials. Second, a copy-paste slip left the key name
duplicated inside its own value in .env. After fixing both, I still hit inconsistent
results from bKash's sandbox itself — clean failures on their own documented
"success" test wallets, and even raw HTTP errors (401/403) on calls that had worked
moments earlier. I confirmed this wasn't my integration by cross-checking against
Stripe (architecturally identical, worked reliably every time) and by writing a test
with the bKash client mocked (@MockBean) that proves the success path is handled
correctly whenever bKash does return success — the sandbox's shared, publicly-used
credentials being flaky is a known, external limitation, not a code bug.
Crashes instead of clean failures. My first pass at error handling only expected
providers to reply with a normal response and a status field to check — it didn't
account for a provider call failing outright (a 403, a dropped connection, a
timeout). A couple of real bKash sandbox errors exposed this as an actual unhandled
exception bubbling to a 500 instead of returning a clean error. Fixed by wrapping
every provider call in both initiate() and the confirm/webhook paths, catching the
broad RestClientException (covers bad status codes and connection/timeout
failures) and Stripe's own StripeException, and consistently routing both through
one shared failPayment(...) helper that marks the payment FAILED and the order
CANCELLED and persists the raw error. I also gave the bKash RestClient explicit
5s/15s connect/read timeouts so a hanging sandbox can't pin a request thread.
CORS and a self-inflicted follow-up. Deploying the frontend to Vercel meant every
request became cross-origin, and free-tier ngrok adds its own wrinkle: it shows a
browser-warning interstitial page (plain HTML, not JSON) to any request that looks
like it's coming from a browser. The fix — sending a custom
ngrok-skip-browser-warning header — then broke CORS preflight itself, since a
non-default custom header has to be explicitly allowed via allowedHeaders
(CORS_ALLOWED_HEADERS in .env), or the browser blocks the real request before
it's even sent. Good reminder that fixing one integration issue can quietly introduce
another one layer up.
git clone https://github.com/Nafis-Rohan/EcommercePaymentSpring.git
cd EcommercePaymentSpring
cp .env.example .env # then fill in real values (see .env.example)
docker compose up -d --build # Postgres + Redis + backend, all threeBackend is now running at http://localhost:8080. Flyway runs the migrations on
startup, and V2__seed.sql creates the admin user and sample products
automatically — there's no manual migrate/seed step. Interactive API docs are at
http://localhost:8080/swagger-ui.html.
A Postman collection is included at docs/postman_collection.json for exercising
every endpoint directly, and the published, browsable version is here:
https://documenter.getpostman.com/view/47893159/2sBY4SLJMb
To run it without Docker you need JDK 21 plus a local Postgres and Redis, then:
./mvnw spring-boot:runapplication.yml reads every setting as ${VAR:default}, so it boots on sensible
localhost defaults.
Stripe's webhook and a deployed frontend both need a real, public URL to reach a
backend that's only running on localhost. ngrok creates a
secure tunnel to your local port:
ngrok config add-authtoken <your-authtoken-from-the-ngrok-dashboard>
ngrok http 8080This prints a forwarding line that looks like:
Forwarding https://detonator-folk-claw.ngrok-free.dev -> http://localhost:8080
That https://...ngrok-free.dev URL is now a real, internet-reachable address for
your local backend. Every time ngrok restarts it assigns a new random URL, so each
time you need to update:
- the Stripe webhook destination →
https://<random>.ngrok-free.dev/api/payments/webhook/stripe/ API_BASE_URLinfrontend/js/config.jsAPP_BASE_URLin.env(so the bKashcallbackURLpoints back through the tunnel)CORS_ALLOWED_ORIGINSin.env— add whatever origin you're serving the frontend from
…and recreate the backend container (up -d --force-recreate), not just restart it,
for the .env changes to take effect. Keep ngrok-skip-browser-warning in
CORS_ALLOWED_HEADERS — it's already in .env.example, and preflight breaks without
it.
Plain HTML/CSS/JS, no framework, no build step — deployed as a static site on Vercel.
Live: https://ecommerce-payment-theta.vercel.app/
To run it yourself, pointed at your own backend:
cd frontend
python -m http.server 5500Then open http://localhost:5500/index.html. Before it'll actually talk to your
backend, update frontend/js/config.js:
API_BASE_URL→ your backend's public URL (ngrok, or wherever it's deployed) +/api(the port is8080here, not8000)STRIPE_PUBLISHABLE_KEY→ your Stripe publishable key (pk_test_..., safe to expose client-side — never the secret key)
And make sure whatever origin you're serving it from is added to
CORS_ALLOWED_ORIGINS in the backend's .env.
mvn testNeeds Docker Desktop running — the suite spins up real Postgres and real Redis via
Testcontainers, not H2 or an embedded fake. That's deliberate: the concurrency test
depends on genuine SELECT ... FOR UPDATE row locking, which H2's compatibility mode
doesn't reproduce faithfully — a green test there would be meaningless — and the cache
tests assert on actual Redis keys. Containers are static and reused, so they start
once per run.
Covers model/constraint tests (unique slug, unique transaction_id), API tests for
auth/orders/payments (including the per-user scoping "authorization leak" checks), a
threaded concurrency test proving the stock race-condition fix actually works, and
webhook/callback tests for both providers with the provider clients mocked out
(@MockBean) — plus a webhook-idempotency test (the same Stripe event delivered
twice reduces stock once, not twice).
The Docker image builds with tests skipped on purpose — a container build shouldn't depend on a Docker daemon being reachable from inside itself.
- Multi-stage build — a Maven + JDK stage compiles the fat jar; the runtime image
is
eclipse-temurin:21-jre-alpinewith only the jar in it.mvn dependency:go-offlineis its own layer, so dependencies re-download only whenpom.xmlchanges. - Non-root — the runtime image adds a
springuser and runs as it. - Healthcheck-gated startup — the backend
depends_onPostgres and Redis withcondition: service_healthy(pg_isready,redis-cli ping), so it never races an unready database and Flyway doesn't fail on boot. .dockerignoredeliberately excludes.env(secrets never belong in an image),target/and*.jar(the image builds its own), IDE/OS noise,.git/, anddocs/+frontend/+README.md(not needed at runtime).