← Writing
article expert

Multi-vendor Platform Architecture in Django

How to model vendors, products, inventory, and orders for a multi-vendor marketplace — tenant isolation, commission logic, and the decisions that matter at scale.

#django#architecture#multivendor#database#design#ecommerce

A multi-vendor platform (think Amazon Marketplace, Etsy) is one of the most instructive Django architecture problems. Every design decision — data isolation, permission boundaries, commission models, inventory — compounds as the platform grows. These are the patterns I’ve used building One Pasal.

Tenant Isolation Strategies

The fundamental question: how separated are vendor data and accounts?

Shared Schema (Row-Level Isolation)

All vendors share the same tables; rows are tagged with a vendor_id foreign key. This is the simplest approach and the right default for most platforms.

class Vendor(models.Model):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
    name = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    is_active = models.BooleanField(default=False)  # approved by admin
    commission_rate = models.DecimalField(max_digits=5, decimal_places=4, default=Decimal("0.10"))

    class Meta:
        indexes = [models.Index(fields=["slug"])]


class Product(models.Model):
    vendor = models.ForeignKey(Vendor, on_delete=models.CASCADE, related_name="products")
    name = models.CharField(max_length=300)
    slug = models.SlugField()
    base_price = models.DecimalField(max_digits=10, decimal_places=2)
    is_active = models.BooleanField(default=True)

    class Meta:
        unique_together = [["vendor", "slug"]]
        indexes = [
            models.Index(fields=["vendor", "is_active"]),
            models.Index(fields=["slug"]),
        ]

Enforcement: Every QuerySet that touches vendor data must be filtered by vendor. The cleanest way is a custom manager:

class VendorScopedManager(models.Manager):
    """Use this for any vendor-owned model."""

    def for_vendor(self, vendor):
        return self.get_queryset().filter(vendor=vendor)


class Product(models.Model):
    objects = VendorScopedManager()
    ...

# In views
products = Product.objects.for_vendor(request.user.vendor)

And a mixin for CBVs:

class VendorRequiredMixin(LoginRequiredMixin):
    def get_vendor(self):
        return get_object_or_404(Vendor, owner=self.request.user, is_active=True)

    def get_queryset(self):
        return super().get_queryset().filter(vendor=self.get_vendor())

Inventory Model

Separating product definition from stock allows a single product to have multiple variants (size, color) each with their own SKU and inventory count.

class ProductVariant(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="variants")
    sku = models.CharField(max_length=100, unique=True, db_index=True)
    attributes = models.JSONField(default=dict)  # {"size": "L", "color": "red"}
    price_override = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)

    @property
    def price(self):
        return self.price_override or self.product.base_price


class InventoryItem(models.Model):
    variant = models.OneToOneField(ProductVariant, on_delete=models.CASCADE, related_name="inventory")
    quantity = models.PositiveIntegerField(default=0)
    reserved = models.PositiveIntegerField(default=0)  # held by pending orders

    @property
    def available(self):
        return self.quantity - self.reserved

    def reserve(self, qty: int):
        """Atomically reserve stock. Raises if insufficient."""
        updated = InventoryItem.objects.filter(
            pk=self.pk,
            quantity__gte=F("reserved") + qty,
        ).update(reserved=F("reserved") + qty)
        if not updated:
            raise InsufficientStockError(f"Cannot reserve {qty} of {self.variant.sku}")

    def release(self, qty: int):
        InventoryItem.objects.filter(pk=self.pk).update(
            reserved=F("reserved") - qty
        )

    def fulfill(self, qty: int):
        """Deduct on actual shipment."""
        InventoryItem.objects.filter(pk=self.pk).update(
            quantity=F("quantity") - qty,
            reserved=F("reserved") - qty,
        )

The F() expression in reserve() makes the check and increment atomic at the database level — no race condition even under concurrent orders.

Order Model and Line Items

class Order(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending", "Pending"
        CONFIRMED = "confirmed", "Confirmed"
        SHIPPED = "shipped", "Shipped"
        DELIVERED = "delivered", "Delivered"
        CANCELLED = "cancelled", "Cancelled"
        REFUNDED = "refunded", "Refunded"

    customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
    status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING, db_index=True)
    created_at = models.DateTimeField(auto_now_add=True, db_index=True)
    total_amount = models.DecimalField(max_digits=12, decimal_places=2)
    shipping_address = models.JSONField()

    class Meta:
        indexes = [models.Index(fields=["customer", "-created_at"])]


