Build a Persistent Catalog Service

Extend OrderFlow with a validated FastAPI catalog backed by PostgreSQL.

Introduction

30 Second Summary

Shoppers expect the same name and price every time they view a product. When separate parts of a system disagree, trust disappears fast.

In this project, you will extend the existing OrderFlow workspace with a Catalog API that validates product requests. Docker Compose will run the FastAPI service beside a dedicated PostgreSQL database, so saved products survive API container recreation.

What You'll Build

In FastAPI's interactive docs, you will see invalid prices rejected while a valid product remains available after API container recreation.

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

  • A live Catalog health check you can execute in interactive documentation to confirm the new service is running beside Order and Inventory.
  • Typed product validation that rejects a negative price_cents value before storage.
  • A persistent product catalog that still returns TEE-001 with its saved name and price after API container recreation.
  • Secret Mission: Add a validated price update that remains visible after the API container is recreated.

What do I need before starting?

You need the completed Persistent Checkout workspace from Project 1 with its existing Compose stack able to start successfully.

Your Linux device must already have Docker, Docker Compose, and Visual Studio Code installed.

Before We Start

Before the hands-on work begins, lock in the service boundary that protects OrderFlow as it grows. You will extend Project 1 without replacing compose.yaml while Catalog owns SKU, name, and price data in a separate database.

Reopen the Persistent Checkout Stack

Catalog can only join OrderFlow safely when the existing Order and Inventory stack is already healthy. A known baseline keeps future Catalog problems separate from unfinished Project 1 work.

You will return to the completed workspace in Visual Studio Code.

Docker Compose validates compose.yaml before it starts the existing containers.

In this step, get ready to:
  • Open the completed Project 1 OrderFlow workspace in Visual Studio Code.
  • Confirm that compose.yaml resolves successfully.
  • Bring the existing Project 1 stack to a running or healthy state.
Return to the completed workspace

Project 1 provides the OrderFlow foundation that Catalog extends. Its workspace must be complete before you add another service boundary.

  • Complete Project 1 before continuing if the OrderFlow workspace is unfinished.
  • Launch Visual Studio Code from your Linux application menu.
  • Open the completed Project 1 OrderFlow workspace folder in Visual Studio Code.
  • Confirm compose.yaml appears at the top level of the open folder.
  • Open an integrated terminal in Visual Studio Code.
  • Check that the terminal prompt points to the folder containing compose.yaml.

You are back in the correct workspace. The existing Compose file is ready for its first baseline check.

Catch base-file problems first

A configuration check resolves the current Compose model without starting services. This catches problems in the existing workspace before runtime behavior enters the picture.

  • Render the applied Compose model by running this command:
docker compose config

What does this check prove?

Compose reads the current files as one applied model. A successful result proves the existing service definitions resolve before any containers start.

You should see the rendered Compose configuration without an error. That clean result gives you a trustworthy base for the runtime check.

Did the configuration check fail?

  • Check the terminal output for the file location that Compose reports.
  • Fix any unfinished Project 1 configuration before running the check again.

Ask for help with the specific configuration output by using help me diagnose why my existing OrderFlow Compose configuration does not resolve.

Prove the existing stack is healthy

The configuration check proves that Compose understands the model. Runtime health proves the existing Order and Inventory services can start from it.

  • Build and start the existing Project 1 stack in the background by running this command:
docker compose up --build -d

What does this command do?

  • The --build flag builds the service images before startup.
  • The -d flag leaves the services running in the background.

Did a service stop during startup?

  • Read the build output for the first Project 1 service that failed.
  • Resolve that service's unfinished Project 1 setup before starting the stack again.

Use help me troubleshoot why my existing OrderFlow containers did not start if you need help tracing the startup failure.

Before you check, which status do you expect to see for the existing Project 1 containers?

  • Inspect the current stack status by running this command:
docker compose ps

What does this status check prove?

This check lists the containers in the current Compose application. Their status shows whether the existing stack reached a usable runtime state.

You should see the existing Project 1 Order and Inventory containers with running or healthy status. Baseline confirmed: your existing stack works before Catalog enters the picture.

Is a container missing or unhealthy?

  • Read the status for the Project 1 service that did not reach running or healthy.
  • Review that service's Project 1 configuration before continuing.

Use help me understand why my Project 1 container is missing or unhealthy for guidance based on the status you see.

Your Order and Inventory baseline is healthy. Next, you will add a visible Catalog API without replacing compose.yaml.

Launch the Catalog API

Your existing Order and Inventory services are healthy. The Docker Compose application is ready for a new Catalog boundary.

