E-Commerce Microservices on Kubernetes

Build 3 Spring Boot microservices with Kafka, Docker, and Kubernetes locally.

Introduction

30 Second Summary

When you order something online, dozens of systems coordinate behind the scenes. Your payment gets processed, inventory updates, and a confirmation email lands in your inbox.

In this project, you will build an E-Commerce Order Processing System using three independent microservices that communicate through REST APIs and Apache Kafka events. You'll containerize everything with Docker and deploy it to a local Kubernetes cluster.

What You'll Build

A microservices-based order processing system with three Spring Boot services, each owning its own database, communicating via REST and Kafka events, and deployed on local Kubernetes.

By the end of this project, you'll have:

  • 3 Spring Boot microservices with independent databases following the Database-per-Service pattern.
  • Event-driven communication with Apache Kafka connecting services through asynchronous events.
  • Docker containers and Kubernetes deployments running your entire system locally with scaling and self-healing.
  • Secret Mission: Implement the Circuit Breaker pattern with Resilience4j for fault tolerance.

Are there any prerequisites?

You'll need basic Java knowledge (classes, methods, and interfaces). You should also have Java JDK 17 or higher installed on your machine.

Docker Desktop and all infrastructure (Kafka, PostgreSQL, MongoDB) will be installed and configured as part of the project.

Install Docker Desktop and Start Infrastructure

Microservices need supporting infrastructure to communicate and store data. Your three services will rely on Kafka for event messaging, PostgreSQL for transactional data, and MongoDB for flexible document storage.

Instead of installing each tool separately, you will run all of them as Docker containers. In this step, you will install Docker Desktop, enable its built-in Kubernetes cluster, and spin up the infrastructure containers using Docker Compose.

In this step, get ready to:
  • Install Docker Desktop with WSL 2 backend enabled.
  • Enable Kubernetes in Docker Desktop.
  • Create and start infrastructure containers (Kafka, PostgreSQL, MongoDB).
Download and install Docker Desktop

Docker Desktop bundles the Docker Engine, Docker Compose, and Kubernetes into a single installer. It lets you run containers on your machine without managing a Linux VM yourself.

  • Check if Docker is already installed by running this command in your terminal:
docker --version

✔️ I see a version number

Docker is already installed on your machine. Skip ahead to the next substep where you enable Kubernetes.

  • Confirm Docker Compose is also available by running:
docker compose version

You should see a version number like v2.x.x. If you see both version numbers, you are ready to proceed.

ⓧ Command not found

Docker is not installed yet. Follow the instructions below for your operating system.

Windows

  • Open your browser and navigate to docker.com/products/docker-desktop.
  • Click the **Download for Windows** button.
  • Run the downloaded installer.
  • When prompted to choose a backend, select WSL (this is the default). WSL 2 gives Docker near-native Linux performance on Windows.
  • Follow the remaining installer prompts and click Finish when complete.
  • Restart your computer if prompted.
What is WSL 2?

WSL 2 (Windows Subsystem for Linux 2) runs a real Linux kernel inside Windows. Docker Desktop uses it to run Linux containers with minimal overhead. The installer enables it automatically if it is not already active.

macOS

  • Open your browser and navigate to docker.com/products/docker-desktop.
  • Click the **Download for Mac** button matching your chip (Apple Silicon or Intel).
  • Open the downloaded .dmg file and drag Docker to your Applications folder.
  • Open Docker Desktop from Applications or Spotlight search.
  • Accept the service agreement when prompted.
  • After installation, open Docker Desktop:
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Docker Desktop and press Enter to open it.
  • Wait for the Docker engine to finish starting. You will see a green whale icon in your system tray when it is ready.
  • Verify both Docker and Docker Compose are working by running these commands in a new terminal window:
docker --version
docker compose version

You should see version numbers for both. This confirms Docker Desktop is installed and running.

Docker command not found after install?

  • Make sure Docker Desktop is running (look for the whale icon in your system tray).
  • Close and reopen your terminal. Docker adds itself to your PATH during installation, but existing terminal sessions may not pick it up.
  • On Windows, ensure WSL 2 is enabled. Open PowerShell as Administrator and run wsl --install if needed, then restart.

Still stuck? Help me troubleshoot Docker Desktop installation

Enable Kubernetes in Docker Desktop

Docker Desktop includes a built-in single-node Kubernetes cluster. You will use this later in the project to deploy your microservices to a production-like environment.

  • In the Docker Desktop Dashboard, click the Settings icon (gear icon in the top-right area).
  • Select the Kubernetes tab in the left sidebar.
  • Check the box labelled Enable Kubernetes.
  • Click Apply & Restart.
  • Wait for Docker Desktop to download and configure Kubernetes. This may take 2-5 minutes on the first run. You will see a green indicator beside "Kubernetes running" in Settings when it is ready.

Why enable Kubernetes now?

Kubernetes takes a few minutes to download its control-plane images the first time. Enabling it now means it will be ready when you reach Step 6, where you deploy your services to a local cluster.

  • Verify Kubernetes is running by opening a terminal and running:
kubectl cluster-info

You should see output showing the Kubernetes control plane is running at a local address.

kubectl command not found?

  • Make sure the Kubernetes green indicator shows "running" in Docker Desktop Settings.
  • Close and reopen your terminal after enabling Kubernetes.
  • If kubectl returns errors about context, run kubectl config use-context docker-desktop to point it at the correct cluster.

Need more help? Help me fix kubectl not connecting to Docker Desktop Kubernetes

Create the Docker Compose infrastructure file

Your three microservices each need their own backing infrastructure. You will define all three infrastructure services (Kafka, PostgreSQL, MongoDB) in a single docker-compose.yml file so they start together with one command.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ecommerce-microservices
cd ecommerce-microservices
  • Confirm the folder exists by running:
ls

The folder should be empty for now. This is your project root where all services will live.

  • Open this folder in your editor. In your editor, click File in the top menu bar.
  • Click Open Folder.
  • Select the ecommerce-microservices folder on your Desktop and click Open.
  • Create a new file and name it docker-compose.yml.
  • Add the Kafka service configuration to docker-compose.yml by pasting the following code:
version: '3.8'
services:
  kafka:
    image: apache/kafka:latest
    container_name: kafka
    ports:
      - '9092:9092'
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'

What does this Kafka config do?

  • apache/kafka:latest uses the official Apache Kafka image running in KRaft mode. KRaft eliminates the need for a separate ZooKeeper instance.
  • KAFKA_PROCESS_ROLES: broker,controller means this single node handles both message brokering and cluster coordination.
  • KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 is safe for a single-node development setup. In production you would use 3 or more.
  • Port 9092 is exposed so your Spring Boot services can connect from the host machine.

This is the same pattern interviewers expect you to explain: KRaft mode is production-ready since Kafka 3.3 and removes ZooKeeper as a dependency.

  • Add the PostgreSQL, MongoDB, and volumes configuration directly below the Kafka service block in the same file:
  postgres:
    image: postgres:15
    container_name: postgres
    ports:
      - '5432:5432'
    environment:
      POSTGRES_DB: orderdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data

  mongodb:
    image: mongo:6
    container_name: mongodb
    ports:
      - '27017:27017'
    volumes:
      - mongo_data:/data/db

volumes:
  postgres_data:
  mongo_data:

Why two different databases?

This is the Database-per-Service pattern. PostgreSQL handles the Order Service because orders require ACID transactions. MongoDB handles the Inventory Service because product catalogs have flexible attributes and are read-heavy.

Interview talking point: each service owns its schema independently. Trade-off is distributed transactions become harder, which you solve with Kafka events for eventual consistency.

  • Save docker-compose.yml.

✔️ Awesome, I've got everything!

Great. Double check you have saved docker-compose.yml before continuing.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml file. Make sure your file matches exactly:

version: '3.8'
services:
  kafka:
    image: apache/kafka:latest
    container_name: kafka
    ports:
      - '9092:9092'
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'

  postgres:
    image: postgres:15
    container_name: postgres
    ports:
      - '5432:5432'
    environment:
      POSTGRES_DB: orderdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data

  mongodb:
    image: mongo:6
    container_name: mongodb
    ports:
      - '27017:27017'
    volumes:
      - mongo_data:/data/db

volumes:
  postgres_data:
  mongo_data:
  • Start all infrastructure containers by running:
docker compose up -d

What does this command do?

docker compose up -d reads your docker-compose.yml and starts all defined services in detached mode (-d). This means the containers run in the background so your terminal stays free.

Docker will pull the images on first run, which may take 2-3 minutes depending on your connection.

  • Verify all three containers are running:
docker compose ps

You should see three containers listed (kafka, postgres, mongodb) all with a status of "running" or "Up".

Containers not starting?

  • Make sure Docker Desktop is running (green whale icon in system tray).
  • Check if the ports are already in use. If you have a local PostgreSQL or MongoDB running, stop them first or change the host ports in docker-compose.yml.
  • Run docker compose logs to see detailed error messages from any failing container.

