Marketplace Catalog Model¶
Purpose¶
The four entities behind every marketplace listing - what each one owns, who's allowed to touch it, and how a customer's cart line traces back to a specific seller's stock at a specific warehouse. Written for the seller-marketplace-completion slice (Product Variant Administration, Seller Marketplace Listings, Warehouse Inventory Management, Seller Dashboard) - see Application Architecture for where this sits in the wider backend.
The four entities¶
Product - shared marketplace/catalog product identity (Admin-owned)
└─ ProductVariant - catalog variation, e.g. Size/Colour/Model (Admin-owned)
SellerProduct - one seller's offer/listing against a Product (+ optional Variant)
├─ seller SKU, price, discount price, listing status
└─ SellerInventory[] - stock for this SellerProduct at one Warehouse
| Entity | Owned by | What it represents |
|---|---|---|
Product |
Admin | The shared catalog identity - name, slug, description, category, images. Multiple sellers can list against the same Product. |
ProductVariant |
Admin | A catalog-level variation of a Product (Size/Colour/Model). AttributesJson holds free-form key/value pairs, e.g. {"size":"XL","color":"Blue"} - deliberately not a normalized attribute schema in this phase. |
SellerProduct |
Seller (the listing's own seller) | A seller's offer against a Product (+ optional ProductVariant): seller SKU, price, discount price, listing status (Draft/Active/Inactive/OutOfStock/Suspended). |
SellerInventory |
Seller (same seller as the SellerProduct) |
Stock for one SellerProduct at one Warehouse: AvailableQuantity, ReservedQuantity, DamagedQuantity, ReorderLevel. |
Why this split exists¶
Before this slice, the seller portal created and edited Product rows directly (/products/seller/me,
productsApi.create/update/delete/publish) - a single-seller model where "the product" and "the
listing" were the same row. The marketplace model separates what is being sold (Product +
ProductVariant, catalog master data) from who is selling it and on what terms
(SellerProduct + SellerInventory, seller-owned). This is what makes multiple sellers able to
list the same catalog Product independently, each with their own SKU, price, and stock.
The legacy Product CRUD endpoints and seller UI code path still exist (backward compatibility -
admin product management and the storefront still read Product directly) but the current
seller-facing UI (frontend/src/pages/Seller/Products/, frontend/src/pages/Seller/Inventory/)
only talks to the marketplace endpoints (/api/marketplace-catalog/*), never the legacy ones.
Seller listing workflow¶
Admin creates/owns Product (+ optional ProductVariant)
│
▼
Seller searches the catalog, selects a Product (+ Variant if one exists)
│
▼
Seller creates a SellerProduct: seller SKU, price, optional discount price
- one active listing per seller/product/variant (enforced in the controller,
see MarketplaceCatalogController.CreateSellerProduct)
- seller SKU unique per seller (DB unique index: SellerId + SellerSku)
│
▼
Seller adds SellerInventory at one of their own Warehouses
- a seller can never create inventory against another seller's warehouse
(ownership check on every write)
│
▼
Seller activates the listing (Draft -> Active)
│
▼
Listing appears in customer-facing product search/add-to-cart
(CartController resolves a default SellerProduct per Product when the
customer doesn't specify one - see GetDefaultSellerProductForProductAsync)
A seller can never mutate the shared Product/ProductVariant catalog through this flow - only
Admin can create/update/deactivate a ProductVariant
(POST/PUT /api/marketplace-catalog/products/{productId}/variants,
PUT /api/marketplace-catalog/variants/{variantId},
POST /api/marketplace-catalog/variants/{variantId}/deactivate). A seller only selects an
existing Product/ProductVariant when creating a SellerProduct.
Product, variant, and SKU are immutable on a SellerProduct once created - editing a listing only
ever changes price/discount/status. To sell a different product or variant, a seller creates a new
listing rather than repurposing an old one (this is also why deactivating, not deleting, is the
normal way to retire a listing - see below).
Warehouse inventory¶
Each SellerInventory row belongs to exactly one SellerProduct + one Warehouse (DB unique
index: SellerProductId + WarehouseId - a seller cannot create two inventory rows for the same
listing at the same warehouse). A listing can have inventory at multiple warehouses.
ReservedQuantity is system-managed - the seller UI cannot set it. The seller-facing
inventory-update contract (PUT /api/marketplace-catalog/inventory/{id},
SellerInventory.UpdateStock) only accepts AvailableQuantity, DamagedQuantity, and
ReorderLevel. ReservedQuantity is exclusively mutated by three domain methods, all reachable
only from the checkout/payment pipeline (see PR #4's integrity notes):
| Method | Called by | Effect |
|---|---|---|
Reserve(qty) |
Razorpay order creation | ReservedQuantity += qty |
CommitReservation(qty) |
Payment verified successful | ReservedQuantity -= qty, AvailableQuantity -= qty |
Release(qty) |
Payment failed / cancelled | ReservedQuantity -= qty (clamped at 0), AvailableQuantity untouched |
If a seller's stock edit would reduce AvailableQuantity below the currently reserved amount,
UpdateStock rejects the edit outright (InvalidOperationException → 409 RESERVATION_CONFLICT)
rather than silently shrinking or clearing an active reservation. The invariant
ReservedQuantity <= AvailableQuantity (and therefore SellableQuantity = AvailableQuantity -
ReservedQuantity >= 0) holds after every mutation path. See
backend/tests/ECommerce.Tests/Domain/SellerInventoryTests.cs for the full reservation-protection
test suite, and Api/SellerInventoryEndpointTests.cs for the controller-level equivalent.
Related pages¶
- Seller Order Fulfilment - what happens to a listing's stock after checkout: the SellerOrder state machine, tracking, cancellation, and parent-order aggregation
- Application Architecture - where this fits in the backend's Clean Architecture layers
- Environments