Product rules and storage come later. First, you need a small FastAPI service that runs in a container beside the existing services.

In this step, get ready to:
  • Create the Catalog dependency file and container recipe.
  • Add a health endpoint and extend the Compose application.
  • Launch the Catalog API and execute its health request.
Create the Catalog container files

The Catalog service needs pinned dependencies for repeatable builds. Its Dockerfile packages those dependencies with Python 3.13.15.

  • Select the Explorer view in the Visual Studio Code Activity Bar.
  • Select the New Folder button.
  • Enter catalog-service as the folder name.
  • Press Enter.
  • Select the new catalog-service folder.
  • Select the New File button.
  • Enter requirements.txt as the file name.
  • Add the pinned API dependencies to requirements.txt by copying this code:
fastapi==0.141.1
uvicorn==0.52.4

What do these dependencies do?

  • FastAPI 0.141.1 provides the web framework and interactive API documentation.
  • Uvicorn 0.52.4 runs the FastAPI application inside the container.
  • Save requirements.txt.
  • Confirm requirements.txt appears inside catalog-service in the Explorer view.

Is the dependency file in the wrong place?

Drag requirements.txt onto the catalog-service folder in the Explorer view. The file path must be catalog-service/requirements.txt.

Ask for help if the folder layout still looks different: help me place requirements.txt inside catalog-service in Visual Studio Code

  • Select the catalog-service folder in the Explorer view.
  • Select the New File button.
  • Enter Dockerfile as the file name.
  • Build the Catalog container recipe in Dockerfile by copying 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"]

How does the image run the API?

  • The Python image provides the pinned runtime for the Catalog service.
  • The dependency layer installs the packages from requirements.txt.
  • The final command starts Uvicorn with the application object from main.py on container port 8000.
  • Save Dockerfile.
  • Confirm Dockerfile appears beside requirements.txt in the catalog-service folder.

Does the Dockerfile have an extension?

Make sure the file is named exactly Dockerfile. Remove any extension added to its name.

Ask for help if Visual Studio Code shows a different file name: help me correct the Dockerfile name and location

Wire the health endpoint into Compose

A health endpoint gives you a small request that proves the API process is reachable. The Compose override adds this service without replacing the existing compose.yaml.

  • Select the catalog-service folder in the Explorer view.
  • Select the New File button.
  • Enter main.py as the file name.
  • Add the FastAPI application and health route to main.py by copying this code:
from fastapi import FastAPI

app = FastAPI(title="OrderFlow Catalog API")


@app.get("/health")
def health():
    return {"status": "ok", "service": "catalog"}

What does this code do?

  • The app object represents the OrderFlow Catalog API.
  • The @app.get("/health") decorator connects a GET request to the health() function.
  • The returned dictionary becomes the JSON health response.
  • Save main.py.
  • Confirm the editor tab no longer shows an unsaved-change indicator.

Is the health route highlighted as invalid?

Check that each quote and bracket matches the reference code. Keep main.py inside the catalog-service folder.

Ask for help with any highlighted Python syntax: help me compare my Catalog health route with the reference code

  • Select the OrderFlow workspace folder at the top of the Explorer view.
  • Select the New File button.
  • Enter compose.override.yaml as the file name.
  • Extend the existing Compose application in compose.override.yaml by copying this code:
services:
  catalog-service:
    build: ./catalog-service
    ports:
      - "127.0.0.1:8002:8000"

How does the override extend OrderFlow?

  • The catalog-service entry becomes another service in the existing Compose application.
  • The build path points Compose to the new catalog-service directory.
  • The port mapping exposes container port 8000 at 127.0.0.1:8002 on your Linux device.
  • Save compose.override.yaml.
  • Return to the integrated terminal from the previous step.
  • Validate the merged Compose model by running this command:
docker compose config

What does this check prove?

Compose merges compose.yaml with compose.override.yaml before rendering the resolved configuration. A rendered catalog-service entry proves the extension file is being applied.

You should see the existing Project 1 configuration with a resolved catalog-service section. The output should finish without a configuration error.

Does the Compose model fail to resolve?

Check the indentation in compose.override.yaml. The catalog-service entry must sit beneath services.

Confirm compose.override.yaml is beside the existing compose.yaml.

Ask for help with the merged configuration: help me diagnose why my Catalog Compose override does not resolve

Use this checkpoint to compare every Catalog file before building the image.

✔️ Awesome, I've got everything!

Great. Make sure all four files are saved before you launch the service.

ⓧ I'd like to double check the full code

  • Compare each file with the complete reference below.
