Microservices Foundation with Spring Boot

Scaffold a multi-module microservices monorepo with Docker infrastructure.

Introduction

30 Second Summary

When you order something online, dozens of services work together behind the scenes to check inventory, process payment, and confirm your order. Each service needs its own database, messaging system, and routing layer to stay independent yet coordinated.

In this project, you will build the foundation layer of a production-ready microservices ecosystem. You'll scaffold a multi-module Java monorepo, bring up infrastructure with Docker Compose, and prove end-to-end request flow from an API gateway through to a database-backed service.

What You'll Build

A fully scaffolded microservices monorepo with production-grade infrastructure running locally via Docker Compose, where HTTP requests flow through a Spring Cloud Gateway to an Order Service persisting data in MySQL.

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

  • A multi-module Gradle project with a shared library, three business services, and an API gateway.
  • A Docker Compose stack running six infrastructure services (MySQL, Kafka, Redis, MongoDB, Keycloak, Vault) across isolated networks.
  • An Order Service with REST endpoints, JPA persistence, and Flyway-managed schema migrations.
  • Secret Mission: Add a Kafka health check endpoint and implement a Docker Compose "lite" profile for low-memory machines.

Are there any prerequisites?

This project assumes familiarity with Java, Spring Boot basics, Docker fundamentals, and REST APIs. You don't need prior experience with microservices architecture or Spring Cloud.

Initialize the Multi-Module Gradle Project

Your tools are verified and ready. Now it's time to scaffold the actual project structure that all your microservices will live inside.

A Gradle multi-module project lets you manage multiple services in a single repository while keeping each service's dependencies isolated. You'll create one root project that declares shared configuration, then individual modules for each microservice and a shared library.

In this step, get ready to:
  • Create the root project directory and initialize the Gradle wrapper.
  • Configure multi-module build files with Kotlin DSL.
  • Create all 5 module directories with their own build files and source layouts.
Create the root project and initialize Gradle

Every Gradle project starts with a wrapper. The wrapper locks the Gradle version so anyone cloning your repo uses the exact same build tool version without installing Gradle globally.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir microservices-ecosystem
cd microservices-ecosystem
  • Initialize the Gradle wrapper by running:
gradle init --type basic --dsl kotlin --project-name microservices-ecosystem

What does this command do?

The --type basic flag tells Gradle to create a minimal project with no source code. The --dsl kotlin flag generates build scripts using Kotlin DSL instead of Groovy.

The key output is the Gradle wrapper: gradlew, gradlew.bat, and the gradle/wrapper/ directory. These let anyone build your project without installing Gradle themselves.

  • Confirm the wrapper files exist by running:
ls

You should see gradlew, gradlew.bat, a gradle/ directory, and the generated settings.gradle.kts and build.gradle.kts files.

Don't see gradlew?

Make sure you ran the command from inside the microservices-ecosystem directory. Run pwd to check your current location.

If you see a "gradle: command not found" error, confirm Gradle is on your PATH by running gradle --version. Help me fix my Gradle init.

  • Open your project in your IDE. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ (or Cursor) and press Enter to open it.
  • Open the microservices-ecosystem folder from your Desktop.
Configure the multi-module build files

The generated settings.gradle.kts only knows about a single project. You need to replace it with a version that declares all five subprojects your ecosystem will contain.

  • Open settings.gradle.kts in your IDE and replace its entire contents with this:
rootProject.name = "microservices-ecosystem"

include("common-lib")
include("order-service")
include("inventory-service")
include("payment-service")
include("api-gateway")

What does this file do?

The settings.gradle.kts file is Gradle's project registry. Each include() statement tells Gradle that a subdirectory is a buildable module. Without this, Gradle would treat the root as a single project.

Next you'll configure the root build file. This file declares plugins and shared configuration that applies to all subprojects, so you don't repeat yourself in each module.

  • Open build.gradle.kts (the root one, not inside any module) and replace its entire contents with this:
plugins {
    java
    id("org.springframework.boot") version "3.5.14" apply false
    id("io.spring.dependency-management") version "1.1.7" apply false
}

allprojects {
    group = "com.ecosystem"
    version = "0.0.1-SNAPSHOT"

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply(plugin = "java")

    java {
        toolchain {
            languageVersion = JavaLanguageVersion.of(21)
        }
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }
}

What does this build file do?

  • apply false declares the Spring Boot and dependency-management plugins at the root level without activating them. Each module opts in individually.
  • The allprojects block sets the Maven group, version, and repository for every module in one place.
  • The subprojects block applies the Java plugin, locks the Java 21 toolchain, and configures JUnit 5 for all modules.

Why Kotlin DSL over Groovy?

Kotlin DSL build scripts are type-safe. Your IDE can autocomplete plugin names, dependency declarations, and configuration blocks. Groovy DSL relies on dynamic typing, so typos only surface at build time.

  • Save both files.

IDE showing red underlines?

Your IDE may highlight errors because the subproject directories don't exist yet. That's expected. You'll create them in the next substep and the errors will resolve.

Create module directories and build files

Each module needs its own directory with a standard Java source layout. Gradle expects src/main/java for source code and src/main/resources for configuration files.

  • Create all module directories and their source layouts by running these commands from your project root:
mkdir -p common-lib/src/main/java
mkdir -p common-lib/src/main/resources
mkdir -p order-service/src/main/java
mkdir -p order-service/src/main/resources
mkdir -p inventory-service/src/main/java
mkdir -p inventory-service/src/main/resources
mkdir -p payment-service/src/main/java
mkdir -p payment-service/src/main/resources
mkdir -p api-gateway/src/main/java
mkdir -p api-gateway/src/main/resources

What does mkdir -p do?

The -p flag creates parent directories as needed. Without it, mkdir common-lib/src/main/java would fail because common-lib/src/main doesn't exist yet.

  • Confirm the module directories exist by running:
ls

You should see api-gateway, common-lib, inventory-service, order-service, and payment-service listed alongside the Gradle wrapper files.

Now each module needs its own build.gradle.kts to declare its specific dependencies. The common library is different from the services, so you'll create it first.

  • Create a new file called build.gradle.kts inside the common-lib directory and add this content:
plugins {
    `java-library`
}

dependencies {
    implementation("com.fasterxml.jackson.core:jackson-annotations:2.18.3")
}

What does this file do?

The java-library plugin marks this module as a reusable library (not a runnable app). It exposes an api configuration for transitive dependencies. Jackson annotations are included because shared DTOs and events will use JSON serialization.

The Order, Inventory, and Payment services all share the same dependency profile. They need Spring Boot web, JPA, Flyway, and the MySQL driver.

  • Create a new file called build.gradle.kts inside the order-service directory and add this content:
plugins {
    id("org.springframework.boot")
    id("io.spring.dependency-management")
}