Need help? Help me debug why my Docker Compose containers are not starting

Your infrastructure is ready. Kafka is waiting for events, PostgreSQL is ready for order data, and MongoDB is ready for product catalogs. Next up, you will build the Order Service that talks to all of them.

Build Order Service

Your infrastructure is live. Kafka, PostgreSQL, and MongoDB are all running in Docker containers, ready to serve your microservices.

Now it's time to build the first microservice. The Order Service accepts customer orders, stores them in PostgreSQL, and publishes events to Kafka so other services can react. This is where you'll see the Database-per-Service pattern and event-driven communication in action.

In this step, get ready to:
  • Create a Spring Boot project with REST, JPA, PostgreSQL, and Kafka dependencies.
  • Build the Order entity, repository, DTOs, and controller.
  • Implement Kafka event publishing and test the service locally.
Generate the Order Service project

Every Spring Boot project starts with a pom.xml that declares its dependencies. You'll create the project structure and configure the four dependencies this service needs.

  • Open your browser and navigate to start.spring.io.
  • Configure the project with these settings:
  • Project: Maven.
  • Language: Java.
  • Spring Boot: 3.2.0.
  • Group: com.nextwork.
  • Artifact: order-service.
  • Dependencies: Spring Web, Spring Data JPA, PostgreSQL Driver, Spring for Apache Kafka.
  • Click Generate to download the project zip file.
  • Extract the zip into the same directory where your docker-compose.yml lives, so you have an order-service/ folder alongside it.

Now open the project in your IDE. The generated pom.xml should contain the four key dependencies.

  • Verify your pom.xml includes these dependencies by opening it in your editor:
<project>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
  </parent>
  <groupId>com.nextwork</groupId>
  <artifactId>order-service</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
    <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
    <dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
  </dependencies>
</project>

What do these dependencies do?

  • spring-boot-starter-web enables REST API endpoints with Spring MVC.
  • spring-boot-starter-data-jpa provides the ORM layer to map Java objects to database tables.
  • postgresql is the JDBC driver that connects JPA to your PostgreSQL container.
  • spring-kafka gives you KafkaTemplate for publishing events and @KafkaListener for consuming them.

Why JPA for Order data?

Orders involve financial transactions that need ACID guarantees. If a user places an order, you need the database to confirm it was saved completely or not at all. PostgreSQL with JPA gives you transactional integrity out of the box.

  • Save pom.xml.
  • If your IDE prompts you to reload Maven dependencies, click Yes or Import Changes.

Maven not resolving dependencies?

Make sure you have an internet connection. Maven downloads dependencies from remote repositories on first use. If you see connection errors, check your firewall or proxy settings.

Also verify that your <parent> block has version 3.2.0 exactly. A typo here prevents all Spring Boot starters from resolving.

Help me fix Maven dependency resolution errors in my Spring Boot project.

Create the Order entity and repository

The Order entity maps directly to a database table. JPA uses annotations to define how Java fields translate to table columns.

  • Inside order-service/src/main/java/com/nextwork/orderservice/, create a model directory.
  • Create a new file called Order.java inside the model directory with this code:
package com.nextwork.orderservice.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

// JPA entity mapping to the "orders" table in PostgreSQL
@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String productCode;
    private Integer quantity;
    private String status;
    private LocalDateTime createdAt;

    // getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getProductCode() { return productCode; }
    public void setProductCode(String productCode) { this.productCode = productCode; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this code do?

  • @Entity tells JPA this class represents a database table.
  • @Table(name = "orders") names the table explicitly because "order" is a reserved SQL keyword.
  • @Id and @GeneratedValue make PostgreSQL auto-generate a unique ID for each order.
  • The five fields (id, productCode, quantity, status, createdAt) each become a column in the table.
  • Save Order.java.

Next, create the repository and the DTO records. The repository gives you database CRUD operations for free. The DTOs use Java Records to define immutable data shapes for API requests and responses.

Why Java Records for DTOs?

Records were introduced in Java 16 as a way to declare immutable data carriers with zero boilerplate. No getters, no constructors, no equals/hashCode to write. They're perfect for DTOs because request and response objects should never be mutated after creation.

Interview talking point: "Records enforce immutability at the language level. A traditional POJO DTO requires discipline to keep immutable. A Record makes it impossible to mutate."

  • Create a repository directory inside com/nextwork/orderservice/.
  • Create OrderRepository.java in the repository directory:
package com.nextwork.orderservice.repository;

import com.nextwork.orderservice.model.Order;
import org.springframework.data.jpa.repository.JpaRepository;

// Spring Data JPA auto-generates CRUD methods (save, findById, findAll, delete)
public interface OrderRepository extends JpaRepository<Order, Long> {}
  • Create a dto directory inside com/nextwork/orderservice/.
  • Create OrderRequest.java in the dto directory:
package com.nextwork.orderservice.dto;

// Record = immutable DTO. Compiler generates constructor, getters, equals, hashCode, toString.
public record OrderRequest(String productCode, Integer quantity) {}
  • Create OrderResponse.java in the same dto directory:
package com.nextwork.orderservice.dto;

import java.time.LocalDateTime;

// Response DTO carries all order fields back to the client
public record OrderResponse(Long id, String productCode, Integer quantity, String status, LocalDateTime createdAt) {}

What does this code do?

OrderRepository extends JpaRepository, which means Spring Data auto-generates save(), findById(), findAll(), and delete() methods without you writing a single SQL query.

OrderRequest defines what the client sends (product code and quantity). OrderResponse defines what the API returns (all fields including the generated ID and timestamp).

  • Save all three files.
  • Verify your project has no red compile errors in the IDE. If you see import errors, wait for Maven to finish downloading dependencies.

Now create the REST controller that exposes the order endpoints.

  • Create a controller directory inside com/nextwork/orderservice/.
  • Create OrderController.java with this code:
package com.nextwork.orderservice.controller;

import com.nextwork.orderservice.dto.OrderRequest;
import com.nextwork.orderservice.dto.OrderResponse;
import com.nextwork.orderservice.service.OrderService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

// REST controller handling order creation and retrieval
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final OrderService orderService;

    // Constructor injection - Spring auto-wires the OrderService bean
    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    // POST /api/orders - creates a new order and publishes a Kafka event
    @PostMapping
    public ResponseEntity<OrderResponse> placeOrder(@RequestBody OrderRequest request) {
        return ResponseEntity.ok(orderService.placeOrder(request));
    }

    // GET /api/orders/{id} - retrieves an order by ID using Optional for null safety
    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
        return orderService.getOrder(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}

What does this code do?

  • @RestController marks this class as a REST API endpoint that returns JSON responses.
  • @RequestMapping("/api/orders") sets the base URL path for all endpoints in this controller.
  • The getOrder method uses Java's Optional to safely handle cases where an order ID doesn't exist. Instead of returning null, it returns a 404 response.
  • Constructor injection means Spring creates the OrderService bean and passes it automatically. No new keyword needed.
  • Save OrderController.java. Your IDE will show a compile error because OrderService doesn't exist yet. You'll create it next.

Seeing a lot of red import errors?

Check that your package declarations match the directory structure exactly. The package com.nextwork.orderservice.controller must live at src/main/java/com/nextwork/orderservice/controller/.

If imports like org.springframework.web.bind.annotation are unresolved, Maven hasn't finished downloading. Right-click your pom.xml and select Reload Project (IntelliJ) or run mvn dependency:resolve in your terminal.

Help me fix import resolution errors in my Spring Boot project.

Configure the database, Kafka, and implement the service layer

The service layer is where the business logic lives. When an order is placed, the service saves it to PostgreSQL and publishes a Kafka event so other services can react without being directly coupled to the Order Service.

Why Kafka for events instead of direct REST calls?

If Order Service called Inventory Service directly via REST every time an order was placed, a failure in Inventory Service would break order creation. That's tight coupling.

With Kafka, Order Service publishes an event and moves on. If Inventory Service is temporarily down, the event waits in the Kafka topic until it recovers. Interview talking point: "Kafka gives us event log semantics. Events are immutable and replayable. If a consumer goes down, it catches up when it comes back."

  • Create the application.yml file at order-service/src/main/resources/application.yml with this configuration:
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/orderdb
    username: postgres
    password: postgres
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer

What does this configuration do?

  • datasource.url connects to the PostgreSQL container you started in Step 1. The database orderdb was created automatically by the POSTGRES_DB environment variable.
  • ddl-auto: update tells Hibernate to auto-create or update the database table based on your Order entity. No manual SQL migrations needed for development.
  • bootstrap-servers: localhost:9092 points to the Kafka broker running in Docker.
  • The JsonSerializer converts your Java event objects to JSON before sending them to Kafka.
  • Save application.yml.

Now create the event class and the service that ties everything together.

  • Create an event directory inside com/nextwork/orderservice/.
  • Create OrderPlacedEvent.java in the event directory:
package com.nextwork.orderservice.event;

// Event published to Kafka when an order is placed
// Other services (Inventory, Notification) consume this event
public record OrderPlacedEvent(Long orderId, String productCode, Integer quantity) {}
  • Create a service directory inside com/nextwork/orderservice/.
  • Create OrderService.java in the service directory:
package com.nextwork.orderservice.service;

import com.nextwork.orderservice.dto.OrderRequest;
import com.nextwork.orderservice.dto.OrderResponse;
import com.nextwork.orderservice.event.OrderPlacedEvent;
import com.nextwork.orderservice.model.Order;
import com.nextwork.orderservice.repository.OrderRepository;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.Optional;

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;

    // Spring injects both the repository and KafkaTemplate automatically
    public OrderService(OrderRepository orderRepository, KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate) {
        this.orderRepository = orderRepository;
        this.kafkaTemplate = kafkaTemplate;
    }

    // Place a new order: save to DB, then publish event to Kafka
    public OrderResponse placeOrder(OrderRequest request) {
        Order order = new Order();
        order.setProductCode(request.productCode());
        order.setQuantity(request.quantity());
        order.setStatus("PLACED");
        order.setCreatedAt(LocalDateTime.now());
        Order saved = orderRepository.save(order);
        // Publish event so Inventory and Notification services can react
        kafkaTemplate.send("order-placed", new OrderPlacedEvent(saved.getId(), saved.getProductCode(), saved.getQuantity()));
        return new OrderResponse(saved.getId(), saved.getProductCode(), saved.getQuantity(), saved.getStatus(), saved.getCreatedAt());
    }

    // Retrieve order by ID - uses Optional for null safety (no NullPointerException)
    public Optional<OrderResponse> getOrder(Long id) {
        return orderRepository.findById(id)
            .map(o -> new OrderResponse(o.getId(), o.getProductCode(), o.getQuantity(), o.getStatus(), o.getCreatedAt()));
    }
}