fastapi==0.141.1
uvicorn==0.52.4

Dependency file checkpoint

This file pins the two packages used by the visible Catalog API.

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"]

Container file checkpoint

This file builds the Python image and starts the Catalog API on container port 8000.

from fastapi import FastAPI

app = FastAPI(title="OrderFlow Catalog API")


@app.get("/health")
def health():
    return {"status": "ok", "service": "catalog"}

Application file checkpoint

This file defines the FastAPI application and its GET /health operation.

services:
  catalog-service:
    build: ./catalog-service
    ports:
      - "127.0.0.1:8002:8000"

Compose file checkpoint

This file adds the Catalog service to the existing OrderFlow Compose application.

Launch and test the health endpoint

The first build can take a few minutes while Docker downloads the Python image and installs the packages. A quiet wait during those downloads is expected.

  • Build and start only the Catalog service by running this command:
docker compose up --build -d catalog-service

What does this command do?

  • Compose builds the image from catalog-service/Dockerfile.
  • Detached mode leaves the Catalog container running after the terminal returns to its prompt.
  • The service name limits this launch to catalog-service.

The build output should complete successfully. Your terminal should return to its prompt while the Catalog container keeps running.

Does the Catalog container stop?

Compare requirements.txt and Dockerfile with the checkpoint above. A package pin typo or file-name mismatch can stop the image from starting.

Confirm the build path in compose.override.yaml points to ./catalog-service.

Ask for help with the startup output: help me diagnose why the OrderFlow Catalog container stops after its first build

Before you send the request, what response do you expect from a healthy Catalog service?

  • Open http://127.0.0.1:8002/docs in your browser.
  • Select GET /health to expand the operation.
  • Click Try it out.
  • Click Execute.

You should see a successful response with {"status":"ok","service":"catalog"} in the response body.

What did this request prove?

The interactive documentation reached the Catalog container through 127.0.0.1:8002. Uvicorn routed the request to health() inside main.py.

The Catalog service is now visible beside the existing OrderFlow services.

Can't load the Catalog docs?

Confirm the browser address uses port 8002. Check that the port mapping in compose.override.yaml matches the checkpoint.

If the docs load without the health operation, save main.py and rebuild the Catalog service.

Ask for help with the failed health check: help me diagnose why the Catalog docs or GET /health response is unavailable

That is your first Catalog milestone complete. The API is live inside OrderFlow and ready for product validation in the next step.

Validate Products and Reveal Data Loss

Your Catalog health route is live inside the existing OrderFlow Docker Compose application. The next boundary is deciding which product requests the Catalog accepts.

FastAPI can apply a Pydantic request model before your endpoint handles data. You will store accepted products in a temporary dictionary.

You will then recreate the Catalog container to test whether that storage survives its lifecycle.

In this step, get ready to:
  • Add typed product validation with a positive integer price.
  • Expose the in-memory catalog through product endpoints.
  • Test product retention across Catalog service recreation.
Define the product contract

A request model defines the fields that every product must provide. The positive integer type prevents zero or negative prices from reaching your endpoint.

  • Switch back to catalog-service/requirements.txt in VS Code.
  • Replace its contents with the dependency pins below:
fastapi==0.141.1
pydantic==2.13.5
uvicorn==0.52.4

What does this dependency add?

  • The pydantic==2.13.5 pin gives the API typed request models.
  • The existing FastAPI pin uses those models to validate request bodies.
  • The existing Uvicorn pin continues to run the API inside the container.
  • Save catalog-service/requirements.txt.
  • Rebuild the Catalog image by running this command:
docker compose up --build -d catalog-service

Why rebuild the image?

The Catalog image installs packages from requirements.txt during its build. Rebuilding places the new Pydantic pin inside the container.

When the command completes, you will see the Catalog service return to a running state.

Catalog service not running?

Check that every dependency occupies its own line in catalog-service/requirements.txt.

Confirm that the Pydantic pin contains two equals signs.

Help me diagnose a Catalog image rebuild that fails after adding Pydantic.

✔️ Awesome, I've got everything!

Your dependency file now contains all three required pins.

ⓧ I'd like to double check the full code

fastapi==0.141.1
pydantic==2.13.5
uvicorn==0.52.4

This file pins the API framework, request validation library, and application server.

The dependency is available inside the image. Now the API needs a model that describes one valid product.

  • Switch back to catalog-service/main.py in the VS Code editor.
  • Select the import and app declaration above the existing @app.get("/health") route.
  • Replace the selected section with this code:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, PositiveInt