dependencies {
    implementation(project(":common-lib"))
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.flywaydb:flyway-core")
    implementation("org.flywaydb:flyway-mysql")
    runtimeOnly("com.mysql:mysql-connector-j")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

What does this file do?

  • The Spring Boot and dependency-management plugins are applied here (they were only declared at the root). This activates Spring Boot's auto-configuration and BOM-managed versions.
  • implementation(project(":common-lib")) creates a dependency on your shared library module.
  • runtimeOnly("com.mysql:mysql-connector-j") includes the MySQL driver only at runtime. Your code never imports it directly.
  • Create a new file called build.gradle.kts inside the inventory-service directory with the exact same content as the Order Service build file above.
  • Create a new file called build.gradle.kts inside the payment-service directory with the exact same content as the Order Service build file above.

All three services share the same build file

The Order, Inventory, and Payment services all use the same dependencies right now. As the project grows, each service will diverge (e.g., Payment might add Stripe SDK). Starting identical keeps things simple.

The API Gateway has a different dependency profile. It uses Spring Cloud Gateway instead of the standard web starter, and it pulls in the Spring Cloud BOM for version management.

  • Create a new file called build.gradle.kts inside the api-gateway directory and add this content:
plugins {
    id("org.springframework.boot")
    id("io.spring.dependency-management")
}

ext {
    set("springCloudVersion", "2025.0.2")
}

dependencies {
    implementation("org.springframework.cloud:spring-cloud-starter-gateway")
    implementation("org.springframework.boot:spring-boot-starter-actuator")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
    }
}

What does this file do?

  • The ext block stores the Spring Cloud version (2025.0.2 Northfields release train) as a project property.
  • The dependencyManagement block imports the Spring Cloud BOM. This ensures all Spring Cloud libraries use compatible versions without you specifying each one.
  • spring-cloud-starter-gateway brings in the reactive gateway built on Netty. It does not use the traditional servlet stack.
  • Save all your build files.
  • Verify Gradle recognizes all modules by running this from your project root:
./gradlew projects

You should see output listing all five subprojects under the root project:

Root project 'microservices-ecosystem'
+--- Project ':api-gateway'
+--- Project ':common-lib'
+--- Project ':inventory-service'
+--- Project ':order-service'
\--- Project ':payment-service'

Seeing "Could not find method" errors?

Double check that your root build.gradle.kts uses apply false on the Spring Boot and dependency-management plugins. Without apply false, Gradle tries to apply them to the root project and fails.

Also verify that settings.gradle.kts module names match the directory names exactly. Help me debug my Gradle multi-module setup.

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved before moving on to the next step.

ⓧ I'd like to double check the full code

Here are all the build files you should have at this point. Compare yours against these:

rootProject.name = "microservices-ecosystem"

include("common-lib")
include("order-service")
include("inventory-service")
include("payment-service")
include("api-gateway")
plugins {
    java
    id("org.springframework.boot") version "3.5.14" apply false
    id("io.spring.dependency-management") version "1.1.7" apply false
}

allprojects {
    group = "com.ecosystem"
    version = "0.0.1-SNAPSHOT"

    repositories {
        mavenCentral()
    }
}

subprojects {
    apply(plugin = "java")

    java {
        toolchain {
            languageVersion = JavaLanguageVersion.of(21)
        }
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }
}
plugins {
    `java-library`
}

dependencies {
    implementation("com.fasterxml.jackson.core:jackson-annotations:2.18.3")
}
plugins {
    id("org.springframework.boot")
    id("io.spring.dependency-management")
}

