Mini project / POC · end to end
E-Commerce Microservices Platform: a complete Java 21 + Spring Boot mini project.
Four services, database-per-service PostgreSQL, Kafka-driven order choreography (SAGA pattern), JWT role-based security, and AWS S3 file upload/download for product images — with architecture, ER, and sequence diagrams, real code, and the full project folder structure.
Project overview and requirements
This is a deliberately realistic, moderately scoped e-commerce backend: enough services to make "microservices communication" mean something, without ballooning into an unfinishable enterprise system. It's sized to actually be built, run locally with Docker Compose, and explained end to end in an interview.
Functional requirements
Non-functional requirements
Technology stack and why each piece is there
Every technology below earns its place for a specific reason — nothing is included just to pad a resume.
| Technology | Role in this project | Why this one |
|---|---|---|
| Java 21 | Runtime and language for all four services. | Virtual threads make blocking JDBC/JPA code scale without a reactive rewrite; records simplify DTOs and events. |
| Spring Boot 3.x | Application framework, auto-configuration, embedded server per service. | Fastest path to a production-shaped Spring service with Actuator, validation, and starters built in. |
| Spring Data JPA | Persistence layer in every service. | Repository abstraction over Hibernate keeps entity and query code declarative and testable. |
| Spring Security + JWT | Stateless authentication and role-based authorization. | Stateless tokens fit a horizontally scaled, multi-service architecture better than server-side sessions. |
| PostgreSQL | One instance per service (database-per-service). | Strong relational guarantees for money-and-inventory data, with mature JSON support where flexibility is needed. |
| Apache Kafka | Asynchronous events driving the order lifecycle. | Durable, replayable event log fits a choreographed SAGA far better than fire-and-forget messaging. |
| AWS S3 | Product image storage. | Offloads binary storage from application servers; presigned URLs let clients upload directly. |
| OpenFeign | Synchronous service-to-service REST calls. | Declarative HTTP client keeps the Order → Product lookup call readable and typed. |
| Docker Compose | Local orchestration of all services and infrastructure. | One command reproduces the full multi-service environment for development and demos. |
Jump to a section
High-level architecture
A client talks only to the API Gateway. The Gateway routes to the four services; each service independently validates the caller's JWT rather than trusting an upstream header, so security still holds even if a service is reachable directly inside the network. The Order Service is the only service both services talk to synchronously and that publishes/consumes Kafka events — it's the coordinator of the checkout SAGA, even though there's no central orchestrator process.
Service breakdown
Each service is a separate Spring Boot application with its own database, its own deployable JAR, and its own Dockerfile. Boundaries follow business capability (auth, catalog, ordering, fulfillment), not technical layers.
User Service — identity, authentication, roles
Owns registration, login, password hashing (BCrypt), and JWT issuance. Roles are stored relationally (users, roles, user_roles) so a user can hold more than one role, for example a SELLER who is also an ADMIN during early testing.
POST /auth/register— create account (default role CUSTOMER)POST /auth/login— verify credentials, return a signed JWTGET /users/me— return the authenticated caller's profilePATCH /users/{id}/roles— ADMIN-only role assignment
Product Service — catalog and image storage
Owns products and categories, and mediates AWS S3 access for product images by issuing presigned URLs rather than proxying file bytes through the service itself. Read endpoints are public; write endpoints require SELLER or ADMIN.
GET /products,GET /products/{id}— public catalog browsingPOST /products— SELLER/ADMIN, creates a product recordPOST /products/{id}/image-upload-url— returns a presigned S3 PUT URLGET /products/{id}/image-url— returns a presigned S3 GET URL
Order Service — checkout and SAGA coordination
Owns the shopping cart-to-order lifecycle. On checkout it synchronously validates price and existence via the Product Service, persists an order in PENDING status, and publishes an order.placed event — then reacts to inventory events asynchronously to move the order to CONFIRMED or CANCELLED. It never calls Inventory synchronously; that coupling is deliberately async.
POST /orders— CUSTOMER, creates an order from the current cartGET /orders/{id}— CUSTOMER (own orders) or ADMIN (any order)GET /orders/{id}/status— poll or the client can subscribe via SSE
Inventory & Notification Service — stock and fulfillment
Owns stock levels and reservations, and is the only consumer of order.placed. It atomically checks and reserves stock in one transaction, then publishes either inventory.reserved or inventory.failed. The same service also consumes order.confirmed and order.cancelled to send the customer an email — bundled here as one service since notification has no state of its own worth splitting into a fifth deployable.
- consumes
order.placed→ reserves stock, publishes reservation result - consumes
order.confirmed/order.cancelled→ sends email notification GET /inventory/{productId}— internal/ADMIN stock lookup
Database design: one schema per service
Every service owns its data exclusively — no foreign keys cross a service boundary, and no service queries another service's database directly. Cross-service relationships (an order item referencing a product) are represented by storing the referenced id as a plain value, not a database-level foreign key, because the referenced row physically lives in a different database.
order_items.product_id can theoretically point at a product that was later deleted. That's an accepted trade-off of database-per-service — you gain service autonomy and independent deployability, and you pay for it with application-level referential integrity instead of a database-level guarantee.Security and role-based access control
Authentication is stateless: the User Service issues a signed JWT containing the user id and roles as claims, and every service — not just the Gateway — independently verifies the signature and reads the roles claim before authorizing a request. This means a service is still protected even if someone reaches it directly on the internal network, bypassing the Gateway.
Roles and what they can do
| Role | Can do |
|---|---|
ROLE_CUSTOMER | Browse products, place orders, view own orders and profile. |
ROLE_SELLER | Everything CUSTOMER can, plus create/update own products and upload product images. |
ROLE_ADMIN | Manage any user's roles, view all orders, manage categories. |
// SecurityConfig.java (Product Service)
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/products/**").hasAnyRole("SELLER", "ADMIN")
.requestMatchers(HttpMethod.DELETE, "/products/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
// JwtAuthFilter.java (shared pattern across all four services)
public class JwtAuthFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
Jws jws = jwtService.parseAndValidate(header.substring(7));
String userId = jws.getBody().getSubject();
List roles = jws.getBody().get("roles", List.class);
var authorities = roles.stream().map(SimpleGrantedAuthority::new).toList();
var authToken = new UsernamePasswordAuthenticationToken(userId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authToken);
}
chain.doFilter(req, res);
}
}
X-User-Roles set by a gateway, since anything reachable directly on the network could forge it.Service-to-service communication: sync REST + async Kafka SAGA
Two different communication styles are used deliberately, not interchangeably. Synchronous REST (via OpenFeign) is used only where the caller needs an immediate answer to proceed — validating a product exists and its price before creating an order. Everything about the order's downstream fulfillment is asynchronous, coordinated through Kafka events with no central orchestrator: this is a choreographed SAGA.
Kafka topics
| Topic | Producer | Consumer(s) | Payload |
|---|---|---|---|
order.placed | Order Service | Inventory Service | orderId, items[productId, qty] |
inventory.reserved | Inventory Service | Order Service | orderId, reservationId |
inventory.failed | Inventory Service | Order Service | orderId, reason |
order.confirmed | Order Service | Notification (same service as Inventory) | orderId, userId |
order.cancelled | Order Service | Notification (same service as Inventory) | orderId, userId, reason |
// OrderService.java -- synchronous validation, then async handoff
public Order checkout(Long userId, List cart) {
List items = cart.stream().map(line -> {
ProductDto product = productClient.getProduct(line.productId()); // OpenFeign, sync
return new OrderItem(line.productId(), line.qty(), product.price());
}).toList();
Order order = orderRepository.save(Order.pending(userId, items));
kafkaTemplate.send("order.placed",
new OrderPlacedEvent(order.getId(), items));
return order;
}
// InventoryEventListener.java -- reacts to order.placed
@KafkaListener(topics = "order.placed", groupId = "inventory-service")
public void onOrderPlaced(OrderPlacedEvent event) {
boolean reserved = inventoryService.tryReserve(event.orderId(), event.items()); // one DB transaction
String topic = reserved ? "inventory.reserved" : "inventory.failed";
kafkaTemplate.send(topic, new InventoryResultEvent(event.orderId(), reserved));
}
AWS S3: product image upload and download
Product images never pass through the Product Service's own memory or disk. The service only issues short-lived, permission-scoped presigned URLs; the browser uploads and downloads directly against S3. This keeps the service stateless and avoids it becoming a bottleneck for large file transfers.
// S3StorageService.java
@Service
public class S3StorageService {
private final S3Presigner presigner;
private final String bucket = "jiquest-ecommerce-product-images";
public URL generateUploadUrl(String productId, String contentType) {
PutObjectRequest putRequest = PutObjectRequest.builder()
.bucket(bucket)
.key("products/" + productId + "/" + UUID.randomUUID())
.contentType(contentType)
.build();
PresignedPutObjectRequest presigned = presigner.presignPutObject(b -> b
.signatureDuration(Duration.ofMinutes(5))
.putObjectRequest(putRequest));
return presigned.url();
}
public URL generateDownloadUrl(String s3Key) {
GetObjectRequest getRequest = GetObjectRequest.builder().bucket(bucket).key(s3Key).build();
PresignedGetObjectRequest presigned = presigner.presignGetObject(b -> b
.signatureDuration(Duration.ofMinutes(10))
.getObjectRequest(getRequest));
return presigned.url();
}
}
- Client calls
POST /products/{id}/image-upload-url(SELLER/ADMIN only) and gets back a presigned S3 PUT URL. - Client uploads the image bytes directly to that S3 URL — the Product Service is not in this data path at all.
- Client calls back
PATCH /products/{id}with the resulting S3 object key, which the service stores ass3_image_key. - Anyone viewing the product calls
GET /products/{id}/image-url, which returns a short-lived presigned GET URL for the browser to load the image from directly.
Project folder structure
A single monorepo with one Maven module per service keeps this buildable and browsable as one unit, while each service still ships as its own independent JAR and Docker image.
ecommerce-microservices-poc/ ├── api-gateway/ │ ├── src/main/java/com/jiquest/gateway/GatewayApplication.java │ ├── src/main/resources/application.yml │ ├── Dockerfile │ └── pom.xml │ ├── user-service/ │ ├── src/main/java/com/jiquest/user/ │ │ ├── UserServiceApplication.java │ │ ├── config/SecurityConfig.java │ │ ├── controller/AuthController.java │ │ ├── controller/UserController.java │ │ ├── service/AuthService.java │ │ ├── service/JwtService.java │ │ ├── repository/UserRepository.java │ │ ├── repository/RoleRepository.java │ │ ├── entity/User.java │ │ ├── entity/Role.java │ │ ├── dto/LoginRequest.java, RegisterRequest.java │ │ └── security/JwtAuthFilter.java │ ├── src/main/resources/application.yml │ ├── src/test/java/com/jiquest/user/AuthControllerIT.java │ ├── Dockerfile │ └── pom.xml │ ├── product-service/ │ ├── src/main/java/com/jiquest/product/ │ │ ├── ProductServiceApplication.java │ │ ├── config/SecurityConfig.java, S3Config.java │ │ ├── controller/ProductController.java, FileController.java │ │ ├── service/ProductService.java, S3StorageService.java │ │ ├── repository/ProductRepository.java, CategoryRepository.java │ │ └── entity/Product.java, Category.java │ ├── src/main/resources/application.yml │ ├── Dockerfile │ └── pom.xml │ ├── order-service/ │ ├── src/main/java/com/jiquest/order/ │ │ ├── OrderServiceApplication.java │ │ ├── client/ProductClient.java // OpenFeign │ │ ├── config/SecurityConfig.java, KafkaProducerConfig.java, KafkaConsumerConfig.java │ │ ├── controller/OrderController.java │ │ ├── service/OrderService.java │ │ ├── event/OrderPlacedEvent.java, InventoryResultListener.java │ │ ├── repository/OrderRepository.java │ │ └── entity/Order.java, OrderItem.java │ ├── src/main/resources/application.yml │ ├── Dockerfile │ └── pom.xml │ ├── inventory-notification-service/ │ ├── src/main/java/com/jiquest/inventory/ │ │ ├── InventoryServiceApplication.java │ │ ├── config/KafkaConsumerConfig.java, KafkaProducerConfig.java │ │ ├── listener/OrderPlacedListener.java, OrderStatusListener.java │ │ ├── service/InventoryService.java, NotificationService.java │ │ ├── repository/InventoryRepository.java, ReservationRepository.java │ │ └── entity/Inventory.java, StockReservation.java │ ├── src/main/resources/application.yml │ ├── Dockerfile │ └── pom.xml │ ├── docker-compose.yml ├── .env.example └── README.md
Local development setup
The entire stack — four services, four Postgres instances, and Kafka — starts with one command.
# docker-compose.yml (excerpt)
services:
postgres-user:
image: postgres:16
environment: { POSTGRES_DB: user_db, POSTGRES_PASSWORD: postgres }
ports: ["5433:5432"]
postgres-product:
image: postgres:16
environment: { POSTGRES_DB: product_db, POSTGRES_PASSWORD: postgres }
ports: ["5434:5432"]
postgres-order:
image: postgres:16
environment: { POSTGRES_DB: order_db, POSTGRES_PASSWORD: postgres }
ports: ["5435:5432"]
postgres-inventory:
image: postgres:16
environment: { POSTGRES_DB: inventory_db, POSTGRES_PASSWORD: postgres }
ports: ["5436:5432"]
kafka:
image: apache/kafka:3.7.0
ports: ["9092:9092"]
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
user-service:
build: ./user-service
depends_on: [postgres-user]
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-user:5432/user_db
ports: ["8081:8080"]
# product-service, order-service, inventory-notification-service follow the same shape,
# each pointing at its own postgres-* host and the shared kafka:9092 broker.
api-gateway:
build: ./api-gateway
depends_on: [user-service, product-service, order-service, inventory-notification-service]
ports: ["8080:8080"]
Run docker compose up --build, then hit the gateway at localhost:8080. AWS credentials for S3 are supplied via environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) or, for fully offline local development, pointed at a LocalStack S3 container instead of real AWS.
Testing strategy
Unit tests cover service logic with mocked repositories; integration tests use Testcontainers so the test suite exercises real PostgreSQL and real Kafka instead of embedded fakes that can behave subtly differently in production.
@Testcontainers
@SpringBootTest
class InventoryReservationIT {
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16");
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("apache/kafka:3.7.0"));
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Test
void reservesStockWhenAvailableAndPublishesReservedEvent() {
// publish an order.placed test event, then assert inventory_db was
// decremented and an inventory.reserved event was published
}
}
Path to production on AWS
This POC is scoped for local Docker Compose, but every piece maps cleanly onto managed AWS services when it's time to deploy for real.
| Local (this POC) | AWS equivalent |
|---|---|
| 4 services in Docker Compose | ECS Fargate or EKS, one service/task-definition per microservice |
| 4 Postgres containers | 4 Amazon RDS PostgreSQL instances (or one instance, 4 databases, if cost matters more than isolation) |
| Single-broker Kafka container | Amazon MSK (managed Kafka) |
| S3 with local credentials | S3 + CloudFront, IAM roles per task instead of static keys |
| Manual docker compose up | Infrastructure as code (Terraform) provisioning all of the above |
Terraform, ECS/EKS, and observability (centralized logging, distributed tracing) are natural next layers on top of this same design — worth their own dedicated follow-up build once this core service set is solid.
Key design decisions and interview talking points
These are the questions an interviewer is most likely to ask about a project like this — each answer is also the reasoning that actually shaped the design above.
Why database-per-service instead of one shared PostgreSQL database?
A shared database lets any service accidentally couple to another's internal schema, so a column rename in one team's table silently breaks another team's queries. Database-per-service forces every cross-service interaction through an explicit API or event contract, which is slower to build but keeps services independently deployable — the entire point of choosing microservices in the first place.
Why Kafka instead of direct REST calls for the inventory step of checkout?
If Order called Inventory synchronously and Inventory was slow or down, checkout itself would fail or hang, coupling the availability of one service to another's. Publishing an event lets Order return quickly with a PENDING order, and lets Inventory process at its own pace — including retrying safely from Kafka's durable log if Inventory itself restarts mid-processing.
How do you keep the inventory.placed consumer idempotent if Kafka redelivers a message?
Kafka's at-least-once delivery means the same event can arrive twice. The reservation table stores order_id as a unique constraint, so a duplicate order.placed event for an order that already has a reservation is a no-op detected at the database level, not something the consumer has to track in memory.
Why does Order Service call Product Service synchronously but never Inventory Service synchronously?
The price and existence check is needed immediately to build a valid order line, and a brief synchronous call is acceptable for that read-only lookup. Stock reservation, by contrast, can legitimately take time (it's a write, potentially contended under load), and the checkout flow should not block on it — that's exactly the shape of problem asynchronous events solve.
What happens to an order if the Order Service crashes right after publishing order.placed but before persisting the PENDING order?
The design in this POC intentionally persists the order before publishing the event, not after, precisely to avoid that gap — a "phantom" event with no corresponding order. The realistic remaining risk is the reverse: the order is saved but the publish fails; the fix for that is the transactional outbox pattern (write the event to an outbox table in the same DB transaction, then a separate relay publishes it to Kafka), which is a natural hardening step beyond this POC's scope.
Why JWT/stateless authentication instead of server-side sessions?
A session store would need to be shared and available to all four services, becoming another piece of shared infrastructure and a single point of coupling. A signed JWT carries its own proof of authenticity, so any service can verify a caller independently with just a public key, no shared session store or network call required.
Why validate the JWT independently in every service instead of only at the API Gateway?
Trusting the Gateway's validation and passing an unsigned internal header forward means any service reachable directly on the internal network — bypassing the Gateway, whether by misconfiguration or a compromised host — would accept forged identity headers. Independent verification in every service is defense in depth: the Gateway becomes a convenience, not a security boundary the whole system depends on.
Why presigned S3 URLs instead of uploading the file through the Product Service?
Routing file bytes through the service means the service's memory, threads, and bandwidth scale with upload traffic, not just request logic — a large image upload can tie up a request thread for seconds. Presigned URLs let the client talk to S3 directly; the service's only job is issuing a short-lived, scoped permission slip, which keeps it lightweight and horizontally scalable.
How would you prevent a SELLER from uploading an image to another seller's product using a presigned URL?
Authorization happens before the presigned URL is ever issued: POST /products/{id}/image-upload-url checks that the authenticated caller's user id matches the product's seller_id (or the caller is ADMIN) before generating the URL. The presigned URL itself is scoped to one specific S3 key, so even a leaked URL can't be used to overwrite a different product's image.
Why is Notification bundled into the Inventory service instead of being its own fifth microservice?
Notification has no meaningful state of its own to own — it only reacts to events and calls an email provider. Splitting it out would add a fifth deployable, a fifth thing to monitor, and a fifth set of infrastructure, for a component with no independent scaling or ownership need at this project's scale. This is a judgment call, not a rule — a real e-commerce platform with high notification volume would likely split it out.
How would you handle a partial failure where inventory reservation succeeds but the notification email fails to send?
The order's business state (CONFIRMED) should not depend on the notification succeeding — notification failures are logged and retried independently (or sent to a dead-letter topic for manual review), never allowed to roll back or block the order status update, because the customer having a confirmed order is the more important guarantee than them receiving an email promptly.
Why choreography (services reacting to each other's events) instead of a central saga orchestrator here?
Orchestration centralizes the workflow logic in one coordinator, which is easier to reason about as the number of steps grows, at the cost of a new service that becomes a critical dependency. With only one downstream reaction (inventory, which triggers notification), choreography keeps total moving parts lower; the trade-off flips once you'd need five or six coordinated steps with complex compensation logic.
What's the compensating action if inventory reservation fails after the order was already shown to the customer as "placed"?
The Order Service consumes inventory.failed and transitions the order to CANCELLED rather than leaving it stuck in PENDING, then publishes order.cancelled so the Notification listener can inform the customer. This is the compensating transaction half of the SAGA pattern — undoing forward progress with a new action, since there's no cross-service database rollback available.
How would you extend this project to demonstrate infrastructure as code without over-scoping the core build?
Add Terraform modules that provision the AWS equivalents from the production-mapping table above — VPC, RDS instances, an MSK cluster, an S3 bucket with a lifecycle policy, and ECS task definitions — as a clearly separate, optional phase on top of the working application, rather than building infrastructure and application code simultaneously and risking neither being finished.
What would you point to as the single best design decision in this project if an interviewer asks you to pick one?
The split between synchronous validation (Order → Product) and asynchronous fulfillment (Order ↔ Inventory via Kafka) — because it's the decision that most directly demonstrates understanding when to accept the complexity of eventual consistency versus when a simple blocking call is the right, simpler tool, which is the core judgment call microservices architecture actually tests.
Related guides
Update these hrefs to your published Blogger post URLs once each page is live.
Post a Comment
Add