app = FastAPI(title="OrderFlow Catalog API")

products = {}


class ProductCreate(BaseModel):
    sku: str
    name: str
    price_cents: PositiveInt

What does this code do?

  • The products dictionary holds products under their SKU values.
  • The ProductCreate model requires a SKU.
  • The model also requires a name.
  • The PositiveInt type requires price_cents to be greater than zero.
  • Save catalog-service/main.py.
  • Rebuild the Catalog image by running this command:
docker compose up --build -d catalog-service

What does this rebuild confirm?

The rebuild copies the updated main.py into the image. A running service confirms that Python imported the new model successfully.

Your Catalog service returns to its running state with the product contract loaded.

Catalog exits after the model change?

Check that BaseModel and PositiveInt use the same capitalization as the import.

Confirm that each field inside ProductCreate is indented by four spaces.

Help me find the import or indentation problem in my ProductCreate model.

Add product endpoints

The dictionary uses each SKU as its key. Sorting those keys gives every product listing a stable order.

  • Place your cursor below the existing health() function in catalog-service/main.py.
  • Add the product listing endpoint by copying this code:
@app.get("/products")
def list_products():
    return [products[sku] for sku in sorted(products)]

How does listing work?

The list_products() endpoint sorts the dictionary keys. It returns each stored product in SKU order.

  • Save catalog-service/main.py.
  • Rebuild the Catalog image by running this command:
docker compose up --build -d catalog-service

Why rebuild after the endpoint?

The source file is copied into the Catalog image. Rebuilding makes the new route available to the running API.

  • Return to the interactive docs at http://127.0.0.1:8002/docs.
  • Expand GET /products.
  • Submit the request.

You will see an empty list because the dictionary does not contain a product yet.

Product route missing?

Refresh the interactive docs after the rebuild completes.

Check that the route decorator is outside the health() function.

Help me find why GET /products is missing from my FastAPI docs.

A listing shows the whole catalog. A lookup route answers a narrower question by searching for one SKU.

  • Place your cursor below the list_products() function.
  • Add the product lookup endpoint by copying this code:
@app.get("/products/{sku}")
def get_product(sku: str):
    product = products.get(sku)

    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    return product

How does lookup work?

  • The route receives the SKU from the request path.
  • The dictionary lookup returns the matching product when the key exists.
  • The HTTPException produces a 404 response for an unknown SKU.
  • Save catalog-service/main.py.
  • Rebuild the Catalog image by running this command:
docker compose up --build -d catalog-service

What changes after this rebuild?

The running API now includes the product lookup route. Its response distinguishes an unknown SKU from an existing product.

  • Return to the interactive docs.
  • Expand GET /products/{sku}.
  • Enable path parameter entry.
  • Enter UNKNOWN-001 in the sku field.
  • Submit the request.

You will see status 404 with the detail Product not found.

Unknown SKU returns a server error?

Confirm that HTTPException appears in the FastAPI import.

Check that the exception is raised only when product is None.

Help me diagnose why an unknown SKU does not return the intended 404 response.

The API can now read the temporary catalog. The creation route completes the flow by validating a request before adding it to the dictionary.

  • Place your cursor below the get_product() function.
  • Add the product creation endpoint by copying this code:
@app.post("/products", status_code=201)
def create_product(product: ProductCreate):
    products[product.sku] = product.model_dump()
    return products[product.sku]

How does product creation work?

  • FastAPI validates the request against ProductCreate before calling the function.
  • The model_dump() method converts the validated model into a dictionary.
  • The SKU becomes the storage key for the product.
  • A successful creation returns status 201.
  • Save catalog-service/main.py.
  • Rebuild the Catalog image by running this command:
docker compose up --build -d catalog-service

What does this rebuild activate?

The rebuilt API exposes the creation route. Its request schema now applies the positive price rule.

  • Return to the interactive docs.
  • Expand POST /products.
  • Enable request body editing.
  • Set sku to SKU-001.
  • Set name to Desk Lamp.
  • Set price_cents to -1.
  • Submit the request.

You will see status 422. The invalid price is rejected before the endpoint stores anything.

  • Change price_cents to 2500.
  • Submit the request again.

You will see status 201 with the SKU, name, and price from your valid product.

  • Expand GET /products/{sku}.
  • Enable path parameter entry.
  • Enter SKU-001 in the sku field.
  • Submit the lookup request.

Good work. The lookup returns the valid product with a price of 2500 cents.

Valid product still rejected?

Check that price_cents contains a positive whole number without quotation marks.