dependencies {
    implementation(project(":common-lib"))
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.flywaydb:flyway-core")
    implementation("org.flywaydb:flyway-mysql")
    runtimeOnly("com.mysql:mysql-connector-j")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
plugins {
    id("org.springframework.boot")
    id("io.spring.dependency-management")
}

dependencies {
    implementation(project(":common-lib"))
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.flywaydb:flyway-core")
    implementation("org.flywaydb:flyway-mysql")
    runtimeOnly("com.mysql:mysql-connector-j")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
plugins {
    id("org.springframework.boot")
    id("io.spring.dependency-management")
}

dependencies {
    implementation(project(":common-lib"))
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-actuator")
    implementation("org.flywaydb:flyway-core")
    implementation("org.flywaydb:flyway-mysql")
    runtimeOnly("com.mysql:mysql-connector-j")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}
plugins {
    id("org.springframework.boot")
    id("io.spring.dependency-management")
}

ext {
    set("springCloudVersion", "2025.0.2")
}

dependencies {
    implementation("org.springframework.cloud:spring-cloud-starter-gateway")
    implementation("org.springframework.boot:spring-boot-starter-actuator")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
    }
}

Your multi-module project structure is locked in. Next up, you'll bring the infrastructure to life by spinning up MySQL, Kafka, Redis, MongoDB, Keycloak, and Vault in Docker containers.

Launch the Docker Compose Infrastructure Stack

Your monorepo skeleton is in place with all five modules ready to go. Now you need the infrastructure those services will talk to.

Microservices depend on shared infrastructure: databases, message brokers, caches, and identity providers. In this step, you will define all of these as Docker containers orchestrated by a single Docker Compose file.

In this step, get ready to:
  • Create a Docker Compose file defining 7 infrastructure services across isolated networks.
  • Bring up the entire stack and verify every container is healthy.
  • Access the Keycloak, Vault, and Kafka UI web consoles to confirm connectivity.
Create the Docker directory and database initialization script

Each microservice will own its own database schema. MySQL can host multiple databases in a single instance, so you need a script that creates the additional databases on first startup.

  • Inside your microservices-ecosystem folder, create a docker directory by running this command:
mkdir docker
  • Create the database initialization script by creating a file called init-db.sql inside the docker/ directory with this content:
CREATE DATABASE IF NOT EXISTS inventorydb;
CREATE DATABASE IF NOT EXISTS paymentdb;

What does this script do?

Docker's MySQL image automatically runs any SQL files placed in /docker-entrypoint-initdb.d/ when the container starts for the first time. This script creates the inventorydb and paymentdb databases alongside the default orderdb.

  • Save init-db.sql.
  • Confirm the file exists by running:
ls docker/

You should see init-db.sql in the output.

Define the core data and messaging services

The backbone of this ecosystem is a MySQL database for persistence and an Apache Kafka broker for event-driven messaging. Kafka runs in KRaft mode, which eliminates the need for a separate ZooKeeper process.

  • Create a file called docker-compose.yml inside the docker/ directory and add the following content:
services:
  mysql:
    image: mysql:8.4
    container_name: ecosystem-mysql
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: orderdb
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - data
      - backend

  kafka:
    image: apache/kafka:4.1.1
    container_name: ecosystem-kafka
    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_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
    ports:
      - "9092:9092"
    volumes:
      - kafka_data:/tmp/kraft-combined-logs
    healthcheck:
      test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server=localhost:9092"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend

What does this code do?

The MySQL service creates a default orderdb database and mounts your init-db.sql script so Docker creates the additional databases on first boot. The mysql_data volume persists data across container restarts.

The Kafka service uses the official apache/kafka:4.1.1 image with KRaft mode. The KAFKA_PROCESS_ROLES: broker,controller setting makes this single node act as both broker and controller, removing the need for ZooKeeper entirely.

Why the official Apache Kafka image?

Bitnami deprecated their public Kafka images in August 2025. The official apache/kafka image supports KRaft natively and is maintained by the Kafka project itself.

  • Add the Redis and MongoDB service definitions below the kafka service block:
  redis:
    image: redis:7.4-alpine
    container_name: ecosystem-redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - backend

  mongodb:
    image: mongo:7.0
    container_name: ecosystem-mongodb
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: rootpassword
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend
      - data

What does this code do?

Redis uses the lightweight Alpine image for caching. It responds to a simple ping command for health checks.

MongoDB is on both the backend and data networks. This lets services reach it through the backend network while keeping it grouped with the other data stores on the data network.

  • Add the Keycloak and Vault service definitions, then the volumes and networks sections at the bottom of the file:
  keycloak:
    image: keycloak/keycloak:26.6
    container_name: ecosystem-keycloak
    command: start-dev
    environment:
      KC_DB: dev-mem
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
      KC_HTTP_PORT: 8180
    ports:
      - "8180:8180"
    healthcheck:
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8180 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '200'"]
      interval: 15s
      timeout: 5s
      retries: 10
      start_period: 30s
    networks:
      - frontend
      - backend

  vault:
    image: hashicorp/vault:latest
    container_name: ecosystem-vault
    environment:
      VAULT_DEV_ROOT_TOKEN_ID: myroot
      VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200
    ports:
      - "8200:8200"
    cap_add:
      - IPC_LOCK
    healthcheck:
      test: ["CMD", "vault", "status"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - backend

volumes:
  mysql_data:
  kafka_data:
  redis_data:
  mongo_data:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
  data:
    driver: bridge

What does this code do?

Keycloak runs in dev mode with an in-memory database (KC_DB: dev-mem). It listens on port 8180 to avoid conflicting with the API Gateway on 8080. The start-dev command disables production features like HTTPS requirements.

HashiCorp Vault runs in dev mode with a root token of myroot. The IPC_LOCK capability prevents sensitive secrets from being swapped to disk.

The three networks isolate traffic. The frontend network exposes only the gateway and Keycloak. The backend network connects services to middleware. The data network restricts direct database access to only the services that need it.

  • Save docker-compose.yml.

YAML indentation issues?

YAML is whitespace-sensitive. Each service must be indented with exactly 2 spaces under services:. Nested keys (like environment:) get 4 spaces total. If your editor uses tabs, switch to spaces.

help me fix YAML indentation errors in my docker-compose.yml

Start the infrastructure stack and verify health

With the Compose file saved, you can bring up all 6 services with a single command. Docker will pull the images on first run, which may take a few minutes depending on your internet speed.

  • Navigate into the docker/ directory by running:
cd docker
  • Start all containers in detached mode by running:
docker compose up -d

What does this command do?

The -d flag runs containers in the background (detached mode). Docker will pull any images it does not already have locally before starting each container.

The first pull can take 3-5 minutes. You will see progress bars for each image layer being downloaded.

  • Once the command completes, verify all containers are healthy by running:
docker compose ps

You should see all 6 containers with a status of healthy or running. Keycloak takes the longest to become healthy (up to 60 seconds due to its start_period setting).

✔️ All containers show healthy/running

Your infrastructure stack is live. Now verify you can access the web consoles.

  • Open your browser and navigate to http://localhost:8180 to access the Keycloak admin console.
  • Log in with username admin and password admin.
  • Open a new tab and navigate to http://localhost:8200 to access the Vault UI.
  • Enter the token myroot to sign in.

ⓧ Some containers show unhealthy or exited

Check the logs for the failing container to identify the issue.

  • View the logs for a specific container by running:
docker compose logs mysql
  • Replace mysql with the name of whichever service is failing (kafka, redis, mongodb, keycloak, or vault).
  • If you see a port conflict error, check that nothing else is using ports 3306, 9092, 6379, 27017, 8180, or 8200.
  • If Docker reports insufficient memory, close other applications and ensure Docker Desktop has at least 4GB of RAM allocated in its settings.

help me debug Docker Compose containers that won't start

Add the Kafka UI for visual topic inspection

Now that Kafka is healthy, you can attach a web-based UI for inspecting topics, consumers, and messages. This makes debugging event-driven flows much easier in later parts of the project.

  • In docker-compose.yml, find the vault service block.
  • Add the following kafka-ui service definition between the vault service and the volumes: section:
  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    container_name: ecosystem-kafka-ui
    environment:
      KAFKA_CLUSTERS_0_NAME: ecosystem
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
    ports:
      - "8085:8080"
    depends_on:
      kafka:
        condition: service_healthy
    networks:
      - backend

What does this code do?

The Kafka UI container connects to the Kafka broker using the internal Docker network hostname kafka:9092. It maps its internal port 8080 to your host's port 8085, avoiding a conflict with the API Gateway you will build later.

The depends_on condition ensures Kafka UI only starts after the Kafka broker passes its health check.

  • Save docker-compose.yml.
  • Apply the updated Compose file by running:
docker compose up -d

Docker Compose detects that only the Kafka UI service is new and starts it without restarting the existing containers.

  • Open your browser and navigate to http://localhost:8085.

You should see the Kafka UI dashboard with your ecosystem cluster listed and connected.

Kafka UI not loading at localhost:8085?

Run docker compose ps and confirm the ecosystem-kafka-ui container shows as running. If it restarted, the Kafka broker may not have been healthy yet.

Check that port 8085 is not already in use by another application on your machine. You can verify with docker compose logs kafka-ui.

help me troubleshoot why Kafka UI is not connecting to my Kafka broker

✔️ Awesome, I've got everything!

Make sure you have saved your docker-compose.yml and init-db.sql files.

ⓧ I'd like to double check the full code

Here is the complete docker/docker-compose.yml file:

services:
  mysql:
    image: mysql:8.4
    container_name: ecosystem-mysql
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: orderdb
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - data
      - backend

  kafka:
    image: apache/kafka:4.1.1
    container_name: ecosystem-kafka
    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_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
    ports:
      - "9092:9092"
    volumes:
      - kafka_data:/tmp/kraft-combined-logs
    healthcheck:
      test: ["CMD", "kafka-broker-api-versions", "--bootstrap-server=localhost:9092"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend

  redis:
    image: redis:7.4-alpine
    container_name: ecosystem-redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - backend

  mongodb:
    image: mongo:7.0
    container_name: ecosystem-mongodb
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: rootpassword
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - backend
      - data

  keycloak:
    image: keycloak/keycloak:26.6
    container_name: ecosystem-keycloak
    command: start-dev
    environment:
      KC_DB: dev-mem
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
      KC_HTTP_PORT: 8180
    ports:
      - "8180:8180"
    healthcheck:
      test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8180 && echo -e 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 && cat <&3 | grep -q '200'"]
      interval: 15s
      timeout: 5s
      retries: 10
      start_period: 30s
    networks:
      - frontend
      - backend

  vault:
    image: hashicorp/vault:latest
    container_name: ecosystem-vault
    environment:
      VAULT_DEV_ROOT_TOKEN_ID: myroot
      VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200
    ports:
      - "8200:8200"
    cap_add:
      - IPC_LOCK
    healthcheck:
      test: ["CMD", "vault", "status"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - backend

  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    container_name: ecosystem-kafka-ui
    environment:
      KAFKA_CLUSTERS_0_NAME: ecosystem
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
    ports:
      - "8085:8080"
    depends_on:
      kafka:
        condition: service_healthy
    networks:
      - backend

volumes:
  mysql_data:
  kafka_data:
  redis_data:
  mongo_data:

networks:
  frontend:
    driver: bridge
  backend:
    driver: bridge
  data:
    driver: bridge

And here is the complete docker/init-db.sql file:

CREATE DATABASE IF NOT EXISTS inventorydb;
CREATE DATABASE IF NOT EXISTS paymentdb;

Your entire infrastructure backbone is running locally. Next up, you will build the common library and Order Service that connect to this MySQL database.

Build the Common Library and Order Service

Your infrastructure is running, but containers without application code are just idle processes burning memory. The first real code you need is a shared contract layer that every service agrees on.

In this step, you will build the common-lib module with reusable DTOs and event records, then implement the full Order Service with a REST API, JPA entity, and Flyway migration. By the end, you will POST an order and GET it back from MySQL.

In this step, get ready to:
  • Create the common-lib module with shared event and response records.
  • Implement the Order Service with JPA entity, repository, controller, and Flyway migration.
  • Start the Order Service and verify end-to-end persistence via curl.
Implement the common library

In a microservices architecture, services communicate through events and share response formats. The common-lib module holds these shared contracts so every service speaks the same language without duplicating code.

  • Create the directory path for the event package by running:
mkdir -p common-lib/src/main/java/com/ecosystem/common/event
  • Create the directory path for the DTO package by running:
mkdir -p common-lib/src/main/java/com/ecosystem/common/dto
  • Create OrderEvent.java inside the event package with the following code:
package com.ecosystem.common.event;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.UUID;

// Immutable event record that all services use to communicate order state changes
public record OrderEvent(
    @JsonProperty("eventId") UUID eventId,
    @JsonProperty("orderId") UUID orderId,
    @JsonProperty("eventType") String eventType,
    @JsonProperty("timestamp") Instant timestamp,
    @JsonProperty("payload") String payload
) {
    // Factory method to create an event with auto-generated ID and timestamp
    public static OrderEvent create(UUID orderId, String eventType, String payload) {
        return new OrderEvent(UUID.randomUUID(), orderId, eventType, Instant.now(), payload);
    }
}

What does this code do?

This Java record defines the shape of every order-related event in the system.

  • The @JsonProperty annotations ensure consistent field names when serialized to JSON for Kafka messages.
  • The static create() factory generates a unique eventId and timestamp automatically, so callers only supply the business-relevant fields.
  • You won't see a change yet. The next file completes the common-lib module.
  • Create BaseResponse.java inside the dto package with the following code:
package com.ecosystem.common.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;

// Generic API response wrapper that standardizes all HTTP responses across services
public record BaseResponse<T>(
    @JsonProperty("success") boolean success,
    @JsonProperty("message") String message,
    @JsonProperty("data") T data,
    @JsonProperty("timestamp") Instant timestamp
) {
    // Factory for successful responses
    public static <T> BaseResponse<T> ok(T data) {
        return new BaseResponse<>(true, "Success", data, Instant.now());
    }

    // Factory for error responses
    public static <T> BaseResponse<T> error(String message) {
        return new BaseResponse<>(false, message, null, Instant.now());
    }
}

What does this code do?

Every REST endpoint in the ecosystem returns this same wrapper. Clients always know to check the success field, read the message for context, and find the payload in data.

  • Save both files.
  • Verify the common-lib compiles by running:
./gradlew :common-lib:build

You should see BUILD SUCCESSFUL in the output.

Seeing a compilation error?

  • Check that the package declarations match the directory structure exactly. The path must be com/ecosystem/common/event for OrderEvent and com/ecosystem/common/dto for BaseResponse.
  • Make sure your common-lib/build.gradle.kts includes the Jackson annotations dependency. It should already exist from the previous step.

Help me debug my common-lib build failure.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

package com.ecosystem.common.event;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.UUID;

public record OrderEvent(
    @JsonProperty("eventId") UUID eventId,
    @JsonProperty("orderId") UUID orderId,
    @JsonProperty("eventType") String eventType,
    @JsonProperty("timestamp") Instant timestamp,
    @JsonProperty("payload") String payload
) {
    public static OrderEvent create(UUID orderId, String eventType, String payload) {
        return new OrderEvent(UUID.randomUUID(), orderId, eventType, Instant.now(), payload);
    }
}
package com.ecosystem.common.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;

public record BaseResponse<T>(
    @JsonProperty("success") boolean success,
    @JsonProperty("message") String message,
    @JsonProperty("data") T data,
    @JsonProperty("timestamp") Instant timestamp
) {
    public static <T> BaseResponse<T> ok(T data) {
        return new BaseResponse<>(true, "Success", data, Instant.now());
    }

    public static <T> BaseResponse<T> error(String message) {
        return new BaseResponse<>(false, message, null, Instant.now());
    }
}
Implement the Order Service

The Order Service is the first real business service in your ecosystem. It exposes REST endpoints, persists data to MySQL via Spring Data JPA, and uses Flyway to manage database schema versions.

  • Create the full package directory structure for the Order Service by running:
mkdir -p order-service/src/main/java/com/ecosystem/order/entity
mkdir -p order-service/src/main/java/com/ecosystem/order/repository
mkdir -p order-service/src/main/java/com/ecosystem/order/controller
mkdir -p order-service/src/main/java/com/ecosystem/order/dto
mkdir -p order-service/src/main/resources/db/migration
  • Create the Spring Boot application entry point OrderServiceApplication.java in the com/ecosystem/order directory:
package com.ecosystem.order;

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

// Entry point that bootstraps the Spring context and starts the embedded server
@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

What does @SpringBootApplication do?

This single annotation combines component scanning, auto-configuration, and configuration properties. Spring Boot uses it to discover all your beans (controllers, repositories, entities) and wire them together automatically.

  • Create OrderStatus.java in the entity package. This enum defines all possible states an order can pass through during the saga lifecycle:
package com.ecosystem.order.entity;

// All possible order states throughout the saga orchestration lifecycle
public enum OrderStatus {
    PENDING,
    INVENTORY_RESERVED,
    PAYMENT_PROCESSING,
    PAYMENT_COMPLETED,
    CONFIRMED,
    CANCELLED,
    COMPENSATING
}

Why so many statuses?

Each status represents a checkpoint in the distributed transaction. In later parts, the saga orchestrator will move orders through these states. COMPENSATING is triggered when a downstream service fails and the system needs to undo previous steps.

  • Create Order.java in the entity package. This is the JPA entity that maps to the orders table:
package com.ecosystem.order.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "customer_id", nullable = false)
    private String customerId;

    @Column(name = "product_id", nullable = false)
    private String productId;

    @Column(nullable = false)
    private Integer quantity;

    @Column(name = "total_amount", nullable = false, precision = 10, scale = 2)
    private BigDecimal totalAmount;

    @Column(nullable = false, length = 20)
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    // Automatically set timestamp and default status before first persist
    @PrePersist
    protected void onCreate() {
        this.createdAt = Instant.now();
        if (this.status == null) {
            this.status = OrderStatus.PENDING;
        }
    }

    // Getters and setters
    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }
    public String getCustomerId() { return customerId; }
    public void setCustomerId(String customerId) { this.customerId = customerId; }
    public String getProductId() { return productId; }
    public void setProductId(String productId) { this.productId = productId; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
    public Instant getCreatedAt() { return createdAt; }
    public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}

What does this entity do?

  • @GeneratedValue(strategy = GenerationType.UUID) generates UUIDs in the application layer. MySQL stores them as BINARY(16) for efficient indexing.
  • @Enumerated(EnumType.STRING) stores the status as a readable string (like PENDING) instead of an integer ordinal that would break if you reorder the enum.
  • @PrePersist hooks into the JPA lifecycle to set defaults before the INSERT query runs.
  • Create OrderRepository.java in the repository package:
package com.ecosystem.order.repository;

import com.ecosystem.order.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;

// Spring Data auto-implements CRUD methods at runtime based on the entity type and ID
public interface OrderRepository extends JpaRepository<Order, UUID> {
}

Why is the repository empty?

Spring Data JPA generates the implementation at runtime. By extending JpaRepository<Order, UUID>, you get save(), findById(), findAll(), and delete() for free.

  • Create CreateOrderRequest.java in the dto package. This record defines the shape of incoming POST request bodies:
package com.ecosystem.order.dto;

import java.math.BigDecimal;

// Incoming request shape for creating a new order
public record CreateOrderRequest(
    String customerId,
    String productId,
    Integer quantity,
    BigDecimal totalAmount
) {}
  • Create OrderController.java in the controller package. This wires together the repository and DTOs into REST endpoints:
package com.ecosystem.order.controller;

import com.ecosystem.common.dto.BaseResponse;
import com.ecosystem.order.dto.CreateOrderRequest;
import com.ecosystem.order.entity.Order;
import com.ecosystem.order.repository.OrderRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    // Constructor injection for the repository
    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // POST /api/orders - creates a new order with PENDING status
    @PostMapping
    public ResponseEntity<BaseResponse<Order>> createOrder(@RequestBody CreateOrderRequest request) {
        Order order = new Order();
        order.setCustomerId(request.customerId());
        order.setProductId(request.productId());
        order.setQuantity(request.quantity());
        order.setTotalAmount(request.totalAmount());

        Order saved = orderRepository.save(order);
        return ResponseEntity.status(HttpStatus.CREATED).body(BaseResponse.ok(saved));
    }

    // GET /api/orders/{id} - retrieves a single order by UUID
    @GetMapping("/{id}")
    public ResponseEntity<BaseResponse<Order>> getOrder(@PathVariable UUID id) {
        return orderRepository.findById(id)
            .map(order -> ResponseEntity.ok(BaseResponse.ok(order)))
            .orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(BaseResponse.error("Order not found")));
    }

    // GET /api/orders - lists all orders
    @GetMapping
    public ResponseEntity<BaseResponse<java.util.List<Order>>> getAllOrders() {
        return ResponseEntity.ok(BaseResponse.ok(orderRepository.findAll()));
    }
}