What does OrderService do?

The placeOrder method does three things: creates an Order entity, saves it to PostgreSQL via the repository, then publishes an OrderPlacedEvent to the order-placed Kafka topic.

The getOrder method uses Optional so the caller never receives a null. The .map() call transforms the entity into a response DTO only if the order exists.

Notice request.productCode() instead of request.getProductCode(). Records generate accessor methods named after the field, not with the get prefix.

  • Save both OrderPlacedEvent.java and OrderService.java.
  • Verify that the compile error in OrderController.java is now resolved since OrderService exists.

KafkaTemplate showing as unresolved?

Make sure your pom.xml has the spring-kafka dependency and that Maven has reloaded. The KafkaTemplate class comes from the org.springframework.kafka.core package.

Help me resolve KafkaTemplate import issues in Spring Boot.

Run and test the Order Service

Time to see everything working together. You'll start the service and test both endpoints with curl.

  • Make sure your Docker containers are still running. Verify by running:
docker ps

You should see kafka, postgres, and mongodb containers listed.

  • Navigate into the order-service directory and start the application by running:
cd order-service
mvn spring-boot:run

Wait for the application to start. You should see a line in the output that says the application started on port 8080.

Application fails to start?

If you see a connection refused error for PostgreSQL, your Docker containers might have stopped. Run docker compose up -d from the directory with your docker-compose.yml to restart them.

If you see a port 8080 already in use error, another application is using that port. Stop it or add server.port: 8081 to your application.yml.

Help me fix Spring Boot startup errors for my Order Service.

  • Open a new terminal window. Test the POST endpoint by running:
curl -X POST http://localhost:8080/api/orders -H "Content-Type: application/json" -d '{"productCode": "LAPTOP-001", "quantity": 2}'

You should see a JSON response with an id, productCode, quantity, status of PLACED, and a createdAt timestamp.

  • Test the GET endpoint by running:
curl http://localhost:8080/api/orders/1

You should see the same order you just created, retrieved from PostgreSQL.

What just happened behind the scenes?

  • Your POST request hit the controller, which called OrderService.placeOrder().
  • The service saved the order to PostgreSQL (you can see the SQL in your first terminal thanks to show-sql: true).
  • It then published an OrderPlacedEvent to the order-placed Kafka topic. No consumer exists yet, so the event sits in the topic waiting.
  • The GET request used Optional to safely retrieve the order. If you try /api/orders/999, you'll get a 404 instead of a null pointer error.

Getting a 404 or connection refused on curl?

Make sure the Spring Boot application is still running in your first terminal. The output should show "Started" with no stack traces after it.

If you're on Windows and curl doesn't work, try using PowerShell's Invoke-RestMethod or install curl from curl.se.

Help me test my Spring Boot REST API with curl.

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here are the complete files for your Order Service:

<project>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
  </parent>
  <groupId>com.nextwork</groupId>
  <artifactId>order-service</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
    <dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
    <dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
  </dependencies>
</project>
package com.nextwork.orderservice.model;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String productCode;
    private Integer quantity;
    private String status;
    private LocalDateTime createdAt;

    // getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getProductCode() { return productCode; }
    public void setProductCode(String productCode) { this.productCode = productCode; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.nextwork.orderservice.repository;

import com.nextwork.orderservice.model.Order;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, Long> {}
package com.nextwork.orderservice.dto;

public record OrderRequest(String productCode, Integer quantity) {}
package com.nextwork.orderservice.dto;

import java.time.LocalDateTime;

public record OrderResponse(Long id, String productCode, Integer quantity, String status, LocalDateTime createdAt) {}
package com.nextwork.orderservice.event;

public record OrderPlacedEvent(Long orderId, String productCode, Integer quantity) {}
package com.nextwork.orderservice.service;

import com.nextwork.orderservice.dto.OrderRequest;
import com.nextwork.orderservice.dto.OrderResponse;
import com.nextwork.orderservice.event.OrderPlacedEvent;
import com.nextwork.orderservice.model.Order;
import com.nextwork.orderservice.repository.OrderRepository;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.Optional;

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;

    public OrderService(OrderRepository orderRepository, KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate) {
        this.orderRepository = orderRepository;
        this.kafkaTemplate = kafkaTemplate;
    }

    public OrderResponse placeOrder(OrderRequest request) {
        Order order = new Order();
        order.setProductCode(request.productCode());
        order.setQuantity(request.quantity());
        order.setStatus("PLACED");
        order.setCreatedAt(LocalDateTime.now());
        Order saved = orderRepository.save(order);
        kafkaTemplate.send("order-placed", new OrderPlacedEvent(saved.getId(), saved.getProductCode(), saved.getQuantity()));
        return new OrderResponse(saved.getId(), saved.getProductCode(), saved.getQuantity(), saved.getStatus(), saved.getCreatedAt());
    }

    public Optional<OrderResponse> getOrder(Long id) {
        return orderRepository.findById(id)
            .map(o -> new OrderResponse(o.getId(), o.getProductCode(), o.getQuantity(), o.getStatus(), o.getCreatedAt()));
    }
}
package com.nextwork.orderservice.controller;

