31.3. Product Management
Table of Contents
Overview
In an e‑commerce backend, “product management” is everything related to storing, updating, and serving product data to clients. This includes product details, pricing, inventory links, categories, and media like images.
In this chapter you will focus on how to design and implement product management for the e‑commerce backend project. Other chapters will cover categories, inventory, and file handling in more depth, so here you will mostly connect to them and focus on product‑specific concerns.
Defining Product Requirements
Before writing any code, you should define what a “product” means in your system. Even for a simple shop, a product usually has at least:
- Basic info: name, description, SKU, slug
- Pricing: regular price, sale price, currency
- Availability: is it active, is it visible
- Categorization: primary category, possibly multiple categories or tags
- Images: main image, gallery
- Inventory: stock quantity, or a link to an inventory system
- Metadata: attributes like color, size, brand
Think in terms of API consumers:
- The storefront needs to:
- List products (with pagination and filtering).
- Show product details.
- Show corrected prices and availability.
- The admin panel needs to:
- Create products.
- Update products.
- Archive or delete products.
- Manage their relationships (categories, images, variants).
Try to write a short example of a product in JSON, which will guide your model:
{
"id": 123,
"name": "Wireless Mouse",
"slug": "wireless-mouse",
"description": "Ergonomic wireless mouse with 2.4 GHz receiver.",
"sku": "MOUSE-001-BLK",
"price": 24.99,
"currency": "USD",
"sale_price": 19.99,
"is_active": true,
"category_id": 5,
"image_url": "https://cdn.example.com/products/mouse-1.jpg"
}This JSON is not final, but it shows the core shape you will work with.
Database Model for Products
The database design of products must integrate nicely with the rest of the project’s schema. For this chapter you only need the core product table and its direct fields. Relationships like categories, inventory, and images will be refined in their own chapters, but you will still reference them here.
A simple products table can look like this:
| Column | Type | Notes |
|---|---|---|
| id | bigint, PK | Autogenerated primary key |
| name | text | Product name shown to customers |
| slug | text, unique | URL‑friendly unique identifier |
| description | text | Long description |
| short_desc | text, nullable | Short description for listings |
| sku | text, unique | Stock keeping unit, internal code |
| price_cents | integer | Price stored in smallest unit |
| sale_price_cents | integer, nullable | Optional sale price |
| currency | char(3) | ISO currency code, for example "USD" |
| is_active | boolean | Controls visibility in the shop |
| created_at | timestamptz | Record creation time |
| updated_at | timestamptz | Last update time |
| category_id | bigint, FK | Reference to categories table |
Use integer cents for pricing, not floating‑point types:
Rule: Store money as an integer number of the smallest currency unit, for example cents, not as a floating‑point type.
For example:
- $price\_cents = \text{round}(price\_dollars \times 100)$
This avoids rounding problems that can show up with float or double.
A basic SQL definition could be:
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
short_description TEXT,
sku TEXT NOT NULL UNIQUE,
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
sale_price_cents INTEGER CHECK (sale_price_cents >= 0),
currency CHAR(3) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
category_id BIGINT REFERENCES categories(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
You can add database‑side triggers or application logic to keep updated_at in sync.
Product Domain Rules
Your backend should enforce important product rules consistently, both at the API layer and in the database or domain layer.
Typical rules:
- A product must have:
- A non‑empty name.
- A unique slug.
- A unique SKU.
- A non‑negative price.
- If
sale_price_centsis set, it must be less than or equal toprice_cents. - Only
is_active = trueproducts should appear in public listings. - Currency must be from a known set, for example
{"USD", "EUR", "GBP"}.
You can enforce the sale price rule in domain logic:
def validate_prices(price_cents: int, sale_price_cents: int | None) -> None:
if price_cents < 0:
raise ValueError("Price must be non-negative")
if sale_price_cents is not None and sale_price_cents > price_cents:
raise ValueError("Sale price must be less than or equal to regular price")And optionally as a database constraint:
ALTER TABLE products
ADD CONSTRAINT sale_price_lte_price
CHECK (
sale_price_cents IS NULL
OR sale_price_cents <= price_cents
);Product Models and DTOs
In a typical Python + FastAPI + SQLAlchemy setup, you will have different layers of models:
- ORM models for database representation.
- Pydantic models for API input and output (DTOs).
Example SQLAlchemy model (simplified):
from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey
from sqlalchemy.orm import relationship
from app.db.base_class import Base # your project’s base class
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(Text, nullable=False)
slug = Column(String, unique=True, nullable=False, index=True)
description = Column(Text, nullable=False)
short_description = Column(Text, nullable=True)
sku = Column(String, unique=True, nullable=False, index=True)
price_cents = Column(Integer, nullable=False)
sale_price_cents = Column(Integer, nullable=True)
currency = Column(String(3), nullable=False)
is_active = Column(Boolean, nullable=False, default=True)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
category = relationship("Category", back_populates="products")Example Pydantic models:
from pydantic import BaseModel, Field, validator
from typing import Optional
class ProductBase(BaseModel):
name: str = Field(..., max_length=255)
slug: str = Field(..., max_length=255)
description: str
short_description: Optional[str] = None
sku: str = Field(..., max_length=64)
price: float = Field(..., gt=0)
sale_price: Optional[float] = Field(None, gt=0)
currency: str = Field(..., min_length=3, max_length=3)
is_active: bool = True
category_id: Optional[int] = None
@validator("sale_price")
def check_sale_price(cls, sale_price, values):
price = values.get("price")
if sale_price is not None and price is not None and sale_price > price:
raise ValueError("Sale price must be less than or equal to price")
return sale_price
class ProductCreate(ProductBase):
pass
class ProductUpdate(BaseModel):
name: Optional[str] = None
slug: Optional[str] = None
description: Optional[str] = None
short_description: Optional[str] = None
price: Optional[float] = Field(None, gt=0)
sale_price: Optional[float] = Field(None, gt=0)
currency: Optional[str] = Field(None, min_length=3, max_length=3)
is_active: Optional[bool] = None
category_id: Optional[int] = None
class ProductOut(ProductBase):
id: int
class Config:
orm_mode = TrueNotice the separation:
ProductCreatefor POST.ProductUpdatefor PATCH or PUT.ProductOutfor responses, which includesid.
In the repository or service layer you will convert between price (float or decimal in the API) and price_cents (int in the database).
Product CRUD Operations
Your product management API will at least support:
| Operation | HTTP Method | URI | Who uses it |
|---|---|---|---|
| Create product | POST | /admin/products | Admins |
| List products | GET | /products | Public |
| Get product | GET | /products/{slug} | Public |
| Update product | PUT/PATCH | /admin/products/{id} | Admins |
| Delete/archive | DELETE | /admin/products/{id} | Admins |
For the public API, prefer lookups by slug so URLs are stable and readable:
/products/wireless-mouse/products/hoodie-blue
For the admin API, using numeric id is often simpler and fine.
Example create endpoint (FastAPI style, simplified, without auth):
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.schemas.product import ProductCreate, ProductOut
from app import crud
router = APIRouter(prefix="/admin/products", tags=["admin:products"])
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(
product_in: ProductCreate,
db: Session = Depends(get_db),
):
# Check if slug or SKU already exists
existing_by_slug = crud.product.get_by_slug(db, slug=product_in.slug)
if existing_by_slug:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Slug already exists",
)
existing_by_sku = crud.product.get_by_sku(db, sku=product_in.sku)
if existing_by_sku:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="SKU already exists",
)
product = crud.product.create(db, obj_in=product_in)
return productExample public listing endpoint:
@router.get("/products", response_model=list[ProductOut])
def list_products(
db: Session = Depends(get_db),
limit: int = 20,
offset: int = 0,
):
products = crud.product.get_multi_public(
db,
limit=limit,
offset=offset,
)
return productsFor the project, you will connect these endpoints with authentication and authorization in other chapters.
Product Filtering and Pagination
In an e‑commerce store, listing products means more than just “SELECT * FROM products”. You need filters and ordering.
Common filters:
- Category
- Price range
- Search term (keyword)
- Is on sale
- Sorting (price low to high, latest, etc.)
Define query parameters like:
GET /products?category=electronics&min_price=10&max_price=100&sort=price_asc&page=1&page_size=20
Example FastAPI signature:
from typing import Optional
@router.get("/products", response_model=list[ProductOut])
def list_products(
db: Session = Depends(get_db),
category: Optional[str] = None,
search: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
on_sale: Optional[bool] = None,
sort: str = "newest",
page: int = 1,
page_size: int = 20,
):
products = crud.product.search_public(
db=db,
category_slug=category,
search=search,
min_price=min_price,
max_price=max_price,
on_sale=on_sale,
sort=sort,
page=page,
page_size=page_size,
)
return productsInside the repository, you can translate these into SQLAlchemy queries.
Example SQLAlchemy filter logic:
from sqlalchemy import select, or_, and_
from sqlalchemy.orm import Session
from app.models import Product, Category
def search_public(
db: Session,
category_slug: str | None,
search: str | None,
min_price: float | None,
max_price: float | None,
on_sale: bool | None,
sort: str,
page: int,
page_size: int,
):
query = select(Product).where(Product.is_active == True)
if category_slug:
query = (
query
.join(Product.category)
.where(Category.slug == category_slug)
)
if search:
pattern = f"%{search.lower()}%"
query = query.where(
or_(
Product.name.ilike(pattern),
Product.description.ilike(pattern),
)
)
if min_price is not None:
query = query.where(Product.price_cents >= int(min_price * 100))
if max_price is not None:
query = query.where(Product.price_cents <= int(max_price * 100))
if on_sale is True:
query = query.where(Product.sale_price_cents.isnot(None))
elif on_sale is False:
query = query.where(Product.sale_price_cents.is_(None))
if sort == "price_asc":
query = query.order_by(Product.price_cents.asc())
elif sort == "price_desc":
query = query.order_by(Product.price_cents.desc())
else:
# default sort
query = query.order_by(Product.created_at.desc())
offset = (page - 1) * page_size
query = query.limit(page_size).offset(offset)
return db.execute(query).scalars().all()Pagination and filtering will need careful performance tuning when you add indexes, but this pattern is a solid starting point.
Product Status and Soft Deletion
A real store rarely hard‑deletes products. Historical orders still reference them, and analytics need them.
There are two typical approaches:
- Use an
is_activeflag: - Keep the row.
- Hide it from public listings.
- Possibly still show it in admin and in past orders.
- Use both
is_activeand adeleted_attimestamp, often called soft deletion.
For this project, an is_active field is usually enough. Use it systematically:
- Public queries: always filter with
Product.is_active == True. - Admin queries: show both active and inactive, and maybe filter on it explicitly.
Delete endpoint example that uses soft deletion instead of actual delete:
@router.delete("/admin/products/{product_id}", status_code=204)
def archive_product(
product_id: int,
db: Session = Depends(get_db),
):
product = crud.product.get(db, id=product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
product.is_active = False
db.add(product)
db.commit()
returnYou can still offer a “hard delete” in admin if necessary, for example when a product has no orders.
Product Pricing and Display Logic
The API that returns a product should provide a clear “effective price” that the frontend can show without guessing.
You can either:
- Compute
effective_priceon the backend, or - Let the frontend compare
priceandsale_price.
Having the backend compute it is usually better for consistency. For example, extend the response model:
class ProductOut(BaseModel):
id: int
name: str
slug: str
description: str
short_description: Optional[str]
sku: str
price: float
sale_price: Optional[float]
currency: str
is_active: bool
category_id: Optional[int]
effective_price: float
class Config:
orm_mode = TrueAnd in your service layer:
def to_product_out(product: Product) -> ProductOut:
price = product.price_cents / 100
sale_price = (
product.sale_price_cents / 100 if product.sale_price_cents is not None else None
)
effective_price = sale_price if sale_price is not None else price
return ProductOut(
id=product.id,
name=product.name,
slug=product.slug,
description=product.description,
short_description=product.short_description,
sku=product.sku,
price=price,
sale_price=sale_price,
currency=product.currency,
is_active=product.is_active,
category_id=product.category_id,
effective_price=effective_price,
)
You can also add flags like is_on_sale:
is_on_sale = sale_price is not None and sale_price < priceThis makes it easy for clients to show sale badges.
Connecting Products with Categories and Inventory
This chapter focuses on products, but you must keep in mind how products will connect to categories and inventory, which have their own chapters.
You will typically:
- Use
category_idor a many‑to‑many table for product‑category relationships. - Use a separate
inventory_itemstable or astock_quantitycolumn.
Some common patterns:
| Concern | Minimal approach | More advanced approach |
|---|---|---|
| Categories | products.category_id | product_categories join table |
| Inventory | products.stock_quantity | inventory_items table per warehouse |
| Media | products.image_url | product_images table |
For the project:
- Start with a simple
category_idfield and a basic inventory representation. - Add more advanced structures in the separate “Categories” and “Inventory” chapters.
When designing endpoints consider both:
- Public product details may need to show:
- Category:
category_name,category_slug. - Availability: “in stock” or “out of stock”.
To avoid circular dependencies or heavy joins everywhere, you can create a “rich product” response model that includes nested objects:
class CategorySummary(BaseModel):
id: int
name: str
slug: str
class InventorySummary(BaseModel):
in_stock: bool
stock_quantity: int
class ProductDetail(ProductOut):
category: Optional[CategorySummary]
inventory: InventorySummary
Then use this model only for detail views, for example GET /products/{slug}, while list views use the lighter ProductOut model.
Typical Product Management Workflows
To wrap up, here is how a standard product management flow looks from the admin side and the customer side.
Admin workflow:
- Admin creates a product draft:
- POST
/admin/products - Fills in basic details, price, SKU, and chooses category.
- Admin uploads product images:
- Uses file upload endpoints described in the “Working with Files” and “Image Uploads” chapters.
- Admin adjusts inventory:
- Uses “Inventory” or “Stock” related endpoints.
- Admin publishes the product:
- Sets
is_active = trueif it is not already.
Customer workflow:
- Customer opens a category page:
- GET
/products?category=some-category&sort=newest&page=1 - Backend returns only
is_active = trueproducts. - Customer opens a product detail page:
- GET
/products/some-product-slug - Backend returns product with category, effective price, sale information, and availability.
- Customer adds product to cart:
- Uses endpoints described in the “Shopping Cart” chapter.
- Backend uses product and inventory info to validate the operation.
Your product management implementation must support both these workflows reliably and efficiently.
As you continue the project, you will integrate these product concepts with categories, images, inventory, cart, and orders, but the core idea remains: a clear and consistent product model, with strict domain rules, and well designed CRUD and listing APIs.
Views: 8
KAHIBARO