What does this controller do?

  • The @RequestMapping("/api/orders") sets the base path. All methods inherit this prefix.
  • The POST endpoint maps incoming JSON to the CreateOrderRequest record, builds an Order entity, saves it, and wraps the result in BaseResponse.
  • The GET-by-ID endpoint uses Optional chaining to return 404 with an error message when the order does not exist.
  • Create the Flyway migration file V1__create_orders_table.sql inside order-service/src/main/resources/db/migration/:
CREATE TABLE orders (
    id BINARY(16) NOT NULL PRIMARY KEY,
    customer_id VARCHAR(255) NOT NULL,
    product_id VARCHAR(255) NOT NULL,
    quantity INT NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    INDEX idx_orders_customer_id (customer_id),
    INDEX idx_orders_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

What is Flyway doing here?

Flyway runs versioned SQL migrations automatically when the service starts. The V1__ prefix marks this as version 1. Flyway tracks which migrations have already run, so it never re-applies them.

The BINARY(16) column type stores UUIDs efficiently. The indexes on customer_id and status speed up common query patterns.

  • Create application.yml inside order-service/src/main/resources/. This configures the server port, database connection, and Flyway:
server:
  port: 8081

spring:
  application:
    name: order-service
  datasource:
    url: jdbc:mysql://localhost:3306/orderdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
    username: root
    password: rootpassword
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: true
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect
  flyway:
    enabled: true
    locations: classpath:db/migration

management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: always

What do these settings mean?

  • ddl-auto: validate tells Hibernate to check that the entity matches the database schema but never modify it. Flyway owns all schema changes.
  • The datasource URL points to the MySQL container you started in the previous step on port 3306, using the orderdb database.
  • The management block exposes Spring Boot Actuator health and info endpoints for monitoring.

Why validate instead of update?

In production systems, you never let Hibernate auto-generate DDL. Flyway migrations are version-controlled, reviewable, and repeatable. The validate setting catches mismatches between your entity and schema early, forcing you to fix them in a migration.

  • Save all files in the order-service module.

✔️ Awesome, I've got everything!

Great. Double check that you have saved every file before starting the service.

ⓧ I'd like to double check the full code

package com.ecosystem.order;

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

@SpringBootApplication
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}
package com.ecosystem.order.entity;