import com.nextwork.orderservice.dto.OrderRequest;
import com.nextwork.orderservice.dto.OrderResponse;
import com.nextwork.orderservice.service.OrderService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<OrderResponse> placeOrder(@RequestBody OrderRequest request) {
        return ResponseEntity.ok(orderService.placeOrder(request));
    }

    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
        return orderService.getOrder(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/orderdb
    username: postgres
    password: postgres
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer

Your first microservice is live. It accepts orders, persists them to PostgreSQL, and fires events into Kafka. Next up, you'll build the Inventory Service that listens for those events and automatically adjusts stock levels.

Build Inventory Service

Your Order Service publishes events to Kafka whenever an order is placed. Now you need a second service that listens for those events and reacts to them.

The Inventory Service manages product stock. It stores product data in MongoDB, exposes a REST endpoint to check stock levels, and automatically reduces inventory when it receives an OrderPlacedEvent from Kafka.

In this step, get ready to:
  • Create the Inventory Service Spring Boot project with MongoDB and Kafka dependencies.
  • Build a Kafka event listener that reduces stock using CompletableFuture for non-blocking processing.
  • Test the end-to-end flow from placing an order to automatic stock reduction.
Create the Inventory Service project and REST endpoint

Each microservice in this architecture is its own independent project with its own dependencies. The Inventory Service needs Spring Web for REST, Spring Data MongoDB for persistence, and Spring Kafka for event consumption.

  • Create the inventory-service directory structure alongside your existing order-service folder by running these commands:
mkdir -p inventory-service/src/main/java/com/nextwork/inventoryservice/model
mkdir -p inventory-service/src/main/java/com/nextwork/inventoryservice/repository
mkdir -p inventory-service/src/main/java/com/nextwork/inventoryservice/controller
mkdir -p inventory-service/src/main/java/com/nextwork/inventoryservice/event
mkdir -p inventory-service/src/main/java/com/nextwork/inventoryservice/listener
mkdir -p inventory-service/src/main/resources

What does this command do?

This creates the full Maven-standard package structure for your Inventory Service. Each package has a specific responsibility: model holds the data classes, repository handles database access, controller defines REST endpoints, event defines event schemas, and listener processes incoming Kafka messages.

  • Verify the directory was created by running:
ls inventory-service/src/main/java/com/nextwork/inventoryservice/
  • You should see model, repository, controller, event, and listener listed in the output.
  • Create the pom.xml file inside inventory-service/ with the following content:
<project>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
  </parent>
  <groupId>com.nextwork</groupId>
  <artifactId>inventory-service</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <dependencies>
    <!-- REST API support -->
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <!-- MongoDB document database -->
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-mongodb</artifactId></dependency>
    <!-- Kafka event consumer -->
    <dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
  </dependencies>
</project>

What does this code do?

This pom.xml defines three dependencies. spring-boot-starter-web gives you REST controllers. spring-boot-starter-data-mongodb provides the MongoDB driver and Spring Data repositories. spring-kafka enables Kafka consumer support.

Version numbers are omitted for the dependencies. Spring Boot's parent POM manages compatible versions automatically.

  • Save pom.xml.

IDE not recognizing the project?

If your IDE shows unresolved imports, you may need to reload the Maven project. In IntelliJ, right-click the pom.xml and select Maven then Reload Project.

Help me set up my Inventory Service Maven project.

Now create the Product document class. This is the data model that maps to a MongoDB collection.

  • Create Product.java inside inventory-service/src/main/java/com/nextwork/inventoryservice/model/ with the following code:
package com.nextwork.inventoryservice.model;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.Map;

// Maps to the "products" collection in MongoDB
@Document(collection = "products")
public class Product {
    @Id
    private String id;
    private String productCode;
    private String name;
    private Integer stockQuantity;
    // Flexible key-value map for variable product attributes
    private Map<String, Object> attributes;

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getProductCode() { return productCode; }
    public void setProductCode(String productCode) { this.productCode = productCode; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
    public Map<String, Object> getAttributes() { return attributes; }
    public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
}

Why MongoDB for inventory?

Products have flexible attributes. A laptop might have RAM and screen size, while a shirt has color and material. MongoDB's document model lets you store these variable fields in a Map<String, Object> without rigid schema constraints.

Compare this to Order Service's PostgreSQL, where every order has the exact same fields and needs ACID transactions. The database choice matches the data's nature.

  • Save Product.java.

Next, create the repository interface that Spring Data uses to generate database queries automatically.

  • Create ProductRepository.java inside inventory-service/src/main/java/com/nextwork/inventoryservice/repository/ with this code:
package com.nextwork.inventoryservice.repository;

import com.nextwork.inventoryservice.model.Product;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.Optional;

// Spring Data generates the implementation at runtime
public interface ProductRepository extends MongoRepository<Product, String> {
    Optional<Product> findByProductCode(String productCode);
}

What does this code do?

By extending MongoRepository, Spring Data automatically provides CRUD methods (save, findAll, delete). The custom method findByProductCode is generated from the method name. Spring parses it and creates a MongoDB query that filters by the productCode field.

  • Save ProductRepository.java.

Now create the REST controller that exposes the stock-check endpoint.

  • Create InventoryController.java inside inventory-service/src/main/java/com/nextwork/inventoryservice/controller/ with this code:
package com.nextwork.inventoryservice.controller;

import com.nextwork.inventoryservice.model.Product;
import com.nextwork.inventoryservice.repository.ProductRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/inventory")
public class InventoryController {
    private final ProductRepository productRepository;

    public InventoryController(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // GET /api/inventory/{productCode} - returns stock info for a product
    @GetMapping("/{productCode}")
    public ResponseEntity<Product> getStock(@PathVariable String productCode) {
        return productRepository.findByProductCode(productCode)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}

What does this code do?

This controller exposes a single GET endpoint at /api/inventory/{productCode}. It looks up the product by its code and returns the full product document (including stock quantity) or a 404 if not found.

Notice the same Optional pattern used in Order Service. This is a Java 8+ feature that eliminates null checks and makes the found-or-not-found logic explicit.

  • Save InventoryController.java.
Implement the Kafka event listener with CompletableFuture

The Inventory Service needs to react when Order Service publishes an event. You will create a Kafka listener that consumes OrderPlacedEvent messages and reduces stock asynchronously using CompletableFuture.

  • Create OrderPlacedEvent.java inside inventory-service/src/main/java/com/nextwork/inventoryservice/event/ with this code:
package com.nextwork.inventoryservice.event;

// Java Record - immutable DTO matching the event schema from Order Service
public record OrderPlacedEvent(Long orderId, String productCode, Integer quantity) {}

What does this code do?

This record mirrors the OrderPlacedEvent from Order Service. Both services define the same event schema independently. This is the Event Notification pattern. The consumer only knows about the event shape, not about the producer's internals.

Interview talking point: the Inventory Service has zero coupling to Order Service. It only knows about the event schema, not the producer's database, models, or internal logic.

  • Save OrderPlacedEvent.java.

Now create the listener that processes incoming events and reduces stock.

  • Create InventoryEventListener.java inside inventory-service/src/main/java/com/nextwork/inventoryservice/listener/ with this code:
package com.nextwork.inventoryservice.listener;

import com.nextwork.inventoryservice.event.OrderPlacedEvent;
import com.nextwork.inventoryservice.repository.ProductRepository;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

import java.util.concurrent.CompletableFuture;

@Component
public class InventoryEventListener {
    private final ProductRepository productRepository;

    public InventoryEventListener(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // Listens to the "order-placed" topic as part of the "inventory-service" consumer group
    @KafkaListener(topics = "order-placed", groupId = "inventory-service")
    public void handleOrderPlaced(OrderPlacedEvent event) {
        // CompletableFuture makes this non-blocking - frees the Kafka consumer thread
        CompletableFuture.runAsync(() ->
            productRepository.findByProductCode(event.productCode()).ifPresent(product -> {
                product.setStockQuantity(product.getStockQuantity() - event.quantity());
                productRepository.save(product);
                System.out.println("Stock reduced for " + event.productCode() + " by " + event.quantity());
            })
        );
    }
}

Why use CompletableFuture here?

The @KafkaListener method runs on a Kafka consumer thread. If the database call is slow, it blocks the consumer from processing the next message. CompletableFuture.runAsync() offloads the work to a separate thread pool so the consumer stays responsive.

Interview talking point: async processing keeps the consumer thread available for the next event. In a high-throughput system, blocking the consumer thread creates back-pressure that cascades upstream.

  • Save InventoryEventListener.java.

Finally, configure the application properties to connect to MongoDB and Kafka.

  • Create application.yml inside inventory-service/src/main/resources/ with this configuration:
spring:
  data:
    mongodb:
      uri: mongodb://localhost:27017/inventorydb
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: inventory-service
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "*"
server:
  port: 8081

What does this configuration do?

The MongoDB URI points to the inventorydb database running in your Docker container. The Kafka consumer config tells Spring how to deserialize incoming messages from JSON back into Java objects.

The spring.json.trusted.packages property allows deserialization of any package. In production you would restrict this to specific packages for security. The server runs on port 8081 to avoid conflicting with Order Service on 8080.

  • Save application.yml.

Port conflict when starting?

If you see an error about port 8081 already being in use, check whether another process is occupying it. On macOS/Linux, run lsof -i :8081 to find and stop the conflicting process.

Help me fix a port conflict for my Inventory Service.

✔️ Awesome, I've got everything!

Great. Double check that you have saved all files in the inventory-service directory.

ⓧ I'd like to double check the full code

Here is the complete pom.xml:

<project>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
  </parent>
  <groupId>com.nextwork</groupId>
  <artifactId>inventory-service</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-mongodb</artifactId></dependency>
    <dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
  </dependencies>
</project>

Here is the complete Product.java:

package com.nextwork.inventoryservice.model;

import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.Map;

@Document(collection = "products")
public class Product {
    @Id
    private String id;
    private String productCode;
    private String name;
    private Integer stockQuantity;
    private Map<String, Object> attributes;

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getProductCode() { return productCode; }
    public void setProductCode(String productCode) { this.productCode = productCode; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
    public Map<String, Object> getAttributes() { return attributes; }
    public void setAttributes(Map<String, Object> attributes) { this.attributes = attributes; }
}

Here is the complete ProductRepository.java:

package com.nextwork.inventoryservice.repository;

import com.nextwork.inventoryservice.model.Product;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.Optional;

public interface ProductRepository extends MongoRepository<Product, String> {
    Optional<Product> findByProductCode(String productCode);
}

Here is the complete InventoryController.java:

package com.nextwork.inventoryservice.controller;

import com.nextwork.inventoryservice.model.Product;
import com.nextwork.inventoryservice.repository.ProductRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/inventory")
public class InventoryController {
    private final ProductRepository productRepository;

    public InventoryController(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @GetMapping("/{productCode}")
    public ResponseEntity<Product> getStock(@PathVariable String productCode) {
        return productRepository.findByProductCode(productCode)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }
}

Here is the complete OrderPlacedEvent.java:

package com.nextwork.inventoryservice.event;

public record OrderPlacedEvent(Long orderId, String productCode, Integer quantity) {}

Here is the complete InventoryEventListener.java:

package com.nextwork.inventoryservice.listener;

import com.nextwork.inventoryservice.event.OrderPlacedEvent;
import com.nextwork.inventoryservice.repository.ProductRepository;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

import java.util.concurrent.CompletableFuture;

@Component
public class InventoryEventListener {
    private final ProductRepository productRepository;

    public InventoryEventListener(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @KafkaListener(topics = "order-placed", groupId = "inventory-service")
    public void handleOrderPlaced(OrderPlacedEvent event) {
        CompletableFuture.runAsync(() ->
            productRepository.findByProductCode(event.productCode()).ifPresent(product -> {
                product.setStockQuantity(product.getStockQuantity() - event.quantity());
                productRepository.save(product);
                System.out.println("Stock reduced for " + event.productCode() + " by " + event.quantity());
            })
        );
    }
}

Here is the complete application.yml:

spring:
  data:
    mongodb:
      uri: mongodb://localhost:27017/inventorydb
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: inventory-service
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "*"
server:
  port: 8081
Test the end-to-end event-driven flow

With all the code in place, it is time to verify that placing an order in Order Service triggers a Kafka event that Inventory Service consumes to reduce stock.

  • Create the main application class. Create InventoryServiceApplication.java inside inventory-service/src/main/java/com/nextwork/inventoryservice/ with this code:
package com.nextwork.inventoryservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

// Entry point for the Inventory Service application
@SpringBootApplication
public class InventoryServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(InventoryServiceApplication.class, args);
    }
}

What does this code do?

This is the Spring Boot entry point. @SpringBootApplication enables auto-configuration, component scanning, and configuration properties. Spring Boot detects your MongoDB, Kafka, and Web dependencies and auto-configures them using application.yml.

  • Save InventoryServiceApplication.java.
  • Make sure your Docker containers (Kafka, PostgreSQL, MongoDB) are still running. Verify by running:
docker compose ps
  • You should see all three infrastructure containers in a running state.
  • Start Order Service in one terminal by running:
cd order-service
mvn spring-boot:run
  • Open a second terminal and start Inventory Service by running:
cd inventory-service
mvn spring-boot:run
  • You should see both services start successfully. Inventory Service logs will show it connected to MongoDB and subscribed to the order-placed Kafka topic.

Before testing the event flow, you need test data in MongoDB. The Inventory Service needs a product to reduce stock from.

  • Open a third terminal and connect to MongoDB by running:
docker exec -it $(docker ps -qf "ancestor=mongo") mongosh
  • Insert a test product into the inventory database by running these commands inside the mongosh shell:
use inventorydb
db.products.insertOne({
  productCode: "LAPTOP-001",
  name: "Gaming Laptop",
  stockQuantity: 100,
  attributes: { brand: "NextWork", ram: "16GB", storage: "512GB SSD" }
})

What does this command do?

This switches to the inventorydb database and inserts a product document with 100 units of stock. The attributes field demonstrates MongoDB's flexible schema. No migration needed.

  • Verify the product exists by checking the stock endpoint. Open a fourth terminal and run:
curl http://localhost:8081/api/inventory/LAPTOP-001
  • You should see a JSON response showing stockQuantity: 100.
  • Now place an order to trigger the full event flow by running:
curl -X POST http://localhost:8080/api/orders -H "Content-Type: application/json" -d "{\"productCode\": \"LAPTOP-001\", \"quantity\": 2}"
  • Check the Inventory Service terminal. You should see the log message: Stock reduced for LAPTOP-001 by 2.
  • Verify the stock was reduced by running:
curl http://localhost:8081/api/inventory/LAPTOP-001
  • The response should now show stockQuantity: 98 (reduced from 100 by 2).

What just happened?

You just witnessed the full event-driven flow. Order Service created the order in PostgreSQL, published an OrderPlacedEvent to Kafka, and Inventory Service consumed that event and reduced stock in MongoDB. The two services never called each other directly for this operation.

Interview talking point: stock reduction is eventually consistent. The user gets their order confirmation immediately. Stock updates happen asynchronously. If Inventory Service is temporarily down, Kafka retains the event and replays it when the service recovers.

Stock not reducing?

Check that the productCode in your curl command matches exactly what you inserted into MongoDB. Codes are case-sensitive.

Also verify the Kafka consumer group is active. Look for a log line in Inventory Service mentioning inventory-service joining the consumer group on startup.

Help me debug why my Inventory Service is not receiving Kafka events.

Your Inventory Service is fully operational. It listens for order events and updates stock without any direct coupling to Order Service. Next up, you will build the Notification Service to complete the event-driven architecture.

Build Notification Service

Your Order Service places orders and publishes events. Your Inventory Service listens and reduces stock. The pipeline works end-to-end.

Now you need the third piece: a Notification Service that confirms orders to customers. This service is a pure Kafka consumer. It has no REST API and no database. It listens for order events and logs a simulated email notification.

In this step, get ready to:
  • Create a lightweight Spring Boot project with only Kafka dependencies.
  • Implement a Kafka listener that logs order confirmation notifications.
  • Verify the full event-driven flow across all three services.
Create the Notification Service project

Unlike the Order and Inventory services, the Notification Service has no web layer and no database. It only needs spring-kafka and the base spring-boot-starter to run.

  • In your terminal, navigate to your project's parent directory (the same folder that contains order-service/ and inventory-service/).
  • Create the notification-service directory structure by running these commands:
mkdir -p notification-service/src/main/java/com/nextwork/notificationservice/event
mkdir -p notification-service/src/main/java/com/nextwork/notificationservice/listener
mkdir -p notification-service/src/main/resources
  • Create the pom.xml file inside the notification-service/ folder and paste in the following content:
<project>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
  </parent>
  <groupId>com.nextwork</groupId>
  <artifactId>notification-service</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <dependencies>
    <!-- Kafka consumer support -->
    <dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
    <!-- Base Spring Boot auto-configuration (no web server) -->
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>
  </dependencies>
</project>

What does this pom.xml do?

This Maven file declares only two dependencies. spring-kafka provides the @KafkaListener annotation and consumer auto-configuration. spring-boot-starter provides Spring Boot's core auto-configuration without starting an embedded web server.

Because there is no spring-boot-starter-web, this application will not listen on an HTTP port. The Kafka listener thread keeps the JVM alive instead.

Why no web layer or database?

The Notification Service is a pure event consumer. It reacts to events that arrive on a Kafka topic and performs a side effect (sending a notification). It never needs to serve HTTP responses or store data.

In an interview, this demonstrates the Single Responsibility Principle applied at the service level. Each microservice does one thing well.

  • Create the main application class at notification-service/src/main/java/com/nextwork/notificationservice/NotificationServiceApplication.java and paste in this content:
package com.nextwork.notificationservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class NotificationServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(NotificationServiceApplication.class, args);
    }
}

What does this class do?

This is the standard Spring Boot entry point. The @SpringBootApplication annotation triggers component scanning and auto-configuration. Spring Boot detects the spring-kafka dependency and automatically sets up the Kafka consumer infrastructure.

  • Save pom.xml and NotificationServiceApplication.java.
  • Confirm the directory structure exists by running:
ls notification-service/src/main/java/com/nextwork/notificationservice/

You should see the event and listener directories along with NotificationServiceApplication.java.

Missing directories?

Make sure you ran the mkdir -p commands from the correct parent directory. The -p flag creates all intermediate directories. If you accidentally created them inside order-service/, delete them and re-run from the parent folder.

help me fix my notification-service directory structure

Implement the event record and Kafka listener

The Notification Service needs to understand the same event schema that the Order Service publishes. You will create an OrderPlacedEvent record and a listener class that reacts to it.

  • Create the event record at notification-service/src/main/java/com/nextwork/notificationservice/event/OrderPlacedEvent.java and paste in this content:
package com.nextwork.notificationservice.event;

// Immutable event representing an order that was placed
public record OrderPlacedEvent(Long orderId, String productCode, Integer quantity) {}
  • Create the listener class at notification-service/src/main/java/com/nextwork/notificationservice/listener/NotificationListener.java and paste in this content:
package com.nextwork.notificationservice.listener;

import com.nextwork.notificationservice.event.OrderPlacedEvent;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class NotificationListener {

    // Listens to the same topic that Order Service publishes to
    @KafkaListener(topics = "order-placed", groupId = "notification-service")
    public void handleOrderPlaced(OrderPlacedEvent event) {
        // Simulate sending an email/SMS notification
        System.out.println("[NOTIFICATION] Sending order confirmation email for Order ID: " + event.orderId()
            + ", Product: " + event.productCode()
            + ", Quantity: " + event.quantity());
    }
}

What does this code do?

The OrderPlacedEvent record matches the same schema the Order Service publishes. Java Records give you an immutable data carrier with zero boilerplate.

The @KafkaListener annotation on handleOrderPlaced subscribes this method to the order-placed topic. The groupId is notification-service, which is different from the Inventory Service's group. This means both services receive every event independently.

In production, the System.out.println would be replaced with an actual email or SMS SDK call. For this project, logging the notification proves the event flow works.

Why separate consumer groups?

Kafka delivers each message to one consumer per group. Because Inventory Service uses groupId = "inventory-service" and Notification Service uses groupId = "notification-service", both receive every order event. This is the fan-out pattern.

Interview talking point: "Each downstream service has its own consumer group, so adding a new consumer never disrupts existing ones."

  • Now create the application configuration file at notification-service/src/main/resources/application.yml and paste in this content:
spring:
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: notification-service
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "*"
server:
  port: 8082

What does this configuration do?

The bootstrap-servers tells the consumer where to find Kafka. The JsonDeserializer converts the JSON event payload back into an OrderPlacedEvent object. The spring.json.trusted.packages: "*" setting allows deserialization of any package.

The server.port: 8082 setting is included even though there is no web server. Spring Boot reads it without error, and it avoids port conflicts if you later add an actuator endpoint.

  • Save all three files: OrderPlacedEvent.java, NotificationListener.java, and application.yml.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files before moving on to test the service.

ⓧ I'd like to double check the full code

Here is the complete code for each file in notification-service/:

<project>
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
  </parent>
  <groupId>com.nextwork</groupId>
  <artifactId>notification-service</artifactId>
  <version>0.0.1-SNAPSHOT</version>
  <dependencies>
    <dependency><groupId>org.springframework.kafka</groupId><artifactId>spring-kafka</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>
  </dependencies>
</project>
package com.nextwork.notificationservice.event;

public record OrderPlacedEvent(Long orderId, String productCode, Integer quantity) {}
package com.nextwork.notificationservice.listener;

import com.nextwork.notificationservice.event.OrderPlacedEvent;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class NotificationListener {

    @KafkaListener(topics = "order-placed", groupId = "notification-service")
    public void handleOrderPlaced(OrderPlacedEvent event) {
        System.out.println("[NOTIFICATION] Sending order confirmation email for Order ID: " + event.orderId()
            + ", Product: " + event.productCode()
            + ", Quantity: " + event.quantity());
    }
}
spring:
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: notification-service
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "*"
server:
  port: 8082
Test the full event-driven flow across all three services

With all three services ready, you can now verify the complete flow: placing an order triggers both inventory reduction and a notification log.

  • Make sure your Docker containers (Kafka, PostgreSQL, MongoDB) are still running. Verify by running:
docker compose ps
  • Start the Notification Service by opening a new terminal, navigating to notification-service/, and running:
mvn spring-boot:run

You should see Spring Boot start up without any web server log. The Kafka consumer will connect and wait for events.

  • Make sure Order Service and Inventory Service are also running in separate terminals (both started with mvn spring-boot:run in their respective directories).
  • Place a test order by running this curl command in another terminal:
curl -X POST http://localhost:8080/api/orders -H "Content-Type: application/json" -d '{"productCode":"LAPTOP-001","quantity":1}'
  • Switch to the Notification Service terminal.

You should see a log line like this:

[NOTIFICATION] Sending order confirmation email for Order ID: 1, Product: LAPTOP-001, Quantity: 1
  • Switch to the Inventory Service terminal to confirm it also received the event and reduced stock.

What just happened?

One HTTP request to the Order Service triggered a Kafka event that fanned out to two independent consumers. Inventory Service reduced stock. Notification Service logged a confirmation. Neither consumer knows about the other.

This is the Event Notification pattern in action. The event carries just enough data (order ID, product code, quantity) for downstream services to react. The producer does not wait for consumers to finish.

No notification log appearing?

Check that the Notification Service started without errors. Look for a line mentioning the consumer group notification-service connecting to Kafka.

If you see deserialization errors, make sure the spring.json.trusted.packages: "*" line is in your application.yml. Without it, Spring refuses to deserialize the event object.

help me debug why my Notification Service is not receiving Kafka events

Event-Driven Architecture patterns for interviews

There are three main event-driven patterns. Event Notification (what you just built) sends a lightweight event so consumers can react. Event-Carried State Transfer includes the full state in the event so consumers never need to call back. Event Sourcing stores every state change as an immutable event log.

Your system uses Event Notification. The Notification Service has zero coupling to Order Service. It only knows about the event schema. This is the most common pattern in production microservices.

All three microservices are now running and communicating through Kafka events. Next up, you will containerize each service with Docker so they can run anywhere.

Dockerize All Services

Your three microservices are built, tested, and communicating through Kafka events. They work on your local machine. But that is only your machine.

In production, you need a way to package each service so it runs identically on any server. Docker solves this by bundling your application, its runtime, and its configuration into a single portable image. In this step, you will containerize all three services and run the entire system through Docker Compose.

In this step, get ready to:
  • Write multi-stage Dockerfiles for each service.
  • Build Docker images and update your Docker Compose configuration.
  • Test the full 6-container system running together.
Write multi-stage Dockerfiles for each service

A multi-stage Dockerfile splits your build into two phases. Stage 1 compiles the code using a full JDK and Maven image. Stage 2 copies only the finished JAR into a lightweight JRE image. The final image contains zero build tools.

  • Create a file called Dockerfile inside your order-service folder.
  • Paste the following multi-stage Dockerfile into it:
# Stage 1: Build
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

What does this Dockerfile do?

Stage 1 uses the maven:3.9-eclipse-temurin-17 image which contains both Maven and JDK 17. It copies pom.xml first and downloads dependencies (mvn dependency:go-offline). This layer gets cached, so future builds only re-download if pom.xml changes.

Stage 2 starts fresh from eclipse-temurin:17-jre-alpine. This image is about 80 MB compared to the 500 MB+ build image. It copies only the compiled JAR and sets the entrypoint to run it.

Why multi-stage builds?

The final image has no Maven, no JDK, no source code. This means a smaller attack surface, faster deployments, and less storage. In an interview, this is your answer for image optimization.

  • Create a file called Dockerfile inside your inventory-service folder.
  • Paste the following into it:
# Stage 1: Build
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]
  • Create a file called Dockerfile inside your notification-service folder.
  • Paste the following into it:
# Stage 1: Build
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]

What changes between the three Dockerfiles?

Only the EXPOSE port differs. Order Service exposes 8080, Inventory Service exposes 8081, and Notification Service exposes 8082. The build process is identical because all three are Maven-based Spring Boot projects.

  • Save all three Dockerfiles.
  • Confirm you have order-service/Dockerfile, inventory-service/Dockerfile, and notification-service/Dockerfile in your project.

Dockerfile not being recognized?

Make sure the file is named exactly Dockerfile with a capital D and no file extension. Some editors add a hidden .txt extension.

Also confirm the Dockerfile sits in the root of each service folder (same level as pom.xml), not inside src/.

Why isn't Docker finding my Dockerfile?

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is each Dockerfile for reference:

# Stage 1: Build
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# Stage 1: Build
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]
# Stage 1: Build
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]
Build images and update Docker Compose

