Persistent Checkout Microservices
Build a checkout system with separate databases and HTTP stock reservations.
Introduction
30 Second Summary
Online checkout feels like one click, yet the order record and stock count must agree. A restart should preserve both results.
In this project, you will build a local checkout backend with two FastAPI microservices: Order and Inventory. Docker Compose will run each service with its own PostgreSQL database.
What You'll Build
Picture submitting an order for two keyboards in the Order service's interactive API page while the Inventory service's stock count drops from 5 to 3.
One checkout crosses an HTTP boundary, while each service keeps sole ownership of its database and persistent volume.
This roadmap follows the backend from one temporary API to two cooperating services with durable data and retry protection.
By the end of this project, you'll have:
- A live checkout flow at http://127.0.0.1:8000/docs that returns a confirmed keyboard order or an HTTP 409 response when stock is insufficient.
- An independent inventory view at http://127.0.0.1:8001/docs where you can watch keyboard stock fall after a reservation.
- A persistent checkout state that keeps each service's data after you recreate its containers.
- Secret Mission: Add an idempotency boundary that returns the same order for a repeated client reference without reducing stock twice.
Are there any prerequisites?
You should be comfortable building a FastAPI API on Linux.
The project also needs Docker Engine, Docker Compose, Visual Studio Code, and approximately 1 GB of free disk space.
Before We Start
Before the hands-on work begins, commit to the system you are building. You will create a local checkout system where the Order service and Inventory service each control a separate durable database.
Verify Your Linux Workspace
Your checkout backend depends on several containers plus local networking. A tooling problem here can look like an application failure later.
First, you will verify Docker Engine from Visual Studio Code's integrated terminal. You will check Docker Compose from the same place.
Next, you will check the Visual Studio Code command-line interface. A test container will prove the Docker runtime can complete real work.
The checks in this step prove that commands from your editor can reach Docker Engine, obtain an image, and run a container successfully.
In this step, get ready to:
- Confirm the local development tools report their versions.
- Run a test container through Docker Engine.
- Create the persistent-checkout workspace in VS Code.
Verify Docker Engine
Docker Engine includes a client that sends commands to a server. Seeing information for both parts confirms your terminal can reach the running engine.
- Select Terminal in the VS Code menu bar.
- Select New Terminal to open the integrated terminal.
- Check the Docker client plus server by running this command:
docker version
What Does This Check?
The command asks Docker for information about its command-line client. It also contacts the Docker server that creates containers.
You should see separate client and server sections. Their presence confirms the Docker service is reachable.
Missing Docker Information?
- Confirm the Docker service is running on your Linux system.
- Confirm your Linux user has permission to access Docker.
Use this if the client or server check is still failing: Help me diagnose my Docker Engine check.
Verify Docker Compose and VS Code
Docker Compose coordinates multiple containers from one configuration file. The VS Code command-line interface lets your terminal open a folder as a workspace.
- Check the Compose plugin plus the editor command-line interface by running these commands:
docker compose version
code --version
What Do These Checks Confirm?
- The first command confirms that Docker Compose is available through Docker.
- The second command confirms that your shell can run the VS Code command-line interface.
You should see version details after each command. The shell should return to its prompt without reporting a missing command.
Is a Command Unavailable?
- Confirm the Docker Compose plugin is available in your Docker installation.
- Confirm the VS Code command-line interface is available from your shell.
Use this if one of the checks still fails: Help me troubleshoot the missing command.
Test Docker and prepare the workspace
A container smoke test checks the complete local runtime path. The hello-world image prints a success message after Docker runs it.
Before you run the test, consider whether Docker can reach its success message with no project code in place.
- Run the Docker smoke test with this command:
docker run --rm hello-world
What Does the Smoke Test Prove?
Docker obtains the test image before creating its container. The --rm option removes that container after it exits.
You should see Docker's success message. This confirms the engine can obtain an image and run a container.
Does the Test Container Fail?
- Check your internet connection if Docker cannot obtain the test image.
- Confirm the Docker service remains running if the container cannot start.
Use this if the smoke test still fails: Help me diagnose the Docker smoke test.
The runtime is working. Your next commands give every project file a predictable home on the Desktop.
- Move to your Desktop with the first command below.
- Create the workspace and open it by running the second command below:
cd ~/Desktop
mkdir -p persistent-checkout && cd persistent-checkout && code .
What Does This Workspace Command Do?
- The first line moves the terminal to your Desktop.
- The mkdir -p command creates the persistent-checkout folder.
- The next command changes the terminal's current directory to that folder.
- The code . command opens the current folder in VS Code.
VS Code opens persistent-checkout as the current workspace. The Explorer is ready for the service folders you create next.
Does the Workspace Stay Closed?
- Confirm the earlier VS Code command-line interface check returned version details.
- Look for another VS Code window containing the persistent-checkout workspace.
Use this if the workspace still does not open: Help me open my workspace from the terminal.
- Select Terminal in the workspace window's menu bar.
- Select New Terminal to start a terminal inside persistent-checkout.
Before the final check, consider whether opening a new workspace changes the terminal's access to Docker.
- Confirm Docker still runs from the workspace by running this command:
docker run --rm hello-world
What Does the Final Run Confirm?
The repeated smoke test runs from the integrated terminal inside persistent-checkout. Its success proves your workspace can reach Docker before you add any services.
You should see Docker's success message again. Your editor, terminal, and container runtime are ready for the project.
Does Docker Fail in the Workspace?
- Confirm the new terminal belongs to the VS Code window showing persistent-checkout.
- Confirm the Docker service is still running on your Linux system.
Use this if Docker worked earlier but fails here: Help me compare the two terminal environments.
Your local toolchain is ready. Next, you will build an Order service and watch its in-memory data disappear after container recreation.
Build an In-Memory Order Service
The Docker check from the previous step proves your machine can run a container. Your checkout now needs a working Order service that accepts requests.
This first version keeps orders in process memory. You will test what that storage choice means when the service is recreated.
In this step, get ready to:
- Build an Order service with health, creation, and listing operations.
- Package the service as a container managed by Docker Compose.
- Recreate the container to test whether a submitted order survives.
Create the in-memory API
A FastAPI application maps HTTP paths to Python functions. The shared orders list gives those functions a temporary place to store submitted orders.
- Select the Explorer view in the VS Code Activity Bar.
- Create an order-service folder inside persistent-checkout by using the Explorer context menu.
- Select the new order-service folder.
- Select New File... in the Explorer.
- Enter main.py as the file name.
- Add the application setup plus the read operations by pasting this code into main.py:
from typing import Annotated
from fastapi import Body, FastAPI, HTTPException
app = FastAPI(title="Order Service")
orders: list[dict] = []
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/orders")
def list_orders() -> list[dict]:
return orders
What Does This Code Do?
- The app object defines an Order service that FastAPI can run.
- The orders list holds order dictionaries inside the running Python process.
- The health() operation provides a simple readiness response at /health.
- The list_orders() operation returns the current list at /orders.
- Save order-service/main.py.
- Confirm main.py appears inside order-service in the Explorer.
Is the File in the Wrong Place?
Check that main.py is nested inside order-service. Move the file into that folder if it appears beside it.
Confirm the file name ends with .py.
Help me check my Order service file location.
The creation operation validates each request before adding an order. A valid order receives an ID plus a confirmed status.
- Place your cursor after the list_orders() function in main.py.
- Add the order creation operation by pasting this code below it:
@app.post("/orders")
def create_order(payload: Annotated[dict, Body()]) -> dict:
sku = str(payload.get("sku", "")).strip()
quantity = payload.get("quantity")
if not sku or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity < 1:
raise HTTPException(
status_code=400,
detail="sku must be non-empty and quantity must be a positive integer",
)
order = {
"id": len(orders) + 1,
"sku": sku,
"quantity": quantity,
"status": "confirmed",
}
orders.append(order)
return order
How Does Order Creation Work?
- The create_order() operation reads sku plus quantity from the request body.
- The validation rejects blank product codes plus invalid quantities before changing the list.
- The new order receives the next numeric ID plus the literal status confirmed.
- The append() call places the order in the shared in-memory list.
- Save order-service/main.py.
- Confirm the file now contains the POST /orders decorator below the two read operations.
Seeing Editor Warnings?
Confirm every opening parenthesis has a matching closing parenthesis. Check that the lines inside create_order() remain indented.
Package import warnings are expected until the container installs the dependencies in the next substep.
Help me compare my in-memory Order service code.
✔️ Awesome, I've got everything!
Great. Your Order service now has its in-memory list plus all three operations.
ⓧ I'd like to double check the full code
from typing import Annotated
from fastapi import Body, FastAPI, HTTPException
app = FastAPI(title="Order Service")
orders: list[dict] = []
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/orders")
def list_orders() -> list[dict]:
return orders
@app.post("/orders")
def create_order(payload: Annotated[dict, Body()]) -> dict:
sku = str(payload.get("sku", "")).strip()
quantity = payload.get("quantity")
if not sku or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity < 1:
raise HTTPException(
status_code=400,
detail="sku must be non-empty and quantity must be a positive integer",
)
order = {
"id": len(orders) + 1,
"sku": sku,
"quantity": quantity,
"status": "confirmed",
}
orders.append(order)
return order
The successful response proves that the containerized API can validate and create an order while its process is running.
Package the Order service
The container needs exact package versions so every build uses the same application stack. This service starts with FastAPI 0.141.1 plus Uvicorn 0.52.4.
- Select the order-service folder in the Explorer.
- Select New File....
- Enter requirements.txt as the file name.
- Pin the service dependencies by pasting this code into requirements.txt:
fastapi==0.141.1
uvicorn==0.52.4
Why Pin These Packages?
The exact pins keep the web framework plus application server consistent across container builds. This removes package drift from your persistence experiment.
- Save order-service/requirements.txt.
- Confirm requirements.txt appears beside main.py in the Explorer.
Do the Package Pins Look Different?
Confirm each dependency uses two equals signs. Check that each package appears on its own line.
Help me check my pinned Python dependencies.
✔️ Awesome, I've got everything!
Your dependency file now pins both packages used by this service.
ⓧ I'd like to double check the full code
fastapi==0.141.1
uvicorn==0.52.4
A Dockerfile describes how Docker turns the Python source into a runnable image. Its final instruction starts the API inside the container.
- Select the order-service folder in the Explorer.
- Select New File....
- Enter Dockerfile as the file name.
- Define the container image by pasting this code into Dockerfile:
FROM python:3.13.15-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
What Does This Dockerfile Do?
- The FROM instruction starts from the python:3.13.15-slim image.
- The WORKDIR plus COPY instructions place the service files inside /app.
- The RUN instruction installs the pinned dependencies during the image build.
- The final CMD starts Uvicorn with main:app on container port 8000.
- Save order-service/Dockerfile.
- Confirm Dockerfile appears beside the two Order service files.
Is the Dockerfile Named Incorrectly?
Confirm the name is exactly Dockerfile with no extension. Check that it sits inside order-service.
Help me check my Order service Dockerfile.
✔️ Awesome, I've got everything!
Your Order service now has a complete container build definition.
ⓧ I'd like to double check the full code
FROM python:3.13.15-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
The replacement process starts with a fresh Python list, so the earlier order disappears even though the API still runs.
A Docker Compose file gives the container a service name plus a host port. The mapping keeps the API reachable only through your local machine at port 8000.
- Select the persistent-checkout workspace folder in the Explorer.
- Select New File....
- Enter compose.yaml as the file name.
- Define the Order service by pasting this code into compose.yaml:
services:
order-service:
build: ./order-service
ports:
- "127.0.0.1:8000:8000"
What Does This Compose File Do?
- The order-service entry gives the container a stable service name.
- The build value points Compose at the files inside order-service.
- The 127.0.0.1:8000:8000 mapping publishes container port 8000 on the same port of your local machine.
- Save compose.yaml.
- Confirm compose.yaml appears at the top level of persistent-checkout.
Is Compose Unable to Find the Build?
Confirm compose.yaml sits beside the order-service folder. Check that the build path is exactly ./order-service.
Help me check my Compose file layout.
✔️ Awesome, I've got everything!
Your workspace now contains every file needed to build the Order service.
ⓧ I'd like to double check the full code
services:
order-service:
build: ./order-service
ports:
- "127.0.0.1:8000:8000"
Prove the container forgets
Compose can now build the image plus start the Order service. The first build can take longer while Docker downloads the Python image plus installs the packages.
- Switch back to the VS Code integrated terminal from earlier.
- Build the image plus start the Order service by running this command:
docker compose up --build --wait
What Does This Command Do?
- The up command creates plus starts the service from compose.yaml.
- The --build flag builds the image from order-service/Dockerfile.
- The --wait flag keeps the command active until the service reaches its running state.
When the command returns control to the terminal, the Order service is running on your local port 8000.
Does the Build Stop Early?
Check that the dependency file is named requirements.txt. Confirm it sits in the same folder as Dockerfile.
Compare the first reported file path with the Explorer. A misplaced file prevents Docker from copying it into the image.
Help me diagnose my Order service container build.
The Swagger UI page turns each API operation into an interactive test. You can use it to create an order without writing a separate client.
- Open http://127.0.0.1:8000/docs in your browser.
- Expand POST /orders.
- Click Try it out.
- Replace the request body with {"sku":"keyboard","quantity":1}.
- Click Execute.
The response body shows an order with id set to 1. It also shows keyboard plus a confirmed status.
- Expand GET /orders.
- Click Try it out.
- Click Execute.
The response contains the same keyboard order. Your creation plus listing operations now share the in-memory orders list.
Before you recreate the service, do you think GET /orders will return the keyboard order again?
- Return to the VS Code integrated terminal.
- Remove the running container plus its Compose network by running this command:
docker compose down
What Does This Teardown Remove?
The command removes the service container plus the default network created for this stack. The image plus project files remain available.
The terminal confirms that the container plus network have been removed.
- Rebuild plus start a fresh Order service container by running this command:
docker compose up --build --wait
What Changes During Recreation?
Compose starts a new container process from the same image. The new process creates a fresh empty orders list when main.py loads.
The command returns control once the replacement Order service is running.
- Return to http://127.0.0.1:8000/docs in your browser.
- Expand GET /orders.
- Click Try it out.
- Click Execute.
You will see an empty array shown as []. The order lived only in the old process's orders list.
What Does the Empty List Prove?
Container recreation starts the application with new process memory. The API still works because its source files remain in the image.
The submitted order disappeared because no durable storage exists yet. This is the persistence gap your next step closes.
🙋♀️ Does the Old Order Still Appear?
Click Execute again after the replacement service is running. An older response can remain visible higher on the Swagger UI page.
Confirm the terminal completed both Compose commands before checking the list.
Help me verify the in-memory reset.
That is the baseline proven: your Order service handles requests while its container is running. Next, you will give it a service-owned PostgreSQL database so orders survive recreation.
Give Orders Their Own Database
Your in-memory Order service handled requests inside a container. Recreating that container exposed the missing source of truth.
A service-owned PostgreSQL database keeps orders beyond the life of one process. A persistent volume keeps the database files beyond the life of one container.
PostgreSQL owns the order rows, while the named volume keeps its database files independent from the containers.
In this step, get ready to:
- Add a PostgreSQL database with its own persistent volume.
- Replace the in-memory list with Psycopg database queries.
- Recreate the containers without losing the saved order.
Create the Order database
The Order service needs exclusive ownership of its database. Docker Compose gives that database a separate service named order-db.
These credentials are local development values for this stack. The order-data named volume protects the database files during container recreation.
- Select compose.yaml in the VS Code Explorer sidebar.
- Replace the opening services: line with this database service definition:
services:
order-db:
image: postgres:18.6
environment:
POSTGRES_DB: orders
POSTGRES_USER: order_user
POSTGRES_PASSWORD: local_order_password
volumes:
- order-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
What Does This Configuration Do?
- The postgres:18.6 image runs PostgreSQL 18.6 in a separate container.
- The initialization variables create the orders database for order_user.
- The volume mount stores PostgreSQL files under /var/lib/postgresql.
- The health check tests whether PostgreSQL can accept connections.
- Add the named volume declaration at the end of compose.yaml using this code:
volumes:
order-data:
Why Declare the Volume?
The top-level declaration asks Docker to manage order-data independently from the database container. Standard container recreation leaves this volume in place.
- Save compose.yaml.
- Return to the integrated terminal from earlier.
- Start the database alongside the current Order service by running:
docker compose up --build --wait
What Does This Command Do?
Docker Compose builds the Order service image. It starts both services.
The --wait flag keeps the command active until the services are running or healthy.
You will see the command finish after order-db becomes healthy. The first run may take a few minutes while Docker downloads the PostgreSQL image.
Does the Database Fail to Start?
Confirm that order-data appears under the top-level volumes section. Check that the database volume target is /var/lib/postgresql.
Check the indentation beneath order-db. YAML nesting depends on spaces.
Help me diagnose the PostgreSQL container startup.
The database now runs beside the Order service. The application still needs connection settings before it can use that database.
- Find the port mapping under order-service in compose.yaml.
- Add these connection settings directly below the port mapping:
environment:
PGHOST: order-db
PGPORT: "5432"
PGDATABASE: orders
PGUSER: order_user
PGPASSWORD: local_order_password
depends_on:
order-db:
condition: service_healthy
How Does the Service Find Its Database?
- The PGHOST value uses order-db as the database hostname on the Compose network.
- The other connection variables identify the database port plus the local database credentials.
- The dependency condition waits for the database health check before starting the Order service.
- Save compose.yaml.
- Apply the connection settings by running:
docker compose up --build --wait
What Changes on This Run?
Docker Compose recreates the Order service with its PostgreSQL connection settings. The service waits for order-db to become healthy.
- Return to http://127.0.0.1:8000/docs in the browser from earlier.
- Call GET /health from the interactive API documentation.
You will see a response containing {"status":"ok"}. The new database settings have not disrupted the existing API.
Connect FastAPI to PostgreSQL
Psycopg lets Python send SQL to PostgreSQL. The Order service will initialize its table during the FastAPI lifespan hook.
The list plus create operations will query that table directly. The process-level list can then leave the application.
- Select order-service/requirements.txt in the Explorer sidebar.
- Add the pinned Psycopg dependency below the Uvicorn dependency:
psycopg[binary]==3.3.4
Why Use the Binary Extra?
The binary extra installs Psycopg with its binary implementation. The exact 3.3.4 pin keeps the container build reproducible.
- Save order-service/requirements.txt.
- Install the new dependency in the rebuilt image by running:
docker compose up --build --wait
What Happens During the Build?
The Dockerfile installs the updated requirements inside the Order service image. The existing in-memory API starts again after the build.
The command returns when the database is healthy. The Order service is ready for its database initialization code.
- Select order-service/main.py in the Explorer sidebar.
- Replace everything from the first import through the existing app = FastAPI(title="Order Service") line with this code:
from contextlib import asynccontextmanager
from typing import Annotated
import psycopg
from fastapi import Body, FastAPI, HTTPException
from psycopg.rows import dict_row
def initialize_database() -> None:
with psycopg.connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
status TEXT NOT NULL
)
"""
)
@asynccontextmanager
async def lifespan(app: FastAPI):
initialize_database()
yield
app = FastAPI(title="Order Service", lifespan=lifespan)
What Does This Code Do?
- The initialize_database() function connects with the environment settings from compose.yaml.
- The SQL creates the orders table only when that table is missing.
- The lifespan() hook initializes the table before the API handles requests.
- The updated FastAPI() call activates that lifespan hook.
- Keep the existing orders: list[dict] = [] line in place for this checkpoint.
- Save order-service/main.py.
- Initialize the database table by running:
docker compose up --build --wait
What Does This Run Prove?
The Order service connects to PostgreSQL during startup. The service starts only after the orders table initialization succeeds.
- Return to the interactive API documentation in your browser.
- Call GET /health.
You will see {"status":"ok"}. The lifespan hook completed without blocking application startup.
The same database-generated order ID remains available because the new PostgreSQL container mounts the existing volume.
Does the Order Service Stop During Startup?
Confirm that PGHOST is set to order-db. Check that order-service depends on a healthy database.
Compare the database name plus username with the matching initialization values under order-db.
Help me diagnose the Order service startup.
The table now exists. The read operation can use it as the source of truth.
- Find the existing list_orders() function in order-service/main.py.
- Replace the entire function with this database-backed version:
@app.get("/orders")
def list_orders() -> list[dict]:
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
return cursor.execute(
"SELECT id, sku, quantity, status FROM orders ORDER BY id"
).fetchall()
How Does Order Listing Work?
- The connection opens for this request.
- The dict_row row factory converts each database row into a dictionary.
- The query returns orders in their database ID order.
- Save order-service/main.py.
- Rebuild the Order service with the new read operation by running:
docker compose up --build --wait
What Changes After the Rebuild?
The GET /orders operation now reads from PostgreSQL. The new database starts with no order rows.
- Return to the interactive API documentation.
- Call GET /orders.
You will see an empty JSON list. This result comes from the new PostgreSQL table.
The write operation is the last piece. It will insert a confirmed row plus return the values generated by PostgreSQL.
- Find the existing create_order() function in order-service/main.py.
- Replace the entire function with this database-backed version:
@app.post("/orders")
def create_order(payload: Annotated[dict, Body()]) -> dict:
sku = str(payload.get("sku", "")).strip()
quantity = payload.get("quantity")
if not sku or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity < 1:
raise HTTPException(
status_code=400,
detail="sku must be non-empty and quantity must be a positive integer",
)
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
order = cursor.execute(
"""
INSERT INTO orders (sku, quantity, status)
VALUES (%s, %s, %s)
RETURNING id, sku, quantity, status
""",
(sku, quantity, "confirmed"),
).fetchone()
return order
How Does Order Creation Work?
- The existing validation still rejects an empty SKU or an invalid quantity.
- The parameterized SQL inserts the validated values into orders.
- The RETURNING clause retrieves the generated ID plus the saved order fields.
- The connection context commits the successful insert when it exits.
- Remove the old orders: list[dict] = [] line from order-service/main.py.
- Save order-service/main.py.
✔️ Awesome, I've got everything!
Great. Your Order service code now uses its PostgreSQL database as the source of truth.
ⓧ I'd like to double check the full code
Your completed project files should match these references before you test persistence.
services:
order-db:
image: postgres:18.6
environment:
POSTGRES_DB: orders
POSTGRES_USER: order_user
POSTGRES_PASSWORD: local_order_password
volumes:
- order-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
order-service:
build: ./order-service
ports:
- "127.0.0.1:8000:8000"
environment:
PGHOST: order-db
PGPORT: "5432"
PGDATABASE: orders
PGUSER: order_user
PGPASSWORD: local_order_password
depends_on:
order-db:
condition: service_healthy
volumes:
order-data:
fastapi==0.141.1
uvicorn==0.52.4
psycopg[binary]==3.3.4
FROM python:3.13.15-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
from contextlib import asynccontextmanager
from typing import Annotated
import psycopg
from fastapi import Body, FastAPI, HTTPException
from psycopg.rows import dict_row
def initialize_database() -> None:
with psycopg.connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
status TEXT NOT NULL
)
"""
)
@asynccontextmanager
async def lifespan(app: FastAPI):
initialize_database()
yield
app = FastAPI(title="Order Service", lifespan=lifespan)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/orders")
def list_orders() -> list[dict]:
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
return cursor.execute(
"SELECT id, sku, quantity, status FROM orders ORDER BY id"
).fetchall()
@app.post("/orders")
def create_order(payload: Annotated[dict, Body()]) -> dict:
sku = str(payload.get("sku", "")).strip()
quantity = payload.get("quantity")
if not sku or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity < 1:
raise HTTPException(
status_code=400,
detail="sku must be non-empty and quantity must be a positive integer",
)
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
order = cursor.execute(
"""
INSERT INTO orders (sku, quantity, status)
VALUES (%s, %s, %s)
RETURNING id, sku, quantity, status
""",
(sku, quantity, "confirmed"),
).fetchone()
return order
Prove the order survives recreation
The API now writes orders to the named volume through PostgreSQL. Container recreation will test whether that storage boundary works.
- Return to the integrated terminal.
- Rebuild the completed database-backed service by running:
docker compose up --build --wait
What Does This Build Apply?
The rebuilt image contains the final database-backed read plus write operations. Docker Compose starts the API after PostgreSQL becomes healthy.
- Return to http://127.0.0.1:8000/docs.
- Expand POST /orders.
- Enable request body editing.
- Enter the valid order request body you used in the previous step.
- Submit the request.
You will see a confirmed order with a database-generated ID. The response also includes the SKU plus quantity you submitted.
- Expand GET /orders.
- Submit the list request.
You will see the confirmed order in the returned list. That row is now stored in PostgreSQL.
Before you recreate the stack, do you expect the order row to remain after its container disappears?
- Return to the integrated terminal.
- Remove the service containers plus their default network by running:
docker compose down
What Does This Remove?
The command removes the service containers plus the Compose network. It leaves the declared order-data volume intact.
The terminal confirms that the containers have been removed. The saved database files still exist outside them.
- Recreate the stack from the saved project files by running:
docker compose up --build --wait
What Happens During Recreation?
Docker Compose creates fresh containers. PostgreSQL mounts the existing order-data volume.
The lifespan hook safely checks the existing table. The API then starts against the preserved rows.
- Return to the interactive API documentation.
- Call GET /orders again.
You will see the same confirmed order with the same database-generated ID. The named volume preserved the row across container recreation.
Did the Order Disappear?
Confirm that you used docker compose down without the volume-removal option. Removing the named volume permanently deletes its database files.
Check that order-db mounts order-data:/var/lib/postgresql.
Help me diagnose the missing persisted order.
That persistence gap is closed. Your Order service now owns durable data that survives container recreation.
Next, you will give Inventory the same independence with its own API plus its own PostgreSQL database.
Add an Inventory Service
Your Order service already keeps confirmed orders in its own PostgreSQL database. Inventory represents a separate business capability with a separate source of truth.
This step gives Inventory its own FastAPI API. It also creates a service boundary that prevents either service from reaching into the other service's tables.
Each service controls its own API, database, and volume, which keeps business data behind a clear ownership boundary.
The two service boundaries keep order rows and stock quantities independent, while each named volume preserves its database files across container recreation.
In this step, get ready to:
- Build an Inventory API that seeds products without resetting existing quantities.
- Add an atomic reservation operation that protects stock from overdrawing.
- Run Inventory with its own database plus its own persistent volume.
Create the inventory API
The Inventory service needs the same web server plus database driver as the Order service. Its database setup also seeds two products only when their rows are missing.
- In the VS Code Explorer sidebar, create a folder named inventory-service inside persistent-checkout.
- Inside inventory-service, create requirements.txt.
- Add the Inventory service dependencies by pasting this code:
fastapi==0.141.1
uvicorn==0.52.4
psycopg[binary]==3.3.4
What Do These Pins Provide?
- FastAPI 0.141.1 provides the API routes plus interactive documentation.
- Uvicorn 0.52.4 runs the application inside its container.
- Psycopg 3.3.4 connects the service to PostgreSQL.
- Save inventory-service/requirements.txt.
- Confirm requirements.txt appears under inventory-service in the Explorer sidebar.
Missing the Requirements File?
Confirm that requirements.txt sits inside inventory-service. Check that the file name has no extra extension.
Help me check my Inventory dependency file.
- Inside inventory-service, create a file named Dockerfile.
- Package the Inventory API into its own container image by pasting this code:
FROM python:3.13.15-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
What Does This Dockerfile Do?
- The Docker image supplies Python 3.13.15 in a slim base.
- The copy plus install instructions prepare the dependencies before adding main.py.
- The final command starts Uvicorn on container port 8000.
- Save inventory-service/Dockerfile.
- Confirm Dockerfile appears beside requirements.txt in the Explorer sidebar.
Dockerfile in the Wrong Folder?
Confirm that Dockerfile has no file extension. Keep it inside inventory-service with the dependency file.
Help me check my Inventory Dockerfile.
- Inside inventory-service, create a file named main.py.
- Add the database initialization logic by pasting this first code chunk:
from contextlib import asynccontextmanager
from typing import Annotated
import psycopg
from fastapi import Body, FastAPI, HTTPException
from psycopg.rows import dict_row
def initialize_database() -> None:
with psycopg.connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS inventory (
sku TEXT PRIMARY KEY,
quantity INTEGER NOT NULL CHECK (quantity >= 0)
)
"""
)
connection.execute(
"""
INSERT INTO inventory (sku, quantity)
VALUES (%s, %s), (%s, %s)
ON CONFLICT (sku) DO NOTHING
""",
("keyboard", 5, "mouse", 10),
)
How Does Inventory Initialization Work?
- The inventory table gives each product a unique sku plus a non-negative quantity.
- The seed operation adds keyboard with quantity 5.
- The same operation adds mouse with quantity 10.
- The conflict rule preserves existing rows when the service restarts.
- Save inventory-service/main.py.
- Confirm initialize_database() contains both the table creation plus seed operations.
Seeing Python Indentation Warnings?
Keep both connection.execute() calls inside the database connection block. Align their closing parentheses with their matching calls.
Help me check the initialization function.
- Add the application lifespan plus read operations below initialize_database() by pasting this code:
@asynccontextmanager
async def lifespan(app: FastAPI):
initialize_database()
yield
app = FastAPI(title="Inventory Service", lifespan=lifespan)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/inventory")
def list_inventory() -> list[dict]:
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
return cursor.execute(
"SELECT sku, quantity FROM inventory ORDER BY sku"
).fetchall()
How Does the Read API Work?
- The lifespan hook initializes the database before the application serves requests.
- The /health operation provides a lightweight service check.
- The /inventory operation returns dictionary-shaped rows ordered by product code.
The seeded response confirms that Inventory can read its own database and exposes both starting stock values.
- Save inventory-service/main.py.
- Confirm main.py now ends with the fetchall() call.
Is the Read Operation Misaligned?
Keep the cursor block nested inside the connection block. Confirm that the SQL string remains inside cursor.execute().
Help me check the Inventory read operation.
Give Inventory its own database
A named volume keeps Inventory data outside the database container lifecycle. Docker Compose also places both Inventory containers on the project's default network.
- Select compose.yaml in the VS Code Explorer sidebar.
- Find the order-service section.
- Add the Inventory database plus service directly above order-service by pasting this code:
inventory-db:
image: postgres:18.6
environment:
POSTGRES_DB: inventory
POSTGRES_USER: inventory_user
POSTGRES_PASSWORD: local_inventory_password
volumes:
- inventory-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
inventory-service:
build: ./inventory-service
ports:
- "127.0.0.1:8001:8000"
environment:
PGHOST: inventory-db
PGPORT: "5432"
PGDATABASE: inventory
PGUSER: inventory_user
PGPASSWORD: local_inventory_password
depends_on:
inventory-db:
condition: service_healthy
How Is the Boundary Enforced?
- The inventory-db container initializes a database named inventory.
- The inventory-service environment points only to inventory-db.
- The health dependency delays the API until its database accepts connections.
- The host port mapping exposes Inventory documentation at http://127.0.0.1:8001/docs.
- Save compose.yaml.
- Confirm the new service section points to ./inventory-service.
Seeing YAML Indentation Warnings?
Align inventory-db plus inventory-service with order-db plus order-service. Keep every nested property indented beneath its service.
Help me check my Compose service definitions.
- Find this volume declaration at the bottom of compose.yaml:
volumes:
order-data:
What Is Declared So Far?
The current block declares only the Order service volume. Inventory needs a separate declaration for its own database files.
- Replace the current volume block with this updated block:
volumes:
order-data:
inventory-data:
Why Use a Separate Volume?
The inventory-data volume gives Inventory an independent persistence lifecycle. Removing an Order container cannot erase Inventory rows.
- Save compose.yaml.
- Confirm the bottom of the file lists both named volumes.
Building the new Python image can take a few minutes while Docker downloads layers. Before you start the stack, predict whether Compose will report all four services as running.
- Build the new image plus start the complete stack by running:
docker compose up --build --wait
What Does This Command Do?
Compose builds the Inventory image from inventory-service/Dockerfile. It waits for the services to reach their configured running or healthy states.
You should see order-service, order-db, inventory-service, plus inventory-db reach their expected states.
Did the Inventory Containers Fail to Start?
Check that inventory-service contains all three files. Confirm that the inventory-data declaration remains under the top-level volume block.
Help me diagnose the Inventory container startup.
- Enter http://127.0.0.1:8001/docs in your browser address bar.
- Load the Inventory Swagger UI page.
- Expand the GET /inventory operation.
- Use the operation controls to send the request.
You should see keyboard with quantity 5. You should also see mouse with quantity 10.
Why Do Restarts Preserve Quantities?
The seed operation ignores products that already exist. A later restart leaves their stored quantities untouched.
The named volume preserves those rows when Compose recreates the database container.
Add atomic stock reservations
An atomic update checks the available quantity inside the same database statement that decrements it. A request changes stock only when enough units remain.
- Return to inventory-service/main.py in the Explorer sidebar.
- Find the end of list_inventory().
- Add the reservation operation below list_inventory() by pasting this code:
@app.post("/reservations")
def reserve_inventory(payload: Annotated[dict, Body()]) -> dict:
sku = str(payload.get("sku", "")).strip()
quantity = payload.get("quantity")
if not sku or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity < 1:
raise HTTPException(
status_code=400,
detail="sku must be non-empty and quantity must be a positive integer",
)
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
item = cursor.execute(
"""
UPDATE inventory
SET quantity = quantity - %s
WHERE sku = %s AND quantity >= %s
RETURNING sku, quantity AS remaining
""",
(quantity, sku, quantity),
).fetchone()
if item is None:
raise HTTPException(status_code=409, detail="Insufficient inventory")
return {"reserved": quantity, **item}
How Does the Reservation Stay Atomic?
- The input check rejects missing product codes plus invalid quantities before touching the database.
- The update matches a row only when its stored quantity covers the request.
- The database decrements stock plus returns the remaining amount in one statement.
- A missing result becomes an HTTP 409 response with the detail Insufficient inventory.
- Save inventory-service/main.py.
- Confirm the file now ends with the reservation response dictionary.
Seeing an Error in the Reservation Function?
Confirm that the SQL placeholders remain %s. Check that the tuple supplies quantity plus product code plus quantity in that order.
Help me diagnose the reservation function.
✔️ Awesome, I've got everything!
Great. Your Inventory service now has its own API code plus database configuration. Double-check that every changed file is saved.
ⓧ I'd like to double check the full code
Compare each project file with the cumulative code below. The Order service files remain unchanged in this step.
Compare the Compose configuration
services:
order-db:
image: postgres:18.6
environment:
POSTGRES_DB: orders
POSTGRES_USER: order_user
POSTGRES_PASSWORD: local_order_password
volumes:
- order-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
inventory-db:
image: postgres:18.6
environment:
POSTGRES_DB: inventory
POSTGRES_USER: inventory_user
POSTGRES_PASSWORD: local_inventory_password
volumes:
- inventory-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
inventory-service:
build: ./inventory-service
ports:
- "127.0.0.1:8001:8000"
environment:
PGHOST: inventory-db
PGPORT: "5432"
PGDATABASE: inventory
PGUSER: inventory_user
PGPASSWORD: local_inventory_password
depends_on:
inventory-db:
condition: service_healthy
order-service:
build: ./order-service
ports:
- "127.0.0.1:8000:8000"
environment:
PGHOST: order-db
PGPORT: "5432"
PGDATABASE: orders
PGUSER: order_user
PGPASSWORD: local_order_password
depends_on:
order-db:
condition: service_healthy
volumes:
order-data:
inventory-data:
What Should Match?
The file defines four services plus two named volumes. Each API points only to its own database service.
Compare the Inventory dependencies
fastapi==0.141.1
uvicorn==0.52.4
psycopg[binary]==3.3.4
What Should Match?
The Inventory dependency file contains the three exact package pins used by its API plus database connection.
Compare the Inventory Dockerfile
FROM python:3.13.15-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
What Should Match?
The Dockerfile installs the pinned dependencies plus launches the Inventory application on container port 8000.
Compare the Inventory application
from contextlib import asynccontextmanager
from typing import Annotated
import psycopg
from fastapi import Body, FastAPI, HTTPException
from psycopg.rows import dict_row
def initialize_database() -> None:
with psycopg.connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS inventory (
sku TEXT PRIMARY KEY,
quantity INTEGER NOT NULL CHECK (quantity >= 0)
)
"""
)
connection.execute(
"""
INSERT INTO inventory (sku, quantity)
VALUES (%s, %s), (%s, %s)
ON CONFLICT (sku) DO NOTHING
""",
("keyboard", 5, "mouse", 10),
)
@asynccontextmanager
async def lifespan(app: FastAPI):
initialize_database()
yield
app = FastAPI(title="Inventory Service", lifespan=lifespan)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/inventory")
def list_inventory() -> list[dict]:
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
return cursor.execute(
"SELECT sku, quantity FROM inventory ORDER BY sku"
).fetchall()
@app.post("/reservations")
def reserve_inventory(payload: Annotated[dict, Body()]) -> dict:
sku = str(payload.get("sku", "")).strip()
quantity = payload.get("quantity")
if not sku or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity < 1:
raise HTTPException(
status_code=400,
detail="sku must be non-empty and quantity must be a positive integer",
)
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
item = cursor.execute(
"""
UPDATE inventory
SET quantity = quantity - %s
WHERE sku = %s AND quantity >= %s
RETURNING sku, quantity AS remaining
""",
(quantity, sku, quantity),
).fetchone()
if item is None:
raise HTTPException(status_code=409, detail="Insufficient inventory")
return {"reserved": quantity, **item}
What Should Match?
The application initializes stock once. It exposes health plus inventory reads plus atomic reservations.
Compare the Order dependencies
fastapi==0.141.1
uvicorn==0.52.4
psycopg[binary]==3.3.4
What Should Match?
The Order dependency file remains unchanged from the previous step.
Compare the Order Dockerfile
FROM python:3.13.15-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
What Should Match?
The Order container build remains unchanged from the previous step.
Compare the Order application
from contextlib import asynccontextmanager
from typing import Annotated
import psycopg
from fastapi import Body, FastAPI, HTTPException
from psycopg.rows import dict_row
def initialize_database() -> None:
with psycopg.connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
status TEXT NOT NULL
)
"""
)
@asynccontextmanager
async def lifespan(app: FastAPI):
initialize_database()
yield
app = FastAPI(title="Order Service", lifespan=lifespan)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/orders")
def list_orders() -> list[dict]:
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
return cursor.execute(
"SELECT id, sku, quantity, status FROM orders ORDER BY id"
).fetchall()
@app.post("/orders")
def create_order(payload: Annotated[dict, Body()]) -> dict:
sku = str(payload.get("sku", "")).strip()
quantity = payload.get("quantity")
if not sku or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity < 1:
raise HTTPException(
status_code=400,
detail="sku must be non-empty and quantity must be a positive integer",
)
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
order = cursor.execute(
"""
INSERT INTO orders (sku, quantity, status)
VALUES (%s, %s, %s)
RETURNING id, sku, quantity, status
""",
(sku, quantity, "confirmed"),
).fetchone()
return order
What Should Match?
The Order application remains connected only to its own database. Inventory has no access to the orders table.
- Rebuild the Inventory image plus apply the saved API change by running:
docker compose up --build --wait
What Does This Rebuild Apply?
Compose rebuilds the Inventory image with the completed main.py file. The existing named volumes remain attached.
You should see the complete four-container stack return to its running or healthy state.
Did the Rebuild Fail?
Check the first Inventory build error for the affected file plus line. Confirm that the reservation function appears after list_inventory().
Help me diagnose the Inventory rebuild.
- Return to http://127.0.0.1:8001/docs in your browser.
- Refresh the Inventory documentation page.
- Expand the POST /reservations operation.
- Set sku to keyboard in the request body.
- Set quantity to 6 in the request body.
Before you send the request, predict whether the stock check will accept a quantity above the current total.
- Use the operation controls to send the reservation request.
You should receive status 409 with the detail Insufficient inventory. The planned rejection proves that the database refused to overdraw the product.
- Expand the GET /inventory operation again.
- Use the operation controls to send another inventory request.
You should still see keyboard with quantity 5. The rejected reservation did not partially change the row.
That is a complete ownership boundary: Inventory has its own API plus durable database state. Next, you will connect the Order service to Inventory through HTTP.
Connect the Checkout Flow
Your Order and Inventory microservices already protect separate PostgreSQL databases. The remaining gap is checkout coordination.
An order currently reaches order-db without asking Inventory to reserve stock. The Order service now needs to reserve stock through Inventory's API before it writes a confirmed order.
The checkout succeeds only after Inventory commits the stock change, so Order never confirms a request that Inventory rejected.
A successful checkout changes Inventory first and records the confirmed order second, so rejected stock requests never create an order row.
In this step, get ready to:
- Add HTTPX to the Order service.
- Reserve inventory before saving a confirmed order.
- Test successful checkout plus the insufficient-stock boundary.
Add HTTPX to the Order service
The Order service needs an HTTP client to send reservation requests. HTTPX version 0.28.1 gives the service that client.
- Select order-service/requirements.txt in the VS Code Explorer sidebar.
- Add the HTTPX dependency by replacing the file contents with this package list:
fastapi==0.141.1
uvicorn==0.52.4
psycopg[binary]==3.3.4
httpx==0.28.1
What Does This Dependency Add?
- The existing packages continue to serve the API plus connect it to PostgreSQL.
- The httpx==0.28.1 requirement installs the HTTP client used by the checkout request.
- Save order-service/requirements.txt.
✔️ Awesome, I've got everything!
Great. Your Order service now includes the pinned HTTPX dependency.
ⓧ I'd like to double check the full code
fastapi==0.141.1
uvicorn==0.52.4
psycopg[binary]==3.3.4
httpx==0.28.1
- Rebuild the stack with the new dependency by running this command in the VS Code terminal:
docker compose up --build --wait
What Does This Command Do?
Docker Compose rebuilds the service images from their current files. The --wait flag keeps the command active until the services are running or healthy.
The command returns after the complete four-container stack is ready. Your existing named volumes keep both databases intact.
Does the Rebuild Fail?
Check that httpx==0.28.1 appears on its own line in order-service/requirements.txt.
Confirm that the terminal is still inside the persistent-checkout folder containing compose.yaml.
Help me diagnose the Order service rebuild.
Reserve stock before saving orders
A synchronous HTTP boundary lets Order request a reservation without reading Inventory's tables. Inventory remains the only service that changes stock.
- Select order-service/main.py in the VS Code Explorer sidebar.
- Find the existing Psycopg import shown below:
import psycopg
What Are You Locating?
This import marks the start of the third-party packages. HTTPX belongs directly above it.
- Insert the HTTPX import directly above the Psycopg import.
- Confirm the two imports now look like this:
import httpx
import psycopg
What Changed?
The new import makes the HTTPX request API available inside create_order().
- Save order-service/main.py.
- Verify the new import by rebuilding the stack with this command:
docker compose up --build --wait
What Does This Check?
The rebuild installs HTTPX before Python imports it. A ready stack confirms that the dependency plus import load successfully.
The four services reach their running or healthy states. The Order service is ready for its reservation logic.
- Return to create_order() in order-service/main.py.
- Find the closing parenthesis of the request-validation block.
- Add this reservation request directly above the existing with psycopg.connect() as connection: line:
try:
response = httpx.post(
"http://inventory-service:8000/reservations",
json={"sku": sku, "quantity": quantity},
timeout=5.0,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = exc.response.json().get("detail", "Inventory reservation failed")
raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
except httpx.RequestError as exc:
raise HTTPException(status_code=503, detail="Inventory service unavailable") from exc
How Does the Checkout Boundary Work?
- The HTTP request sends the validated SKU plus quantity to http://inventory-service:8000/reservations.
- Docker's container networking resolves inventory-service to the Inventory container.
- The HTTPStatusError handler preserves Inventory's client-visible rejection status.
- The RequestError handler turns a network failure into HTTP 503.
- Save order-service/main.py.
✔️ Awesome, I've got everything!
Your Order service now reserves stock before inserting a confirmed order.
ⓧ I'd like to double check the full code
from contextlib import asynccontextmanager
from typing import Annotated
import httpx
import psycopg
from fastapi import Body, FastAPI, HTTPException
from psycopg.rows import dict_row
def initialize_database() -> None:
with psycopg.connect() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
status TEXT NOT NULL
)
"""
)
@asynccontextmanager
async def lifespan(app: FastAPI):
initialize_database()
yield
app = FastAPI(title="Order Service", lifespan=lifespan)
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.get("/orders")
def list_orders() -> list[dict]:
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
return cursor.execute(
"SELECT id, sku, quantity, status FROM orders ORDER BY id"
).fetchall()
@app.post("/orders")
def create_order(payload: Annotated[dict, Body()]) -> dict:
sku = str(payload.get("sku", "")).strip()
quantity = payload.get("quantity")
if not sku or not isinstance(quantity, int) or isinstance(quantity, bool) or quantity < 1:
raise HTTPException(
status_code=400,
detail="sku must be non-empty and quantity must be a positive integer",
)
try:
response = httpx.post(
"http://inventory-service:8000/reservations",
json={"sku": sku, "quantity": quantity},
timeout=5.0,
)
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = exc.response.json().get("detail", "Inventory reservation failed")
raise HTTPException(status_code=exc.response.status_code, detail=detail) from exc
except httpx.RequestError as exc:
raise HTTPException(status_code=503, detail="Inventory service unavailable") from exc
with psycopg.connect() as connection:
with connection.cursor(row_factory=dict_row) as cursor:
order = cursor.execute(
"""
INSERT INTO orders (sku, quantity, status)
VALUES (%s, %s, %s)
RETURNING id, sku, quantity, status
""",
(sku, quantity, "confirmed"),
).fetchone()
return order
- Rebuild the Order service with its checkout logic by running this command:
docker compose up --build --wait
What Does This Rebuild Apply?
Docker Compose replaces the Order service container with an image containing the new code. The database volumes remain attached throughout the rebuild.
The stack reaches its ready state with the checkout request path loaded.
Before you submit the checkout, do you expect both service databases to change from one request?
- Open http://127.0.0.1:8000/docs in your browser.
- Expand the POST /orders operation in Swagger UI.
- Enter this checkout payload in the request body:
{"sku":"keyboard","quantity":2}
What Does This Request Ask For?
The payload asks Order to create a checkout for two keyboards. Order passes the same SKU plus quantity to Inventory before inserting its row.
- Submit the request from the POST /orders operation panel.
You'll see an order with status set to confirmed. The response also includes its database-generated id.
- Switch back to the Inventory Swagger UI at http://127.0.0.1:8001/docs.
- Expand the GET /inventory operation.
- Submit the inventory request.
You'll see the keyboard quantity reduced from 5 to 3. You have a complete checkout spanning two independently owned databases.
Does Checkout Return HTTP 503?
Check the Compose output for a running Inventory service. The Order service returns HTTP 503 when its reservation request cannot reach Inventory.
Confirm the URL in order-service/main.py uses the service name inventory-service plus container port 8000.
Help me diagnose the checkout request.
Rebuild and test the failure boundary
A Compose dependency records the startup relationship between the services. Order can begin only after Inventory has started.
- Select compose.yaml in the VS Code Explorer sidebar.
- Find the existing Order service dependency block:
depends_on:
order-db:
condition: service_healthy
What Does the Existing Dependency Cover?
The existing condition waits for order-db to pass its health check. It does not yet record the Order service's dependency on Inventory.
- Add the Inventory service condition below the Order database condition.
- Confirm the complete dependency block now looks like this:
depends_on:
order-db:
condition: service_healthy
inventory-service:
condition: service_started
How Does Startup Ordering Help?
The service_healthy condition waits for the Order database health check. The service_started condition records that Order also needs the Inventory service process.
- Save compose.yaml.
✔️ Awesome, I've got everything!
Your Compose configuration now records both Order service dependencies.
ⓧ I'd like to double check the full code
services:
order-db:
image: postgres:18.6
environment:
POSTGRES_DB: orders
POSTGRES_USER: order_user
POSTGRES_PASSWORD: local_order_password
volumes:
- order-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
inventory-db:
image: postgres:18.6
environment:
POSTGRES_DB: inventory
POSTGRES_USER: inventory_user
POSTGRES_PASSWORD: local_inventory_password
volumes:
- inventory-data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
inventory-service:
build: ./inventory-service
ports:
- "127.0.0.1:8001:8000"
environment:
PGHOST: inventory-db
PGPORT: "5432"
PGDATABASE: inventory
PGUSER: inventory_user
PGPASSWORD: local_inventory_password
depends_on:
inventory-db:
condition: service_healthy
order-service:
build: ./order-service
ports:
- "127.0.0.1:8000:8000"
environment:
PGHOST: order-db
PGPORT: "5432"
PGDATABASE: orders
PGUSER: order_user
PGPASSWORD: local_order_password
depends_on:
order-db:
condition: service_healthy
inventory-service:
condition: service_started
volumes:
order-data:
inventory-data:
- Apply the completed four-container configuration by running this command:
docker compose up --build --wait
What Does the Final Rebuild Confirm?
Compose starts both databases plus both API services on its default network. Each database keeps its independent named volume.
Before you request four keyboards, do you expect Inventory to reduce the remaining quantity or reject the checkout?
- Return to the POST /orders operation at http://127.0.0.1:8000/docs.
- Keep keyboard as the sku value.
- Set quantity to 4.
- Submit the checkout request.
You'll see HTTP 409 with the detail Insufficient inventory. Inventory has only three keyboards remaining.
- Expand the GET /orders operation in the Order Swagger UI.
- Submit the order-list request.
You'll see the confirmed order from the successful checkout. No second order exists for the rejected request.
- Switch back to http://127.0.0.1:8001/docs.
- Submit another GET /inventory request.
You'll see the keyboard quantity remain at 3. The atomic reservation protected stock plus prevented an unconfirmed order.
What Does This Failure Prove?
Inventory owns the stock decision. Its HTTP 409 crosses the service boundary without creating an Order database row.
A network failure follows a separate path. The Order service converts that failure into HTTP 503 so clients can distinguish unavailable Inventory from insufficient stock.
That completes the checkout boundary. Your two services now collaborate over HTTP while retaining separate databases plus persistent volumes.
Secret mission
Make Checkout Idempotent
Make checkout retries safe with a shared client reference. Repeated requests return one order while inventory is reduced only once.
Clean Up Your Resources
Clean Up Your Resources
Choose whether your local stack stays active, pauses for another session, or disappears with its stored data. Everything runs on your Linux machine, so leaving it in place creates no cloud charges or paid service costs.
Resources you used:
- Two locally built FastAPI service containers managed by Docker Compose: order-service and inventory-service.
- Two PostgreSQL 18.6 database containers named order-db and inventory-db.
- One default Compose network connecting the four containers.
- Two named Docker volumes called order-data and inventory-data.
Keep everything running
No action is needed. Choose this option if you want to continue testing checkout requests through the two interactive API pages.
- Leave the four-container stack running.
- Use the Order service documentation at http://127.0.0.1:8000/docs to submit more checkout requests.
- Use the Inventory service documentation at http://127.0.0.1:8001/docs to inspect stock changes.
Pause - I'll come back to this later
Pausing frees the memory used by all four containers. Your orders, stock levels, reservations, project files, and database volumes remain ready for another session.
- Return to the VS Code integrated terminal in persistent-checkout.
- Pause the stack by running this command:
docker compose stop
What Happens When You Pause?
This stops the four containers without removing them. The database records remain stored in order-data and inventory-data.
- Refresh http://127.0.0.1:8000/docs.
- Refresh http://127.0.0.1:8001/docs.
Both documentation pages stop loading. This confirms that the services have paused while their stored data remains intact.
Stack Did Not Stop?
- Confirm that the terminal path ends in persistent-checkout.
- Check that compose.yaml appears in the VS Code Explorer.
Help me diagnose why Docker Compose did not stop this stack.
- Resume the same containers later by running this command:
docker compose start
What Happens When You Resume?
This starts the existing containers with the same named volumes attached. Your saved orders, stock quantities, and idempotency records become available again.
- Refresh http://127.0.0.1:8000/docs.
- Refresh http://127.0.0.1:8001/docs.
Both documentation pages load again. This confirms that all four containers restarted with their persisted data.
Stack Did Not Restart?
- Confirm that no other process is using ports 8000 or 8001.
- Review the terminal output for the service that failed to start.
Help me diagnose why my persistent checkout containers did not restart.
Delete - I don't want to use this again
Deletion removes the four containers, the default network, and both database volumes. Your source files remain in persistent-checkout.
Deletion Is Permanent
Deleting the volumes is final, so take a moment before continuing. This permanently erases your orders, inventory changes, and idempotency records.
- Return to the VS Code integrated terminal in persistent-checkout.
- Remove the Compose runtime resources by running this command:
docker compose down --volumes
What Does This Delete?
This removes the Order service, Inventory service, and both database containers. It also removes the default network plus the order-data and inventory-data volumes.
- Refresh http://127.0.0.1:8000/docs.
- Refresh http://127.0.0.1:8001/docs.
Neither documentation page loads. The terminal also reports that the Compose containers, network, and volumes were removed.
Resources Were Not Removed?
- Confirm that the terminal path ends in persistent-checkout.
- Check that Docker Engine is still running.
Help me diagnose why Docker Compose did not remove this project's resources.
Nice Work!
Nice Work!
You did it! You built a local checkout backend where two independent microservices collaborate through HTTP.
You've learned how to:
- Build independently runnable Order and Inventory services with FastAPI. Coordinate the four-container stack with Docker Compose health checks.
- Give each service its own PostgreSQL database. Preserve order data in order-data across container recreation. Preserve inventory data independently in inventory-data.
- Build a synchronous checkout flow that reserves stock before persisting a confirmed order. Surface insufficient stock as HTTP 409 without creating a new order.
- Secret Mission: Add an idempotency boundary around client_reference. Return the same Order ID for a retry. Prevent a second stock reduction. Reject conflicting reuse with HTTP 409.
Ready to quiz yourself?