Confirm that the request contains the exact field names from ProductCreate.

Help me understand why my valid product request still receives a validation response.

✔️ Awesome, I've got everything!

Your Catalog API now validates, creates, lists, and looks up in-memory products.

ⓧ I'd like to double check the full code

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, PositiveInt

app = FastAPI(title="OrderFlow Catalog API")

products = {}


class ProductCreate(BaseModel):
    sku: str
    name: str
    price_cents: PositiveInt


@app.get("/health")
def health():
    return {"status": "ok", "service": "catalog"}


@app.get("/products")
def list_products():
    return [products[sku] for sku in sorted(products)]


@app.get("/products/{sku}")
def get_product(sku: str):
    product = products.get(sku)

    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    return product


@app.post("/products", status_code=201)
def create_product(product: ProductCreate):
    products[product.sku] = product.model_dump()
    return products[product.sku]

This complete file contains the request model, temporary dictionary, health route, and three product endpoints.

Recreate the Catalog container

Container recreation tests whether the current storage choice can meet the Catalog persistence goal. Your existing product gives this test a visible result.

Before you run this, do you expect SKU-001 to remain in the catalog after the Catalog API container is replaced?

  • Force the Catalog service to recreate its container by running this command:
docker compose up -d --force-recreate catalog-service

What does this command do?

The command replaces the Catalog API container without recreating the rest of the OrderFlow application. The service starts again from the same image.

  • Return to http://127.0.0.1:8002/docs.
  • Refresh the interactive docs.
  • Expand GET /products.
  • Submit the request.

You will see []. The product you created has disappeared.

Why did the product disappear?

The products dictionary existed in the previous API process. Recreating the container started a new process with an empty dictionary.

This is the designed failure. Application memory cannot preserve Catalog data across the container lifecycle.

Still seeing the product?

Confirm that the recreate command names only catalog-service.

Refresh the docs before submitting GET /products again.

Help me verify whether Docker Compose recreated my Catalog service container.

That was the crucial test. Next, you will move Catalog ownership into a dedicated PostgreSQL database that survives API container recreation.

Persist the Catalog in PostgreSQL

Your recreation test exposed the Catalog's weak point. The product disappeared because the API container held it in memory instead of storing it in PostgreSQL.

This step gives Catalog its own database and durable volume. Docker Compose keeps the database healthy before FastAPI starts serving requests.

In this step, get ready to:
  • Add a dedicated Catalog database with persistent storage.
  • Connect the Catalog API to PostgreSQL through Psycopg.
  • Recreate the API container without losing the saved product.
Configure the Catalog database

The Catalog needs a database that follows the same service boundary as the API. Its named volume keeps the database files outside the lifecycle of either container.

The database credentials below are intentionally simple because they stay inside this local Compose workspace. Use stronger credentials for a shared or production environment.

  • Switch back to compose.override.yaml in Visual Studio Code.
  • Replace the contents of compose.override.yaml with this configuration:
services:
  catalog-db:
    image: postgres:18.6
    environment:
      POSTGRES_USER: catalog
      POSTGRES_PASSWORD: catalog
      POSTGRES_DB: catalog
    volumes:
      - catalog-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s

  catalog-service:
    build: ./catalog-service
    ports:
      - "127.0.0.1:8002:8000"
    depends_on:
      catalog-db:
        condition: service_healthy

volumes:
  catalog-data:
    name: orderflow-catalog-data

How does this preserve Catalog data?

  • The catalog-db service runs PostgreSQL 18.6 for Catalog alone.
  • The catalog-data mount stores PostgreSQL files at /var/lib/postgresql.
  • The explicit orderflow-catalog-data name gives the volume a stable Docker identity.
  • The health check delays catalog-service until PostgreSQL accepts connections.
  • Save compose.override.yaml.
  • Validate the merged Compose service list by running this command:
docker compose config --services

What does this check prove?

Compose merges the existing compose.yaml with compose.override.yaml. Printing the service list confirms that the override resolves before you start any new containers.

You should see catalog-db and catalog-service alongside the existing Project 1 services.

Catalog services missing from the list?

Confirm that compose.override.yaml is beside the existing compose.yaml. Check the YAML indentation beneath services and volumes.

Ask for help with the merged configuration if the service list still looks wrong: help me diagnose why Docker Compose is not merging my catalog services.

✔️ The Catalog services are listed

Your base Compose application now includes the Catalog database without replacing Project 1's configuration.

ⓧ I'd like to double check the full code