Now you will verify each Dockerfile builds successfully, then update your docker-compose.yml to orchestrate all six containers together.

  • First, stop your existing infrastructure containers by running:
docker compose down
  • Build the Order Service image by running this command from your project root:
docker build -t order-service ./order-service
  • Build the Inventory Service image by running:
docker build -t inventory-service ./inventory-service
  • Build the Notification Service image by running:
docker build -t notification-service ./notification-service

Each build will take a few minutes the first time as Maven downloads dependencies. Subsequent builds are much faster thanks to Docker layer caching.

Build failing with dependency errors?

Make sure Docker Desktop is running. The Maven build runs inside the container, so it needs network access to download dependencies.

If you see a COPY failed error, confirm your Dockerfile is in the same directory as pom.xml and the src folder.

Help me debug my Docker build failure for Spring Boot.

Now update your docker-compose.yml to include all three services alongside the infrastructure containers.

  • Replace the entire contents of your docker-compose.yml with the following:
version: '3.8'
services:
  kafka:
    image: apache/kafka:latest
    container_name: kafka
    ports:
      - '9092:9092'
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'

  postgres:
    image: postgres:15
    container_name: postgres
    ports:
      - '5432:5432'
    environment:
      POSTGRES_DB: orderdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data

  mongodb:
    image: mongo:6
    container_name: mongodb
    ports:
      - '27017:27017'
    volumes:
      - mongo_data:/data/db

  order-service:
    build: ./order-service
    container_name: order-service
    ports:
      - '8080:8080'
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/orderdb
      SPRING_DATASOURCE_USERNAME: postgres
      SPRING_DATASOURCE_PASSWORD: postgres
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
    depends_on:
      - postgres
      - kafka

  inventory-service:
    build: ./inventory-service
    container_name: inventory-service
    ports:
      - '8081:8081'
    environment:
      SPRING_DATA_MONGODB_URI: mongodb://mongodb:27017/inventorydb
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
    depends_on:
      - mongodb
      - kafka

  notification-service:
    build: ./notification-service
    container_name: notification-service
    ports:
      - '8082:8082'
    environment:
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
    depends_on:
      - kafka

