Case study · Team project
Marketplace
An e-commerce platform on Spring Boot microservices: sellers list products, buyers search, rate and pay with Stripe, admins moderate. The interesting part is what happens between the services: how they stay consistent without a shared database, and how a purchase never sells the same unit twice.
What it does
- Buyers browse, search and filter products, rate them, and pay through Stripe's hosted checkout; they can see their own orders.
- Sellers create and edit their products with images from a seller dashboard.
- Admins manage users and roles, remove products, and rebuild the search indexes.
- Everyone gets full-text search over products and users, backed by OpenSearch.
Architecture
The browser talks to one HTTPS edge. Behind it, a Spring Cloud Gateway checks the JWT and routes each path to a service found in Eureka. Each service owns its own MongoDB database; Kafka events keep their copies of shared data in step. Select a component to see its role, what it talks to and the events it uses, or follow a purchase step by step.
The interactive diagram needs JavaScript. In short: Angular → Gateway (JWT, Redis rate limit, Eureka) → users, products, media and payments services, each with its own MongoDB; Kafka between them; OpenSearch for search; Stripe for payments.
Decisions worth explaining
Reserve stock before payment, in one atomic update
If stock only went down after payment, two buyers could both pay for the last unit. So the payments service
reserves first. The products service runs one MongoDB findAndModify whose filter requires
quantity ≥ requested and that this reservation id isn't recorded yet, and whose update lowers the
quantity and records the reservation. Because the check and the change happen in a single document operation,
only one of two racing buyers matches; the other gets 409 Conflict, and the quantity can't go below zero.
Criteria.where("_id").is(id)
.and("quantity").gte(quantity)
.and("stockReservations.reservationId").ne(reservationId)
// update: quantity -= n, push { reservationId, n }
Stripe's signed webhook decides, not the redirect
The success page a buyer lands on can be visited without paying, so it changes nothing. Orders move only when a
webhook passes Stripe's signature check: completed publishes a sale event and marks the order paid;
expired releases the exact reservation and cancels the order. Each move is conditional on the order
still being PENDING, so a webhook delivered twice has no second effect.
Wait for Kafka before saying "paid"
The sale event is sent with kafkaTemplate.send(...).join(), which waits for the broker's
acknowledgement. Only then does the order become PAID. If Kafka is down, the webhook fails, the order
stays pending, and Stripe retries later.
The server owns the price and the buyer
The checkout request carries only a product id and a quantity. The price comes from the products service, and the buyer id from the gateway, which takes it from the verified token. A modified browser can't pay less or buy as someone else.
One database per service, events in between
Users, products, media and payments each own a MongoDB. When a user is created or removed, or an image is confirmed or replaced, an event tells the services that keep a copy or a file. Unused uploads are cleaned up after an hour, so images from abandoned forms don't pile up.
Security
- RSA-signed JWTs: the users service signs with the private key, the gateway verifies with the public one.
- Role-based access (guest, buyer, seller, admin) enforced in the services, from identity headers set by the gateway.
- HTTPS between the gateway and the services with client certificates (mTLS); a script generates a local CA and a certificate per service.
- Redis rate limiting at the gateway: 5 requests per second per IP, bursts of 10.
- The Stripe secret key stays in the payments service; card details are entered on Stripe's page only.
- Secrets reach the server as Jenkins credentials and are deleted from the workspace after each run.
Deployment and operations
Each part runs in Docker, started with Docker Compose on a cloud VM, on one shared Docker network.
- CheckoutThe commit to deploy
- Build and testMaven / Gradle builds and tests, only for services that changed
- AnalysisSonarCloud code analysis
- DeploySecrets injected, services restarted in order
- RollbackOn failure, back to the last successful commit
- Monitoring: Prometheus scrapes the gateway and the users, products and media services, plus host, container and Kafka exporters; Grafana shows the dashboards.
- A 30 GB disk: Docker cleanup that never touches volumes, rotated container logs, bounded Prometheus and Kafka retention, and disk alerts below 20% and 10% free.
- Data retention: cancelled orders kept 30 days, paid orders 365 days, pending orders never deleted automatically.
The monitoring dashboards and Jenkins are private and aren't linked here.
Testing
- 166 JUnit test methods across the services (Mockito for unit tests, REST Assured for API tests), counted in October 2026.
- 11 Angular spec files for the frontend.
- k6 smoke and load scripts: the load test ramps to 200 virtual users with targets of p95 under 2 s and under 5% errors. These are the test's thresholds, not published results.