services:
  catalog-db:
    image: postgres:18.6
    environment:
      POSTGRES_USER: catalog
      POSTGRES_PASSWORD: catalog
      POSTGRES_DB: catalog
    volumes:
      - catalog-data:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 10s
      retries: 5
      start_period: 30s
      timeout: 10s

  catalog-service:
    build: ./catalog-service
    ports:
      - "127.0.0.1:8002:8000"
    depends_on:
      catalog-db:
        condition: service_healthy

volumes:
  catalog-data:
    name: orderflow-catalog-data

What should match?

Compare the service names and indentation first. The volume name and health-aware dependency must also match this reference exactly.

Connect the API to PostgreSQL

The Catalog container needs a database driver before its Python code can connect to PostgreSQL. The binary Psycopg package supplies that connection layer inside the pinned Python image.

  • Switch back to catalog-service/requirements.txt.
  • Replace the file contents with these dependency pins:
fastapi==0.141.1
pydantic==2.13.5
uvicorn==0.52.4
psycopg[binary]==3.3.5

What changed in the image?

The psycopg[binary]==3.3.5 dependency gives the API a PostgreSQL driver. Rebuilding the image installs it through the existing Dockerfile.

  • Save catalog-service/requirements.txt.

The first rebuild may take a few minutes while Docker downloads the PostgreSQL image and installs the new Python package.

  • Rebuild the Catalog API with its database dependency by running this command:
docker compose up --build -d catalog-service

What starts with this command?

Compose builds catalog-service from the updated requirements. Its dependency starts catalog-db before the API container.

  • Check both Catalog containers by running this command:
docker compose ps

What should the container state show?

The database should reach a healthy state. The Catalog API should remain running after that health check passes.

You should see catalog-db with healthy status and catalog-service with running status.

Did either Catalog container exit?

Check the Catalog service output for the first database or dependency failure. A stale Catalog volume can retain initialization settings from an earlier configuration.

Ask for help interpreting the container output: help me diagnose why catalog-service or catalog-db exits during Docker Compose startup.

✔️ Both Catalog containers are running

The API image now contains Psycopg. The dedicated PostgreSQL container is ready for the application connection.

ⓧ I'd like to double check the dependencies

fastapi==0.141.1
pydantic==2.13.5
uvicorn==0.52.4
psycopg[binary]==3.3.5

What should match?

Your file should contain four pins in this order. The final line includes the binary extra inside square brackets.

The dependency is installed. Next, the application needs a reusable connection and a startup initializer for its products table.

  • Switch back to catalog-service/main.py.
  • Select the existing import section at the top of the file.
  • Replace the selected imports with this connection setup:
from contextlib import asynccontextmanager

import psycopg
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, PositiveInt
from psycopg.rows import dict_row

DSN = "dbname=catalog user=catalog password=catalog host=catalog-db"


def get_connection():
    return psycopg.connect(DSN, row_factory=dict_row)

How does the connection helper work?

  • The DSN points to the database through the Compose service name catalog-db.
  • The get_connection() helper gives every endpoint the same connection setup.
  • The dict_row row factory turns query results into response-ready dictionaries.
  • Save catalog-service/main.py.
  • Rebuild the API to verify that the new imports load by running this command:
docker compose up --build -d catalog-service

What does this rebuild verify?

A running API confirms that Python can import Psycopg and parse the connection helper. The endpoints still use memory until the next edits replace their storage logic.

  • Return to the interactive docs from earlier.
  • Execute GET /health to confirm the API still responds.

You should still receive {"status":"ok","service":"catalog"}.

Did the API stop after the import change?

Confirm that psycopg[binary]==3.3.5 is saved in catalog-service/requirements.txt. Make sure the API image was rebuilt after that save.

Ask for help with the import failure: help me fix the Psycopg import failure in my Catalog container.

  • Find the current app = FastAPI(title="OrderFlow Catalog API") line in catalog-service/main.py.
  • Replace that line with this table initializer and application lifespan:
