Polyglot Microservices Polyglot Microservices

Polyglot Microservices: The Definitive Guide to Building Production-Ready Services with Python, Go, and Rust

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.

Polyglot Microservices

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:

  1. GRPCClients — the real implementation connecting to User and Inventory services.
  2. ServiceClients — an interface that both the real and mock clients satisfy.
  3. 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)
    1. Validates the JWT via gRPC call to User Service
    2. Fetches product details via gRPC call to Inventory Service
    3. Checks stock availability
    4. Creates the order in the database
    5. 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:

  1. user-db — PostgreSQL for user data (port 5433)
  2. order-db — PostgreSQL for order data (port 5434)
  3. inventory-db — PostgreSQL for inventory data (port 5435)
  4. user-service — Python/FastAPI (REST 8001, gRPC 50051)
  5. inventory-service — Rust/Actix (REST 8003, gRPC 50052)
  6. order-service — Go (REST 8002)
  7. 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:

  1. Defined shared gRPC contracts using Protocol Buffers, ensuring type-safe communication across three programming languages.
  2. Built a User Service in Python with FastAPI, featuring JWT authentication, async database access, and a gRPC server for internal token validation.
  3. Built an Inventory Service in Rust with Actix-web and Tonic, leveraging Rust’s performance and safety for stock management operations.
  4. Built an Order Service in Go that orchestrates the other services through gRPC, demonstrating Go’s strengths in networked service development.
  5. Created a Docker Compose environment with Envoy as an API gateway, giving you a one-command local development setup.
  6. Wrote comprehensive unit tests for every service and end-to-end integration tests that verify the full request flow across all services.
  7. Prepared Kubernetes manifests with Istio service mesh configuration for production deployment, including mTLS, circuit breaking, retries, and traffic management.
  8. 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