public enum OrderStatus {
    PENDING,
    INVENTORY_RESERVED,
    PAYMENT_PROCESSING,
    PAYMENT_COMPLETED,
    CONFIRMED,
    CANCELLED,
    COMPENSATING
}
package com.ecosystem.order.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.UUID;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "customer_id", nullable = false)
    private String customerId;

    @Column(name = "product_id", nullable = false)
    private String productId;

    @Column(nullable = false)
    private Integer quantity;

    @Column(name = "total_amount", nullable = false, precision = 10, scale = 2)
    private BigDecimal totalAmount;

    @Column(nullable = false, length = 20)
    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    @Column(name = "created_at", nullable = false, updatable = false)
    private Instant createdAt;

    @PrePersist
    protected void onCreate() {
        this.createdAt = Instant.now();
        if (this.status == null) {
            this.status = OrderStatus.PENDING;
        }
    }

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }
    public String getCustomerId() { return customerId; }
    public void setCustomerId(String customerId) { this.customerId = customerId; }
    public String getProductId() { return productId; }
    public void setProductId(String productId) { this.productId = productId; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public BigDecimal getTotalAmount() { return totalAmount; }
    public void setTotalAmount(BigDecimal totalAmount) { this.totalAmount = totalAmount; }
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
    public Instant getCreatedAt() { return createdAt; }
    public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
}
package com.ecosystem.order.repository;

import com.ecosystem.order.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;

public interface OrderRepository extends JpaRepository<Order, UUID> {
}
package com.ecosystem.order.dto;

import java.math.BigDecimal;

public record CreateOrderRequest(
    String customerId,
    String productId,
    Integer quantity,
    BigDecimal totalAmount
) {}
package com.ecosystem.order.controller;