def create_products_table():
    with get_connection() as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS products (
                sku TEXT PRIMARY KEY,
                name TEXT NOT NULL,
                price_cents INTEGER NOT NULL CHECK (price_cents > 0)
            )
            """
        )


@asynccontextmanager
async def lifespan(_app: FastAPI):
    create_products_table()
    yield


app = FastAPI(title="OrderFlow Catalog API", lifespan=lifespan)

What happens during startup?

  • The products table owns the SKU as its primary key.
  • The database requires every product name.
  • The database also enforces a positive price_cents value.
  • The lifespan function creates the table before FastAPI begins handling requests.
  • Save catalog-service/main.py.
  • Rebuild the API with its startup initializer by running this command:
docker compose up --build -d catalog-service

What does a running API prove now?

The API reaches its running state only after create_products_table() completes. This proves the application can connect to the healthy database during startup.

  • Execute GET /health in the interactive docs.

You should receive the existing Catalog health response after the table initializer completes.

Did startup fail during table creation?

Check that the DSN uses catalog-db as its host. Confirm that the database environment values in compose.override.yaml all use catalog.

Ask for help with the connection path: help me diagnose why the Catalog lifespan cannot create the PostgreSQL table.

  • Find the current health() function in catalog-service/main.py.
  • Replace the function with this database-backed health check:
@app.get("/health")
def health():
    with get_connection() as connection:
        connection.execute("SELECT 1")
    return {"status": "ok", "service": "catalog"}

Why query the database during health checks?

The SELECT 1 query confirms that the API can reach PostgreSQL. A successful health response now covers both the web application and its storage dependency.

  • Save catalog-service/main.py.
  • Rebuild the database-backed health endpoint by running this command:
docker compose up --build -d catalog-service

What changed in this rebuild?

The health route now opens a real database connection for every check. The response stays the same while its meaning becomes stronger.

  • Execute GET /health in the interactive docs.

You should receive {"status":"ok","service":"catalog"} after the database query succeeds.

Is the health request failing?

Check the indentation inside the connection context. Confirm that SELECT 1 stays inside the with block.

Ask for help tracing the health query: help me troubleshoot why the Catalog database health query fails.

Move product endpoints into PostgreSQL

The database connection is working. Each product endpoint can now replace its dictionary operation with parameterized SQL.

  • Find the current list_products() function in catalog-service/main.py.
  • Replace the function with this ordered database query:
@app.get("/products")
def list_products():
    with get_connection() as connection:
        return connection.execute(
            "SELECT sku, name, price_cents FROM products ORDER BY sku"
        ).fetchall()

How does product listing work now?

The query reads every product from PostgreSQL. Ordering by sku keeps the response stable across requests.

  • Save catalog-service/main.py.
  • Rebuild the new listing endpoint by running this command:
docker compose up --build -d catalog-service

What does this rebuild activate?

The running API now serves product lists from the database table. The previous in-memory product was already lost during the earlier container recreation.

  • Execute GET /products in the interactive docs.

You should receive [] because the new database table does not contain a product yet.

Is the product list request failing?

Confirm that the query uses the table name products. Check that fetchall() remains attached to the query result.

Ask for help with the listing query: help me fix the PostgreSQL-backed list_products endpoint.

  • Find the current get_product() function in catalog-service/main.py.
  • Replace the function with this parameterized lookup:
@app.get("/products/{sku}")
def get_product(sku: str):
    with get_connection() as connection:
        product = connection.execute(
            "SELECT sku, name, price_cents FROM products WHERE sku = %s",
            (sku,),
        ).fetchone()

    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    return product

Why pass the SKU separately?

The %s placeholder keeps the SQL structure separate from the SKU value. fetchone() returns one matching product or no row.

The existing 404 response still handles an unknown SKU. Storage changed without changing the API contract.

  • Save catalog-service/main.py.
  • Rebuild the lookup endpoint by running this command:
docker compose up --build -d catalog-service

What does this rebuild activate?

SKU lookups now query PostgreSQL through a parameterized statement. The route keeps its previous response behavior for missing products.

  • Execute GET /products/{sku} with TEE-001 in the interactive docs.

You should receive a 404 response with Product not found because that SKU has not been inserted yet.

Did the lookup return a server error?

Check the comma in (sku,). Python needs that comma to create the one-value parameter tuple expected by the query.

Ask for help with the lookup parameters: help me diagnose the parameterized SKU lookup.

  • Find the current create_product() function in catalog-service/main.py.
  • Replace the function with this parameterized insert:
@app.post("/products", status_code=201)
def create_product(product: ProductCreate):
    with get_connection() as connection:
        return connection.execute(
            """
            INSERT INTO products (sku, name, price_cents)
            VALUES (%s, %s, %s)
            RETURNING sku, name, price_cents
            """,
            (product.sku, product.name, product.price_cents),
        ).fetchone()

How is a product stored?

The insert sends the SKU, name, and validated price as separate parameters. PostgreSQL returns the saved row so the API can send the created product back immediately.

Pydantic still rejects non-positive prices before this query runs. PostgreSQL enforces the same rule inside the table.

  • Save catalog-service/main.py.
  • Rebuild the product creation endpoint by running this command:
docker compose up --build -d catalog-service

What does this rebuild activate?

New products now enter PostgreSQL through the API. The response comes from the row stored in the database.

  • Expand POST /products in the interactive docs.
  • Enable editing for the request body.
  • Enter TEE-001 as the sku value.
  • Enter a product name as the name value.
  • Enter a positive whole number as the price_cents value.
  • Submit the product request.

You should receive the created product with TEE-001 and the values you submitted.

Was the product creation request rejected?

Confirm that price_cents contains a positive whole number. Check that all three request fields match the ProductCreate model.

Ask for help with the insert request: help me troubleshoot the PostgreSQL product insert.

  • Find the old products = {} line near the top of catalog-service/main.py.
  • Delete the unused in-memory dictionary line.
  • Save catalog-service/main.py.

✔️ My database-backed code is complete

Your API now reads and writes products through its dedicated PostgreSQL database. Make sure catalog-service/main.py is saved before the persistence test.

ⓧ I'd like to double check the full code

from contextlib import asynccontextmanager

import psycopg
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, PositiveInt
from psycopg.rows import dict_row

DSN = "dbname=catalog user=catalog password=catalog host=catalog-db"


def get_connection():
    return psycopg.connect(DSN, row_factory=dict_row)


def create_products_table():
    with get_connection() as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS products (
                sku TEXT PRIMARY KEY,
                name TEXT NOT NULL,
                price_cents INTEGER NOT NULL CHECK (price_cents > 0)
            )
            """
        )


