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?