31.5. Shopping Cart
Table of Contents
Overview
In an e‑commerce backend, the shopping cart connects browsing to buying. It temporarily stores what the user wants, lets them adjust quantities, and feeds data into pricing, discounts, inventory checks, and order creation.
This chapter focuses on how to design and implement the backend side of a shopping cart for a typical REST API, assuming other chapters will cover authentication, products, and orders in detail.
Core Responsibilities of a Shopping Cart
At minimum, your cart must:
- Track which user owns the cart
- Store items, each linked to a product and a quantity
- Allow adding, updating, and removing items
- Calculate totals (subtotal, taxes, discounts, final total)
- Validate products and quantities against business rules
- Be robust to concurrent changes like inventory updates or price changes
You will often expose it through endpoints such as:
GET /cartPOST /cart/itemsPATCH /cart/items/{item_id}DELETE /cart/items/{item_id}
Later, the order creation process will typically use the current cart contents to build an order.
Cart Data Model
Choosing a Cart Ownership Model
There are two common ownership models:
| Model | Description | Pros | Cons |
|---|---|---|---|
| User‑based cart | Cart belongs to an authenticated user | Simple, persists across devices, easy to link | Guests need a temporary user or token |
| Anonymous / session cart | Cart belongs to a session or device identifier | Works for guests, no signup needed | Harder to merge carts when user later logs in |
A practical approach:
- For logged‑in users: use a
user_idto own the cart. - For guests: use a
cart_tokenstored in a cookie or local storage, and later merge into a user cart on login.
Basic Relational Schema
A typical relational model uses two tables: carts and cart_items.
CREATE TABLE carts (
id UUID PRIMARY KEY,
user_id UUID UNIQUE, -- nullable if guest
guest_token VARCHAR(255) UNIQUE, -- nullable if user
currency VARCHAR(3) NOT NULL, -- e.g. 'USD'
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE cart_items (
id UUID PRIMARY KEY,
cart_id UUID NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
product_id UUID NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(10, 2) NOT NULL,
currency VARCHAR(3) NOT NULL,
added_at TIMESTAMP NOT NULL
);Key points:
user_idis usuallyUNIQUEso each user has at most one active cart.- For guests,
guest_tokenidentifies the cart. unit_priceis stored at the time the item is added. This helps with price stability and auditing, even if product prices later change.currencyis stored to avoid mixing currencies in a single cart.
Important rule: Never trust client‑side prices. Always calculate totals on the server, using prices from the database or trusted service.
Capturing More Business Data
Depending on your business rules, you may extend the model:
cart_itemsvariant_id(size, color)max_quantity_per_orderis_gift,notecartscoupon_codediscount_amountexpires_at
Do not prematurely add every field you can imagine. Start with the essentials and extend as needed.
Cart Operations
Adding Items to the Cart
A typical add‑item request:
POST /cart/items
Content-Type: application/json
{
"product_id": "c0b7a50c-...",
"quantity": 2
}Backend workflow:
- Identify the cart (by authenticated user or guest token).
- Validate product:
- Product exists and is active.
- Product is purchasable (not deleted or out of catalog).
- Validate quantity:
quantityis a positive integer.- Optional: respects per‑product max/min.
- Check inventory (depending on when you reserve stock).
- If the product is already in the cart, either:
- Increase existing quantity, or
- Replace quantity with the requested one, depending on API contract.
- Calculate and store current
unit_price. - Recalculate cart totals if you store aggregate fields.
Example business logic in Python‑style pseudocode:
def add_item_to_cart(cart, product_id, quantity):
if quantity <= 0:
raise ValueError("Quantity must be positive")
product = get_product(product_id)
if not product or not product.is_active:
raise ValueError("Product not available")
# Optional inventory check
if product.stock is not None and quantity > product.stock:
raise ValueError("Not enough stock")
item = find_cart_item(cart.id, product_id)
if item:
item.quantity += quantity
item.unit_price = product.price # update to current price or keep old
save_cart_item(item)
else:
item = CartItem(
cart_id=cart.id,
product_id=product_id,
quantity=quantity,
unit_price=product.price,
currency=cart.currency
)
save_cart_item(item)
update_cart_totals(cart.id)
return itemDecide and document whether adding the same product again increments or overwrites quantity.
Updating Item Quantities
Example request:
PATCH /cart/items/{item_id}
Content-Type: application/json
{
"quantity": 3
}Backend workflow:
- Load item by
item_idand cart. - If
quantity == 0, either: - Remove the item, or
- Reject the request. Choose one behavior and document it.
- Validate quantity, product state, and inventory.
- Update the item and recalculate totals.
You can also support partial updates like:
PATCH /cart/items/{item_id}
{
"increment_by": 1
}But that requires a carefully defined API, especially in concurrent environments.
Removing Items
Example request:
DELETE /cart/items/{item_id}Steps:
- Ensure the item belongs to the current cart.
- Delete the item.
- Update cart totals or mark cart as empty if no items remain.
You can also support:
DELETE /cart/itemsTo clear the entire cart.
Retrieving the Cart
Typical endpoint:
GET /cartExample response:
{
"id": "9d6c0d21-...",
"currency": "USD",
"items": [
{
"id": "item-1",
"product_id": "prod-1",
"name": "Red T‑Shirt",
"quantity": 2,
"unit_price": 19.99,
"line_total": 39.98
},
{
"id": "item-2",
"product_id": "prod-2",
"name": "Blue Jeans",
"quantity": 1,
"unit_price": 49.50,
"line_total": 49.50
}
],
"subtotal": 89.48,
"discounts": 5.00,
"tax": 7.56,
"shipping": 0.00,
"total": 92.04
}Separation of concerns:
- The cart backend knows how to compute
subtotal,tax,total. - The frontend only displays what the backend decides.
Calculating Totals
Basic Price Calculations
The simplest model:
- Line total: $line\_total = quantity \times unit\_price$
- Cart subtotal: $subtotal = \sum line\_totals$
- Total: $total = subtotal - discounts + tax + shipping$
Key pricing formula:
$$total = \left(\sum_i quantity_i \times unit\_price_i\right) - discounts + tax + shipping$$
Always compute this on the server.
A small example:
| Item | Quantity | Unit price | Line total |
|---|---|---|---|
| Red T‑Shirt | 2 | 19.99 | $2 \times 19.99 = 39.98$ |
| Blue Jeans | 1 | 49.50 | $1 \times 49.50 = 49.50$ |
Subtotal:
$$subtotal = 39.98 + 49.50 = 89.48$$
If discount is $5.00, tax is $7.56, shipping is $0.00:
$$total = 89.48 - 5.00 + 7.56 + 0 = 92.04$$
When to Recalculate
You can recalculate totals:
- On every cart modification (add, update, remove)
- On explicit request (for example when user clicks "Recalculate")
Most applications recalculate automatically with every write operation so GET /cart always returns up‑to‑date totals.
Handling Price Changes
Two typical strategies when product prices change:
| Strategy | Behavior | Pros | Cons |
|---|---|---|---|
| Sticky cart prices | Keep unit_price as stored in cart_items | Predictable for user | May sell at outdated price |
| Dynamic cart prices | Refresh unit_price from product table periodically | Always uses current price | User total can change unexpectedly |
A common compromise:
- Use sticky prices during a short cart lifetime.
- Refresh prices at checkout and inform the user if any item changed.
Implementation idea:
- Store
unit_priceandprice_checked_atper item. - When user goes to checkout, compare with current product prices.
- If difference is above a threshold, require user confirmation.
Inventory and Availability
When to Reserve Inventory
You must decide when to reduce stock:
- On add to cart
- On order creation
- On payment success
Reserving on add to cart can lead to "stock locked in carts" that never turn into orders. Most systems:
- Only check stock at add to cart and update quantity limits.
- Deduct stock when the order is placed or when payment is confirmed.
Example soft check on add:
if product.stock is not None and quantity > product.stock:
# Limit the item quantity to available stock
quantity = product.stockWhen user proceeds to checkout, do a final inventory validation.
Handling Out‑of‑Stock Items in Cart
Several strategies if an item becomes unavailable:
- Mark the item as unavailable and show a warning.
- Remove the item automatically and notify the user.
- Limit the quantity to the maximum available.
Data model addition:
ALTER TABLE cart_items
ADD COLUMN is_available BOOLEAN NOT NULL DEFAULT TRUE;During cart retrieval:
def refresh_cart_item_availability(cart):
for item in cart.items:
product = get_product(item.product_id)
item.is_available = bool(product and product.is_active and product.stock > 0)Frontends can then disable checkout for unavailable items.
Guest Carts and Merging
Identifying Guest Carts
Typical approach:
- Server generates a
cart_tokenlike a UUID. - The token is stored in an HTTP‑only cookie or local storage.
- Each request includes this token so the backend can find the cart.
Example cookie:
Set-Cookie: cart_token=bd4c...; Path=/; Max-Age=2592000; HttpOnly; SameSite=LaxBackend flow:
- Check authenticated user.
- If no user, look for
cart_token. - If none, create a new cart and return a token.
Merging Carts on Login
When a guest with a cart logs in and they also have an existing user cart, you must decide how to merge.
Strategies:
- Combine quantities for same product.
- Prefer the latest cart and ignore the old one.
- Ask the user which cart to keep (more complex for backend).
Example merge logic:
def merge_carts(user_cart, guest_cart):
for guest_item in guest_cart.items:
user_item = find_cart_item(user_cart.id, guest_item.product_id)
if user_item:
user_item.quantity += guest_item.quantity
save_cart_item(user_item)
else:
move_item_to_cart(guest_item, user_cart.id)
delete_cart(guest_cart.id)
update_cart_totals(user_cart.id)Be explicit about how you handle:
- Different prices in the two carts.
- Out‑of‑stock items.
- Different currencies (usually you do not allow this).
Currency and Tax Considerations
Single Currency vs Multi‑Currency Carts
Simplest approach: one currency per store and cart. The currency field is fixed.
For multi‑currency:
- Store
currencyon the cart and each item. - All prices must be in the same currency for one cart.
- Conversions happen before an item is added to the cart, not inside the cart logic.
If a user changes currency, often you create a new cart in the new currency and discard or convert the old one.
Basic Tax Handling
Tax rules are complex and vary across regions, so only simple logic should be inside the cart service. For example:
- A flat tax rate, like 10 %:
$$tax = subtotal \times 0.10$$
- Or per‑product tax category, resolved by a pricing service.
The cart might delegate tax calculation:
tax = tax_service.calculate_tax(
items=cart.items,
shipping_address=user.shipping_address
)
You then store tax on the cart or compute it on the fly.
API Design for Shopping Cart
Example REST Endpoints
A basic RESTful API design:
| Method | Path | Description |
|---|---|---|
| GET | /cart | Get current cart |
| POST | /cart/items | Add item to cart |
| PATCH | /cart/items/{id} | Update item quantity or options |
| DELETE | /cart/items/{id} | Remove item |
| DELETE | /cart/items | Clear cart |
Sample OpenAPI‑style schema for a cart item:
CartItem:
type: object
properties:
id:
type: string
format: uuid
product_id:
type: string
format: uuid
quantity:
type: integer
minimum: 1
unit_price:
type: number
format: float
currency:
type: string
minLength: 3
maxLength: 3
line_total:
type: number
format: floatExample FastAPI‑Style Models
You might implement these models in Python with Pydantic:
from pydantic import BaseModel, conint, constr
from typing import List
class CartItemCreate(BaseModel):
product_id: str
quantity: conint(gt=0)
class CartItemRead(BaseModel):
id: str
product_id: str
quantity: int
unit_price: float
currency: constr(min_length=3, max_length=3)
line_total: float
class CartRead(BaseModel):
id: str
currency: constr(min_length=3, max_length=3)
items: List[CartItemRead]
subtotal: float
discounts: float
tax: float
shipping: float
total: floatAnd endpoints:
from fastapi import APIRouter, Depends
router = APIRouter(prefix="/cart")
@router.get("", response_model=CartRead)
def get_cart(current_cart = Depends(get_current_cart)):
return build_cart_response(current_cart)
@router.post("/items", response_model=CartItemRead)
def add_cart_item(payload: CartItemCreate,
current_cart = Depends(get_or_create_cart)):
item = add_item_to_cart(current_cart, payload.product_id, payload.quantity)
return build_cart_item_response(item)Consistency and Concurrency
Dealing with Concurrent Updates
Examples of conflicts:
- User A and user B both adjust quantities in the same cart (for example shared account).
- Inventory changes while the user is modifying the cart.
- Price updates while items are being changed.
Common safeguards:
- Database transactions to ensure atomic operations.
- Row‑level locks on
cart_itemsorcartsduring updates. - Optimistic locking using a
versioncolumn.
Simple optimistic locking schema:
ALTER TABLE carts
ADD COLUMN version INTEGER NOT NULL DEFAULT 0;Every update:
- Read current
version. - Update with
WHERE id = ? AND version = ?. - If no rows are updated, someone else changed the cart. Client must reload.
Cart Expiration
Carts can grow without limit if you never remove them. Simple approach:
- Add
expires_atto cart. - If
expires_at < now, treat the cart as expired and create a new one. - Background job that deletes expired carts periodically.
Example field:
ALTER TABLE carts
ADD COLUMN expires_at TIMESTAMP;Expiration strategy:
- Set
expires_at = now() + interval '30 days'on each cart update. - A daily job removes carts where
expires_at < now()and that are not linked to orders.
Putting It All Together
A typical cart lifecycle in your e‑commerce backend:
- User visits the site.
- Backend looks for user or
cart_token. - Returns existing cart or creates new one.
- User adds items.
- Backend validates product and quantity.
- Saves item, recalculates totals.
- User updates or removes items.
- Backend applies changes atomically.
- Prices and availability are rechecked as needed.
- User logs in (if previously a guest).
- Backend merges guest cart into user cart.
- User proceeds to checkout.
- Backend validates prices, stock, discounts.
- Cart contents are transformed into an order.
Throughout this process, the cart acts as a temporary, consistent, server‑trusted representation of the user’s purchase intent, ready to be turned into an order in the next steps of your e‑commerce backend.
Views: 8
KAHIBARO