@asynccontextmanager
async def lifespan(_app: FastAPI):
    create_products_table()
    yield


app = FastAPI(title="OrderFlow Catalog API", lifespan=lifespan)


class ProductCreate(BaseModel):
    sku: str
    name: str
    price_cents: PositiveInt


@app.get("/health")
def health():
    with get_connection() as connection:
        connection.execute("SELECT 1")
    return {"status": "ok", "service": "catalog"}


@app.get("/products")
def list_products():
    with get_connection() as connection:
        return connection.execute(
            "SELECT sku, name, price_cents FROM products ORDER BY sku"
        ).fetchall()


@app.get("/products/{sku}")
def get_product(sku: str):
    with get_connection() as connection:
        product = connection.execute(
            "SELECT sku, name, price_cents FROM products WHERE sku = %s",
            (sku,),
        ).fetchone()

    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    return product


@app.post("/products", status_code=201)
def create_product(product: ProductCreate):
    with get_connection() as connection:
        return connection.execute(
            """
            INSERT INTO products (sku, name, price_cents)
            VALUES (%s, %s, %s)
            RETURNING sku, name, price_cents
            """,
            (product.sku, product.name, product.price_cents),
        ).fetchone()

What should match?

Compare the imports and connection helpers first. Check the lifespan initializer before comparing each database-backed endpoint.

Before you recreate the API, do you expect TEE-001 to disappear again?

  • Force-recreate only the Catalog API container by running this command:
docker compose up -d --force-recreate catalog-service

What survives this recreation?

Compose replaces the API container while leaving catalog-db and its named volume intact. The product row remains outside the recreated container.

  • Refresh the interactive docs at http://127.0.0.1:8002/docs.
  • Expand GET /products/{sku}.
  • Enter TEE-001 as the SKU.
  • Submit the lookup request.

You should receive TEE-001 with the same name and price_cents you created before the API recreation.

Did TEE-001 disappear?

Confirm that catalog-data is mounted at /var/lib/postgresql. Check that only catalog-service was force-recreated.

Ask for help tracing the persistence path: help me diagnose why TEE-001 disappeared after recreating only catalog-service.

That's the durability gap closed. Catalog products now outlive the API container that serves them.

Secret mission

Add Persistent Price Updates

A persistent catalog also needs a safe way to correct product prices. Add a validated price update endpoint. Then prove the new value survives API container recreation.

Clean Up Your Resources

The generated Docker image uses local disk. These cleanup choices keep that image available for future builds.

Nice Work!

Nice Work!

You did it! Your Catalog service now preserves validated product data through API container recreation.

You've learned how to:

  • Extend an existing Docker Compose workspace through compose.override.yaml. Keep the base compose.yaml intact.
  • Build a FastAPI Catalog service with REST endpoints. Validate positive prices with Pydantic before storage.
  • Reproduce in-memory data loss during API container recreation. Fix it with a dedicated PostgreSQL volume. Protect database operations with parameterized SQL.
  • Secret Mission: Add a validated price update that remains visible after the Catalog API container is recreated.

Ready to quiz yourself?