import com.ecosystem.common.dto.BaseResponse;
import com.ecosystem.order.dto.CreateOrderRequest;
import com.ecosystem.order.entity.Order;
import com.ecosystem.order.repository.OrderRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @PostMapping
    public ResponseEntity<BaseResponse<Order>> createOrder(@RequestBody CreateOrderRequest request) {
        Order order = new Order();
        order.setCustomerId(request.customerId());
        order.setProductId(request.productId());
        order.setQuantity(request.quantity());
        order.setTotalAmount(request.totalAmount());

        Order saved = orderRepository.save(order);
        return ResponseEntity.status(HttpStatus.CREATED).body(BaseResponse.ok(saved));
    }

    @GetMapping("/{id}")
    public ResponseEntity<BaseResponse<Order>> getOrder(@PathVariable UUID id) {
        return orderRepository.findById(id)
            .map(order -> ResponseEntity.ok(BaseResponse.ok(order)))
            .orElse(ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(BaseResponse.error("Order not found")));
    }

    @GetMapping
    public ResponseEntity<BaseResponse<java.util.List<Order>>> getAllOrders() {
        return ResponseEntity.ok(BaseResponse.ok(orderRepository.findAll()));
    }
}
CREATE TABLE orders (
    id BINARY(16) NOT NULL PRIMARY KEY,
    customer_id VARCHAR(255) NOT NULL,
    product_id VARCHAR(255) NOT NULL,
    quantity INT NOT NULL,
    total_amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    INDEX idx_orders_customer_id (customer_id),
    INDEX idx_orders_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
server:
  port: 8081

spring:
  application:
    name: order-service
  datasource:
    url: jdbc:mysql://localhost:3306/orderdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
    username: root
    password: rootpassword
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: true
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect
  flyway:
    enabled: true
    locations: classpath:db/migration

management:
  endpoints:
    web:
      exposure:
        include: health,info
  endpoint:
    health:
      show-details: always
Start and verify the Order Service

All the code is in place. Time to start the service and prove that the full chain works: Spring Boot boots, Flyway creates the table, and the REST endpoints persist data to MySQL.

  • Make sure your Docker containers are still running. If not, start them again from the docker/ directory.
  • Start the Order Service by running this from the project root:
./gradlew :order-service:bootRun

Watch the console output. You should see Flyway run the migration (Successfully applied 1 migration) and the service start on port 8081.

Service won't start?

  • If you see a connection refused error to port 3306, confirm your MySQL container is running with docker ps. Look for ecosystem-mysql in the output.
  • If you see a Flyway validation error, check that the V1__ filename uses double underscores (V1__create_orders_table.sql, not V1_create_orders_table.sql).
  • If you see a compilation error about BaseResponse, confirm the common-lib built successfully in the previous substep.

Help me debug my Order Service startup failure.

  • Open a new terminal window (keep the service running in the first one).
  • Create a test order by running this curl command:
curl -X POST http://localhost:8081/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerId": "cust-001", "productId": "prod-abc", "quantity": 2, "totalAmount": 49.99}'

You should see a JSON response with "success": true and a data object containing your order with a generated UUID id and status PENDING.

  • Copy the id value from the response.
  • Retrieve the order by running (replace the UUID with your actual id):
curl http://localhost:8081/api/orders/[[ORDER_ID="your-order-uuid-from-the-post-response"]]

You should see the same order returned with all fields intact. This confirms the full round-trip: REST request to JPA entity to MySQL and back.

Getting a 404 or empty response?

  • Make sure you are hitting port 8081 (the Order Service), not 8080.
  • If the POST returned a 500 error, check the service terminal for a stack trace. A common cause is a mismatch between the entity column names and the Flyway migration.
  • If the GET returns 404, double check you copied the full UUID from the POST response including all dashes.

Help me troubleshoot my Order Service API responses.

Your first business service is alive and persisting data. Next up, you will put the API Gateway in front of it so external clients never hit service ports directly.

Configure the API Gateway and Verify End-to-End Routing

Your Order Service is running and persisting data to MySQL. Clients can call it directly on port 8081, but in a real microservices architecture, external traffic never hits individual services.

An API gateway sits at the front of all your services and routes incoming requests to the correct backend. In this step, you will configure Spring Cloud Gateway to forward requests to the Order Service and prove the full Client to Gateway to Service to Database flow works.

In this step, get ready to:
  • Create the API Gateway application with route definitions for all three services.
  • Configure CORS, request size limits, and gateway-level debug logging.
  • Verify end-to-end request flow from client through gateway to Order Service and MySQL.
Create the API Gateway application class

The api-gateway module already has its build.gradle.kts with the Spring Cloud Gateway dependency. Now you need the Spring Boot entry point class.

  • Create the file api-gateway/src/main/java/com/ecosystem/gateway/ApiGatewayApplication.java and add the following code:
package com.ecosystem.gateway;

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

// Spring Boot entry point for the API Gateway
@SpringBootApplication
public class ApiGatewayApplication {
    public static void main(String[] args) {
        SpringApplication.run(ApiGatewayApplication.class, args);
    }
}

What does this code do?

This is the same standard Spring Boot launcher pattern as your other services. The @SpringBootApplication annotation triggers auto-configuration, which detects the Spring Cloud Gateway dependency and starts a reactive Netty server instead of Tomcat.

  • Save ApiGatewayApplication.java.
Configure gateway routes, CORS, and logging

Spring Cloud Gateway uses route definitions to decide where each incoming request should go. Each route has a predicate (the path pattern to match) and a URI (the backend service to forward to).

  • Create the file api-gateway/src/main/resources/application.yml and add the following configuration:
server:
  port: 8080

spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      routes:
        # Route orders traffic to the Order Service
        - id: order-service
          uri: http://localhost:8081
          predicates:
            - Path=/api/orders/**
        # Route inventory traffic to the Inventory Service
        - id: inventory-service
          uri: http://localhost:8082
          predicates:
            - Path=/api/inventory/**
        # Route payment traffic to the Payment Service
        - id: payment-service
          uri: http://localhost:8083
          predicates:
            - Path=/api/payments/**
      # Limit incoming request body size
      default-filters:
        - name: RequestSize
          args:
            maxSize: 5MB
      # Allow cross-origin requests from any origin
      globalcors:
        corsConfigurations:
          '[/**]':
            allowedOrigins: "*"
            allowedMethods: "*"
            allowedHeaders: "*"

# Expose gateway-specific actuator endpoints
management:
  endpoints:
    web:
      exposure:
        include: health,info,gateway
  endpoint:
    health:
      show-details: always
    gateway:
      enabled: true

# Enable debug logging for gateway routing decisions
logging:
  level:
    org.springframework.cloud.gateway: DEBUG

What does this configuration do?

The routes section maps URL path patterns to backend service URIs. Any request matching /api/orders/** gets forwarded to the Order Service on port 8081.

The RequestSize default filter rejects payloads larger than 5MB. The globalcors block allows browser-based clients from any origin to make requests. The CORS wildcards are fine for local development but should be locked down in production.

The gateway actuator endpoint lets you inspect active routes at runtime. DEBUG logging prints every routing decision to the console so you can trace request flow.

  • Save application.yml.

Why does the gateway use port 8080?

Port 8080 is the conventional default for HTTP application servers. The Kafka UI container was already configured on port 8085 in your Docker Compose file specifically to avoid this conflict. The gateway is the public-facing entry point, so it gets the standard port.

Seeing a port conflict?

If you have another process already bound to port 8080, the gateway will fail to start. Run netstat -ano | findstr :8080 (Windows) or lsof -i :8080 (macOS/Linux) to find and stop the conflicting process.

Help me resolve a port 8080 conflict when starting my Spring Cloud Gateway.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete ApiGatewayApplication.java:

package com.ecosystem.gateway;

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

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

Here is the complete application.yml:

server:
  port: 8080

spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      routes:
        - id: order-service
          uri: http://localhost:8081
          predicates:
            - Path=/api/orders/**
        - id: inventory-service
          uri: http://localhost:8082
          predicates:
            - Path=/api/inventory/**
        - id: payment-service
          uri: http://localhost:8083
          predicates:
            - Path=/api/payments/**
      default-filters:
        - name: RequestSize
          args:
            maxSize: 5MB
      globalcors:
        corsConfigurations:
          '[/**]':
            allowedOrigins: "*"
            allowedMethods: "*"
            allowedHeaders: "*"

management:
  endpoints:
    web:
      exposure:
        include: health,info,gateway
  endpoint:
    health:
      show-details: always
    gateway:
      enabled: true

logging:
  level:
    org.springframework.cloud.gateway: DEBUG
Start the gateway and test end-to-end request flow

Your Order Service is already running on port 8081. Now you will start the API Gateway on port 8080 and send requests through it to prove the full routing chain works.

  • Open a new terminal window (keep your Order Service terminal running).
  • Navigate to your microservices-ecosystem root directory and start the API Gateway by running:
./gradlew :api-gateway:bootRun
  • Wait for the startup log line that says Netty started on port 8080. This confirms the gateway is listening.

Why does it say Netty instead of Tomcat?

Spring Cloud Gateway is built on the reactive stack. It uses Netty as its HTTP server instead of the traditional Tomcat that your Order Service uses. This makes it better suited for proxying high volumes of concurrent connections.

Now test the full end-to-end flow by creating an order through the gateway.

  • Open a third terminal window and POST an order through the gateway by running:
curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customerId": "cust-001", "productId": "prod-abc", "quantity": 2, "totalAmount": 49.99}'
  • You should see a JSON response with "success": true and the order data including an auto-generated id and "status": "PENDING".
  • Copy the id value from the response. Retrieve the order through the gateway by running:
curl http://localhost:8080/api/orders/[[ORDER_ID="paste-the-id-from-the-POST-response"]]
  • You should see the same order data returned with "success": true. This confirms the full flow: your request entered the gateway on port 8080, was routed to the Order Service on port 8081, which persisted it to MySQL and returned the result.

What just happened?

The request traveled through four layers. The curl client sent a request to localhost:8080 (gateway). The gateway matched the /api/orders/** predicate and forwarded to localhost:8081 (Order Service). The Order Service used JPA to write to MySQL and returned the saved order.

Check your gateway terminal. You will see DEBUG logs showing the route match and forwarding decision for each request.

Getting a 404 or connection refused?

  • If you see Connection refused, make sure the Order Service is still running on port 8081 in its own terminal. The gateway forwards requests but cannot handle them itself.
  • If you see a 404, confirm the path in your curl command matches the route predicate exactly. The route expects /api/orders (not /orders).
  • Check the gateway terminal for DEBUG output. It shows which routes were evaluated and whether a match was found.

Help me debug why my Spring Cloud Gateway is not routing requests to the Order Service.

Your gateway is live and routing traffic. Next up, you will scaffold the Inventory and Payment service stubs and add their routes to the gateway so the entire service mesh is connected.

Scaffold the Inventory and Payment Service Stubs

Your Order Service proves the full request flow works end-to-end through the gateway. A production microservices ecosystem separates concerns into bounded contexts, each owning its own data and logic.

In this step, you'll scaffold the Inventory and Payment services as minimal Spring Boot applications with their own databases and Flyway migrations. These stubs will hold domain-specific schemas ready for business logic in Part 2.

In this step, get ready to:
  • Create the Inventory Service with a Flyway migration for the products table.
  • Create the Payment Service with a Flyway migration for the payments table.
  • Verify all three services respond through the API Gateway.
Create the Inventory Service source files

The inventory-service directory and its build.gradle.kts already exist from when you created the module structure. Now you need the source files that make it a running Spring Boot application.

  • Create the source directories for the Inventory Service by running these commands from the project root:
mkdir -p inventory-service/src/main/java/com/ecosystem/inventory
mkdir -p inventory-service/src/main/resources/db/migration
  • Create the application entry point by adding the following code to a new file at inventory-service/src/main/java/com/ecosystem/inventory/InventoryServiceApplication.java:
package com.ecosystem.inventory;

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

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

What does this code do?

This is the same Spring Boot bootstrap pattern you used for the Order Service. The @SpringBootApplication annotation enables auto-configuration, component scanning, and configuration properties for the Inventory Service module.

  • Configure the Inventory Service by creating inventory-service/src/main/resources/application.yml with the following content:
server:
  port: 8082

spring:
  application:
    name: inventory-service
  datasource:
    url: jdbc:mysql://localhost:3306/inventorydb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
    username: root
    password: rootpassword
  jpa:
    hibernate:
      ddl-auto: validate
  flyway:
    enabled: true
    locations: classpath:db/migration

management:
  endpoints:
    web:
      exposure:
        include: health,info

What does this configuration do?

This follows the same structure as the Order Service config, with key differences for the Inventory domain:

  • Port 8082 avoids conflicts with the gateway (8080) and Order Service (8081).
  • The datasource points to inventorydb, which your init-db.sql script already created in MySQL.
  • Flyway is enabled with ddl-auto: validate so Hibernate validates entities against Flyway-managed schemas instead of generating DDL itself.
  • Create the Flyway migration by adding the following SQL to inventory-service/src/main/resources/db/migration/V1__create_inventory_table.sql:
CREATE TABLE products (
    id BINARY(16) NOT NULL PRIMARY KEY,
    sku VARCHAR(50) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    quantity_available INT NOT NULL DEFAULT 0,
    reserved_quantity INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    INDEX idx_products_sku (sku)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

What does this migration do?

This creates the products table where inventory data will live. The schema is designed for the Saga pattern you'll implement in Part 2:

  • The sku column has a UNIQUE constraint so each product has one stock record.
  • The reserved_quantity column tracks stock reserved by in-progress orders, separate from available stock.
  • The updated_at column auto-updates on every write, useful for optimistic locking later.
  • Save all three files.
  • Start the Inventory Service by running this from the project root:
./gradlew :inventory-service:bootRun
  • In the terminal output, look for Started InventoryServiceApplication and a Flyway log line showing V1__create_inventory_table.sql was applied successfully.
  • Verify the health endpoint by running this in a new terminal:
curl http://localhost:8082/actuator/health

You should see a JSON response with "status":"UP" confirming the service started and connected to MySQL.

Service fails to start?

  • If you see Communications link failure, make sure your Docker containers are still running with docker ps. The MySQL container must be healthy.
  • If you see Unknown database 'inventorydb', your init-db.sql may not have run. Stop MySQL, delete the volume with docker volume rm docker_mysql_data, then run docker compose up -d again.
  • If the migration fails, check that your SQL file is in the exact path src/main/resources/db/migration/ with the filename starting with V1__ (two underscores).

Why won't my Inventory Service connect to MySQL?

  • Stop the Inventory Service with Ctrl+C once you've confirmed it starts cleanly. You'll restart all services together at the end.
Create the Payment Service source files

The Payment Service follows the same pattern. It gets its own database, migration, and port.

  • Create the source directories for the Payment Service by running:
mkdir -p payment-service/src/main/java/com/ecosystem/payment
mkdir -p payment-service/src/main/resources/db/migration
  • Create the application entry point by adding the following code to payment-service/src/main/java/com/ecosystem/payment/PaymentServiceApplication.java:
package com.ecosystem.payment;

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

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

What does this code do?

Identical pattern to the Inventory and Order services. Spring Boot's auto-configuration detects the JPA, Flyway, and Actuator dependencies in build.gradle.kts and configures them automatically on startup.

  • Configure the Payment Service by creating payment-service/src/main/resources/application.yml with the following content:
server:
  port: 8083

spring:
  application:
    name: payment-service
  datasource:
    url: jdbc:mysql://localhost:3306/paymentdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
    username: root
    password: rootpassword
  jpa:
    hibernate:
      ddl-auto: validate
  flyway:
    enabled: true
    locations: classpath:db/migration

management:
  endpoints:
    web:
      exposure:
        include: health,info

What's different here?

Port 8083 gives the Payment Service its own address. The datasource points to paymentdb, the second database your init-db.sql created. Each microservice owns its database exclusively, so no service can read or write another service's data directly.

  • Create the Flyway migration by adding the following SQL to payment-service/src/main/resources/db/migration/V1__create_payments_table.sql:
CREATE TABLE payments (
    id BINARY(16) NOT NULL PRIMARY KEY,
    order_id BINARY(16) NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    payment_method VARCHAR(50),
    processed_at TIMESTAMP(6) NULL,
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    INDEX idx_payments_order_id (order_id),
    INDEX idx_payments_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

What does this migration do?

This creates the payments table designed for the Saga orchestration pattern:

  • The order_id column links payments to orders. It's not a foreign key because the orders table lives in a different database.
  • The status column tracks the payment lifecycle (PENDING, COMPLETED, FAILED, REFUNDED).
  • The processed_at is nullable because it's only set when processing finishes.
  • Save all three files.
  • Start the Payment Service by running:
./gradlew :payment-service:bootRun
  • In the terminal output, confirm you see Started PaymentServiceApplication and the Flyway migration V1__create_payments_table.sql applied.
  • Verify the health endpoint in a new terminal:
curl http://localhost:8083/actuator/health

You should see "status":"UP" confirming the Payment Service started and connected to its database.

Payment Service won't start?

  • If you see Unknown database 'paymentdb', the same fix applies: remove the MySQL volume and restart Docker Compose so init-db.sql runs again.
  • If you see a port conflict, make sure you stopped the Inventory Service before starting this one. Alternatively, run each in its own terminal window.

Help me debug my Payment Service startup failure.

  • Stop the Payment Service with Ctrl+C.
Verify all services through the gateway

The API Gateway already has routes configured for /api/inventory/** and /api/payments/** from Step 4. Now you can prove all three services are reachable through a single gateway entry point.

  • Start all three services in separate terminal windows. Open three terminals and run one command in each:
# Terminal 1 - Order Service
./gradlew :order-service:bootRun

# Terminal 2 - Inventory Service
./gradlew :inventory-service:bootRun

# Terminal 3 - Payment Service
./gradlew :payment-service:bootRun
  • Wait until all three terminals show their respective Started ...Application messages.
  • Start the API Gateway in a fourth terminal:
./gradlew :api-gateway:bootRun
  • Open a fifth terminal to run verification commands.
  • Verify the Order Service health through the gateway by running:
curl http://localhost:8080/api/orders
  • Verify the Inventory Service health through the gateway:
curl http://localhost:8082/actuator/health
  • Verify the Payment Service health through the gateway:
curl http://localhost:8083/actuator/health

All three commands should return successful responses. The Order Service returns an empty list of orders. Both the Inventory and Payment services return "status":"UP" from their health endpoints.

Why use health endpoints?

Spring Boot Actuator's /actuator/health endpoint reports whether the application and its dependencies (database, messaging) are operational. In production, load balancers and orchestrators (like Kubernetes) poll this endpoint to decide whether to route traffic to an instance.

Gateway returns 503 or connection refused?

  • Make sure all three services finished starting before you send requests through the gateway. Each service takes a few seconds to boot.
  • Check that ports 8081, 8082, and 8083 are not occupied by other processes. Run lsof -i :8082 (macOS/Linux) or netstat -ano | findstr :8082 (Windows) to see what's using the port.
  • Confirm the gateway is running on port 8080 by checking its terminal for Started ApiGatewayApplication.

Why can't I reach my services through the gateway?

✔️ Awesome, I've got everything!

Great. Make sure you've saved all files before moving on.

ⓧ I'd like to double check the full code

Here are the complete files for the Inventory Service:

package com.ecosystem.inventory;

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

@SpringBootApplication
public class InventoryServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(InventoryServiceApplication.class, args);
    }
}
server:
  port: 8082

spring:
  application:
    name: inventory-service
  datasource:
    url: jdbc:mysql://localhost:3306/inventorydb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
    username: root
    password: rootpassword
  jpa:
    hibernate:
      ddl-auto: validate
  flyway:
    enabled: true
    locations: classpath:db/migration

management:
  endpoints:
    web:
      exposure:
        include: health,info
CREATE TABLE products (
    id BINARY(16) NOT NULL PRIMARY KEY,
    sku VARCHAR(50) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    quantity_available INT NOT NULL DEFAULT 0,
    reserved_quantity INT NOT NULL DEFAULT 0,
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
    INDEX idx_products_sku (sku)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

And the complete files for the Payment Service:

package com.ecosystem.payment;

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

@SpringBootApplication
public class PaymentServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(PaymentServiceApplication.class, args);
    }
}
server:
  port: 8083

spring:
  application:
    name: payment-service
  datasource:
    url: jdbc:mysql://localhost:3306/paymentdb?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC
    username: root
    password: rootpassword
  jpa:
    hibernate:
      ddl-auto: validate
  flyway:
    enabled: true
    locations: classpath:db/migration

management:
  endpoints:
    web:
      exposure:
        include: health,info
CREATE TABLE payments (
    id BINARY(16) NOT NULL PRIMARY KEY,
    order_id BINARY(16) NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    payment_method VARCHAR(50),
    processed_at TIMESTAMP(6) NULL,
    created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    INDEX idx_payments_order_id (order_id),
    INDEX idx_payments_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Your microservices ecosystem now has three running services, each with its own database and versioned schema. The API Gateway routes traffic to all of them through a single entry point. In Part 2, these services will communicate through events using Kafka and the Transactional Outbox pattern.

Secret mission

Add Infrastructure Health Monitoring and a Lite Profile

Your microservices stack is running, but how do you know if Kafka is actually healthy? And what about developers on machines with limited RAM who can't run all 7 containers? In this secret mission, you'll build a custom Kafka health check endpoint and create Docker Compose profiles for different hardware constraints.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine using Docker containers. 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: MySQL, Kafka, Redis, MongoDB, Keycloak, Vault, Kafka UI.
  • Docker volumes: mysql_data, kafka_data, redis_data, mongo_data.
  • Docker networks: frontend, backend, data.
  • Project directory: microservices-ecosystem with all Gradle modules and source code.
  • Gradle wrapper and local build cache.

Keep everything running

No action needed. Choose this if you are still actively building or plan to start Part 2 soon.

  • Your Docker containers will keep running in the background and consume memory.
  • The 7 containers use approximately 2-4 GB of RAM combined.
  • All data in MySQL, MongoDB, and Kafka persists across restarts via Docker volumes.

Pause - I'll come back to this later

Shut down the containers to free up memory while keeping your data and project files intact.

  • Stop the Docker Compose stack by running the following from the docker/ directory:
cd ~/Desktop/microservices-ecosystem/docker
docker compose down
  • This stops and removes all containers but keeps your volumes (database data) intact.
  • When you are ready to resume, run docker compose up -d from the same directory.
  • Your project source code in microservices-ecosystem/ stays untouched on your Desktop.

Delete - I don't want to use this again

Remove all Docker resources and delete the project folder from your machine.

  • Stop containers and remove volumes by running this from the docker/ directory:
cd ~/Desktop/microservices-ecosystem/docker
docker compose down -v
  • The -v flag removes all named volumes, deleting your MySQL, Kafka, Redis, and MongoDB data.
  • Remove the Docker images to free disk space:
docker rmi mysql:8.4 apache/kafka:4.1.1 redis:7.4-alpine mongo:7.0 keycloak/keycloak:26.6 hashicorp/vault:latest provectuslabs/kafka-ui:latest
  • Delete the project directory:
rm -rf ~/Desktop/microservices-ecosystem
  • Prune any unused Docker networks:
docker network prune -f

That's a Wrap!

That's a Wrap!

Nice work! You have built the foundation layer of a production-ready microservices ecosystem, complete with infrastructure orchestration, database migrations, and end-to-end request routing.

You've learned how to:

  • Scaffold a multi-module Gradle monorepo with Kotlin DSL, a shared common library, three business services, and a Spring Cloud Gateway.
  • Orchestrate six infrastructure services (MySQL, Kafka KRaft, Redis, MongoDB, Keycloak, Vault) using Docker Compose with isolated networks for security.
  • Implement end-to-end request flow from the API Gateway through the Order Service to MySQL, with Flyway-managed schema migrations for repeatable deployments.
  • Secret Mission: Add infrastructure health monitoring with a Kafka connectivity check and a Docker Compose lite profile for low-memory machines.

Ready to quiz yourself?