Build a Smart Inventory API with FastAPI
Build a REST API using OOP design patterns, abstract classes, and FastAPI.
Introduction
30 Second Summary
Every online store, warehouse app, and checkout system needs a way to track products. When you check if something is in stock or see a discounted price, there's an API behind the scenes making it happen.
In this project, you will build a REST API that manages product inventory using Python and FastAPI. You'll architect the application with layered OOP patterns including abstract classes, inheritance, and the Strategy design pattern.
What You'll Build
A Smart Inventory API that manages products with categories, pricing, and dynamic discount strategies, built with a layered architecture demonstrating core object-oriented programming concepts.
By the end of this project, you'll have:
- A running REST API with full CRUD endpoints and auto-generated interactive documentation you can test in your browser.
- A layered architecture using abstract base classes, inheritance, and the Strategy pattern to keep your code flexible and extensible.
- A clear mental model of how OOP patterns solve real architectural problems in API development.
- Secret Mission: Add category filtering and an inventory statistics endpoint.
Are there any prerequisites?
You need basic Python knowledge (variables, functions, loops). No prior API or OOP experience is required.
Before We Start
Before you can build an API, you need a clean workspace where Python packages stay isolated from the rest of your system.
In this step, you will create your project folder, set up a virtual environment, and install FastAPI. By the end, your project will have the file structure ready to fill with code.
In this step, get ready to:
- Create a project folder and virtual environment.
- Install FastAPI and its standard dependencies.
- Create the four project files.
Create a project folder and virtual environment
A virtual environment gives your project its own private set of Python packages. This prevents conflicts between projects that need different versions of the same library.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (the backtick key, above Tab on your keyboard).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir inventory-api
cd inventory-api
- Now open this folder in VS Code so your Explorer sidebar shows the project.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the inventory-api folder, and click Open.
- Open a fresh terminal in the new workspace by pressing Ctrl+` again.
Your terminal should now show inventory-api as the current directory.
- Create your virtual environment by running this command:
python -m venv venv
What is a virtual environment?
The venv module creates an isolated Python environment inside a venv folder in your project. Any packages you install while it is active stay inside that folder, separate from your system Python.
- Activate the virtual environment by running the command for your operating system:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
Your terminal prompt should now show (venv) at the beginning. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
- On macOS, if python is not recognized, try python3 -m venv venv instead, then activate again.
- On Windows, if you see an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned and try activating again.
- Make sure you are in the inventory-api folder when you run the activate command.
Help me troubleshoot my virtual environment activation.
Install FastAPI
FastAPI is a modern Python web framework built around type hints. The standard install includes Uvicorn (the development server) and other utilities you will need.
- Install FastAPI and its standard dependencies by running:
pip install "fastapi[standard]"
What does fastapi[standard] include?
The [standard] extra installs FastAPI along with Uvicorn (the dev server that runs your API), plus tools for data validation and serialization. You get everything needed to build and serve an API out of the box.
- Verify the installation by running:
pip show fastapi
You should see package information including a version starting with 0.138.
Seeing a 'pip not found' error?
- Make sure your virtual environment is active. You should see (venv) in your terminal prompt. If not, run the activation command from the previous substep.
- On macOS, try pip3 instead of pip if the error persists.
Help me fix my pip installation issue.
Create the project files
Your project needs four Python files. Each file will hold a different layer of the application architecture.
- Create all four project files by running the command for your operating system:
macOS
touch models.py repository.py strategies.py main.py
Windows
New-Item -ItemType "File" -Path "models.py", "repository.py", "strategies.py", "main.py"
What are these files for?
- models.py defines the data shapes for your products using Pydantic models and inheritance.
- repository.py holds the abstract storage interface and its concrete implementation.
- strategies.py contains interchangeable discount calculation strategies.
- main.py wires everything together into a running FastAPI application.
- Confirm the files exist by running:
ls
You should see main.py, models.py, repository.py, strategies.py, and the venv folder in the output.
Missing files in the output?
- Check that your terminal is in the inventory-api directory. Run pwd (macOS) or cd (Windows) to confirm.
- If some files are missing, re-run the creation command from the tab above for your OS.
Help me figure out why my project files are missing.
Your project workspace is ready. Next up, you will fill models.py with data models that use Pydantic inheritance to define your product schemas.
Build Data Models with Inheritance
Your project environment is ready with FastAPI installed and four empty files waiting. Now it's time to define the data your API will manage.
Every API needs a clear contract for what data looks like coming in and going out. Pydantic models give you that contract with built-in validation, and class inheritance lets you share fields across related models without repeating yourself.
In this step, get ready to:
- Define a category enum and base product model with field validation.
- Create specialized models using inheritance for different API operations.
- Test your models in the Python REPL to see validation in action.
Define the base product model and category enum
Products in the inventory belong to a category and have properties like name, price, and quantity. You'll define these as a Pydantic BaseModel with built-in validation constraints.
- In VS Code, open models.py and add the following imports, category enum, and base model:
from pydantic import BaseModel, Field
from enum import Enum
class Category(str, Enum):
electronics = "electronics"
clothing = "clothing"
food = "food"
books = "books"
class ProductBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str | None = None
price: float = Field(gt=0)
category: Category
quantity: int = Field(ge=0)
What does this code do?
Category inherits from both str and Enum. This means each category value is both a string (so it serializes cleanly to JSON) and a restricted enum (so only valid categories are accepted).
ProductBase uses Pydantic's Field to add validation constraints. Field(gt=0) means "greater than 0" so negative prices are rejected automatically. Field(ge=0) means "greater than or equal to 0" so quantity can be zero but never negative.
- Save models.py.
- You won't see output yet. The next substep builds on this foundation with specialized models.
Seeing a red underline on the import?
Make sure your virtual environment is active. Your terminal prompt should start with (venv). If it doesn't, run venv\Scripts\activate again.
Also check that pydantic is spelled correctly in the import line. A typo like pydnatic will fail silently until you try to run the file.
help me fix my Pydantic import in models.py
Create specialized models using inheritance
A single model rarely handles every scenario. You need different shapes for creating a product (no ID yet), reading a product (has an ID), and updating a product (all fields optional). Inheritance lets each shape reuse the base fields without duplicating code.
- In models.py, add these three classes below your ProductBase class:
class ProductCreate(ProductBase):
pass
class Product(ProductBase):
id: int
discounted_price: float | None = None
class ProductUpdate(BaseModel):
name: str | None = None
description: str | None = None
price: float | None = Field(default=None, gt=0)
category: Category | None = None
quantity: int | None = Field(default=None, ge=0)
What does this code do?
ProductCreate inherits everything from ProductBase without adding new fields. It exists as a separate class so the API schema clearly documents what input is expected for creating a product.
Product extends ProductBase with an id field (assigned by the server) and discounted_price (calculated later by discount strategies).
ProductUpdate inherits directly from BaseModel instead of ProductBase. Every field is optional so the API can accept partial updates. Only the fields the caller sends will be changed.
- Save models.py. The next substep tests these models in the Python REPL to prove they work.
Seeing a syntax error on the pipe character?
The float | None syntax requires Python 3.10 or higher. Run python --version to check yours. If you're on an older version, you'll need to upgrade Python before continuing.
help me fix the pipe syntax error in my models.py
✔️ Awesome, I've got everything!
Great. Double check you've saved models.py before moving on to testing.
ⓧ I'd like to double check the full code
from pydantic import BaseModel, Field
from enum import Enum
class Category(str, Enum):
electronics = "electronics"
clothing = "clothing"
food = "food"
books = "books"
class ProductBase(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: str | None = None
price: float = Field(gt=0)
category: Category
quantity: int = Field(ge=0)
class ProductCreate(ProductBase):
pass
class Product(ProductBase):
id: int
discounted_price: float | None = None
class ProductUpdate(BaseModel):
name: str | None = None
description: str | None = None
price: float | None = Field(default=None, gt=0)
category: Category | None = None
quantity: int | None = Field(default=None, ge=0)
Test your models in the Python REPL
The best way to confirm your models work is to instantiate them directly. You'll create a valid product, then try invalid data to see Pydantic's validation in action.
- Open the Python REPL in your terminal by running:
python
- Create a valid product by typing the following into the REPL:
from models import ProductCreate, Category
# Create a product with valid data
product = ProductCreate(
name="Wireless Mouse",
description="Ergonomic Bluetooth mouse",
price=29.99,
category=Category.electronics,
quantity=50
)
print(product)
What should I see?
The REPL prints the product with all fields populated. Pydantic accepted the data because every field meets its validation constraints.
- Now try passing a negative price to see validation reject it:
# Try invalid data - negative price
try:
bad_product = ProductCreate(
name="Broken",
price=-5.00,
category=Category.electronics,
quantity=10
)
except Exception as e:
print(e)
What should I see?
Pydantic raises a ValidationError telling you the price input should be greater than 0. This is the Field(gt=0) constraint doing its job automatically.
- Exit the REPL by typing:
exit()
Getting a ModuleNotFoundError?
Make sure you're running python from inside the inventory-api folder where models.py lives. If your terminal is in a different directory, run cd inventory-api first.
Also confirm that (venv) shows in your terminal prompt. Without the virtual environment active, the pydantic package won't be found.
help me fix the ModuleNotFoundError when importing my models
Your data models are locked in with built-in validation. Next up, you'll create an abstract repository layer that defines how products are stored and retrieved.
Create the Abstract Repository
Your data models are locked in. Every product flowing through the API will be validated by those Pydantic classes before anything else happens.
Now you need somewhere to actually store those products. In this step, you will build a repository layer using Python's abstract base class system. The abstract class defines a contract (the methods any storage implementation must provide), and the concrete class fulfills that contract with an in-memory dictionary.
In this step, get ready to:
- Define an abstract repository interface with five CRUD methods.
- Build a concrete in-memory implementation that stores products in a dictionary.
- Verify the repository in the Python REPL.
Define the abstract interface
An abstract base class is a blueprint that says "any class claiming to be a repository must implement these methods." It cannot be instantiated on its own. Its only job is to enforce a contract on subclasses.
- Switch back to VS Code and open repository.py.
- Add the abstract repository class by copying in this code:
from abc import ABC, abstractmethod
from models import Product, ProductCreate, ProductUpdate
# Abstract base class defining the repository contract
class ProductRepository(ABC):
@abstractmethod
def get_all(self) -> list[Product]:
pass
@abstractmethod
def get_by_id(self, product_id: int) -> Product | None:
pass
@abstractmethod
def create(self, product: ProductCreate) -> Product:
pass
@abstractmethod
def update(self, product_id: int, product: ProductUpdate) -> Product | None:
pass
@abstractmethod
def delete(self, product_id: int) -> bool:
pass
What does this code do?
ProductRepository inherits from ABC (Abstract Base Class), which means Python will refuse to let anyone create an instance of it directly.
Each method decorated with @abstractmethod must be implemented by any subclass. The type hints (-> list[Product], -> Product | None) define the contract: what goes in and what comes out.
- Save repository.py.
- You won't see a change yet. The next chunk builds the concrete class that brings this interface to life.
Implement the concrete in-memory repository
The abstract class defines what methods must exist. Now you need a class that actually implements them. This concrete class will store products in a Python dictionary, using an auto-incrementing counter for IDs.
- In repository.py, add the following class below the ProductRepository class:
class InMemoryRepository(ProductRepository):
def __init__(self):
# Dictionary to store products keyed by their ID
self._products: dict[int, Product] = {}
# Auto-incrementing counter for new product IDs
self._next_id: int = 1
def get_all(self) -> list[Product]:
return list(self._products.values())
def get_by_id(self, product_id: int) -> Product | None:
return self._products.get(product_id)
def create(self, product: ProductCreate) -> Product:
# Convert input model to a dict, then create a Product with an ID
new_product = Product(id=self._next_id, **product.model_dump())
self._products[self._next_id] = new_product
self._next_id += 1
return new_product
def update(self, product_id: int, product: ProductUpdate) -> Product | None:
existing = self._products.get(product_id)
if existing is None:
return None
# Only include fields the caller explicitly set
update_data = product.model_dump(exclude_unset=True)
# Create a new Product with the updated fields merged in
updated_product = existing.model_copy(update=update_data)
self._products[product_id] = updated_product
return updated_product
def delete(self, product_id: int) -> bool:
if product_id in self._products:
del self._products[product_id]
return True
return False
What does this code do?
InMemoryRepository inherits from ProductRepository and implements all five abstract methods. Because every method is implemented, Python allows this class to be instantiated.
Two Pydantic v2 methods do the heavy lifting. model_dump() converts a Pydantic model into a plain dictionary. model_copy(update=...) creates a copy of an existing model with specific fields changed.
The exclude_unset=True argument in the update method is critical. It ensures only fields the caller explicitly provided get changed. Without it, every field defaults to None and wipes out existing data.
- Save repository.py.
- You won't see a visible change yet. The next substep verifies everything works in the Python REPL.
Seeing a red underline in VS Code?
If VS Code shows an import error on from models import ..., make sure your terminal is in the inventory-api folder and that your virtual environment is active (you should see (venv) in the prompt). VS Code sometimes needs a moment to detect the interpreter.
help me fix import errors in my repository file
✔️ Awesome, I've got everything!
Great. Double check you have saved repository.py before moving on.
ⓧ I'd like to double check the full code
from abc import ABC, abstractmethod
from models import Product, ProductCreate, ProductUpdate
class ProductRepository(ABC):
@abstractmethod
def get_all(self) -> list[Product]:
pass
@abstractmethod
def get_by_id(self, product_id: int) -> Product | None:
pass
@abstractmethod
def create(self, product: ProductCreate) -> Product:
pass
@abstractmethod
def update(self, product_id: int, product: ProductUpdate) -> Product | None:
pass
@abstractmethod
def delete(self, product_id: int) -> bool:
pass
class InMemoryRepository(ProductRepository):
def __init__(self):
self._products: dict[int, Product] = {}
self._next_id: int = 1
def get_all(self) -> list[Product]:
return list(self._products.values())
def get_by_id(self, product_id: int) -> Product | None:
return self._products.get(product_id)
def create(self, product: ProductCreate) -> Product:
new_product = Product(id=self._next_id, **product.model_dump())
self._products[self._next_id] = new_product
self._next_id += 1
return new_product
def update(self, product_id: int, product: ProductUpdate) -> Product | None:
existing = self._products.get(product_id)
if existing is None:
return None
update_data = product.model_dump(exclude_unset=True)
updated_product = existing.model_copy(update=update_data)
self._products[product_id] = updated_product
return updated_product
def delete(self, product_id: int) -> bool:
if product_id in self._products:
del self._products[product_id]
return True
return False
Verify your repository works
Time to prove the repository actually works. You will create a product, retrieve it, update it, delete it, and then confirm that the abstract class refuses to be instantiated directly.
- Start the Python REPL by running this command in your terminal:
python
- Test all four CRUD operations by running these commands one at a time:
from repository import InMemoryRepository, ProductRepository
from models import ProductCreate, ProductUpdate, Category
repo = InMemoryRepository()
product = repo.create(ProductCreate(name="Laptop", description="Gaming laptop", price=999.99, category=Category.electronics, quantity=10))
print(product)
found = repo.get_by_id(1)
print(found)
updated = repo.update(1, ProductUpdate(price=899.99))
print(updated)
repo.delete(1)
print(repo.get_all())
What should I see?
The first print(product) shows a Product with id=1 and all the fields you passed in. The get_by_id call returns the same product.
After the update, price changes to 899.99 while every other field stays the same. After the delete, get_all() returns an empty list [].
- Now try to instantiate the abstract class directly:
ProductRepository()
You should see a TypeError that says Python cannot instantiate the abstract class. This proves your contract is enforced. Any class that claims to be a ProductRepository must implement all five methods, or Python rejects it at runtime.
- Exit the Python REPL by running:
exit()
Getting a ModuleNotFoundError?
Make sure your terminal is inside the inventory-api folder. Run ls (or dir on Windows) and confirm you can see models.py and repository.py listed. If not, cd into the correct directory and try again.
help me fix my ModuleNotFoundError when importing repository
Your repository layer is solid. You have an abstract contract that enforces consistency and a concrete implementation that stores products in memory. Next up, you will wire this repository into a full REST API with endpoints you can test in your browser.
Build the REST API
You have data models that validate input and a repository that stores products in memory. The missing piece is a way for the outside world to interact with them.
In this step, you will build a REST API that exposes your repository through HTTP endpoints. Each endpoint maps to a specific CRUD operation (create, read, update, delete), and FastAPI wires everything together using dependency injection.
In this step, get ready to:
- Create the FastAPI app instance with dependency injection.
- Implement five CRUD endpoints for your product inventory.
- Run the development server and test your API through interactive docs.
Create the FastAPI app with dependency injection
Your endpoints need access to the repository so they can store and retrieve products. Instead of importing the repository directly inside each function, you will use a pattern called dependency injection.
- In VS Code, open main.py from the Explorer sidebar.
- Set up the FastAPI app and repository by pasting this code:
from fastapi import FastAPI, HTTPException, Depends
from models import Product, ProductCreate, ProductUpdate
from repository import ProductRepository, InMemoryRepository
# Create the FastAPI application instance
app = FastAPI(title="Smart Inventory API")
# Create a single shared repository instance
repository = InMemoryRepository()
# Dependency injection function - provides the repository to endpoints
def get_repository() -> ProductRepository:
return repository
What does this code do?
The app object is your FastAPI application. The title parameter sets the name that appears in the auto-generated documentation.
The get_repository() function returns the shared InMemoryRepository instance. Endpoints will declare this function as a dependency, and FastAPI will call it automatically before running the endpoint. This keeps your endpoint functions decoupled from the specific repository implementation.
Why dependency injection instead of direct imports?
Your endpoint functions declare what they need (a ProductRepository) without knowing which concrete class fulfills it. If you later swap InMemoryRepository for a database-backed repository, you change one line in get_repository() and every endpoint automatically uses the new implementation.
- Save main.py.
You will not see a change yet. The next chunk adds the actual endpoints that make the API functional.
Implement CRUD endpoints
Each endpoint maps an HTTP method and URL path to a repository operation. FastAPI uses Python type hints to validate requests and generate documentation automatically.
- In main.py, add the two GET endpoints below the get_repository() function:
@app.get("/products", response_model=list[Product])
def list_products(
repo: ProductRepository = Depends(get_repository),
):
# Return all products from the repository
return repo.get_all()
@app.get("/products/{product_id}", response_model=Product)
def get_product(
product_id: int,
repo: ProductRepository = Depends(get_repository),
):
# Look up a single product by its ID
product = repo.get_by_id(product_id)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return product
What does this code do?
The @app.get decorator tells FastAPI to run this function when a GET request hits the specified path. response_model=list[Product] tells FastAPI to validate the response shape and include it in the generated docs.
The Depends(get_repository) parameter is where dependency injection happens. FastAPI calls get_repository() and passes the result as repo.
When get_by_id returns None, the endpoint raises an HTTPException with status code 404. This sends a proper error response to the client instead of returning empty data.
- Save main.py.
You will not see a change yet. The next chunk adds the write operations.
- Below the get_product function, add the POST, PUT, and DELETE endpoints:
@app.post("/products", response_model=Product, status_code=201)
def create_product(
product: ProductCreate,
repo: ProductRepository = Depends(get_repository),
):
# Create a new product and return it
return repo.create(product)
@app.put("/products/{product_id}", response_model=Product)
def update_product(
product_id: int,
product: ProductUpdate,
repo: ProductRepository = Depends(get_repository),
):
# Update an existing product with new data
updated = repo.update(product_id, product)
if updated is None:
raise HTTPException(status_code=404, detail="Product not found")
return updated
@app.delete("/products/{product_id}", status_code=204)
def delete_product(
product_id: int,
repo: ProductRepository = Depends(get_repository),
):
# Remove a product from the repository
deleted = repo.delete(product_id)
if not deleted:
raise HTTPException(status_code=404, detail="Product not found")
What does this code do?
The create_product endpoint accepts a ProductCreate body (FastAPI automatically validates it against your Pydantic model) and returns status code 201 (Created).
The update_product endpoint takes both a path parameter (product_id) and a request body (ProductUpdate). FastAPI knows which is which based on where they appear in the route and their types.
The delete_product endpoint returns status code 204 (No Content) on success. It returns no body, just the status code confirming deletion.
- Save main.py.
Seeing a red underline on an import?
If VS Code shows an import error, make sure your virtual environment is selected as the Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Python: Select Interpreter, and choose the one inside your venv folder.
Help me troubleshoot this import error.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py before moving on.
ⓧ I'd like to double check the full code
from fastapi import FastAPI, HTTPException, Depends
from models import Product, ProductCreate, ProductUpdate
from repository import ProductRepository, InMemoryRepository
# Create the FastAPI application instance
app = FastAPI(title="Smart Inventory API")
# Create a single shared repository instance
repository = InMemoryRepository()
# Dependency injection function - provides the repository to endpoints
def get_repository() -> ProductRepository:
return repository
@app.get("/products", response_model=list[Product])
def list_products(
repo: ProductRepository = Depends(get_repository),
):
# Return all products from the repository
return repo.get_all()
@app.get("/products/{product_id}", response_model=Product)
def get_product(
product_id: int,
repo: ProductRepository = Depends(get_repository),
):
# Look up a single product by its ID
product = repo.get_by_id(product_id)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return product
@app.post("/products", response_model=Product, status_code=201)
def create_product(
product: ProductCreate,
repo: ProductRepository = Depends(get_repository),
):
# Create a new product and return it
return repo.create(product)
@app.put("/products/{product_id}", response_model=Product)
def update_product(
product_id: int,
product: ProductUpdate,
repo: ProductRepository = Depends(get_repository),
):
# Update an existing product with new data
updated = repo.update(product_id, product)
if updated is None:
raise HTTPException(status_code=404, detail="Product not found")
return updated
@app.delete("/products/{product_id}", status_code=204)
def delete_product(
product_id: int,
repo: ProductRepository = Depends(get_repository),
):
# Remove a product from the repository
deleted = repo.delete(product_id)
if not deleted:
raise HTTPException(status_code=404, detail="Product not found")
Run and test the API
FastAPI includes a built-in development server and auto-generates interactive documentation. You can test every endpoint directly in your browser without writing any client code.
- Start the development server by running this command in your terminal:
fastapi dev main.py
- You should see output that includes a server URL of http://127.0.0.1:8000 and a link to the docs at http://127.0.0.1:8000/docs.
What does this command do?
The fastapi dev command detects your app object in main.py and starts a Uvicorn server in development mode. Auto-reload is enabled, so the server restarts automatically whenever you save a file.
Server not starting?
Make sure your virtual environment is activated. You should see (venv) at the beginning of your terminal prompt.
If you see an ImportError, double check that all four files (models.py, repository.py, strategies.py, main.py) are in the same inventory-api folder and that models.py and repository.py contain the code from previous steps.
Help me troubleshoot my FastAPI server startup error.
- Open your browser and navigate to http://127.0.0.1:8000/docs.
You should see the Swagger UI page titled Smart Inventory API with all five endpoints listed.
What is Swagger UI?
FastAPI automatically generates an interactive API documentation page at /docs. It reads your Python type hints, response models, and status codes to build a complete interface where you can test every endpoint without any extra tools.
- Click the POST /products section to expand it.
- Click Try it out.
- Replace the example JSON body with this:
{
"name": "Wireless Keyboard",
"description": "Bluetooth mechanical keyboard",
"price": 79.99,
"category": "electronics",
"quantity": 50
}
- Click Execute.
You should see a response with status code 201 and a JSON body that includes the product you created, now with an id of 1.
- Expand the GET /products section.
- Click Try it out, then click Execute.
You should see a JSON array containing the product you just created.
- Expand GET /products/{product_id} and click Try it out.
- Enter 1 in the product_id field and click Execute.
- Try entering 999 as the product_id to see the 404 error response.
- Expand PUT /products/{product_id} and click Try it out.
- Enter 1 for the product_id and use this body:
{
"price": 69.99
}
- Click Execute. You should see the product returned with the updated price.
- Expand DELETE /products/{product_id} and click Try it out.
- Enter 1 for the product_id and click Execute. You should see status code 204 with no response body.
Your API is live and responding to requests. Next up, you will add the Strategy pattern to make discount logic interchangeable without touching any of the endpoint code you just wrote.
Add the Strategy Pattern
Your API has five working endpoints and a clean repository layer. Now it is time to tackle a common real-world problem: how do you apply different business logic (like discount calculations) to the same data without rewriting your endpoints every time the rules change?
The Strategy pattern solves this by defining a family of interchangeable algorithms. Each algorithm lives in its own class, and your API picks which one to use at request time. In this step, you will build three discount strategies and wire them into your GET endpoints using dependency injection.
In this step, get ready to:
- Define an abstract discount strategy interface and three concrete implementations.
- Wire the strategies into your API with a new dependency function.
- Test that the same endpoint returns different prices depending on the chosen strategy.
Define the discount strategy interface
The Strategy pattern starts with an abstract class that defines the contract every strategy must follow. Any class that inherits from this base must implement the same set of methods, so your API can swap strategies freely without caring which specific one it receives.
- In VS Code, open strategies.py and add the abstract strategy interface by pasting this code:
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
# Every discount strategy must implement these two methods
@abstractmethod
def calculate_discount(self, price: float, quantity: int) -> float:
pass
@abstractmethod
def strategy_name(self) -> str:
pass
What does this code do?
DiscountStrategy inherits from ABC (Abstract Base Class), which means you cannot create an instance of it directly. It exists purely to define the contract.
The two @abstractmethod decorators force any subclass to implement calculate_discount() and strategy_name(). If a subclass skips either one, Python raises a TypeError at instantiation.
Why the Strategy pattern instead of if/else?
You could put all your discount logic in a single function with if/else branches. But every time you add a new discount type, you would have to modify that function. The Strategy pattern lets you add new strategies by creating a new class. Zero changes to existing code.
It is the most beginner-accessible advanced OOP pattern because you see immediate results: the same endpoint returns different prices just by changing a query parameter.
Now you need three concrete classes that implement this interface. Each one calculates discounts differently.
- Add the three strategy implementations below the DiscountStrategy class in strategies.py:
class NoDiscount(DiscountStrategy):
# Returns the price unchanged - the default behavior
def calculate_discount(self, price: float, quantity: int) -> float:
return price
def strategy_name(self) -> str:
return "no_discount"
class BulkDiscount(DiscountStrategy):
# Applies tiered discounts based on quantity ordered
def calculate_discount(self, price: float, quantity: int) -> float:
if quantity >= 100:
return round(price * 0.8, 2)
elif quantity >= 50:
return round(price * 0.9, 2)
return price
def strategy_name(self) -> str:
return "bulk_discount"
class SeasonalDiscount(DiscountStrategy):
# Accepts a configurable percentage (default 15%)
def __init__(self, percentage: float = 15.0):
self.percentage = percentage
def calculate_discount(self, price: float, quantity: int) -> float:
return round(price * (1 - self.percentage / 100), 2)
def strategy_name(self) -> str:
return f"seasonal_discount_{self.percentage}pct"
What does this code do?
- NoDiscount returns the original price with no modification. This is the default strategy.
- BulkDiscount applies 20% off when quantity is 100 or more, 10% off when quantity is 50 or more, and no discount below 50.
- SeasonalDiscount takes a configurable percentage in its constructor (defaulting to 15%) and applies it to any product regardless of quantity.
All three classes satisfy the DiscountStrategy contract. Your API can use any of them interchangeably.
- Save strategies.py.
Seeing a red underline in VS Code?
If VS Code shows import errors on from abc import ABC, abstractmethod, make sure your virtual environment is selected as the Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Python: Select Interpreter, and choose the one with venv in its path.
help me fix import errors in my strategies.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved strategies.py before moving on.
ⓧ I'd like to double check the full code
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def calculate_discount(self, price: float, quantity: int) -> float:
pass
@abstractmethod
def strategy_name(self) -> str:
pass
class NoDiscount(DiscountStrategy):
def calculate_discount(self, price: float, quantity: int) -> float:
return price
def strategy_name(self) -> str:
return "no_discount"
class BulkDiscount(DiscountStrategy):
def calculate_discount(self, price: float, quantity: int) -> float:
if quantity >= 100:
return round(price * 0.8, 2)
elif quantity >= 50:
return round(price * 0.9, 2)
return price
def strategy_name(self) -> str:
return "bulk_discount"
class SeasonalDiscount(DiscountStrategy):
def __init__(self, percentage: float = 15.0):
self.percentage = percentage
def calculate_discount(self, price: float, quantity: int) -> float:
return round(price * (1 - self.percentage / 100), 2)
def strategy_name(self) -> str:
return f"seasonal_discount_{self.percentage}pct"
Wire strategies into the API with dependency injection
Your strategies exist but the API does not know about them yet. You need a way for each incoming request to pick which strategy to use. FastAPI's dependency injection system handles this cleanly.
- Switch back to main.py in VS Code.
- Find the line repository = InMemoryRepository() and add the following code directly below it:
discount_strategies: dict[str, DiscountStrategy] = {
"none": NoDiscount(),
"bulk": BulkDiscount(),
"seasonal": SeasonalDiscount(percentage=15.0),
}
What does this code do?
This dictionary maps friendly string names to strategy instances. When a request comes in with ?discount=bulk, the API can look up the correct strategy object by key. Pre-instantiating them here means you create each strategy once, not on every request.
- Find the get_repository() function and add a new dependency function directly below it:
def get_discount_strategy(
discount: Literal["none", "bulk", "seasonal"] = "none",
) -> DiscountStrategy:
return discount_strategies[discount]
What does this code do?
This function is a FastAPI dependency. It reads the discount query parameter from the URL and returns the matching strategy. The Literal["none", "bulk", "seasonal"] type annotation tells FastAPI to only accept those three values.
If someone sends ?discount=invalid, FastAPI automatically returns a 422 validation error. It also renders a dropdown in the /docs UI showing the valid options.
- Save main.py.
Now you need to update your two GET endpoints to actually use this new dependency.
- Find the list_products function in main.py. It currently looks like this:
@app.get("/products", response_model=list[Product])
def list_products(
repo: ProductRepository = Depends(get_repository),
):
return repo.get_all()
- Replace the entire list_products function with this updated version:
@app.get("/products", response_model=list[Product])
def list_products(
repo: ProductRepository = Depends(get_repository),
strategy: DiscountStrategy = Depends(get_discount_strategy),
):
# Retrieve all products and apply the chosen discount strategy
products = repo.get_all()
result = []
for product in products:
discounted = strategy.calculate_discount(product.price, product.quantity)
updated = product.model_copy(
update={"discounted_price": discounted if discounted != product.price else None}
)
result.append(updated)
return result
What does this code do?
- strategy: DiscountStrategy = Depends(get_discount_strategy) injects whichever strategy the query parameter selected.
- The loop applies calculate_discount() to each product's price and quantity.
- model_copy(update=...) creates a copy of the product with discounted_price set. If the discounted price equals the original, it stays None (no discount applied).
- Now find the get_product function. It currently looks like this:
@app.get("/products/{product_id}", response_model=Product)
def get_product(
product_id: int,
repo: ProductRepository = Depends(get_repository),
):
product = repo.get_by_id(product_id)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return product
- Replace the entire get_product function with this updated version:
@app.get("/products/{product_id}", response_model=Product)
def get_product(
product_id: int,
repo: ProductRepository = Depends(get_repository),
strategy: DiscountStrategy = Depends(get_discount_strategy),
):
product = repo.get_by_id(product_id)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
# Apply the selected discount strategy to this single product
discounted = strategy.calculate_discount(product.price, product.quantity)
return product.model_copy(
update={"discounted_price": discounted if discounted != product.price else None}
)
What does this code do?
The same pattern as list_products, but applied to a single product. The strategy dependency is injected, and the discounted price is calculated before returning the response.
- Save main.py.
Seeing a red underline on Literal or DiscountStrategy?
Check that the imports at the top of main.py include from typing import Literal and from strategies import DiscountStrategy, NoDiscount, BulkDiscount, SeasonalDiscount. These were added in the previous step.
help me fix import issues in my main.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py before moving on.
ⓧ I'd like to double check the full code
from typing import Literal
from fastapi import FastAPI, HTTPException, Depends, Query
from models import Product, ProductCreate, ProductUpdate, Category
from repository import ProductRepository, InMemoryRepository
from strategies import DiscountStrategy, NoDiscount, BulkDiscount, SeasonalDiscount
app = FastAPI(title="Smart Inventory API")
repository = InMemoryRepository()
discount_strategies: dict[str, DiscountStrategy] = {
"none": NoDiscount(),
"bulk": BulkDiscount(),
"seasonal": SeasonalDiscount(percentage=15.0),
}
def get_repository() -> ProductRepository:
return repository
def get_discount_strategy(
discount: Literal["none", "bulk", "seasonal"] = "none",
) -> DiscountStrategy:
return discount_strategies[discount]
@app.get("/products", response_model=list[Product])
def list_products(
repo: ProductRepository = Depends(get_repository),
strategy: DiscountStrategy = Depends(get_discount_strategy),
):
products = repo.get_all()
result = []
for product in products:
discounted = strategy.calculate_discount(product.price, product.quantity)
updated = product.model_copy(
update={"discounted_price": discounted if discounted != product.price else None}
)
result.append(updated)
return result
@app.get("/products/{product_id}", response_model=Product)
def get_product(
product_id: int,
repo: ProductRepository = Depends(get_repository),
strategy: DiscountStrategy = Depends(get_discount_strategy),
):
product = repo.get_by_id(product_id)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
discounted = strategy.calculate_discount(product.price, product.quantity)
return product.model_copy(
update={"discounted_price": discounted if discounted != product.price else None}
)
@app.post("/products", response_model=Product, status_code=201)
def create_product(
product: ProductCreate,
repo: ProductRepository = Depends(get_repository),
):
return repo.create(product)
@app.put("/products/{product_id}", response_model=Product)
def update_product(
product_id: int,
product: ProductUpdate,
repo: ProductRepository = Depends(get_repository),
):
updated = repo.update(product_id, product)
if updated is None:
raise HTTPException(status_code=404, detail="Product not found")
return updated
@app.delete("/products/{product_id}", status_code=204)
def delete_product(
product_id: int,
repo: ProductRepository = Depends(get_repository),
):
deleted = repo.delete(product_id)
if not deleted:
raise HTTPException(status_code=404, detail="Product not found")
Test the strategy pattern
Time to see the Strategy pattern in action. You will create a product with enough quantity to trigger the bulk discount, then switch between strategies and watch the price change.
- Start (or restart) the development server by running this command in your terminal:
fastapi dev main.py
- Open http://127.0.0.1:8000/docs in your browser.
- Expand the POST /products endpoint, click Try it out, and create a product with this request body:
{
"name": "Wireless Headphones",
"description": "Noise-cancelling Bluetooth headphones",
"price": 99.99,
"category": "electronics",
"quantity": 100
}
- Click Execute to create the product.
- Expand the GET /products endpoint and click Try it out.
- You should see a discount dropdown. Select bulk and click Execute.
The response should show a discounted_price of 79.99 (20% off because quantity is 100).
- Change the discount dropdown to seasonal and click Execute again.
Now the discounted_price should be 84.99 (15% off). Same endpoint, same product, different strategy.
- Change the dropdown to none and execute once more. The discounted_price field should be null (no discount applied).
Why is this powerful?
You just swapped the entire discount algorithm by changing one query parameter. Your endpoint code did not change at all between the three requests. If you needed a fourth discount type (loyalty, employee, clearance), you would create a new class, add it to the dictionary, and update the Literal type. The endpoint functions stay untouched.
Not seeing the discount dropdown in /docs?
Make sure you saved main.py and restarted the server. The fastapi dev command auto-reloads on save, but if you see stale docs, stop the server with Ctrl+C and run fastapi dev main.py again.
help me debug why the discount dropdown is not showing in my FastAPI docs
Your API now has interchangeable business logic. The endpoint code stays stable while the strategies do the heavy lifting. Next up, you will take on the Secret Mission and extend the API with filtering and statistics.
Secret mission
Add Filtering and Statistics
Your API can list, create, update, and delete products. But real inventory systems need smarter queries. Can you add category filtering and a statistics endpoint that aggregates your entire inventory?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with in-memory storage, so there are no ongoing costs.
Resources you used:
- The inventory-api project folder (contains all code files and the virtual environment).
- The FastAPI dev server process (running in your terminal).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your API.
- Your FastAPI server continues running at http://127.0.0.1:8000. You can keep testing endpoints through the /docs page.
- Your virtual environment stays active in the terminal session.
- All project files remain in the inventory-api folder on your Desktop.
Pause - I'll come back to this later
Shut down the running server and deactivate the virtual environment to free up your terminal. Your files stay in place for next time.
- In the terminal where the server is running, press Ctrl+C to stop the FastAPI dev server.
- Deactivate the virtual environment by running:
deactivate
- When you come back, reactivate the virtual environment by running:
venv\Scripts\activate
- Then start the server again with:
fastapi dev main.py
Delete - I don't want to use this again
Remove all project files and start fresh. This deletes the code, virtual environment, and all installed packages.
- In the terminal where the server is running, press Ctrl+C to stop the FastAPI dev server.
- Deactivate the virtual environment by running:
deactivate
- Navigate up to the Desktop and delete the project folder.
macOS
cd ~/Desktop
rm -rf inventory-api
Windows
cd ~/Desktop
rmdir /s /q inventory-api
- Confirm the folder is gone by running:
ls
You should no longer see inventory-api in the output.
Nice Work!
Nice Work!
You just built a fully functional inventory API from scratch using object-oriented design patterns and modern Python tooling.
You've learned how to:
- Build a complete REST API with 5 CRUD endpoints and auto-generated interactive documentation using FastAPI.
- Implement the Repository pattern with an abstract base class that enforces a contract on any storage implementation.
- Apply the Strategy design pattern to make discount logic interchangeable without modifying endpoint code.
- Secret Mission: Added category filtering and a statistics aggregation endpoint to extend your API.
Ready to quiz yourself?