Microservices architecture has become the de facto standard for building scalable, maintainable software systems. But most tutorials limit themselves to a single language. In real-world engineering teams, different services are often built with the language best suited for the job. A CPU-bound inventory system benefits from Rust’s zero-cost abstractions. An order orchestration service thrives with Go’s concurrency model. A user-facing authentication layer ships fastest with Python’s rich ecosystem.
This guide walks you through building ShopFlow, a fully functional e-commerce platform comprising three microservices written in three different languages, communicating over gRPC, fronted by an Envoy API gateway, tested end-to-end, and deployable to Kubernetes with Istio service mesh.
You can have access to the complete codes at:
https://github.com/mjmichael73/shopflow-polyglot-microservices-go-python-rust
What you will build:
- User Service (Python / FastAPI) — registration, login, JWT authentication, and a gRPC server for token validation.
- Order Service (Go) — order placement and retrieval, orchestrating calls to the User and Inventory services over gRPC.
- Inventory Service (Rust) — product catalog and stock management with a gRPC server for stock queries and updates.
- Envoy API Gateway — a single entry point that routes HTTP requests to the correct service.
- Docker Compose — a one-command local development environment.
- Kubernetes + Istio — production-grade deployment manifests.
- Complete unit tests for every service and end-to-end integration tests.
Prerequisites:
- Docker and Docker Compose installed
- Python 3.11+ with pip
- Go 1.25+
- Rust 1.88+ with cargo
- protoc (Protocol Buffer compiler)
- kubectl and a Kubernetes cluster (for Section 7)
- Basic familiarity with REST APIs, SQL, and terminal usage
The full source code is available in the accompanying repository. Each section ends with a git commit so you can follow along incrementally or jump to any point in the project’s history.
Let’s begin.