volumes:
  postgres_data:
  mongo_data:

What changed in this Docker Compose file?

  • Each service uses build: ./service-name to build from its Dockerfile automatically.
  • Environment variables override application.yml settings. Spring Boot maps SPRING_DATASOURCE_URL to spring.datasource.url automatically.
  • Database URLs now use container names (postgres, mongodb, kafka) instead of localhost. Inside Docker's network, containers reach each other by name.
  • The depends_on field ensures infrastructure starts before application services.
  • The KAFKA_ADVERTISED_LISTENERS changed from localhost:9092 to kafka:9092 so services inside Docker can reach the broker by container name.
  • Save docker-compose.yml.
  • Start the full system by running:
docker compose up -d
  • Verify all 6 containers are running by running:
docker compose ps

You should see 6 containers listed with status Up: kafka, postgres, mongodb, order-service, inventory-service, and notification-service.

One of the services keeps restarting?

Check the logs for that specific service. For example:

docker compose logs order-service

Common causes: the database container is not ready yet when the service starts. Wait 30 seconds and check docker compose ps again. Services will retry their connections.

My containerized service keeps restarting. Help me troubleshoot.

✔️ Awesome, I've got everything!

Great. Make sure you have saved docker-compose.yml and all 6 containers show as running.

ⓧ I'd like to double check the full code