class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name="items")
    variant = models.ForeignKey(ProductVariant, on_delete=models.PROTECT)
    vendor = models.ForeignKey(Vendor, on_delete=models.PROTECT)  # denormalized for fast vendor queries
    quantity = models.PositiveIntegerField()
    unit_price = models.DecimalField(max_digits=10, decimal_places=2)  # snapshot at order time
    vendor_payout = models.DecimalField(max_digits=10, decimal_places=2)
    platform_commission = models.DecimalField(max_digits=10, decimal_places=2)

Key decision: Snapshot unit_price at order time. Never recalculate from the live product price — if the vendor changes their price, existing orders must not be affected.

Commission Calculation

from decimal import Decimal


def calculate_order_item_financials(variant: ProductVariant, quantity: int) -> dict:
    vendor = variant.product.vendor
    unit_price = variant.price
    line_total = unit_price * quantity
    commission = (line_total * vendor.commission_rate).quantize(Decimal("0.01"))
    payout = line_total - commission
    return {
        "unit_price": unit_price,
        "vendor_payout": payout,
        "platform_commission": commission,
    }

Order Placement — Transactional

Placing an order touches inventory, creates financial records, and triggers notifications. All of it must be atomic:

from django.db import transaction
from django.core.exceptions import ValidationError


def place_order(customer, cart_items: list[dict], shipping_address: dict) -> Order:
    """
    cart_items: [{"variant_id": 1, "quantity": 2}, ...]
    """
    with transaction.atomic():
        # Fetch variants and lock inventory rows
        variant_ids = [item["variant_id"] for item in cart_items]
        variants = {
            v.pk: v
            for v in ProductVariant.objects.select_related("product__vendor", "inventory")
                                           .select_for_update()  # row-level lock
                                           .filter(pk__in=variant_ids)
        }

        total = Decimal("0.00")
        line_items = []

        for item in cart_items:
            variant = variants[item["variant_id"]]
            qty = item["quantity"]
            inventory = variant.inventory

            if inventory.available < qty:
                raise ValidationError(
                    f"'{variant.product.name}' only has {inventory.available} units available."
                )

            financials = calculate_order_item_financials(variant, qty)
            total += financials["unit_price"] * qty
            line_items.append((variant, qty, financials))

        order = Order.objects.create(
            customer=customer,
            status=Order.Status.PENDING,
            total_amount=total,
            shipping_address=shipping_address,
        )

        for variant, qty, financials in line_items:
            OrderItem.objects.create(
                order=order,
                variant=variant,
                vendor=variant.product.vendor,
                quantity=qty,
                **financials,
            )
            variant.inventory.reserve(qty)

        # Fire post-placement events outside the lock
        transaction.on_commit(lambda: order_placed.send(sender=Order, order=order))

    return order

select_for_update() acquires a row-level lock for the duration of the transaction — concurrent orders for the same variant will queue, not race.

transaction.on_commit() defers the signal (and any Celery tasks it triggers) until after the transaction commits. This prevents tasks from running before the data is visible to other database connections.

Vendor Dashboard Queries

from django.db.models import Sum, Count, F, ExpressionWrapper, DecimalField

def vendor_revenue_summary(vendor: Vendor, start_date, end_date) -> dict:
    qs = OrderItem.objects.filter(
        vendor=vendor,
        order__status__in=[Order.Status.CONFIRMED, Order.Status.SHIPPED, Order.Status.DELIVERED],
        order__created_at__date__range=(start_date, end_date),
    )

    return qs.aggregate(
        total_sales=Sum(ExpressionWrapper(
            F("unit_price") * F("quantity"),
            output_field=DecimalField(),
        )),
        total_payout=Sum("vendor_payout"),
        total_commission=Sum("platform_commission"),
        order_count=Count("order", distinct=True),
        units_sold=Sum("quantity"),
    )

Permission Architecture

Three distinct roles require distinct permission checking:

class IsVendorOwner(BasePermission):
    """Vendor can only manage their own products/orders."""
    def has_object_permission(self, request, view, obj):
        return obj.vendor.owner == request.user


class IsPlatformAdmin(BasePermission):
    def has_permission(self, request, view):
        return request.user.is_staff


class IsOrderCustomer(BasePermission):
    """Customer can only view their own orders."""
    def has_object_permission(self, request, view, obj):
        return obj.customer == request.user

Denormalize vendor on OrderItem. Querying “all orders for vendor X” via OrderItem.vendor is a single-table scan with an index. Doing it via OrderItem → ProductVariant → Product → Vendor requires three JOINs and is significantly slower on large datasets. Denormalization here is deliberate and correct.