E-commerce API
Featured project
A full e-commerce backend built with Spring Boot 3 — real payment integrations, event-driven emails, inventory management, and async optimization that cut login response time by 96.6%.
E-commerce API
This started as a school project. The brief was: build a secure e-commerce backend. JWT, OAuth2, role-based access control — get them working properly.
I did that. But I kept going.
By the time I was done, the project had real Stripe and Paystack integrations, a full event system for transactional emails, inventory management with automatic stock tracking, and an async rewrite that cut login response time from 3.4 seconds to 117ms under concurrent load.
This is a full backend. Not a demo.
What the API Actually Does
Authentication and access: Users register and log in with email and password, or through Google OAuth2. Google users are automatically registered and assigned the CUSTOMER role. Every protected endpoint validates a JWT on each request. Roles are enforced at the method level — admins can manage products and users, customers can shop and view their own orders, staff handles inventory and order management.
Products and categories: Full CRUD for products and categories. Products support search and filtering via the JPA Specifications pattern, which keeps the repository layer clean instead of piling up query methods. Product reads are cached — 1.7ms average on a hit.
Cart: Every user gets a persistent cart. Items are added, updated, and removed. When checkout happens, the cart converts directly into an order. After payment is confirmed, the cart is cleared automatically.
Orders: Orders are created from the cart, not manually itemized. The service validates stock, deducts inventory, calculates the total, creates the order record, and immediately initializes payment — all in one transaction. Order statuses follow a strict state machine: you cannot move a cancelled or delivered order to any other status. Cancellation restores inventory automatically.
Inventory: Each product has a separate inventory record with quantity and location. Stock is validated and decremented when an order is placed, and restored if the order is cancelled. The inventory layer is fully cached with targeted eviction — writes clear the relevant entries, reads populate from cache on miss.
Reviews: Customers can leave reviews on products.
Payment
This is the part that took the most thought.
The API supports two payment channels: card payments via Stripe, and mobile money via Paystack. The customer picks one at checkout.
For Stripe, a checkout session is created with the order total, customer email, and metadata embedding the order ID and user ID. The customer is redirected to Stripe's hosted checkout page. After payment, Stripe sends a webhook.
For Paystack (MOMO), the API calls Paystack's transaction initialization endpoint, gets back an authorization URL, and returns it to the client. Paystack sends a webhook on success.
Both webhooks do the same thing on confirmation: mark the payment as COMPLETED, update the order status to PAID, clear the cart, and fire an OrderCreatedEvent. The webhook handlers verify signatures before touching anything — Stripe's Webhook.constructEvent for signature verification, and reference matching for Paystack.
// After payment is confirmed
payment.setStatus(PaymentStatus.COMPLETED);
order.setStatus(OrderStatus.PAID);
cart.getItems().clear();
publisher.publishEvent(new OrderCreatedEvent(
order.getId(), user.getEmail(),
user.getFirstName() + " " + user.getLastName(),
order.getTotalAmount()
));
One thing worth noting: the payment entity reuses the same stripeSessionId field to store the Paystack reference. Not the cleanest naming, but it kept the schema simple while supporting both providers.
Events and Emails
The email system is fully event-driven and non-blocking.
When a user registers, a UserRegistrationEvent is published. When an order is paid, an OrderCreatedEvent is published. In both cases, the event fires after the transaction commits — the HTTP response goes back to the client without waiting for email.
@Async("taskExecutor")
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
emailService.sendOrderConfirmation(
event.getCustomerEmail(),
event.getCustomerName(),
event.getOrderId(),
event.getTotalAmount()
).exceptionallyAsync(ex -> {
log.error("Failed to send order confirmation email: {}", ex.getMessage());
return null;
});
}
The email service itself is also async on taskExecutor. If it fails, the failure is logged — it doesn't propagate back up and crash the order flow. The order is already confirmed at that point.
This means a failed email never breaks a payment. Which is exactly what you want.
The Performance Problem
Everything above was working. Then I did load testing.
Login under 50 concurrent users was averaging 3,451ms. P90 was 5,216ms. Throughput was 10.96 requests per second.
The culprit was BCrypt. 12 rounds, running synchronously on request threads. With concurrent users, threads were queuing on CPU-bound work. No amount of connection pool tuning would fix that — the threads were blocking before they even touched the database.
The fix: move password verification off the request thread entirely.
@Async("authExecutor")
public CompletableFuture<Boolean> verifyPassword(String raw, String encoded) {
return CompletableFuture.completedFuture(passwordEncoder.matches(raw, encoded));
}
Thread pool: 20 core threads, 50 max, 200-capacity queue. BCrypt rounds dropped from 12 to 10 — roughly 40% faster per hash, with minimal real-world security difference at that range.
I also tuned HikariCP (max pool 20, min-idle 10) and profiled with VisualVM to make sure I wasn't chasing the wrong thing.
Results after:
| Metric | Before | After | Change |
|---|---|---|---|
| Login avg | 3,451 ms | 117 ms | 96.6% faster |
| Login P90 | 5,216 ms | 138 ms | 97.4% faster |
| Throughput | 10.96 req/s | 44.28 req/s | 304% increase |
| Product reads | — | 1.7 ms | cached |
| Cart writes | — | 6.1 ms | cached |
| Error rate | 0% | 0% | — |
1,580 total requests. Zero errors.
BCrypt's slowness is intentional — it's designed to be expensive. The problem is assuming that means it should block. Under concurrent load, that assumption is expensive in a different way.
Tech Stack
Java 21 · Spring Boot 3.x · Spring Security 6 · PostgreSQL · HikariCP · JWT (JJWT) · Google OAuth2 · Stripe · Paystack · Hibernate/JPA · Maven · Spring AOP · Spring Actuator · CompletableFuture · Spring Events · JavaMailSender
API Summary
POST /auth/register
POST /auth/login
GET /oauth2/authorization/google
POST /auth/logout
GET|POST|PUT|DELETE /api/products
GET|POST|PUT|DELETE /api/categories
GET|POST|PUT|DELETE /api/inventory
GET|POST /api/cart
POST /api/cart/items
PUT|DELETE /api/cart/items/{id}
POST /api/orders ← creates order + initializes payment
GET /api/orders
GET /api/orders/{id}
PUT /api/orders/{id}/status (ADMIN)
POST /api/payment/stripe/webhook
POST /api/payment/paystack/webhook
GET /api/performance/admin/db-metrics
GET /api/performance/admin/cache-metrics
Also exposes a GraphQL endpoint at /graphiql and Swagger at /swagger-ui.html.