Here is the complete docker-compose.yml:

version: '3.8'
services:
  kafka:
    image: apache/kafka:latest
    container_name: kafka
    ports:
      - '9092:9092'
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true'

  postgres:
    image: postgres:15
    container_name: postgres
    ports:
      - '5432:5432'
    environment:
      POSTGRES_DB: orderdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - postgres_data:/var/lib/postgresql/data

  mongodb:
    image: mongo:6
    container_name: mongodb
    ports:
      - '27017:27017'
    volumes:
      - mongo_data:/data/db

  order-service:
    build: ./order-service
    container_name: order-service
    ports:
      - '8080:8080'
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/orderdb
      SPRING_DATASOURCE_USERNAME: postgres
      SPRING_DATASOURCE_PASSWORD: postgres
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
    depends_on:
      - postgres
      - kafka

  inventory-service:
    build: ./inventory-service
    container_name: inventory-service
    ports:
      - '8081:8081'
    environment:
      SPRING_DATA_MONGODB_URI: mongodb://mongodb:27017/inventorydb
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
    depends_on:
      - mongodb
      - kafka

  notification-service:
    build: ./notification-service
    container_name: notification-service
    ports:
      - '8082:8082'
    environment:
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
    depends_on:
      - kafka

volumes:
  postgres_data:
  mongo_data:
Interview talking points for containerization

Now that your system is running in containers, you have concrete experience to draw from in interviews. Here are the key points to articulate.

Container vs VM

Containers share the host OS kernel. Virtual machines run their own OS. Containers start in seconds, use less memory, and are much more portable. Trade-off: containers have weaker isolation since they share the kernel.

Docker layers and caching

Each Dockerfile instruction creates a layer. Docker caches layers and only rebuilds from the point where something changed. That is why you copy pom.xml and run mvn dependency:go-offline before copying source code. Dependencies rarely change, so that layer stays cached on most builds.

Why each service gets its own container

Each microservice is independently deployable. If Notification Service needs more resources, you scale just that container. If Inventory Service has a bug, you redeploy it without touching the others. This mirrors the microservices principle of independent lifecycle management.

12-Factor App methodology

Your setup follows several 12-Factor principles: configuration through environment variables (not hardcoded in code), stateless processes (databases are external), and port binding (each service declares its own port). In an interview, mention that your Docker Compose setup demonstrates externalized configuration.

Your entire microservices system is now containerized and portable. Next up, you will deploy it to Kubernetes for production-grade orchestration with self-healing, scaling, and service discovery.

Deploy to Kubernetes

Your microservices are running smoothly in Docker Compose. That is perfect for development, but production needs more.

Kubernetes adds the capabilities that production systems demand: self-healing (crashed pods restart automatically), horizontal scaling (spin up more instances with one command), rolling updates (deploy without downtime), and built-in service discovery via DNS. In this step, you'll write Kubernetes manifests and deploy your three services to your local cluster.

In this step, get ready to:
  • Create Kubernetes Deployment and Service manifests for all three microservices.
  • Apply manifests to your local cluster and verify pods are running.
  • Demonstrate scaling, self-healing, and log inspection.
Create Kubernetes manifests for all services

Kubernetes uses YAML manifests to describe the desired state of your system. You'll create a ConfigMap for shared environment variables, plus a Deployment and Service for each microservice.

  • Create a k8s/ directory inside your project folder by running this command:
mkdir k8s
  • Confirm the directory exists by running:
ls

You should see k8s listed alongside your service folders.

  • Create the ConfigMap file by running:
touch k8s/configmap.yml
  • Open k8s/configmap.yml in your editor and paste the following:
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-service:5432/orderdb
  SPRING_DATASOURCE_USERNAME: postgres
  SPRING_DATASOURCE_PASSWORD: postgres
  SPRING_DATA_MONGODB_URI: mongodb://mongodb-service:27017/inventorydb
  SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka-service:9092

What does this ConfigMap do?

A ConfigMap externalizes environment-specific configuration from your container images. Instead of hardcoding database URLs inside each service, you define them once here and inject them into every pod.

Notice the hostnames use Kubernetes service names (postgres-service, mongodb-service, kafka-service). Kubernetes DNS automatically resolves these to the correct pod IPs within the cluster.

  • Save k8s/configmap.yml.
  • Create the Order Service deployment file by running:
touch k8s/order-service-deployment.yml
  • Open k8s/order-service-deployment.yml and paste this manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
      - name: order-service
        image: order-service:latest
        imagePullPolicy: Never
        ports:
        - containerPort: 8080
        envFrom:
        - configMapRef:
            name: app-config
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
  - port: 8080
    targetPort: 8080
  type: NodePort

What does this manifest do?

This file defines two Kubernetes resources separated by ---. The Deployment tells Kubernetes to run 1 replica of your order-service:latest image and inject the ConfigMap as environment variables via envFrom.

The imagePullPolicy: Never tells Kubernetes to use local Docker images instead of pulling from a remote registry. The Service exposes the deployment internally and externally via NodePort.

  • Save k8s/order-service-deployment.yml.
  • Create the Inventory Service deployment file by running:
touch k8s/inventory-service-deployment.yml
  • Open k8s/inventory-service-deployment.yml and paste this manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inventory-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: inventory-service
  template:
    metadata:
      labels:
        app: inventory-service
    spec:
      containers:
      - name: inventory-service
        image: inventory-service:latest
        imagePullPolicy: Never
        ports:
        - containerPort: 8081
        envFrom:
        - configMapRef:
            name: app-config
---
apiVersion: v1
kind: Service
metadata:
  name: inventory-service
spec:
  selector:
    app: inventory-service
  ports:
  - port: 8081
    targetPort: 8081
  type: NodePort

What's different here?

The structure is identical to the Order Service manifest. The only differences are the service name (inventory-service), the image name, and the port (8081). This consistency is deliberate. Each microservice follows the same deployment pattern, making the system predictable and easy to manage.

  • Save k8s/inventory-service-deployment.yml.
  • Create the Notification Service deployment file by running:
touch k8s/notification-service-deployment.yml
  • Open k8s/notification-service-deployment.yml and paste this manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: notification-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: notification-service
  template:
    metadata:
      labels:
        app: notification-service
    spec:
      containers:
      - name: notification-service
        image: notification-service:latest
        imagePullPolicy: Never
        ports:
        - containerPort: 8082
        envFrom:
        - configMapRef:
            name: app-config
---
apiVersion: v1
kind: Service
metadata:
  name: notification-service
spec:
  selector:
    app: notification-service
  ports:
  - port: 8082
    targetPort: 8082
  type: NodePort
  • Save k8s/notification-service-deployment.yml.
  • Confirm all your manifest files exist by running:
ls k8s/

You should see configmap.yml, order-service-deployment.yml, inventory-service-deployment.yml, and notification-service-deployment.yml in the output.

Why Kubernetes over Docker Compose for production?

Docker Compose is excellent for local development, but it lacks self-healing (crashed containers stay dead), auto-scaling (no built-in way to add instances), and service discovery via DNS. Kubernetes solves all three.

Interview talking point: "Docker Compose doesn't restart failed containers automatically, can't scale horizontally, and has no health-based routing. Kubernetes adds self-healing, auto-scaling, rolling updates, and built-in DNS for service-to-service communication."

✔️ Awesome, I've got everything!

Great. Double check you've saved all four files in the k8s/ directory.

ⓧ I'd like to double check the full code

