A full-stack E-commerce Order Management System built with Spring Boot and React. It covers a multi-category catalog, server-side cart and checkout, multi-warehouse inventory that cannot be oversold under concurrency, atomic order placement (cart + inventory + payment), a non-blocking async fulfillment/notification/audit pipeline, an order lifecycle state machine, discounts, taxes, returns and refunds, and role-based access control for three roles.
| Layer | Stack | Docs |
|---|---|---|
| Backend | Spring Boot 3.3.9, Java 23, JPA, JWT | ecom-backend/README.md |
| Frontend | React 18, Vite, Bootstrap | ecom-frontend/README.md |
| AI / contributors | Conventions & pitfalls | Agent.md |
- Architecture overview
- Key design decisions
- Roles & seeded accounts
- Getting started
- API surface
- Testing
- Assumptions & scope
Backend (ecom-backend) — Spring Boot 3.3.9, Java 23, Spring Data JPA, Spring Security + JWT, organized package-by-feature under com.manoj.ecom_proj:
| Module | Responsibility |
|---|---|
common/error | Exception hierarchy + global @RestControllerAdvice → consistent ApiError |
common/audit | Auditable base, AuditLog + admin read endpoint |
security | JWT util/filter, SecurityUser principal (carries userId), JSON 401/403 handlers, ownership provider |
auth / user | Registration, login, User/Address/Role, BCrypt |
catalog | Product + image CRUD, Category |
inventory | Warehouse, Stock (per product+warehouse), Reservation, atomic reservation |
cart | Server-backed cart per user |
discount | Discount codes (percentage/fixed), validation, preview |
order | CustomerOrder/OrderItem, pricing, tax, atomic placement, state machine, history, query |
payment | Payment, PaymentGateway interface + MockPaymentGateway |
fulfillment | Warehouse-staff status transitions + queue |
returns | Return requests, refunds, restock-on-approval |
notification | In-app/log notifications |
Frontend (ecom-frontend) — React 18 + Vite + Bootstrap. Catalog/cart/auth UI extended with customer registration, real checkout, order history + tracking timeline, return requests, an admin console (warehouses / inventory / discounts / return approval), and a warehouse-staff fulfillment board. Role-gated routes via ProtectedRoute.
Stock holds onHandQuantity and reservedQuantity per (product, warehouse) (unique constraint); available = onHand − reserved is derived, never stored. Reservation is a single atomic conditional UPDATE:
UPDATE stock SET reserved_quantity = reserved_quantity + :qty
WHERE id = :id AND (on_hand_quantity - reserved_quantity) >= :qtyThe database evaluates the availability guard and the increment in one statement under its own row lock, so it is immune to lost-update and check-then-act races. Because this relies on single-statement atomicity (not SELECT … FOR UPDATE), the guarantee holds identically on H2 and Postgres — the concurrency test runs on the default H2. Multi-warehouse allocation is a greedy loop of these atomic reserves; correctness comes entirely from the atomic statement, never from the (stale-tolerant) candidate ordering. A pessimistic @Lock finder exists but is reserved for admin manual stock adjustments, never the checkout hot path.
Proof:
InventoryConcurrencyTestfires 20 concurrent buyers at the last unit and asserts exactly one wins, 19 get409 INSUFFICIENT_STOCK, and stock never goes negative.
OrderService.placeOrder runs the whole flow — price → reserve → persist order → charge → confirm — in one @Transactional (READ_COMMITTED). If stock is insufficient or payment is declined, everything rolls back: no order, no leaked reservation. Because reservation is single-statement atomic, no row lock is held across the (mock) payment call — avoiding the classic pessimistic-locking throughput anti-pattern. Line prices and product names are snapshotted onto the order so later catalog edits never mutate historical orders.
On confirmation, OrderService publishes an OrderConfirmedEvent carrying an immutable snapshot. Three independent listeners — fulfillment routing, notification, audit — each run with @Async("orderPipelineExecutor") + @TransactionalEventListener(phase = AFTER_COMMIT). AFTER_COMMIT guarantees listeners never act on rolled-back state; @Async keeps them off the checkout response thread. DB-writing listeners use REQUIRES_NEW. One listener failing doesn't affect the others. The published-event seam is exactly where a real message queue/outbox would later slot in.
OrderStatus centralizes legal transitions (PLACED → CONFIRMED → PACKED → SHIPPED → DELIVERED → RETURNED, plus CANCELLED). OrderStatusService.transition validates legality (else 409 INVALID_STATE_TRANSITION), applies side effects (SHIP commits reservations → on-hand decremented; pre-ship CANCEL releases reservations + refunds), and records OrderStatusHistory. The order's @Version guards against concurrent double-transitions.
Two reversal paths are kept distinct: cancel before shipment releases reservations (on-hand untouched); return after delivery restocks on-hand, gated on admin approval (returned goods may be damaged). Approval transitions the order to RETURNED, restocks the committed units, refunds the payment (full or partial supported via refundedAmount), and records a Refund.
Real User entity with BCrypt passwords replaces the previous hardcoded admin. JWT carries uid + role; the filter reconstructs a SecurityUser principal (stateless, no per-request DB hit) that carries the user id so ownership checks never trust client-supplied ids (findByIdAndUserId → 404, not 403, to avoid leaking existence). Coarse URL rules in SecurityConfig plus @PreAuthorize for fine-grained/ownership checks.
DataLoader seeds three accounts on first run (and 6 products across 2 warehouses):
| Username | Password | Role | Can do |
|---|---|---|---|
admin | admin | ADMIN | everything: catalog, warehouses, inventory, discounts, all orders, approve returns, audit log |
warehouse | warehouse | WAREHOUSE_STAFF | view inventory, advance fulfillment status (pack/ship/deliver) |
customer | customer | CUSTOMER | browse, cart, checkout, track own orders, request returns |
Register new customers at POST /api/auth/register (always CUSTOMER).
- JDK 23 (the
pom.xmltargets Java 23) - Node.js 18+
- Maven (wrapper included in
ecom-backend)
cd ecom-backend
export JAVA_HOME=/path/to/jdk-23 # Maven needs a real JDK 23, not just a jenv shim
./mvnw spring-boot:runRuns on http://localhost:8080 with an in-memory H2 database (zero setup; resets on restart).
Optional profiles:
./mvnw spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=postgres
./mvnw spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=rediscd ecom-frontend
cp .env.example .env # optional — defaults to http://localhost:8080/api
npm install
npm run dev # http://localhost:5173Log in with a demo account from the table above.
The JWT is stored in localStorage, which is per-origin — one port = one logged-in user. To drive admin, customer, and warehouse staff simultaneously, run a dev server per role:
cd ecom-frontend
npm run dev:customer # http://localhost:5173
npm run dev:admin # http://localhost:5174
npm run dev:warehouse # http://localhost:5175All localhost ports are allow-listed in the backend CORS config (app.cors.allowed-origin-patterns in application.properties).
TOKEN=$(curl -s -X POST localhost:8080/api/auth/login -H 'Content-Type: application/json' \ -d '{"username":"customer","password":"customer"}'| sed -E 's/.*"token":"([^"]+)".*/\1/')
curl -s -X POST localhost:8080/api/cart/items -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"productId":1,"quantity":1}'
curl -s -X POST localhost:8080/api/orders/checkout -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"paymentMethod":"card"}'All errors return a consistent ApiError body: { timestamp, status, errorCode, message, path, fieldErrors? }.
| Area | Endpoint | Role |
|---|---|---|
| Auth | POST /api/auth/register, POST /api/auth/login | public |
GET /api/auth/me | authenticated | |
| Catalog | GET /api/products, /product/{id}, /product/{id}/image, /products/search, /categories | public |
POST/PUT/DELETE /api/product, /api/categories/** | ADMIN | |
| Inventory | GET /api/inventory/products/{id}/availability | public |
PUT /api/inventory/warehouses/{wid}/products/{pid}, GET /api/inventory/products/{pid} | ADMIN, WAREHOUSE_STAFF | |
GET/POST/PUT/DELETE /api/warehouses/** | ADMIN | |
| Cart | GET /api/cart, POST /api/cart/items, PUT/DELETE /api/cart/items/{id}, DELETE /api/cart | CUSTOMER, ADMIN |
| Orders | POST /api/orders/checkout, GET /api/orders, GET /api/orders/{id}, GET /api/orders/{id}/tracking | CUSTOMER (own) / ADMIN (all) |
| Fulfillment | GET /api/fulfillment/orders, PUT /api/fulfillment/orders/{id}/status | WAREHOUSE_STAFF, ADMIN |
| Returns | POST /api/returns, GET /api/returns, GET /api/returns/{id} | CUSTOMER (own) / ADMIN |
PUT /api/returns/{id}/approve | ADMIN | |
| Discounts | GET/POST/PUT/DELETE /api/discounts/** | ADMIN |
POST /api/discounts/preview | CUSTOMER, ADMIN | |
| Notifications | GET /api/notifications | authenticated (own) |
| Audit | GET /api/admin/audit | ADMIN |
cd ecom-backend
export JAVA_HOME=/path/to/jdk-23
./mvnw test# unit, integration, and concurrency tests
./mvnw verify # also produces a JaCoCo coverage report (target/site/jacoco)cd ecom-frontend
npm run lint
npm run buildCoverage includes:
- Unit (Mockito): inventory reservation, order placement + payment-decline rollback, discount math, pricing/tax allocation, state-machine guards, return/refund logic, registration, global error mapping.
- Integration (
@SpringBootTest+ MockMvc): register → login → cart → checkout → track → pack/ship/deliver → return → refund; authorization matrix; empty-cart rejection. - Concurrency:
InventoryConcurrencyTestproves no overselling under 20 concurrent buyers for the last unit.
- Payment is mocked (
MockPaymentGateway) — approves positive charges and can be toggled to decline. ThePaymentGatewayinterface is the swap-in seam. - H2 in-memory by default for zero-setup runs; data resets on restart. Postgres/Redis remain optional profiles.
- Restock-on-approval: returned goods are added back to sellable stock only when an admin approves the return.
- Order-level returns/refunds: the API exposes full-order returns;
Payment.refundedAmountsupports partial refunds in the data model. - Single role per user / JWT — sufficient for the three required roles.
Productlives incatalog/withintid (frontend depends on/api/product/**); newer entities useLongids.Product.stockQuantityis a display hint —Stockrows are the authoritative inventory.- Schema via
ddl-auto(no Flyway); warehouses/stock/users are seeded byDataLoader. - Async pipeline is in-process (Spring events +
@Async); a broker/outbox would be the next step for at-least-once delivery at scale.