SECTION 1: Project Initialization and Protobuf Definitions
Every microservices project starts with a shared contract. In ShopFlow, that contract is defined using Protocol Buffers (protobuf). Before writing a single line of application code, we establish the project skeleton and the gRPC interfaces that services will use to communicate.
Step 1.1: Create the project directory
mkdir shopflow && cd shopflow
git init
Step 1.2: Create the root .gitignore
A proper .gitignore prevents compiled artifacts, environment files containing secrets, and editor clutter from entering version control.
Create a file named .gitignore at the project root with the following content:
# Environment files
.env
*.env.local
# Python
__pycache__/
*.py[cod]
*$py.class
*.egg-info/
dist/
build/
.eggs/
*.egg
.venv/
venv/
env/
.pytest_cache/
.mypy_cache/
htmlcov/
.coverage
# Go
order-service/vendor/
order-service/tmp/
# Rust
inventory-service/target/
# Generated protobuf files
*_pb2.py
*_pb2_grpc.py
*.pb.go
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Docker
docker-compose.override.yml
# Kubernetes
*.kubeconfig
# Logs
*.log
logs/
Step 1.3: Create the environment template
Create .env.example at the project root. This file documents every environment variable the system needs without containing real secrets. Each developer copies it to .env and fills in their values.
# ShopFlow Environment Configuration
# PostgreSQL - User Service
USER_DB_HOST=localhost
USER_DB_PORT=5433
USER_DB_NAME=userdb
USER_DB_USER=shopflow
USER_DB_PASSWORD=shopflow_secret
# PostgreSQL - Order Service
ORDER_DB_HOST=localhost
ORDER_DB_PORT=5434
ORDER_DB_NAME=orderdb
ORDER_DB_USER=shopflow
ORDER_DB_PASSWORD=shopflow_secret
# PostgreSQL - Inventory Service
INVENTORY_DB_HOST=localhost
INVENTORY_DB_PORT=5435
INVENTORY_DB_NAME=inventorydb
INVENTORY_DB_USER=shopflow
INVENTORY_DB_PASSWORD=shopflow_secret
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRATION_MINUTES=30
# Service Ports (REST)
USER_SERVICE_PORT=8001
ORDER_SERVICE_PORT=8002
INVENTORY_SERVICE_PORT=8003
# Service Ports (gRPC)
USER_GRPC_PORT=50051
INVENTORY_GRPC_PORT=50052
# Envoy Gateway
GATEWAY_PORT=8000
# gRPC Service Addresses
USER_GRPC_ADDR=user-service:50051
INVENTORY_GRPC_ADDR=inventory-service:50052
Then copy it:
cp .env.example .env
Step 1.4: Define the Protobuf contracts
Create a proto/ directory at the project root. This directory holds the shared interface definitions that both the server (provider) and client (consumer) compile against.
File: proto/user.proto
syntax = "proto3";
package user;
option go_package = "github.com/shopflow/order-service/proto/userpb";
service UserService {
rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message ValidateTokenRequest {
string token = 1;
}
message ValidateTokenResponse {
bool valid = 1;
int32 user_id = 2;
string email = 3;
string username = 4;
}
message GetUserRequest {
int32 user_id = 1;
}
message GetUserResponse {
int32 user_id = 1;
string email = 2;
string username = 3;
string created_at = 4;
}
The UserService exposes two RPCs. ValidateToken lets the Order Service verify a JWT without knowing the secret. GetUser retrieves user details by ID.
File: proto/inventory.proto
syntax = "proto3";
package inventory;
option go_package = "github.com/shopflow/order-service/proto/inventorypb";
service InventoryService {
rpc GetProduct(GetProductRequest) returns (GetProductResponse);
rpc CheckStock(CheckStockRequest) returns (CheckStockResponse);
rpc UpdateStock(UpdateStockRequest) returns (UpdateStockResponse);
}
message GetProductRequest {
int32 product_id = 1;
}
message GetProductResponse {
int32 product_id = 1;
string name = 2;
string description = 3;
double price = 4;
int32 stock = 5;
}
message CheckStockRequest {
int32 product_id = 1;
int32 quantity = 2;
}
message CheckStockResponse {
bool available = 1;
int32 current_stock = 2;
}
message UpdateStockRequest {
int32 product_id = 1;
int32 quantity_change = 2;
}
message UpdateStockResponse {
bool success = 1;
int32 new_stock = 2;
}
The InventoryService exposes three RPCs. GetProduct returns full product details. CheckStock verifies whether the requested quantity is available. UpdateStock adjusts the stock level (negative values decrement).
Step 1.5: Create the initial Makefile
The Makefile serves as the project’s command center. At this stage it contains targets for protobuf generation and placeholder targets for the services we will build in subsequent sections.
Step 1.6: Verify and commit
git add -A
git commit -m "Section 1: Project initialization and protobuf definitions"
At this point you have a clean project skeleton with shared protobuf contracts that every service will compile against. The .env.example documents configuration, and the Makefile provides a consistent interface for every operation you will add.
SECTION 2: User Service – Python / FastAPI with gRPC
The User Service handles registration, login, and JWT-based authentication. It exposes a REST API for external consumers and a gRPC server that other internal services call to validate tokens and retrieve user details.
Technology choices:
- FastAPI for REST (async, automatic OpenAPI docs)
- SQLAlchemy 2.0 async for ORM
- bcrypt for password hashing
- python-jose for JWT encoding and decoding
- grpcio for the gRPC server
- PostgreSQL in production, SQLite for unit tests
Step 2.1: Create the service directory and virtual environment
mkdir -p user-service/app/proto user-service/tests
cd user-service
python3 -m venv .venv
source .venv/bin/activate
Step 2.2: Create the .gitignore for the service
File: user-service/.gitignore
__pycache__/
*.py[cod]
*$py.class
.venv/
venv/
.env
.pytest_cache/
.mypy_cache/
htmlcov/
.coverage
*.egg-info/
dist/
build/
Step 2.3: Create the environment template
File: user-service/.env.example
DATABASE_URL=postgresql+asyncpg://shopflow:shopflow_secret@localhost:5433/userdb
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRATION_MINUTES=30
GRPC_PORT=50051
REST_PORT=8001
Copy it to .env for local development:
cp .env.example .env
Step 2.4: Install dependencies
File: user-service/requirements.txt
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
aiosqlite==0.20.0
pydantic==2.10.4
pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0
bcrypt==4.2.1
grpcio==1.69.0
grpcio-tools==1.69.0
protobuf==5.29.3
httpx==0.28.1
pytest==8.3.4
pytest-asyncio==0.25.0
python-dotenv==1.0.1
Install inside the virtual environment:
pip install -r requirements.txt
Step 2.5: Generate protobuf stubs
From the user-service directory, generate Python stubs from the shared proto definitions:
python -m grpc_tools.protoc \
-I ../proto \
--python_out=app/proto \
--grpc_python_out=app/proto \
--pyi_out=app/proto \
../proto/user.proto ../proto/inventory.proto
touch app/proto/__init__.py
This produces user_pb2.py, user_pb2_grpc.py, and their type stubs inside app/proto/.
Step 2.6: Configuration module
File: user-service/app/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://shopflow:shopflow_secret@localhost:5433/userdb"
jwt_secret: str = "your-super-secret-jwt-key-change-in-production"
jwt_algorithm: str = "HS256"
jwt_expiration_minutes: int = 30
grpc_port: int = 50051
rest_port: int = 8001
model_config = {"env_file": ".env"}
settings = Settings()
pydantic-settings reads every field from environment variables automatically, falling back to the defaults shown here. The env_file directive also loads a .env file if present.
Step 2.7: Database layer
File: user-service/app/database.py
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
try:
yield session
finally:
await session.close()
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
The get_db function is a FastAPI dependency that yields a database session per request and cleans up afterward. init_db creates all tables on startup.
Step 2.8: User model
File: user-service/app/models.py
import datetime
from sqlalchemy import Column, Integer, String, DateTime
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
username = Column(String(150), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.datetime.now(datetime.UTC))
Step 2.9: Pydantic schemas
File: user-service/app/schemas.py
import datetime
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=150)
email: str = Field(..., min_length=5, max_length=255)
password: str = Field(..., min_length=6)
class UserResponse(BaseModel):
id: int
username: str
email: str
created_at: datetime.datetime
class Config:
from_attributes = True
class LoginRequest(BaseModel):
email: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
class MessageResponse(BaseModel):
message: str
Schemas define the shape of request and response bodies. FastAPI uses them for automatic validation, serialization, and OpenAPI documentation.
Step 2.10: Authentication utilities
File: user-service/app/auth.py
import datetime
import bcrypt
from jose import JWTError, jwt
from app.config import settings
def hash_password(password: str) -> str:
salt = bcrypt.gensalt()
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
minutes=settings.jwt_expiration_minutes
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict | None:
try:
payload = jwt.decode(
token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
)
return payload
except JWTError:
return None
We use bcrypt directly rather than passlib for better compatibility with modern Python versions.
Step 2.11: FastAPI application
File: user-service/app/main.py:
import asyncio
import threading
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db, init_db
from app.models import User
from app.schemas import UserCreate, UserResponse, LoginRequest, TokenResponse
from app.auth import hash_password, verify_password, create_access_token, decode_access_token
security = HTTPBearer()
def start_grpc_server():
from app.grpc_server import serve_grpc
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(serve_grpc())
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
grpc_thread = threading.Thread(target=start_grpc_server, daemon=True)
grpc_thread.start()
yield
app = FastAPI(
title="ShopFlow User Service",
description="User registration, authentication, and profile management",
version="1.0.0",
lifespan=lifespan,
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
payload = decode_access_token(credentials.credentials)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
)
user_id = payload.get("user_id")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token payload",
)
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return user
@app.get("/health")
async def health():
return {"status": "healthy", "service": "user-service"}
@app.post("/api/v1/users/register", response_model=UserResponse, status_code=201)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
existing = await db.execute(
select(User).where(
(User.email == user_data.email) | (User.username == user_data.username)
)
)
if existing.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User with this email or username already exists",
)
user = User(
username=user_data.username,
email=user_data.email,
hashed_password=hash_password(user_data.password),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@app.post("/api/v1/users/login", response_model=TokenResponse)
async def login(login_data: LoginRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == login_data.email))
user = result.scalar_one_or_none()
if user is None or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
token = create_access_token(
{"user_id": user.id, "email": user.email, "username": user.username}
)
return TokenResponse(access_token=token)
@app.get("/api/v1/users/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
return current_user
This is the core of the service. It defines three REST endpoints:
- POST /api/v1/users/register — create a new user
- POST /api/v1/users/login — authenticate and receive a JWT
- GET /api/v1/users/me — retrieve the current user’s profile
On startup, the application initializes the database and launches a gRPC server in a background thread so both REST and gRPC run in the same process.
Step 2.12: gRPC server
The gRPC server lets internal services validate tokens and fetch user data without sharing JWT secrets.
File: user-service/app/grpc_server.py
import grpc
from concurrent import futures
from app.config import settings
from app.grpc_servicer import UserServiceServicer
from app.proto import user_pb2_grpc
async def serve_grpc():
server = grpc.aio.server(futures.ThreadPoolExecutor(max_workers=10))
user_pb2_grpc.add_UserServiceServicer_to_server(UserServiceServicer(), server)
listen_addr = f"0.0.0.0:{settings.grpc_port}"
server.add_insecure_port(listen_addr)
await server.start()
await server.wait_for_termination()
File: user-service/app/grpc_servicer.py
import grpc
from sqlalchemy import select
from app.auth import decode_access_token
from app.database import async_session
from app.models import User
from app.proto import user_pb2, user_pb2_grpc
class UserServiceServicer(user_pb2_grpc.UserServiceServicer):
async def ValidateToken(self, request, context):
payload = decode_access_token(request.token)
if payload is None:
return user_pb2.ValidateTokenResponse(
valid=False, user_id=0, email="", username=""
)
return user_pb2.ValidateTokenResponse(
valid=True,
user_id=payload.get("user_id", 0),
email=payload.get("email", ""),
username=payload.get("username", ""),
)
async def GetUser(self, request, context):
async with async_session() as session:
result = await session.execute(
select(User).where(User.id == request.user_id)
)
user = result.scalar_one_or_none()
if user is None:
context.set_code(grpc.StatusCode.NOT_FOUND)
context.set_details(f"User {request.user_id} not found")
return user_pb2.GetUserResponse()
return user_pb2.GetUserResponse(
user_id=user.id,
email=user.email,
username=user.username,
created_at=user.created_at.isoformat() if user.created_at else "",
)
Step 2.13: Dockerfile
File: user-service/Dockerfile:
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY user-service/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY proto/ /app/proto_src/
RUN mkdir -p /app/proto_gen && python -m grpc_tools.protoc \
-I /app/proto_src \
--python_out=/app/proto_gen \
--grpc_python_out=/app/proto_gen \
--pyi_out=/app/proto_gen \
/app/proto_src/user.proto /app/proto_src/inventory.proto \
&& touch /app/proto_gen/__init__.py
COPY user-service/ .
RUN mkdir -p /app/app/proto && cp /app/proto_gen/*.py /app/app/proto/ 2>/dev/null; \
touch /app/app/proto/__init__.py
EXPOSE 8001 50051
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
The build context is the project root (not user-service/), which is why COPY paths begin with user-service/. This allows the Dockerfile to access the shared proto/ directory. Protobuf stubs are compiled at build time so the image is self-contained.
Step 2.14: Unit tests
File: user-service/pytest.ini
[pytest]
asyncio_mode = auto
File: user-service/tests/conftest.py
The test configuration overrides the database URL to use SQLite, so tests run without PostgreSQL. Each test gets a fresh database.
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.config import settings
settings.database_url = "sqlite+aiosqlite:///./test.db"
from app.database import engine, Base, async_session
from app.main import app
@pytest_asyncio.fixture(autouse=True)
async def setup_database():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def client():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
File: user-service/tests/test_api.py:
import pytest
from httpx import AsyncClient
from app.auth import create_access_token, hash_password, verify_password, decode_access_token
@pytest.mark.asyncio
async def test_health(client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["service"] == "user-service"
@pytest.mark.asyncio
async def test_register_user(client: AsyncClient):
response = await client.post(
"/api/v1/users/register",
json={
"username": "testuser",
"email": "[email protected]",
"password": "securepass123",
},
)
assert response.status_code == 201
data = response.json()
assert data["username"] == "testuser"
assert data["email"] == "[email protected]"
assert "id" in data
assert "created_at" in data
@pytest.mark.asyncio
async def test_register_duplicate_user(client: AsyncClient):
user_data = {
"username": "dupuser",
"email": "[email protected]",
"password": "securepass123",
}
await client.post("/api/v1/users/register", json=user_data)
response = await client.post("/api/v1/users/register", json=user_data)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_register_invalid_data(client: AsyncClient):
response = await client.post(
"/api/v1/users/register",
json={"username": "ab", "email": "bad", "password": "123"},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_login_success(client: AsyncClient):
await client.post(
"/api/v1/users/register",
json={
"username": "loginuser",
"email": "[email protected]",
"password": "securepass123",
},
)
response = await client.post(
"/api/v1/users/login",
json={"email": "[email protected]", "password": "securepass123"},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_login_wrong_password(client: AsyncClient):
await client.post(
"/api/v1/users/register",
json={
"username": "wrongpw",
"email": "[email protected]",
"password": "correctpass",
},
)
response = await client.post(
"/api/v1/users/login",
json={"email": "[email protected]", "password": "incorrectpass"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_login_nonexistent_user(client: AsyncClient):
response = await client.post(
"/api/v1/users/login",
json={"email": "[email protected]", "password": "whatever"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_me(client: AsyncClient):
await client.post(
"/api/v1/users/register",
json={
"username": "meuser",
"email": "[email protected]",
"password": "securepass123",
},
)
login_resp = await client.post(
"/api/v1/users/login",
json={"email": "[email protected]", "password": "securepass123"},
)
token = login_resp.json()["access_token"]
response = await client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
data = response.json()
assert data["username"] == "meuser"
assert data["email"] == "[email protected]"
@pytest.mark.asyncio
async def test_get_me_no_token(client: AsyncClient):
response = await client.get("/api/v1/users/me")
assert response.status_code == 403
@pytest.mark.asyncio
async def test_get_me_invalid_token(client: AsyncClient):
response = await client.get(
"/api/v1/users/me",
headers={"Authorization": "Bearer invalid.token.here"},
)
assert response.status_code == 401
def test_hash_password():
hashed = hash_password("mypassword")
assert hashed != "mypassword"
assert verify_password("mypassword", hashed)
assert not verify_password("wrongpassword", hashed)
def test_create_and_decode_token():
token = create_access_token({"user_id": 1, "email": "[email protected]", "username": "alice"})
payload = decode_access_token(token)
assert payload is not None
assert payload["user_id"] == 1
assert payload["email"] == "[email protected]"
def test_decode_invalid_token():
assert decode_access_token("not.a.valid.token") is None
The test suite covers:
- Health check endpoint
- Successful registration and duplicate detection
- Input validation rejection
- Login with correct and incorrect credentials
- Protected endpoint with valid, missing, and invalid tokens
- Password hashing and JWT encode/decode round-trips
Run the tests:
cd user-service
source .venv/bin/activate
python -m pytest tests/ -v --tb=short
Expected output: 13 passed.
Step 2.15: Verify and commit
git add -A
git commit -m "Section 2: User service with FastAPI gRPC and unit tests"
The User Service is now fully functional with REST endpoints, gRPC server, and a comprehensive test suite. In the next section we build the Inventory Service in Rust.
SECTION 3: Inventory Service — Rust with gRPC
The Inventory Service manages the product catalog and stock levels. Rust was chosen for this service because inventory operations must be fast and memory-safe, especially under high concurrency when multiple orders attempt to decrement stock simultaneously.
Technology choices:
- Actix-web for the REST API (one of the fastest web frameworks in any language)
- Tonic for the gRPC server (async, built on Hyper and Tokio)
- SQLx for async PostgreSQL access (compile-time checked queries in production)
- tonic-build in build.rs for automatic protobuf code generation
Step 3.1: Create the service directory
mkdir -p inventory-service/src/proto inventory-service/migrations
cd inventory-service
Step 3.2: Create the .gitignore
File: inventory-service/.gitignore
/target
.env
*.swp
*.swo
Step 3.3: Create the environment template
File: inventory-service/.env.example
DATABASE_URL=postgres://shopflow:shopflow_secret@localhost:5435/inventorydb
REST_PORT=8003
GRPC_PORT=50052
Step 3.4: Define dependencies
File: inventory-service/Cargo.toml
[package]
name = "inventory-service"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "inventory-service"
path = "src/main.rs"
[dependencies]
actix-web = "4"
actix-rt = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "sqlite", "migrate", "chrono"] }
tonic = "0.12"
prost = "0.13"
prost-types = "0.13"
dotenvy = "0.15"
env_logger = "0.11"
log = "0.4"
chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4"] }
[build-dependencies]
tonic-build = "0.12"
[dev-dependencies]
actix-web = { version = "4", features = ["macros"] }
tokio = { version = "1", features = ["full", "test-util"] }
reqwest = { version = "0.12", features = ["json"] }
Step 3.5: Build script for protobuf generation
File: inventory-service/build.rs
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
let (proto_file, include_dir) = if Path::new("../proto/inventory.proto").exists() {
("../proto/inventory.proto", "../proto")
} else {
("proto/inventory.proto", "proto")
};
tonic_build::configure()
.build_server(true)
.build_client(false)
.out_dir("src/proto")
.compile_protos(&[proto_file], &[include_dir])?;
Ok(())
}
Unlike the Python service where we run a manual protoc command, Rust’s build.rs runs automatically during cargo build. The generated code lands in src/proto/ and is included via a module declaration.
The path detection is important: when building locally, the proto files are at ../proto/ relative to the inventory-service directory. Inside a Docker build, the build context is the project root, so the proto files are copied to /app/proto/ and the relative path is just proto/. The build script checks which path exists and uses the correct one.
File: inventory-service/src/proto/mod.rs
pub mod inventory {
include!("inventory.rs");
}
Step 3.6: Configuration
File: inventory-service/src/config.rs
use std::env;
#[derive(Clone, Debug)]
pub struct Config {
pub database_url: String,
pub rest_port: u16,
pub grpc_port: u16,
}
impl Config {
pub fn from_env() -> Self {
Self {
database_url: env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://shopflow:shopflow_secret@localhost:5435/inventorydb".to_string()),
rest_port: env::var("REST_PORT")
.unwrap_or_else(|_| "8003".to_string())
.parse()
.expect("REST_PORT must be a number"),
grpc_port: env::var("GRPC_PORT")
.unwrap_or_else(|_| "50052".to_string())
.parse()
.expect("GRPC_PORT must be a number"),
}
}
}
Step 3.7: Database layer
File: inventory-service/src/db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn create_pool(database_url: &str) -> PgPool {
PgPoolOptions::new()
.max_connections(10)
.connect(database_url)
.await
.expect("Failed to create database pool")
}
pub async fn run_migrations(pool: &PgPool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
price DOUBLE PRECISION NOT NULL DEFAULT 0.0,
stock INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)"
)
.execute(pool)
.await
.expect("Failed to run migrations");
}
Step 3.8: Models
File: inventory-service/src/models.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Product {
pub id: i32,
pub name: String,
pub description: String,
pub price: f64,
pub stock: i32,
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
pub updated_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct CreateProductRequest {
pub name: String,
pub description: Option<String>,
pub price: f64,
pub stock: i32,
}
#[derive(Debug, Deserialize)]
pub struct UpdateStockRequest {
pub quantity_change: i32,
}
Step 3.9: REST handlers
File: inventory-service/src/handlers.rs:
use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use crate::models::{CreateProductRequest, Product, UpdateStockRequest};
pub async fn health() -> HttpResponse {
HttpResponse::Ok().json(serde_json::json!({
"status": "healthy",
"service": "inventory-service"
}))
}
pub async fn create_product(
pool: web::Data<PgPool>,
body: web::Json<CreateProductRequest>,
) -> HttpResponse {
let description = body.description.clone().unwrap_or_default();
let result = sqlx::query_as::<_, Product>(
"INSERT INTO products (name, description, price, stock) VALUES ($1, $2, $3, $4) RETURNING *"
)
.bind(&body.name)
.bind(&description)
.bind(body.price)
.bind(body.stock)
.fetch_one(pool.get_ref())
.await;
match result {
Ok(product) => HttpResponse::Created().json(product),
Err(e) => {
log::error!("Failed to create product: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to create product"
}))
}
}
}
pub async fn list_products(pool: web::Data<PgPool>) -> HttpResponse {
let result = sqlx::query_as::<_, Product>("SELECT * FROM products ORDER BY id")
.fetch_all(pool.get_ref())
.await;
match result {
Ok(products) => HttpResponse::Ok().json(products),
Err(e) => {
log::error!("Failed to list products: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to list products"
}))
}
}
}
pub async fn get_product(pool: web::Data<PgPool>, path: web::Path<i32>) -> HttpResponse {
let product_id = path.into_inner();
let result = sqlx::query_as::<_, Product>("SELECT * FROM products WHERE id = $1")
.bind(product_id)
.fetch_optional(pool.get_ref())
.await;
match result {
Ok(Some(product)) => HttpResponse::Ok().json(product),
Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
"error": format!("Product {} not found", product_id)
})),
Err(e) => {
log::error!("Failed to get product: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to get product"
}))
}
}
}
pub async fn update_stock(
pool: web::Data<PgPool>,
path: web::Path<i32>,
body: web::Json<UpdateStockRequest>,
) -> HttpResponse {
let product_id = path.into_inner();
let result = sqlx::query_as::<_, Product>(
"UPDATE products SET stock = stock + $1, updated_at = NOW() WHERE id = $2 AND stock + $1 >= 0 RETURNING *"
)
.bind(body.quantity_change)
.bind(product_id)
.fetch_optional(pool.get_ref())
.await;
match result {
Ok(Some(product)) => HttpResponse::Ok().json(product),
Ok(None) => HttpResponse::BadRequest().json(serde_json::json!({
"error": "Product not found or insufficient stock"
})),
Err(e) => {
log::error!("Failed to update stock: {}", e);
HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Failed to update stock"
}))
}
}
}
The REST API exposes five endpoints:
- GET /health
- POST /api/v1/products — create a product
- GET /api/v1/products — list all products
- GET /api/v1/products/{id} — get a product by ID
- PUT /api/v1/products/{id}/stock — adjust stock level
The stock update uses a single atomic SQL statement with a WHERE clause that prevents stock from going negative, avoiding race conditions.
Step 3.10: gRPC service implementation
File: inventory-service/src/grpc_service.rs:
use sqlx::PgPool;
use tonic::{Request, Response, Status};
use crate::models::Product;
use crate::proto::inventory::inventory_service_server::InventoryService;
use crate::proto::inventory::{
CheckStockRequest, CheckStockResponse, GetProductRequest, GetProductResponse,
UpdateStockRequest, UpdateStockResponse,
};
pub struct InventoryServiceImpl {
pub pool: PgPool,
}
#[tonic::async_trait]
impl InventoryService for InventoryServiceImpl {
async fn get_product(
&self,
request: Request<GetProductRequest>,
) -> Result<Response<GetProductResponse>, Status> {
let product_id = request.into_inner().product_id;
let product = sqlx::query_as::<_, Product>("SELECT * FROM products WHERE id = $1")
.bind(product_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| Status::internal(format!("Database error: {}", e)))?;
match product {
Some(p) => Ok(Response::new(GetProductResponse {
product_id: p.id,
name: p.name,
description: p.description,
price: p.price,
stock: p.stock,
})),
None => Err(Status::not_found(format!(
"Product {} not found",
product_id
))),
}
}
async fn check_stock(
&self,
request: Request<CheckStockRequest>,
) -> Result<Response<CheckStockResponse>, Status> {
let req = request.into_inner();
let product = sqlx::query_as::<_, Product>("SELECT * FROM products WHERE id = $1")
.bind(req.product_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| Status::internal(format!("Database error: {}", e)))?;
match product {
Some(p) => Ok(Response::new(CheckStockResponse {
available: p.stock >= req.quantity,
current_stock: p.stock,
})),
None => Err(Status::not_found(format!(
"Product {} not found",
req.product_id
))),
}
}
async fn update_stock(
&self,
request: Request<UpdateStockRequest>,
) -> Result<Response<UpdateStockResponse>, Status> {
let req = request.into_inner();
let result = sqlx::query_as::<_, Product>(
"UPDATE products SET stock = stock + $1, updated_at = NOW() WHERE id = $2 AND stock + $1 >= 0 RETURNING *"
)
.bind(req.quantity_change)
.bind(req.product_id)
.fetch_optional(&self.pool)
.await
.map_err(|e| Status::internal(format!("Database error: {}", e)))?;
match result {
Some(p) => Ok(Response::new(UpdateStockResponse {
success: true,
new_stock: p.stock,
})),
None => Ok(Response::new(UpdateStockResponse {
success: false,
new_stock: -1,
})),
}
}
}
The gRPC service implements the InventoryService defined in proto/inventory.proto. It provides GetProduct, CheckStock, and UpdateStock RPCs that the Order Service calls internally.
Step 3.11: Main entry point
File: inventory-service/src/main.rs:
mod config;
mod db;
mod grpc_service;
mod handlers;
mod models;
mod proto;
use actix_web::{web, App, HttpServer};
use tonic::transport::Server as TonicServer;
use crate::config::Config;
use crate::grpc_service::InventoryServiceImpl;
use crate::proto::inventory::inventory_service_server::InventoryServiceServer;
#[tokio::main]
async fn main() -> std::io::Result<()> {
dotenvy::dotenv().ok();
env_logger::init();
let config = Config::from_env();
let pool = db::create_pool(&config.database_url).await;
db::run_migrations(&pool).await;
let grpc_pool = pool.clone();
let grpc_port = config.grpc_port;
tokio::spawn(async move {
let addr = format!("0.0.0.0:{}", grpc_port).parse().unwrap();
let inventory_service = InventoryServiceImpl { pool: grpc_pool };
log::info!("gRPC server listening on {}", addr);
TonicServer::builder()
.add_service(InventoryServiceServer::new(inventory_service))
.serve(addr)
.await
.expect("gRPC server failed");
});
let rest_pool = pool.clone();
let rest_port = config.rest_port;
log::info!("REST server listening on 0.0.0.0:{}", rest_port);
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(rest_pool.clone()))
.route("/health", web::get().to(handlers::health))
.route("/api/v1/products", web::post().to(handlers::create_product))
.route("/api/v1/products", web::get().to(handlers::list_products))
.route("/api/v1/products/{id}", web::get().to(handlers::get_product))
.route(
"/api/v1/products/{id}/stock",
web::put().to(handlers::update_stock),
)
})
.bind(format!("0.0.0.0:{}", rest_port))?
.run()
.await
}
#[cfg(test)]
mod tests {
use actix_web::{test, web, App};
use crate::handlers;
#[actix_web::test]
async fn test_health_endpoint() {
let app = test::init_service(
App::new().route("/health", web::get().to(handlers::health)),
)
.await;
let req = test::TestRequest::get().uri("/health").to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body: serde_json::Value = test::read_body_json(resp).await;
assert_eq!(body["status"], "healthy");
assert_eq!(body["service"], "inventory-service");
}
#[actix_web::test]
async fn test_create_product_request_deserialization() {
let json = r#"{"name": "Widget", "description": "A fine widget", "price": 9.99, "stock": 100}"#;
let req: crate::models::CreateProductRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.name, "Widget");
assert_eq!(req.price, 9.99);
assert_eq!(req.stock, 100);
assert_eq!(req.description.unwrap(), "A fine widget");
}
#[actix_web::test]
async fn test_create_product_request_without_description() {
let json = r#"{"name": "Gadget", "price": 19.99, "stock": 50}"#;
let req: crate::models::CreateProductRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.name, "Gadget");
assert!(req.description.is_none());
}
#[actix_web::test]
async fn test_product_serialization() {
let product = crate::models::Product {
id: 1,
name: "Test Product".to_string(),
description: "A test product".to_string(),
price: 29.99,
stock: 42,
created_at: None,
updated_at: None,
};
let json = serde_json::to_string(&product).unwrap();
assert!(json.contains("Test Product"));
assert!(json.contains("29.99"));
assert!(json.contains("42"));
}
#[actix_web::test]
async fn test_config_defaults() {
std::env::remove_var("DATABASE_URL");
std::env::remove_var("REST_PORT");
std::env::remove_var("GRPC_PORT");
let config = crate::config::Config::from_env();
assert_eq!(config.rest_port, 8003);
assert_eq!(config.grpc_port, 50052);
}
}
The main function initializes the database, spawns the gRPC server on a separate Tokio task, and starts the Actix-web REST server on the main thread. Both servers share the same database connection pool.
Step 3.12: Unit tests
The tests are embedded in src/main.rs using Rust’s built-in test framework. They cover:
- Health endpoint response
- CreateProductRequest deserialization with and without optional fields
- Product serialization to JSON
- Configuration defaults
Run the tests:
cargo test
Expected output: 5 passed.
Step 3.13: Dockerfile
File: inventory-service/Dockerfile
FROM rust:1.88-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY inventory-service/Cargo.toml inventory-service/Cargo.lock* ./
COPY inventory-service/build.rs ./
COPY proto/ /app/proto/
RUN mkdir -p src/proto && echo 'fn main() {}' > src/main.rs \
&& cargo build --release 2>/dev/null || true
COPY inventory-service/src/ ./src/
RUN touch src/main.rs src/*.rs && cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 wget \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/inventory-service /app/inventory-service
EXPOSE 8003 50052
CMD ["/app/inventory-service"]
Important details about this Dockerfile:
The build context is the project root (not inventory-service/), which is why COPY paths begin with inventory-service/. This lets the Dockerfile access both the service code and the shared proto/ directory.
The Rust image version must be 1.88 or higher because several dependencies (actix-web, time) require it. If you see “rustc X.Y is not supported” errors during the build, update the FROM tag to match or exceed the version listed in the error message.
The dependency caching strategy works in two stages. First, a dummy main.rs is created and all dependencies are compiled. Then the real source is copied in and the “touch” command forces cargo to see the source files as newer than the cached dummy build. Without the touch, Docker layer timestamps can cause cargo to skip recompilation, resulting in a binary that does nothing.
Step 3.14: Verify and commit
git add -A
git commit -m "Section 3: Inventory service with Rust gRPC and unit tests"
The Inventory Service is complete with REST and gRPC interfaces, database persistence, and unit tests. Next we build the Order Service in Go, which will tie everything together by calling both the User and Inventory services over gRPC.
SECTION 4: Order Service — Go with gRPC Clients
The Order Service is the orchestrator. When a user places an order, this service validates the JWT by calling the User Service over gRPC, checks and decrements stock by calling the Inventory Service over gRPC, and persists the order in its own database. Go’s standard library HTTP server and its excellent gRPC support make it a natural fit.
Technology choices:
- Go 1.25 standard library net/http (Go 1.22+ supports method-based routing)
- google.golang.org/grpc as the gRPC client library
- database/sql with lib/pq for PostgreSQL
- Interface-based dependency injection for testable handlers
- godotenv for .env file loading
Step 4.1: Initialize the Go module
mkdir -p order-service/{cmd/server,internal/{config,database,handlers,models,grpcclient},proto/{userpb,inventorypb}}
cd order-service
go mod init github.com/shopflow/order-service
Step 4.2: Generate Go protobuf stubs
protoc -I ../proto \
--go_out=proto --go_opt=paths=source_relative \
--go-grpc_out=proto --go-grpc_opt=paths=source_relative \
../proto/user.proto ../proto/inventory.proto
mv proto/user.pb.go proto/userpb/
mv proto/user_grpc.pb.go proto/userpb/
mv proto/inventory.pb.go proto/inventorypb/
mv proto/inventory_grpc.pb.go proto/inventorypb/
Step 4.3: Configuration
File: order-service/internal/config/config.go
package config
import "os"
type Config struct {
DatabaseURL string
RESTPort string
UserGRPCAddr string
InventoryGRPCAddr string
}
func Load() *Config {
return &Config{
DatabaseURL: getEnv("DATABASE_URL", "postgres://shopflow:shopflow_secret@localhost:5434/orderdb?sslmode=disable"),
RESTPort: getEnv("REST_PORT", "8002"),
UserGRPCAddr: getEnv("USER_GRPC_ADDR", "localhost:50051"),
InventoryGRPCAddr: getEnv("INVENTORY_GRPC_ADDR", "localhost:50052"),
}
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
Simple environment-based configuration with sensible defaults. Every setting can be overridden through environment variables or a .env file.
Step 4.4: Database layer
File: order-service/internal/database/database.go
package database
import (
"database/sql"
"log"
_ "github.com/lib/pq"
)
func Connect(databaseURL string) *sql.DB {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
if err := db.Ping(); err != nil {
log.Fatalf("Failed to ping database: %v", err)
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
return db
}
func RunMigrations(db *sql.DB) {
query := `
CREATE TABLE IF NOT EXISTS orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
total_price DOUBLE PRECISION NOT NULL DEFAULT 0.0,
status VARCHAR(50) NOT NULL DEFAULT 'pending',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)`
_, err := db.Exec(query)
if err != nil {
log.Fatalf("Failed to run migrations: %v", err)
}
}
The orders table stores each order with references to the user and product IDs managed by other services.
Step 4.5: Models
File: order-service/internal/models/order.go
package models
import "time"
type Order struct {
ID int `json:"id"`
UserID int `json:"user_id"`
ProductID int `json:"product_id"`
Quantity int `json:"quantity"`
TotalPrice float64 `json:"total_price"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateOrderRequest struct {
ProductID int `json:"product_id" validate:"required,gt=0"`
Quantity int `json:"quantity" validate:"required,gt=0"`
}
type OrderResponse struct {
ID int `json:"id"`
UserID int `json:"user_id"`
ProductID int `json:"product_id"`
Quantity int `json:"quantity"`
TotalPrice float64 `json:"total_price"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func (o *Order) ToResponse() OrderResponse {
return OrderResponse{
ID: o.ID,
UserID: o.UserID,
ProductID: o.ProductID,
Quantity: o.Quantity,
TotalPrice: o.TotalPrice,
Status: o.Status,
CreatedAt: o.CreatedAt.Format(time.RFC3339),
UpdatedAt: o.UpdatedAt.Format(time.RFC3339),
}
}
The Order struct maps directly to database rows. The ToResponse method converts timestamps to RFC3339 format for the JSON API.
Step 4.6: gRPC client layer
File: order-service/internal/grpcclient/client.go
package grpcclient
import (
"context"
"fmt"
"log"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
inventorypb "github.com/shopflow/order-service/proto/inventorypb"
userpb "github.com/shopflow/order-service/proto/userpb"
)
type GRPCClients struct {
UserClient userpb.UserServiceClient
InventoryClient inventorypb.InventoryServiceClient
userConn *grpc.ClientConn
inventoryConn *grpc.ClientConn
}
func NewGRPCClients(userAddr, inventoryAddr string) *GRPCClients {
userConn, err := grpc.NewClient(userAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("Failed to connect to user service: %v", err)
}
inventoryConn, err := grpc.NewClient(inventoryAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("Failed to connect to inventory service: %v", err)
}
return &GRPCClients{
UserClient: userpb.NewUserServiceClient(userConn),
InventoryClient: inventorypb.NewInventoryServiceClient(inventoryConn),
userConn: userConn,
inventoryConn: inventoryConn,
}
}
func (c *GRPCClients) Close() {
if c.userConn != nil {
c.userConn.Close()
}
if c.inventoryConn != nil {
c.inventoryConn.Close()
}
}
func (c *GRPCClients) ValidateToken(token string) (*userpb.ValidateTokenResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return c.UserClient.ValidateToken(ctx, &userpb.ValidateTokenRequest{Token: token})
}
func (c *GRPCClients) GetProduct(productID int32) (*inventorypb.GetProductResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return c.InventoryClient.GetProduct(ctx, &inventorypb.GetProductRequest{ProductId: productID})
}
func (c *GRPCClients) CheckStock(productID int32, quantity int32) (*inventorypb.CheckStockResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return c.InventoryClient.CheckStock(ctx, &inventorypb.CheckStockRequest{
ProductId: productID,
Quantity: quantity,
})
}
func (c *GRPCClients) UpdateStock(productID int32, quantityChange int32) (*inventorypb.UpdateStockResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return c.InventoryClient.UpdateStock(ctx, &inventorypb.UpdateStockRequest{
ProductId: productID,
QuantityChange: quantityChange,
})
}
type MockGRPCClients struct {
ValidateTokenFn func(token string) (*userpb.ValidateTokenResponse, error)
GetProductFn func(productID int32) (*inventorypb.GetProductResponse, error)
CheckStockFn func(productID int32, quantity int32) (*inventorypb.CheckStockResponse, error)
UpdateStockFn func(productID int32, quantityChange int32) (*inventorypb.UpdateStockResponse, error)
}
func (m *MockGRPCClients) ValidateToken(token string) (*userpb.ValidateTokenResponse, error) {
if m.ValidateTokenFn != nil {
return m.ValidateTokenFn(token)
}
return nil, fmt.Errorf("not implemented")
}
func (m *MockGRPCClients) GetProduct(productID int32) (*inventorypb.GetProductResponse, error) {
if m.GetProductFn != nil {
return m.GetProductFn(productID)
}
return nil, fmt.Errorf("not implemented")
}
func (m *MockGRPCClients) CheckStock(productID int32, quantity int32) (*inventorypb.CheckStockResponse, error) {
if m.CheckStockFn != nil {
return m.CheckStockFn(productID, quantity)
}
return nil, fmt.Errorf("not implemented")
}
func (m *MockGRPCClients) UpdateStock(productID int32, quantityChange int32) (*inventorypb.UpdateStockResponse, error) {
if m.UpdateStockFn != nil {
return m.UpdateStockFn(productID, quantityChange)
}
return nil, fmt.Errorf("not implemented")
}
type ServiceClients interface {
ValidateToken(token string) (*userpb.ValidateTokenResponse, error)
GetProduct(productID int32) (*inventorypb.GetProductResponse, error)
CheckStock(productID int32, quantity int32) (*inventorypb.CheckStockResponse, error)
UpdateStock(productID int32, quantityChange int32) (*inventorypb.UpdateStockResponse, error)
}
This file defines:
- GRPCClients — the real implementation connecting to User and Inventory services.
- ServiceClients — an interface that both the real and mock clients satisfy.
- MockGRPCClients — a mock implementation for unit testing.
The interface-based design lets us test handlers without running actual gRPC servers.
Step 4.7: HTTP handlers
File: order-service/internal/handlers/order.go
package handlers
import (
"database/sql"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"github.com/shopflow/order-service/internal/grpcclient"
"github.com/shopflow/order-service/internal/models"
)
type OrderHandler struct {
DB *sql.DB
Clients grpcclient.ServiceClients
}
func NewOrderHandler(db *sql.DB, clients grpcclient.ServiceClients) *OrderHandler {
return &OrderHandler{DB: db, Clients: clients}
}
func (h *OrderHandler) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "healthy",
"service": "order-service",
})
}
func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Missing authorization token"})
return
}
userResp, err := h.Clients.ValidateToken(token)
if err != nil || !userResp.Valid {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
return
}
var req models.CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
return
}
if req.ProductID <= 0 || req.Quantity <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "product_id and quantity must be positive"})
return
}
product, err := h.Clients.GetProduct(int32(req.ProductID))
if err != nil {
log.Printf("Failed to get product: %v", err)
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Product not found"})
return
}
stockResp, err := h.Clients.CheckStock(int32(req.ProductID), int32(req.Quantity))
if err != nil || !stockResp.Available {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Insufficient stock"})
return
}
totalPrice := product.Price * float64(req.Quantity)
var order models.Order
err = h.DB.QueryRow(
`INSERT INTO orders (user_id, product_id, quantity, total_price, status)
VALUES ($1, $2, $3, $4, 'confirmed') RETURNING id, user_id, product_id, quantity, total_price, status, created_at, updated_at`,
userResp.UserId, req.ProductID, req.Quantity, totalPrice,
).Scan(&order.ID, &order.UserID, &order.ProductID, &order.Quantity, &order.TotalPrice, &order.Status, &order.CreatedAt, &order.UpdatedAt)
if err != nil {
log.Printf("Failed to create order: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to create order"})
return
}
_, err = h.Clients.UpdateStock(int32(req.ProductID), int32(-req.Quantity))
if err != nil {
log.Printf("Warning: Failed to decrement stock: %v", err)
}
writeJSON(w, http.StatusCreated, order.ToResponse())
}
func (h *OrderHandler) ListOrders(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Missing authorization token"})
return
}
userResp, err := h.Clients.ValidateToken(token)
if err != nil || !userResp.Valid {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
return
}
rows, err := h.DB.Query(
"SELECT id, user_id, product_id, quantity, total_price, status, created_at, updated_at FROM orders WHERE user_id = $1 ORDER BY id DESC",
userResp.UserId,
)
if err != nil {
log.Printf("Failed to list orders: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to list orders"})
return
}
defer rows.Close()
var orders []models.OrderResponse
for rows.Next() {
var o models.Order
if err := rows.Scan(&o.ID, &o.UserID, &o.ProductID, &o.Quantity, &o.TotalPrice, &o.Status, &o.CreatedAt, &o.UpdatedAt); err != nil {
continue
}
orders = append(orders, o.ToResponse())
}
if orders == nil {
orders = []models.OrderResponse{}
}
writeJSON(w, http.StatusOK, orders)
}
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
token := extractToken(r)
if token == "" {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Missing authorization token"})
return
}
userResp, err := h.Clients.ValidateToken(token)
if err != nil || !userResp.Valid {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Invalid token"})
return
}
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 5 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid order ID"})
return
}
orderID, err := strconv.Atoi(parts[len(parts)-1])
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Invalid order ID"})
return
}
var o models.Order
err = h.DB.QueryRow(
"SELECT id, user_id, product_id, quantity, total_price, status, created_at, updated_at FROM orders WHERE id = $1 AND user_id = $2",
orderID, userResp.UserId,
).Scan(&o.ID, &o.UserID, &o.ProductID, &o.Quantity, &o.TotalPrice, &o.Status, &o.CreatedAt, &o.UpdatedAt)
if err == sql.ErrNoRows {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "Order not found"})
return
}
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to get order"})
return
}
writeJSON(w, http.StatusOK, o.ToResponse())
}
func extractToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
return strings.TrimPrefix(auth, "Bearer ")
}
return ""
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
The handler struct holds a database connection and the gRPC client interface. Endpoints:
- GET /health — liveness check
- POST /api/v1/orders — create an order (requires Bearer token)
- Validates the JWT via gRPC call to User Service
- Fetches product details via gRPC call to Inventory Service
- Checks stock availability
- Creates the order in the database
- Decrements stock via gRPC
- GET /api/v1/orders — list orders for the authenticated user
- GET /api/v1/orders/{id} — get a specific order
Step 4.8: Unit tests
File: order-service/internal/handlers/order_test.go
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/shopflow/order-service/internal/grpcclient"
"github.com/shopflow/order-service/internal/models"
inventorypb "github.com/shopflow/order-service/proto/inventorypb"
userpb "github.com/shopflow/order-service/proto/userpb"
)
func TestHealthEndpoint(t *testing.T) {
handler := &OrderHandler{}
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
handler.Health(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["status"] != "healthy" {
t.Errorf("expected healthy status, got %s", resp["status"])
}
if resp["service"] != "order-service" {
t.Errorf("expected order-service, got %s", resp["service"])
}
}
func TestCreateOrderNoToken(t *testing.T) {
handler := &OrderHandler{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/orders", nil)
w := httptest.NewRecorder()
handler.CreateOrder(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
}
func TestCreateOrderInvalidToken(t *testing.T) {
mock := &grpcclient.MockGRPCClients{
ValidateTokenFn: func(token string) (*userpb.ValidateTokenResponse, error) {
return &userpb.ValidateTokenResponse{Valid: false}, nil
},
}
handler := &OrderHandler{Clients: mock}
req := httptest.NewRequest(http.MethodPost, "/api/v1/orders", nil)
req.Header.Set("Authorization", "Bearer invalid-token")
w := httptest.NewRecorder()
handler.CreateOrder(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
}
func TestCreateOrderInvalidBody(t *testing.T) {
mock := &grpcclient.MockGRPCClients{
ValidateTokenFn: func(token string) (*userpb.ValidateTokenResponse, error) {
return &userpb.ValidateTokenResponse{Valid: true, UserId: 1, Email: "[email protected]"}, nil
},
}
handler := &OrderHandler{Clients: mock}
body := bytes.NewBufferString(`{"product_id": 0, "quantity": -1}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/orders", body)
req.Header.Set("Authorization", "Bearer valid-token")
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.CreateOrder(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
func TestCreateOrderInsufficientStock(t *testing.T) {
mock := &grpcclient.MockGRPCClients{
ValidateTokenFn: func(token string) (*userpb.ValidateTokenResponse, error) {
return &userpb.ValidateTokenResponse{Valid: true, UserId: 1}, nil
},
GetProductFn: func(productID int32) (*inventorypb.GetProductResponse, error) {
return &inventorypb.GetProductResponse{
ProductId: 1, Name: "Widget", Price: 9.99, Stock: 5,
}, nil
},
CheckStockFn: func(productID int32, quantity int32) (*inventorypb.CheckStockResponse, error) {
return &inventorypb.CheckStockResponse{Available: false, CurrentStock: 5}, nil
},
}
handler := &OrderHandler{Clients: mock}
body := bytes.NewBufferString(`{"product_id": 1, "quantity": 100}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/orders", body)
req.Header.Set("Authorization", "Bearer valid-token")
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.CreateOrder(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
}
func TestListOrdersNoToken(t *testing.T) {
handler := &OrderHandler{}
req := httptest.NewRequest(http.MethodGet, "/api/v1/orders", nil)
w := httptest.NewRecorder()
handler.ListOrders(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
}
func TestGetOrderNoToken(t *testing.T) {
handler := &OrderHandler{}
req := httptest.NewRequest(http.MethodGet, "/api/v1/orders/1", nil)
w := httptest.NewRecorder()
handler.GetOrder(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("expected status 401, got %d", w.Code)
}
}
func TestExtractToken(t *testing.T) {
tests := []struct {
name string
header string
expected string
}{
{"valid bearer", "Bearer mytoken123", "mytoken123"},
{"no prefix", "mytoken123", ""},
{"empty", "", ""},
{"only bearer", "Bearer ", ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
got := extractToken(req)
if got != tc.expected {
t.Errorf("extractToken(%q) = %q, want %q", tc.header, got, tc.expected)
}
})
}
}
func TestOrderToResponse(t *testing.T) {
order := models.Order{
ID: 1,
UserID: 42,
ProductID: 10,
Quantity: 3,
TotalPrice: 29.97,
Status: "confirmed",
}
resp := order.ToResponse()
if resp.ID != 1 {
t.Errorf("expected ID 1, got %d", resp.ID)
}
if resp.UserID != 42 {
t.Errorf("expected UserID 42, got %d", resp.UserID)
}
if resp.TotalPrice != 29.97 {
t.Errorf("expected TotalPrice 29.97, got %f", resp.TotalPrice)
}
if resp.Status != "confirmed" {
t.Errorf("expected status confirmed, got %s", resp.Status)
}
}
Tests use MockGRPCClients to simulate gRPC responses without network calls. Covered scenarios:
- Health endpoint returns correct response
- Creating an order without a token returns 401
- Creating an order with an invalid token returns 401
- Creating an order with invalid body returns 400
- Creating an order with insufficient stock returns 400
- Listing orders without a token returns 401
- Getting an order without a token returns 401
- Token extraction from Authorization header
- Order model serialization
Run the tests:
go test ./... -v -count=1
Expected output: 9 passed.
Step 4.9: Entry point
File: order-service/cmd/server/main.go:
package main
import (
"log"
"net/http"
"github.com/joho/godotenv"
"github.com/shopflow/order-service/internal/config"
"github.com/shopflow/order-service/internal/database"
"github.com/shopflow/order-service/internal/grpcclient"
"github.com/shopflow/order-service/internal/handlers"
)
func main() {
godotenv.Load()
cfg := config.Load()
db := database.Connect(cfg.DatabaseURL)
defer db.Close()
database.RunMigrations(db)
clients := grpcclient.NewGRPCClients(cfg.UserGRPCAddr, cfg.InventoryGRPCAddr)
defer clients.Close()
handler := handlers.NewOrderHandler(db, clients)
mux := http.NewServeMux()
mux.HandleFunc("GET /health", handler.Health)
mux.HandleFunc("POST /api/v1/orders", handler.CreateOrder)
mux.HandleFunc("GET /api/v1/orders", handler.ListOrders)
mux.HandleFunc("GET /api/v1/orders/{id}", handler.GetOrder)
addr := "0.0.0.0:" + cfg.RESTPort
log.Printf("Order service listening on %s", addr)
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
The main function loads configuration, connects to the database, initializes gRPC clients, and starts the HTTP server.
Step 4.10: Dockerfile
File: order-service/Dockerfile
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY order-service/go.mod order-service/go.sum ./
RUN go mod download
COPY order-service/ .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server ./cmd/server
FROM alpine:3.21
RUN apk add --no-cache ca-certificates wget
WORKDIR /app
COPY --from=builder /app/bin/server /app/server
EXPOSE 8002
CMD ["/app/server"]
The Go image version must match or exceed the version declared in go.mod. If go.mod says “go 1.25.5”, then the Docker image must be golang:1.25 or newer. A version mismatch causes “go.mod requires go >= X (running Y)” errors during go mod download.
Like the Rust service, the build context is the project root, so COPY paths begin with order-service/.
Step 4.11: Verify and commit
git add -A
git commit -m "Section 4: Order service with Go gRPC clients and unit tests"
All three services are now implemented. The Order Service ties them together by acting as the orchestrator — it does not expose a gRPC server itself but consumes the User and Inventory gRPC APIs. In the next section we wire everything together with Docker Compose and an Envoy API gateway.
SECTION 5: Docker Compose and Envoy API Gateway
Individual services are useless without a way to run them together. Docker Compose gives us a single command that spins up all three services, three PostgreSQL databases, and an Envoy API gateway. Envoy provides a unified entry point on port 8000, routing requests to the correct backend based on URL prefix.
Step 5.1: Docker Compose configuration
File: docker-compose.yml:
services:
# ---------------------------------------------------------------------------
# Databases
# ---------------------------------------------------------------------------
user-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: userdb
POSTGRES_USER: shopflow
POSTGRES_PASSWORD: shopflow_secret
ports:
- "5433:5432"
volumes:
- user-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shopflow -d userdb"]
interval: 5s
timeout: 5s
retries: 5
order-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: orderdb
POSTGRES_USER: shopflow
POSTGRES_PASSWORD: shopflow_secret
ports:
- "5434:5432"
volumes:
- order-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shopflow -d orderdb"]
interval: 5s
timeout: 5s
retries: 5
inventory-db:
image: postgres:16-alpine
environment:
POSTGRES_DB: inventorydb
POSTGRES_USER: shopflow
POSTGRES_PASSWORD: shopflow_secret
ports:
- "5435:5432"
volumes:
- inventory-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shopflow -d inventorydb"]
interval: 5s
timeout: 5s
retries: 5
# ---------------------------------------------------------------------------
# Services
# ---------------------------------------------------------------------------
user-service:
build:
context: .
dockerfile: user-service/Dockerfile
environment:
DATABASE_URL: postgresql+asyncpg://shopflow:shopflow_secret@user-db:5432/userdb
JWT_SECRET: shopflow-jwt-secret-for-dev
JWT_ALGORITHM: HS256
JWT_EXPIRATION_MINUTES: "30"
GRPC_PORT: "50051"
REST_PORT: "8001"
ports:
- "8001:8001"
- "50051:50051"
depends_on:
user-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8001/health')\""]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
inventory-service:
build:
context: .
dockerfile: inventory-service/Dockerfile
environment:
DATABASE_URL: postgres://shopflow:shopflow_secret@inventory-db:5432/inventorydb
REST_PORT: "8003"
GRPC_PORT: "50052"
RUST_LOG: info
ports:
- "8003:8003"
- "50052:50052"
depends_on:
inventory-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8003/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
order-service:
build:
context: .
dockerfile: order-service/Dockerfile
environment:
DATABASE_URL: postgres://shopflow:shopflow_secret@order-db:5432/orderdb?sslmode=disable
REST_PORT: "8002"
USER_GRPC_ADDR: user-service:50051
INVENTORY_GRPC_ADDR: inventory-service:50052
ports:
- "8002:8002"
depends_on:
user-service:
condition: service_healthy
inventory-service:
condition: service_healthy
order-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8002/health || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
# ---------------------------------------------------------------------------
# API Gateway
# ---------------------------------------------------------------------------
envoy:
image: envoyproxy/envoy:v1.31-latest
ports:
- "8000:8000"
- "9901:9901"
volumes:
- ./envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro
depends_on:
user-service:
condition: service_healthy
order-service:
condition: service_healthy
inventory-service:
condition: service_healthy
volumes:
user-db-data:
order-db-data:
inventory-db-data:
The compose file defines seven services:
- user-db — PostgreSQL for user data (port 5433)
- order-db — PostgreSQL for order data (port 5434)
- inventory-db — PostgreSQL for inventory data (port 5435)
- user-service — Python/FastAPI (REST 8001, gRPC 50051)
- inventory-service — Rust/Actix (REST 8003, gRPC 50052)
- order-service — Go (REST 8002)
- envoy — API gateway (port 8000, admin 9901)
Key design decisions:
- Each database has a health check so services only start once their DB is ready.
- Each application service has a health check so downstream services and the gateway only start once upstreams are healthy.
- The build context for every service is the project root (.) rather than the individual service directory. This is essential because every Dockerfile needs access to the shared proto/ directory at build time. As a consequence, COPY commands inside Dockerfiles use paths like “COPY user-service/requirements.txt .” rather than “COPY requirements.txt .”.
- Environment variables are passed directly rather than through .env files to keep the Docker configuration explicit and portable.
Step 5.2: Envoy configuration
File: envoy/envoy.yaml:
admin:
address:
socket_address:
address: 0.0.0.0
port_value: 9901
static_resources:
listeners:
- name: main_listener
address:
socket_address:
address: 0.0.0.0
port_value: 8000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: shopflow
domains: ["*"]
routes:
# User Service routes
- match:
prefix: "/api/v1/users"
route:
cluster: user_service
timeout: 30s
# Order Service routes
- match:
prefix: "/api/v1/orders"
route:
cluster: order_service
timeout: 30s
# Inventory Service routes
- match:
prefix: "/api/v1/products"
route:
cluster: inventory_service
timeout: 30s
# Health checks
- match:
prefix: "/health/user"
route:
cluster: user_service
prefix_rewrite: "/health"
- match:
prefix: "/health/order"
route:
cluster: order_service
prefix_rewrite: "/health"
- match:
prefix: "/health/inventory"
route:
cluster: inventory_service
prefix_rewrite: "/health"
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters:
- name: user_service
connect_timeout: 5s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: user_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: user-service
port_value: 8001
- name: order_service
connect_timeout: 5s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: order_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: order-service
port_value: 8002
- name: inventory_service
connect_timeout: 5s
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: inventory_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: inventory-service
port_value: 8003
Envoy routes based on URL prefix:
- /api/v1/users/* -> user-service:8001
- /api/v1/orders/* -> order-service:8002
- /api/v1/products/* -> inventory-service:8003
- /health/{service} -> respective service /health endpoint
The admin interface on port 9901 provides metrics, cluster health, and configuration dumps for debugging.
Step 5.3: Verify the stack
make docker-up
This command builds all images and starts the stack. After about 30 seconds, all services should be healthy. Verify with:
curl http://localhost:8000/health/user
curl http://localhost:8000/health/order
curl http://localhost:8000/health/inventory
Each should return a JSON object with “status”: “healthy”.
Test the full flow through the gateway:
# Register a user
curl -X POST http://localhost:8000/api/v1/users/register \
-H "Content-Type: application/json" \
-d '{"username": "alice", "email": "[email protected]", "password": "secret123"}'
# Login
curl -X POST http://localhost:8000/api/v1/users/login \
-H "Content-Type: application/json" \
-d '{"email": "[email protected]", "password": "secret123"}'
# Create a product
curl -X POST http://localhost:8000/api/v1/products \
-H "Content-Type: application/json" \
-d '{"name": "Widget", "description": "A fine widget", "price": 9.99, "stock": 100}'
# Place an order (use the token from the login response)
curl -X POST http://localhost:8000/api/v1/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your-token>" \
-d '{"product_id": 1, "quantity": 2}'
Step 5.4: Stop the stack
make docker-down
Step 5.5: Verify and commit
git add -A
git commit -m "Section 5: Docker Compose and Envoy API gateway"
The entire platform now runs with a single command. Envoy handles routing, and Docker Compose manages service dependencies and health checks.
SECTION 6: End-to-End Integration Tests
Unit tests verify each service in isolation. Integration tests verify that the services work together correctly — that gRPC calls succeed across the network, that data flows through the entire system, and that the gateway routes correctly.
Step 6.1: Create the integration test directory
mkdir -p integration-tests
cd integration-tests
Step 6.2: Dependencies
File: integration-tests/requirements.txt
requests==2.32.3
pytest==8.3.4
python-dotenv==1.0.1
Install them:
pip install -r requirements.txt
Step 6.3: Test configuration
File: integration-tests/conftest.py:
import os
import pytest
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://localhost:8000")
@pytest.fixture(scope="session")
def base_url():
return GATEWAY_URL
The tests use the GATEWAY_URL environment variable (defaulting to http://localhost:8000) so they can run against any environment.
Step 6.4: Integration test suite
File: integration-tests/test_e2e.py:
"""
End-to-End Integration Tests for ShopFlow.
These tests run against the full Docker Compose stack.
Start the stack first: `make docker-up`
Test flow:
1. Register a user
2. Login and get a token
3. Create a product in inventory
4. Place an order (triggers gRPC calls to User + Inventory services)
5. Verify the order was created
6. Verify stock was decremented
"""
import time
import uuid
import pytest
import requests
@pytest.fixture(scope="session")
def base_url():
return "http://localhost:8000"
@pytest.fixture(scope="session")
def unique_id():
return uuid.uuid4().hex[:8]
@pytest.fixture(scope="session")
def test_user(unique_id):
return {
"username": f"testuser_{unique_id}",
"email": f"testuser_{unique_id}@shopflow.test",
"password": "integration_test_password_123",
}
@pytest.fixture(scope="session")
def registered_user(base_url, test_user):
"""Register a test user and return the response data."""
resp = requests.post(
f"{base_url}/api/v1/users/register",
json=test_user,
timeout=10,
)
assert resp.status_code == 201, f"Registration failed: {resp.text}"
return resp.json()
@pytest.fixture(scope="session")
def auth_token(base_url, test_user, registered_user):
"""Login and return the JWT access token."""
resp = requests.post(
f"{base_url}/api/v1/users/login",
json={
"email": test_user["email"],
"password": test_user["password"],
},
timeout=10,
)
assert resp.status_code == 200, f"Login failed: {resp.text}"
data = resp.json()
assert "access_token" in data
return data["access_token"]
@pytest.fixture(scope="session")
def auth_headers(auth_token):
"""Return headers with the Bearer token."""
return {"Authorization": f"Bearer {auth_token}"}
@pytest.fixture(scope="session")
def test_product(base_url):
"""Create a test product in the inventory service."""
product_data = {
"name": f"Integration Test Widget {uuid.uuid4().hex[:6]}",
"description": "A widget created during integration testing",
"price": 25.50,
"stock": 100,
}
resp = requests.post(
f"{base_url}/api/v1/products",
json=product_data,
timeout=10,
)
assert resp.status_code == 201, f"Product creation failed: {resp.text}"
return resp.json()
class TestHealthChecks:
"""Verify all services are running and healthy."""
def test_user_service_health(self, base_url):
resp = requests.get(f"{base_url}/health/user", timeout=5)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "healthy"
assert data["service"] == "user-service"
def test_order_service_health(self, base_url):
resp = requests.get(f"{base_url}/health/order", timeout=5)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "healthy"
assert data["service"] == "order-service"
def test_inventory_service_health(self, base_url):
resp = requests.get(f"{base_url}/health/inventory", timeout=5)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "healthy"
assert data["service"] == "inventory-service"
class TestUserRegistrationAndAuth:
"""Test user registration and authentication flow."""
def test_register_user(self, registered_user):
assert "id" in registered_user
assert "username" in registered_user
assert "email" in registered_user
def test_register_duplicate_user(self, base_url, test_user):
resp = requests.post(
f"{base_url}/api/v1/users/register",
json=test_user,
timeout=10,
)
assert resp.status_code == 409
def test_login(self, auth_token):
assert auth_token is not None
assert len(auth_token) > 0
def test_login_wrong_password(self, base_url, test_user):
resp = requests.post(
f"{base_url}/api/v1/users/login",
json={
"email": test_user["email"],
"password": "wrong_password",
},
timeout=10,
)
assert resp.status_code == 401
def test_get_current_user(self, base_url, auth_headers, test_user):
resp = requests.get(
f"{base_url}/api/v1/users/me",
headers=auth_headers,
timeout=10,
)
assert resp.status_code == 200
data = resp.json()
assert data["username"] == test_user["username"]
assert data["email"] == test_user["email"]
def test_get_current_user_no_token(self, base_url):
resp = requests.get(f"{base_url}/api/v1/users/me", timeout=10)
assert resp.status_code == 403
class TestInventoryManagement:
"""Test product and inventory operations."""
def test_create_product(self, test_product):
assert "id" in test_product
assert test_product["price"] == 25.50
assert test_product["stock"] == 100
def test_list_products(self, base_url, test_product):
resp = requests.get(f"{base_url}/api/v1/products", timeout=10)
assert resp.status_code == 200
products = resp.json()
assert isinstance(products, list)
assert len(products) >= 1
def test_get_product_by_id(self, base_url, test_product):
product_id = test_product["id"]
resp = requests.get(
f"{base_url}/api/v1/products/{product_id}",
timeout=10,
)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == product_id
assert data["name"] == test_product["name"]
def test_get_nonexistent_product(self, base_url):
resp = requests.get(f"{base_url}/api/v1/products/99999", timeout=10)
assert resp.status_code == 404
class TestOrderFlow:
"""Test the complete order placement flow with gRPC inter-service calls."""
def test_place_order(self, base_url, auth_headers, test_product):
"""Place an order -- triggers gRPC calls to User + Inventory services."""
resp = requests.post(
f"{base_url}/api/v1/orders",
json={
"product_id": test_product["id"],
"quantity": 3,
},
headers=auth_headers,
timeout=15,
)
assert resp.status_code == 201, f"Order creation failed: {resp.text}"
order = resp.json()
assert order["product_id"] == test_product["id"]
assert order["quantity"] == 3
assert order["total_price"] == pytest.approx(25.50 * 3)
assert order["status"] == "confirmed"
def test_list_orders(self, base_url, auth_headers, test_product):
"""Verify the order appears in the user's order list."""
resp = requests.get(
f"{base_url}/api/v1/orders",
headers=auth_headers,
timeout=10,
)
assert resp.status_code == 200
orders = resp.json()
assert isinstance(orders, list)
assert len(orders) >= 1
latest = orders[0]
assert latest["product_id"] == test_product["id"]
def test_get_order_by_id(self, base_url, auth_headers, test_product):
"""Verify a specific order can be retrieved."""
list_resp = requests.get(
f"{base_url}/api/v1/orders",
headers=auth_headers,
timeout=10,
)
orders = list_resp.json()
order_id = orders[0]["id"]
resp = requests.get(
f"{base_url}/api/v1/orders/{order_id}",
headers=auth_headers,
timeout=10,
)
assert resp.status_code == 200
order = resp.json()
assert order["id"] == order_id
def test_stock_decremented(self, base_url, test_product):
"""Verify that placing the order decremented the stock."""
time.sleep(1)
resp = requests.get(
f"{base_url}/api/v1/products/{test_product['id']}",
timeout=10,
)
assert resp.status_code == 200
product = resp.json()
assert product["stock"] == 97, (
f"Expected stock to be 97 (100 - 3), got {product['stock']}"
)
def test_order_without_token(self, base_url, test_product):
"""Verify that placing an order without a token fails."""
resp = requests.post(
f"{base_url}/api/v1/orders",
json={"product_id": test_product["id"], "quantity": 1},
timeout=10,
)
assert resp.status_code == 401
def test_order_insufficient_stock(self, base_url, auth_headers, test_product):
"""Verify that ordering more than available stock fails."""
resp = requests.post(
f"{base_url}/api/v1/orders",
json={
"product_id": test_product["id"],
"quantity": 99999,
},
headers=auth_headers,
timeout=15,
)
assert resp.status_code == 400
The test file is organized into four test classes:
TestHealthChecks — verifies all three services are running and reachable through the Envoy gateway.
TestUserRegistrationAndAuth — tests the complete user lifecycle:
- Register a new user
- Reject duplicate registration
- Login and receive a JWT
- Reject login with wrong password
- Access protected endpoint with valid token
- Reject access without token
TestInventoryManagement — tests product operations:
- Create a product
- List products
- Get product by ID
- Handle nonexistent product
TestOrderFlow — tests the full order placement flow, which exercises inter-service gRPC communication:
- Place an order (triggers User Service token validation and Inventory Service stock check/decrement via gRPC)
- List orders for the authenticated user
- Get a specific order by ID
- Verify stock was decremented in the Inventory Service
- Reject order placement without a token
- Reject order with insufficient stock
Session-scoped fixtures ensure test data is created once and reused across the entire test session for efficiency.
Step 6.5: Run the integration tests
First, ensure the Docker Compose stack is running:
make docker-up
Wait for all services to become healthy (about 30 seconds), then run:
make integration-test
Or directly:
cd integration-tests
python -m pytest test_e2e.py -v --tb=long
Expected output: 16 passed.
Step 6.6: Verify and commit
git add -A
git commit -m "Section 6: End-to-end integration tests"
The integration tests prove that the entire system works end-to-end: user registration flows through the gateway, orders trigger cross-service gRPC calls, and stock levels update correctly.
SECTION 7: Kubernetes and Istio Production Deployment
Docker Compose is suitable for local development, but production workloads demand orchestration, auto-scaling, self-healing, and advanced traffic management. Kubernetes provides the orchestration layer, and Istio adds a service mesh with mutual TLS, traffic routing, retries, circuit breaking, and observability.
Step 7.1: Directory structure
mkdir -p k8s/{base,istio}
The base/ directory holds core Kubernetes manifests. The istio/ directory holds service mesh configuration.
Step 7.2: Namespace
File: k8s/base/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: shopflow
labels:
app.kubernetes.io/part-of: shopflow
istio-injection: enabled
The istio-injection label tells Istio to automatically inject sidecar proxies into every pod in this namespace.
Step 7.3: Secrets
File: k8s/base/secrets.yaml:
apiVersion: v1
kind: Secret
metadata:
name: shopflow-secrets
namespace: shopflow
type: Opaque
stringData:
jwt-secret: "CHANGE-THIS-IN-PRODUCTION"
user-db-password: "shopflow_secret"
order-db-password: "shopflow_secret"
inventory-db-password: "shopflow_secret"
In production, use a secrets manager (Vault, AWS Secrets Manager, etc.) rather than plain Kubernetes secrets. This file serves as a template.
Step 7.4: Database StatefulSets
Files:
- k8s/base/user-db.yaml:
apiVersion: v1
kind: Service
metadata:
name: user-db
namespace: shopflow
labels:
app: user-db
spec:
ports:
- port: 5432
targetPort: 5432
selector:
app: user-db
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: user-db
namespace: shopflow
spec:
serviceName: user-db
replicas: 1
selector:
matchLabels:
app: user-db
template:
metadata:
labels:
app: user-db
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: userdb
- name: POSTGRES_USER
value: shopflow
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: shopflow-secrets
key: user-db-password
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "shopflow", "-d", "userdb"]
initialDelaySeconds: 5
periodSeconds: 10
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
- k8s/base/order-db.yaml
apiVersion: v1
kind: Service
metadata:
name: order-db
namespace: shopflow
labels:
app: order-db
spec:
ports:
- port: 5432
targetPort: 5432
selector:
app: order-db
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: order-db
namespace: shopflow
spec:
serviceName: order-db
replicas: 1
selector:
matchLabels:
app: order-db
template:
metadata:
labels:
app: order-db
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: orderdb
- name: POSTGRES_USER
value: shopflow
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: shopflow-secrets
key: order-db-password
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "shopflow", "-d", "orderdb"]
initialDelaySeconds: 5
periodSeconds: 10
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
- k8s/base/inventory-db.yaml
apiVersion: v1
kind: Service
metadata:
name: inventory-db
namespace: shopflow
labels:
app: inventory-db
spec:
ports:
- port: 5432
targetPort: 5432
selector:
app: inventory-db
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: inventory-db
namespace: shopflow
spec:
serviceName: inventory-db
replicas: 1
selector:
matchLabels:
app: inventory-db
template:
metadata:
labels:
app: inventory-db
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_DB
value: inventorydb
- name: POSTGRES_USER
value: shopflow
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: shopflow-secrets
key: inventory-db-password
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "shopflow", "-d", "inventorydb"]
initialDelaySeconds: 5
periodSeconds: 10
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi
Each database uses a StatefulSet with a PersistentVolumeClaim for data durability. Readiness probes ensure services do not receive traffic until the database is accepting connections.
In a real production environment, consider using managed database services (RDS, Cloud SQL, etc.) instead of running PostgreSQL in Kubernetes.
Step 7.5: Application Deployments
Files:
- k8s/base/user-service.yaml:
apiVersion: v1
kind: Service
metadata:
name: user-service
namespace: shopflow
labels:
app: user-service
spec:
ports:
- name: http
port: 8001
targetPort: 8001
- name: grpc
port: 50051
targetPort: 50051
selector:
app: user-service
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
namespace: shopflow
labels:
app: user-service
spec:
replicas: 2
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
version: v1
spec:
containers:
- name: user-service
image: shopflow/user-service:latest
ports:
- containerPort: 8001
name: http
- containerPort: 50051
name: grpc
env:
- name: DATABASE_URL
value: "postgresql+asyncpg://shopflow:$(USER_DB_PASSWORD)@user-db:5432/userdb"
- name: USER_DB_PASSWORD
valueFrom:
secretKeyRef:
name: shopflow-secrets
key: user-db-password
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: shopflow-secrets
key: jwt-secret
- name: JWT_ALGORITHM
value: "HS256"
- name: JWT_EXPIRATION_MINUTES
value: "30"
- name: GRPC_PORT
value: "50051"
- name: REST_PORT
value: "8001"
readinessProbe:
httpGet:
path: /health
port: 8001
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8001
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
- k8s/base/order-service.yaml
apiVersion: v1
kind: Service
metadata:
name: order-service
namespace: shopflow
labels:
app: order-service
spec:
ports:
- name: http
port: 8002
targetPort: 8002
selector:
app: order-service
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
namespace: shopflow
labels:
app: order-service
spec:
replicas: 2
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
version: v1
spec:
containers:
- name: order-service
image: shopflow/order-service:latest
ports:
- containerPort: 8002
name: http
env:
- name: DATABASE_URL
value: "postgres://shopflow:$(ORDER_DB_PASSWORD)@order-db:5432/orderdb?sslmode=disable"
- name: ORDER_DB_PASSWORD
valueFrom:
secretKeyRef:
name: shopflow-secrets
key: order-db-password
- name: REST_PORT
value: "8002"
- name: USER_GRPC_ADDR
value: "user-service:50051"
- name: INVENTORY_GRPC_ADDR
value: "inventory-service:50052"
readinessProbe:
httpGet:
path: /health
port: 8002
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8002
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
- k8s/base/inventory-service.yaml:
apiVersion: v1
kind: Service
metadata:
name: inventory-service
namespace: shopflow
labels:
app: inventory-service
spec:
ports:
- name: http
port: 8003
targetPort: 8003
- name: grpc
port: 50052
targetPort: 50052
selector:
app: inventory-service
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: inventory-service
namespace: shopflow
labels:
app: inventory-service
spec:
replicas: 2
selector:
matchLabels:
app: inventory-service
template:
metadata:
labels:
app: inventory-service
version: v1
spec:
containers:
- name: inventory-service
image: shopflow/inventory-service:latest
ports:
- containerPort: 8003
name: http
- containerPort: 50052
name: grpc
env:
- name: DATABASE_URL
value: "postgres://shopflow:$(INVENTORY_DB_PASSWORD)@inventory-db:5432/inventorydb"
- name: INVENTORY_DB_PASSWORD
valueFrom:
secretKeyRef:
name: shopflow-secrets
key: inventory-db-password
- name: REST_PORT
value: "8003"
- name: GRPC_PORT
value: "50052"
- name: RUST_LOG
value: "info"
readinessProbe:
httpGet:
path: /health
port: 8003
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8003
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
Each service deployment includes:
- 2 replicas for high availability
- Readiness and liveness probes on the /health endpoint
- Resource requests and limits
- Environment variables sourced from Kubernetes secrets
- Version labels for Istio traffic management
Step 7.6: Istio Gateway
File: k8s/istio/gateway.yaml:
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
name: shopflow-gateway
namespace: shopflow
spec:
selector:
istio: ingressgateway
servers:
- port:
number: 80
name: http
protocol: HTTP
hosts:
- "shopflow.example.com"
- "*.shopflow.example.com"
- port:
number: 443
name: https
protocol: HTTPS
tls:
mode: SIMPLE
credentialName: shopflow-tls-cert
hosts:
- "shopflow.example.com"
- "*.shopflow.example.com"
The Istio Gateway replaces Envoy as the edge proxy. It accepts traffic on ports 80 and 443 and forwards it to the VirtualService for routing.
Step 7.7: Istio VirtualService
File: k8s/istio/virtual-service.yaml:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: shopflow-routes
namespace: shopflow
spec:
hosts:
- "shopflow.example.com"
gateways:
- shopflow-gateway
http:
- name: user-routes
match:
- uri:
prefix: /api/v1/users
route:
- destination:
host: user-service
port:
number: 8001
timeout: 30s
retries:
attempts: 3
perTryTimeout: 10s
retryOn: 5xx,reset,connect-failure
- name: order-routes
match:
- uri:
prefix: /api/v1/orders
route:
- destination:
host: order-service
port:
number: 8002
timeout: 30s
retries:
attempts: 3
perTryTimeout: 10s
retryOn: 5xx,reset,connect-failure
- name: inventory-routes
match:
- uri:
prefix: /api/v1/products
route:
- destination:
host: inventory-service
port:
number: 8003
timeout: 30s
retries:
attempts: 3
perTryTimeout: 10s
retryOn: 5xx,reset,connect-failure
The VirtualService defines URL-based routing rules identical to the Envoy configuration but with added benefits:
- Automatic retries on 5xx errors and connection failures
- Configurable timeouts per route
- Traffic splitting for canary deployments (add weight-based routing)
Step 7.8: Destination Rules
File: k8s/istio/destination-rules.yaml:
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: user-service
namespace: shopflow
spec:
host: user-service
trafficPolicy:
connectionPool:
tcp:
maxConnections: 100
http:
h2UpgradePolicy: UPGRADE
http1MaxPendingRequests: 100
http2MaxRequests: 100
outlierDetection:
consecutive5xxErrors: 3
interval: 30s
baseEjectionTime: 30s
maxEjectionPercent: 50
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: order-service
namespace: shopflow
spec:
host: order-service
trafficPolicy:
connectionPool:
tcp:
maxConnections: 100
http:
http1MaxPendingRequests: 100
http2MaxRequests: 100
outlierDetection:
consecutive5xxErrors: 3
interval: 30s
baseEjectionTime: 30s
maxEjectionPercent: 50
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: inventory-service
namespace: shopflow
spec:
host: inventory-service
trafficPolicy:
connectionPool:
tcp:
maxConnections: 100
http:
h2UpgradePolicy: UPGRADE
http1MaxPendingRequests: 100
http2MaxRequests: 100
outlierDetection:
consecutive5xxErrors: 3
interval: 30s
baseEjectionTime: 30s
maxEjectionPercent: 50
Destination rules configure connection pooling, circuit breaking, and outlier detection for each service. If a pod returns 3 consecutive 5xx errors, it is ejected from the load balancer for 30 seconds.
Step 7.9: Mutual TLS
File: k8s/istio/peer-authentication.yaml:
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: shopflow
spec:
mtls:
mode: STRICT
STRICT mTLS mode ensures all inter-service communication within the shopflow namespace is encrypted and authenticated. The gRPC calls between Order Service and the User/Inventory services are automatically secured without any code changes.
Step 7.10: Deploy to Kubernetes
# Create the namespace
kubectl apply -f k8s/base/namespace.yaml
# Deploy secrets
kubectl apply -f k8s/base/secrets.yaml
# Deploy databases
kubectl apply -f k8s/base/user-db.yaml
kubectl apply -f k8s/base/order-db.yaml
kubectl apply -f k8s/base/inventory-db.yaml
# Wait for databases to be ready
kubectl -n shopflow wait --for=condition=ready pod -l app=user-db --timeout=120s
kubectl -n shopflow wait --for=condition=ready pod -l app=order-db --timeout=120s
kubectl -n shopflow wait --for=condition=ready pod -l app=inventory-db --timeout=120s
# Deploy application services
kubectl apply -f k8s/base/user-service.yaml
kubectl apply -f k8s/base/inventory-service.yaml
kubectl apply -f k8s/base/order-service.yaml
# Deploy Istio configuration
kubectl apply -f k8s/istio/
# Verify all pods are running
kubectl -n shopflow get pods
Step 7.11: Verify and commit
git add -A
git commit -m "Section 7: Kubernetes manifests and Istio service mesh"
The platform is now deployable to any Kubernetes cluster with Istio installed. The service mesh provides mTLS, circuit breaking, retries, and traffic management without any application code changes.
SECTION 8: Complete Makefile and Project Polish
The final step is ensuring the Makefile provides a single, consistent interface for every operation a developer or CI pipeline might need.
Step 8.1: Final Makefile
File: Makefile:
.PHONY: help proto-gen proto-clean \
user-service-run user-service-test user-service-lint \
inventory-service-build inventory-service-run inventory-service-test \
order-service-build order-service-run order-service-test \
docker-up docker-down docker-build docker-logs docker-restart \
integration-test test-all clean \
k8s-deploy k8s-delete k8s-status
SHELL := /bin/bash
DOCKER_COMPOSE := docker compose
help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
# ---------------------------------------------------------------------------
# Protobuf Generation
# ---------------------------------------------------------------------------
proto-gen: proto-gen-python proto-gen-go proto-gen-rust ## Generate all protobuf files
proto-gen-python: ## Generate Python protobuf stubs
@mkdir -p user-service/app/proto
cd user-service && .venv/bin/python -m grpc_tools.protoc \
-I ../proto \
--python_out=app/proto \
--grpc_python_out=app/proto \
--pyi_out=app/proto \
../proto/user.proto ../proto/inventory.proto
@touch user-service/app/proto/__init__.py
proto-gen-go: ## Generate Go protobuf stubs
@mkdir -p order-service/proto/userpb order-service/proto/inventorypb
protoc -I proto \
--go_out=order-service/proto --go_opt=paths=source_relative \
--go-grpc_out=order-service/proto --go-grpc_opt=paths=source_relative \
proto/user.proto proto/inventory.proto
@mv -f order-service/proto/user.pb.go order-service/proto/userpb/ 2>/dev/null || true
@mv -f order-service/proto/user_grpc.pb.go order-service/proto/userpb/ 2>/dev/null || true
@mv -f order-service/proto/inventory.pb.go order-service/proto/inventorypb/ 2>/dev/null || true
@mv -f order-service/proto/inventory_grpc.pb.go order-service/proto/inventorypb/ 2>/dev/null || true
proto-gen-rust: ## Generate Rust protobuf stubs (handled by build.rs)
@echo "Rust protobuf generation is handled by build.rs during cargo build"
proto-clean: ## Remove generated protobuf files
rm -f user-service/app/proto/*_pb2*.py user-service/app/proto/*_pb2*.pyi
rm -f order-service/proto/userpb/*.go order-service/proto/inventorypb/*.go
# ---------------------------------------------------------------------------
# User Service (Python / FastAPI)
# ---------------------------------------------------------------------------
user-service-setup: ## Set up user service virtual environment
cd user-service && python3 -m venv .venv && \
.venv/bin/pip install -r requirements.txt -q
user-service-run: ## Run user service locally
cd user-service && .venv/bin/python -m uvicorn app.main:app --reload --port 8001
user-service-test: ## Run user service unit tests
cd user-service && .venv/bin/python -m pytest tests/ -v --tb=short
user-service-lint: ## Lint user service
cd user-service && .venv/bin/python -m flake8 app/ tests/ --max-line-length=120 || true
# ---------------------------------------------------------------------------
# Inventory Service (Rust)
# ---------------------------------------------------------------------------
inventory-service-build: ## Build inventory service
cd inventory-service && cargo build
inventory-service-run: ## Run inventory service locally
cd inventory-service && cargo run
inventory-service-test: ## Run inventory service unit tests
cd inventory-service && cargo test -- --nocapture
# ---------------------------------------------------------------------------
# Order Service (Go)
# ---------------------------------------------------------------------------
order-service-build: ## Build order service
cd order-service && go build -o bin/server ./cmd/server
order-service-run: ## Run order service locally
cd order-service && go run ./cmd/server
order-service-test: ## Run order service unit tests
cd order-service && go test ./... -v -count=1
# ---------------------------------------------------------------------------
# Docker Compose
# ---------------------------------------------------------------------------
docker-build: ## Build all Docker images
$(DOCKER_COMPOSE) build
docker-up: ## Start all services with Docker Compose
$(DOCKER_COMPOSE) up -d --build
@echo ""
@echo "Waiting for services to become healthy..."
@sleep 15
@echo "ShopFlow is running at http://localhost:8000"
@echo ""
@echo " Gateway: http://localhost:8000"
@echo " User API: http://localhost:8001"
@echo " Order API: http://localhost:8002"
@echo " Inventory: http://localhost:8003"
@echo " Envoy Admin: http://localhost:9901"
docker-down: ## Stop all services and remove volumes
$(DOCKER_COMPOSE) down -v
docker-logs: ## Tail logs for all services
$(DOCKER_COMPOSE) logs -f
docker-restart: ## Restart all services
$(DOCKER_COMPOSE) down -v && $(DOCKER_COMPOSE) up -d --build
docker-ps: ## Show running containers
$(DOCKER_COMPOSE) ps
# ---------------------------------------------------------------------------
# Integration Tests
# ---------------------------------------------------------------------------
integration-test: ## Run integration tests (requires docker-up)
cd integration-tests && pip install -r requirements.txt -q && \
python -m pytest test_e2e.py -v --tb=long
# ---------------------------------------------------------------------------
# Kubernetes
# ---------------------------------------------------------------------------
k8s-deploy: ## Deploy to Kubernetes
kubectl apply -f k8s/base/namespace.yaml
kubectl apply -f k8s/base/secrets.yaml
kubectl apply -f k8s/base/user-db.yaml
kubectl apply -f k8s/base/order-db.yaml
kubectl apply -f k8s/base/inventory-db.yaml
@echo "Waiting for databases..."
kubectl -n shopflow wait --for=condition=ready pod -l app=user-db --timeout=120s
kubectl -n shopflow wait --for=condition=ready pod -l app=order-db --timeout=120s
kubectl -n shopflow wait --for=condition=ready pod -l app=inventory-db --timeout=120s
kubectl apply -f k8s/base/user-service.yaml
kubectl apply -f k8s/base/inventory-service.yaml
kubectl apply -f k8s/base/order-service.yaml
kubectl apply -f k8s/istio/
@echo "Deployment complete. Run 'make k8s-status' to check."
k8s-delete: ## Remove all Kubernetes resources
kubectl delete namespace shopflow --ignore-not-found
k8s-status: ## Show Kubernetes pod status
kubectl -n shopflow get pods,svc,gateway,virtualservice
# ---------------------------------------------------------------------------
# Combined Targets
# ---------------------------------------------------------------------------
test-all: user-service-test inventory-service-test order-service-test ## Run all unit tests
clean: proto-clean ## Clean all build artifacts
rm -rf user-service/__pycache__ user-service/.pytest_cache user-service/test.db
rm -rf order-service/bin
cd inventory-service && cargo clean 2>/dev/null || true
The complete Makefile includes targets organized into logical groups:
Protobuf Generation: make proto-gen — generate stubs for all languages make proto-gen-python — generate Python stubs only make proto-gen-go — generate Go stubs only make proto-gen-rust — (handled by build.rs) make proto-clean — remove generated files
Per-Service Development: make user-service-setup — create virtual environment and install deps make user-service-run — run with auto-reload make user-service-test — run unit tests
make inventory-service-build — compile the binary make inventory-service-run — run locally make inventory-service-test — run unit tests
make order-service-build — compile the binary make order-service-run — run locally make order-service-test — run unit tests
Docker Compose: make docker-up — build and start everything make docker-down — stop and remove volumes make docker-logs — tail all service logs make docker-restart — full restart
Testing: make test-all — run all unit tests make integration-test — run end-to-end tests
Kubernetes: make k8s-deploy — deploy to a cluster make k8s-delete — tear down the namespace make k8s-status — show pod and service status
Step 8.2: Verify everything
Run the complete unit test suite:
make user-service-test
make inventory-service-test
make order-service-test
Or all at once:
make test-all
Step 8.3: Verify and commit
git add -A
git commit -m "Section 8: Complete Makefile and project polish"
CONCLUSION
You have built ShopFlow, a production-ready polyglot microservices platform from the ground up. Here is what you accomplished:
- Defined shared gRPC contracts using Protocol Buffers, ensuring type-safe communication across three programming languages.
- Built a User Service in Python with FastAPI, featuring JWT authentication, async database access, and a gRPC server for internal token validation.
- Built an Inventory Service in Rust with Actix-web and Tonic, leveraging Rust’s performance and safety for stock management operations.
- Built an Order Service in Go that orchestrates the other services through gRPC, demonstrating Go’s strengths in networked service development.
- Created a Docker Compose environment with Envoy as an API gateway, giving you a one-command local development setup.
- Wrote comprehensive unit tests for every service and end-to-end integration tests that verify the full request flow across all services.
- Prepared Kubernetes manifests with Istio service mesh configuration for production deployment, including mTLS, circuit breaking, retries, and traffic management.
- Built a Makefile that serves as the single entry point for every development, testing, and deployment operation.
Key architectural takeaways:
- Use the right language for the right job. Python excels at rapid API development, Go at concurrent network services, and Rust at performance- critical systems.
- gRPC provides strongly-typed, efficient inter-service communication that works across any language with protobuf support.
- Interface-based dependency injection (as demonstrated in the Go service) makes microservices testable without spinning up the entire stack.
- Docker Compose and Kubernetes serve different stages of the same lifecycle. Design for Docker Compose first, then map to Kubernetes manifests.
- A service mesh like Istio adds security (mTLS), reliability (retries, circuit breaking), and observability without changing application code.
Next steps for a production deployment:
- Add CI/CD pipelines (GitHub Actions, GitLab CI)
- Implement distributed tracing with OpenTelemetry
- Add Prometheus metrics and Grafana dashboards
- Set up centralized logging with the ELK stack or Loki
- Implement rate limiting at the gateway layer
- Add database migrations with versioned migration tools
- Implement health check aggregation and alerting