Here are all four manifest files for comparison:

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-service:5432/orderdb
  SPRING_DATASOURCE_USERNAME: postgres
  SPRING_DATASOURCE_PASSWORD: postgres
  SPRING_DATA_MONGODB_URI: mongodb://mongodb-service:27017/inventorydb
  SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka-service:9092
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
      - name: order-service
        image: order-service:latest
        imagePullPolicy: Never
        ports:
        - containerPort: 8080
        envFrom:
        - configMapRef:
            name: app-config
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
  - port: 8080
    targetPort: 8080
  type: NodePort
apiVersion: apps/v1
kind: Deployment
metadata:
  name: inventory-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: inventory-service
  template:
    metadata:
      labels:
        app: inventory-service
    spec:
      containers:
      - name: inventory-service
        image: inventory-service:latest
        imagePullPolicy: Never
        ports:
        - containerPort: 8081
        envFrom:
        - configMapRef:
            name: app-config
---
apiVersion: v1
kind: Service
metadata:
  name: inventory-service
spec:
  selector:
    app: inventory-service
  ports:
  - port: 8081
    targetPort: 8081
  type: NodePort
apiVersion: apps/v1
kind: Deployment
metadata:
  name: notification-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: notification-service
  template:
    metadata:
      labels:
        app: notification-service
    spec:
      containers:
      - name: notification-service
        image: notification-service:latest
        imagePullPolicy: Never
        ports:
        - containerPort: 8082
        envFrom:
        - configMapRef:
            name: app-config
---
apiVersion: v1
kind: Service
metadata:
  name: notification-service
spec:
  selector:
    app: notification-service
  ports:
  - port: 8082
    targetPort: 8082
  type: NodePort
Apply manifests and verify pods are running

With all manifests written, you can deploy everything to your local Kubernetes cluster in one command.

  • Apply all manifests in the k8s/ directory by running:
kubectl apply -f k8s/

What does this command do?

The kubectl apply -f k8s/ command reads every YAML file in the k8s/ directory and creates (or updates) all the resources defined in them. Kubernetes processes the ConfigMap first, then creates the Deployments and Services.

You should see output confirming each resource was created (configmap, deployments, and services).

  • Verify all pods are running by running:
kubectl get pods

You should see three pods listed, one for each service. The STATUS column shows Running or ContainerCreating (if still starting up). Wait a few seconds and run the command again until all show Running.

Pods showing CrashLoopBackOff?

If pods are crash-looping, it likely means they cannot connect to their backing databases or Kafka. This is expected if your Docker Compose infrastructure containers are not running. The pods demonstrate Kubernetes deployment mechanics regardless of backend connectivity.

To check what went wrong, run kubectl logs <pod-name> to see the application startup error.

Help me debug my Kubernetes pods that are crash-looping after deployment.

  • Verify the services are exposed by running:
kubectl get services

You should see order-service, inventory-service, and notification-service listed with their NodePort type and assigned ports.

How does Kubernetes DNS enable service-to-service communication?

Inside the cluster, each Service gets a DNS entry following the pattern service-name.namespace.svc.cluster.local. Your Order Service can call inventory-service:8081 and Kubernetes DNS resolves it to the correct pod IP automatically.

Interview talking point: "Kubernetes provides built-in service discovery via DNS. Services don't need to know pod IPs. They reference other services by name, and the cluster DNS handles resolution. If a pod dies and gets replaced, the DNS record updates transparently."

Demonstrate scaling, self-healing, and log inspection

The real power of Kubernetes shows up when you need to handle more traffic, recover from failures, or debug issues in production. These three commands demonstrate the features interviewers ask about most.

  • Scale Order Service to 3 replicas by running:
kubectl scale deployment order-service --replicas=3
  • Verify the scaling worked by running:
kubectl get pods

You should now see 3 pods with names starting with order-service- instead of just 1. Kubernetes created 2 additional replicas within seconds.

Why is horizontal scaling important?

With Docker Compose, scaling means manually editing the compose file and restarting. With Kubernetes, one command adds capacity instantly. In production, you'd use a Horizontal Pod Autoscaler to do this automatically based on CPU or memory metrics.

Interview talking point: "Kubernetes lets us scale individual services independently. Order Service handles peak checkout traffic with 3 replicas, while Notification Service stays at 1 because it processes events asynchronously."

  • Test self-healing by deleting one of the order-service pods. First, grab a pod name from the output above, then run:
kubectl delete pod [[POD_NAME="one of your order-service pod names, e.g. order-service-abc123-xyz"]]
  • Immediately check the pods again by running:
kubectl get pods

You should see a new pod being created to replace the deleted one. Kubernetes detected that the desired state (3 replicas) didn't match the actual state (2 running), so it automatically created a replacement.

What is self-healing?

Kubernetes continuously monitors the actual state of your cluster against the desired state defined in your manifests. When a pod dies (hardware failure, OOM kill, application crash), the Deployment controller notices the mismatch and schedules a replacement immediately.

Interview talking point: "Kubernetes is a declarative system. You tell it the desired state (3 replicas), and it continuously reconciles. If a node goes down or a pod crashes, Kubernetes self-heals without human intervention."

  • View logs from one of your pods by running:
kubectl logs deployment/order-service

What does kubectl logs show?

The kubectl logs command streams the stdout/stderr output from a pod's container. Using deployment/order-service targets one of the pods in the deployment. You can also target a specific pod by name, or add --follow to stream logs in real time.

Not seeing any logs?

If the pod is crash-looping, try kubectl logs deployment/order-service --previous to see logs from the last terminated container. This shows what happened before the crash.

Help me understand kubectl logs and troubleshoot my Kubernetes pod output.

Your microservices are now deployed to Kubernetes with full scaling and self-healing capabilities. You've demonstrated the exact concepts Technical Architects discuss in interviews: declarative deployments, horizontal scaling, self-healing reconciliation, and service discovery.

Secret mission

Implement Circuit Breaker Pattern

In distributed systems, one failing service can take down everything else. Your Order Service currently calls Inventory Service synchronously. If Inventory Service goes down, every order request hangs and eventually times out. The Circuit Breaker pattern detects failures and short-circuits requests, returning a fallback response instantly instead of waiting for a timeout. Implement it with Resilience4j to make your system fault-tolerant.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally via Docker Desktop. There are no cloud costs or ongoing charges. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker containers (Kafka, PostgreSQL, MongoDB, order-service, inventory-service, notification-service).
  • Docker volumes (postgres_data and mongo_data).
  • Docker images built for each microservice.
  • Kubernetes deployments and services in the k8s/ directory.
  • Local project files (docker-compose.yml, service directories, Kubernetes manifests).
  • Docker Desktop application.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep practicing with your microservices system.

  • Leave Docker Desktop running with all containers active.
  • Your Kubernetes cluster will remain available for experimentation.
  • Docker Desktop uses system memory while running. Close the application from the system tray if you want to free memory temporarily.

Pause - I'll come back to this later

Shut down running containers to free up memory and CPU, but keep your files and data volumes intact for later.

  • Stop all Docker Compose containers by running this command from the folder containing your docker-compose.yml:
docker compose down

This stops and removes containers but preserves your data volumes. Your PostgreSQL and MongoDB data will still be there when you come back.

  • Remove Kubernetes deployments to free cluster resources:
kubectl delete -f k8s/
  • You can restart everything later with docker compose up -d and kubectl apply -f k8s/.

Delete - I don't want to use this again

Remove all project resources completely and start fresh. This deletes all data including database contents.

  • Stop and remove all containers, networks, and data volumes by running this from the folder containing your docker-compose.yml:
docker compose down -v
  • Remove all Kubernetes resources:
kubectl delete -f k8s/
  • Remove the Docker images built for your microservices:
docker rmi order-service:latest inventory-service:latest notification-service:latest
  • Delete the project folder from your Desktop. On macOS or Linux:
cd ~/Desktop
ls

Find the folder name that contains your microservices project, then remove it:

rm -rf ~/Desktop/[[PROJECT_FOLDER="your project folder name"]]

On Windows, open File Explorer, navigate to your Desktop, right-click the project folder, and select Delete.

  • To uninstall Docker Desktop (optional, only if you no longer need it for any projects):

On Windows, open Settings, select Apps, then Apps & features. Find Docker Desktop in the list and click Uninstall.

On macOS, open Applications in Finder, drag Docker to the Trash, then empty the Trash.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade microservices system that demonstrates the exact architectural patterns Technical Architects are expected to know.

You've learned how to:

  • Build 3 independent Spring Boot microservices with the Database-per-Service pattern, communicating through REST and Kafka events.
  • Containerize services with Docker multi-stage builds and deploy them to a local Kubernetes cluster with scaling, self-healing, and service discovery.
  • Explain every architectural decision with interview-ready talking points covering SQL vs NoSQL trade-offs, sync vs async communication, and container orchestration.
  • Implement the Circuit Breaker pattern with Resilience4j to prevent cascading failures across services.

Ready to quiz yourself?