Integrations Domain - Serializers

Overview

Note: The Integrations domain does not have a serializers.py file. This is by design.

Why No Serializers in Integrations?

The Integrations domain is the infrastructure layer that routes webhooks to domain-specific serializers. It does not define its own Django REST Framework serializers because:

  1. Serializers are domain-specific - Each business domain (Users, Products, Stores, Promotions, Transactions) defines its own serializers to validate and transform webhook payloads
  2. Integrations provides the routing - This domain provides the webhook receiver that routes to appropriate domain serializers
  3. Separation of concerns - Data validation and transformation logic belongs in business domains, not in the integration infrastructure

Where to Find Serializers

Serializers are defined in each domain that receives webhooks from Sanity:

Users Domain Serializers

Location: api/nextango/apps/users/serializers.py

Serializers:

  • UserSerializer - Validates webhook payloads for User model
  • UserRoleSerializer - Validates role assignments
  • Employee, Customer, Manager, Influencer, VIP role serializers

Key Features:

  • Conditional field processing (only update fields present in webhook)
  • camelCase → snake_case transformation
  • Reference extraction from Sanity reference objects

Documentation: docs/overview/users/ (when created)

Products Domain Serializers

Location: api/nextango/apps/products/serializers.py

Serializers:

  • ProductSerializer - Validates Product webhook payloads
  • ProductVariantSerializer - Validates variant-specific fields
  • CategorySerializer - Validates category data

Key Features:

  • Price validation (Decimal with 2 decimal places)
  • Stock quantity validation (non-negative integers)
  • Category hierarchy validation

Documentation: docs/overview/products/ (when created)

Stores Domain Serializers

Location: api/nextango/apps/stores/serializers.py

Serializers:

  • StoreSerializer - Validates Store webhook payloads

Key Features:

  • Geolocation validation
  • Operating hours JSONField validation
  • Store status validation

Documentation: docs/overview/stores/ (when created)

Promotions Domain Serializers

Location: api/nextango/apps/promotions/serializers.py

Serializers:

  • PromotionalCampaignSerializer - Complex campaign type transformation
  • PromoCodeSerializer - Modular discount validation with type-specific rules
  • ABTestSerializer - Performance analytics validation

Key Features:

  • Campaign type label → database value mapping ("Seasonal Sale" → "seasonal_sale")
  • JSONField validation with type-specific logic
  • Code normalization (uppercase, alphanumeric)
  • Date range validation with timezone handling

Documentation: docs/overview/promotions/serializers.md

Transactions Domain Serializers

Location: api/nextango/apps/transactions/serializers.py

Serializers:

  • SaleTransactionSerializer - Transaction validation
  • TransactionLineItemSerializer - Line item validation

Key Features:

  • Payment status validation
  • Total amount recalculation
  • Inventory verification

Documentation: docs/overview/transactions/ (when created)

What Integrations Provides

Instead of serializers, the Integrations domain provides the routing infrastructure that directs webhooks to the appropriate domain serializers:

1. Webhook Receiver

Location: api/nextango/apps/integrations/webhooks.py

Class: SanityWebhookReceiver

Purpose: Route incoming Sanity webhooks to domain-specific serializers

How It Works:

  1. Receives webhook POST request
  2. Validates HMAC-SHA256 signature
  3. Checks ping-pong prevention
  4. Routes to domain serializer based on document type:
    • userUserSerializer
    • productProductSerializer
    • storeStoreSerializer
    • promotionalCampaignPromotionalCampaignSerializer
    • promoCodePromoCodeSerializer
    • abTestABTestSerializer
    • saleTransactionSaleTransactionSerializer
  5. Domain serializer validates and transforms payload
  6. Saves to Django model without triggering outbound sync (using SyncContext)

Documentation: docs/overview/integrations/views.md

2. Webhook Routing Map

Location: api/nextango/apps/integrations/webhooks.py

Pattern:

def route_to_serializer(self, document_type: str, payload: dict):
    """
    Route webhook payload to appropriate domain serializer.

    Args:
        document_type: Sanity document type (_type field)
        payload: Webhook payload

    Returns:
        Validated and saved Django model instance
    """

    # Route to domain serializers
    serializer_map = {
        'user': UserSerializer,
        'product': ProductSerializer,
        'store': StoreSerializer,
        'promotionalCampaign': PromotionalCampaignSerializer,
        'promoCode': PromoCodeSerializer,
        'abTest': ABTestSerializer,
        'saleTransaction': SaleTransactionSerializer,
    }

    serializer_class = serializer_map.get(document_type)

    if not serializer_class:
        raise ValueError(f"No serializer found for type: {document_type}")

    # Use SyncContext to prevent outbound sync loop
    with SyncContext(source='sanity_webhook'):
        serializer = serializer_class(data=payload)
        serializer.is_valid(raise_exception=True)
        instance = serializer.save()

    return instance

3. SyncContext Integration

Location: api/nextango/apps/integrations/sync/sync_context.py

Purpose: Prevent infinite sync loops when domain serializers save instances

Usage in Webhook Processing:

from nextango.apps.integrations.sync.sync_context import SyncContext

# Before calling domain serializer
with SyncContext(source='sanity_webhook'):
    # This context prevents post_save signals from triggering outbound sync
    serializer = DomainSerializer(data=payload)
    serializer.is_valid(raise_exception=True)
    instance = serializer.save()
    # instance._sync_source = 'sanity_webhook' (set by SyncContext)

Documentation: docs/overview/integrations/sync_context.md

Architecture Diagram

┌─────────────────────────────────────────────────────────────────┐
│                     Sanity CMS                                   │
│  Webhook triggered on document change (create, update, delete)  │
└───────────────────┬─────────────────────────────────────────────┘

                    │ POST /integrations/sanity-webhook/
                    │ HMAC-SHA256 signature


┌─────────────────────────────────────────────────────────────────┐
│              Integrations: SanityWebhookReceiver                 │
│                                                                   │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  1. Validate HMAC signature                            │    │
│  │  2. Check ping-pong prevention                         │    │
│  │  3. Extract document_type (_type field)                │    │
│  └───────────────────┬────────────────────────────────────┘    │
│                      │                                           │
│                      ▼                                           │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  Route to Domain Serializer (serializer_map)           │    │
│  │                                                         │    │
│  │  user → UserSerializer                                 │    │
│  │  product → ProductSerializer                           │    │
│  │  store → StoreSerializer                               │    │
│  │  promotionalCampaign → PromotionalCampaignSerializer   │    │
│  │  promoCode → PromoCodeSerializer                       │    │
│  │  abTest → ABTestSerializer                             │    │
│  │  saleTransaction → SaleTransactionSerializer           │    │
│  └───────────────────┬────────────────────────────────────┘    │
└────────────────────┼─────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│              Business Domain Serializers                         │
│  (users/, products/, stores/, promotions/, transactions/)        │
│                                                                   │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  DomainSerializer.to_internal_value(data)              │    │
│  │  • Conditional field processing                        │    │
│  │  • camelCase → snake_case transformation               │    │
│  │  • Reference extraction                                │    │
│  │  • Type-specific validation                            │    │
│  └───────────────────┬────────────────────────────────────┘    │
│                      │                                           │
│                      ▼                                           │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  DomainSerializer.is_valid(raise_exception=True)       │    │
│  │  • Field-level validation                              │    │
│  │  • Object-level validation                             │    │
│  │  • Cross-field validation                              │    │
│  └───────────────────┬────────────────────────────────────┘    │
│                      │                                           │
│                      ▼                                           │
│  ┌────────────────────────────────────────────────────────┐    │
│  │  DomainSerializer.save()                               │    │
│  │  • Create or update Django model instance              │    │
│  │  • Wrapped in SyncContext to prevent outbound sync     │    │
│  └───────────────────┬────────────────────────────────────┘    │
└────────────────────┼─────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                   Django Models                                  │
│  • instance._sync_source = 'sanity_webhook' (set by context)    │
│  • post_save signal checks _sync_source → skips outbound sync   │
│  • Data persisted to PostgreSQL                                 │
└─────────────────────────────────────────────────────────────────┘

Common Serializer Pattern

All domain serializers follow this pattern for webhook processing:

from rest_framework import serializers
from .models import MyModel
import logging

logger = logging.getLogger(__name__)


class MyModelSerializer(serializers.ModelSerializer):
    """
    Serializer for MyModel with webhook payload transformation.

    Handles:
    - Conditional field processing (only update fields present in webhook)
    - camelCase → snake_case transformation
    - Sanity reference extraction
    - Type-specific validation
    """

    class Meta:
        model = MyModel
        fields = '__all__'

    def to_internal_value(self, data):
        """
        Transform webhook payload before validation.

        Performs:
        1. Conditional field processing (only process present fields)
        2. camelCase → snake_case transformation
        3. Reference extraction: {_ref: "..."} → "..."
        4. Type/label → database value mapping

        Args:
            data: Raw webhook payload (camelCase, Sanity references)

        Returns:
            Transformed data ready for validation (snake_case, extracted refs)
        """
        transformed_data = {}

        # Only process fields present in webhook payload
        if 'someField' in data:
            # camelCase → snake_case
            transformed_data['some_field'] = data['someField']

        # Extract references: {_ref: "test-123", _type: "reference"} → "test-123"
        if 'relatedId' in data:
            if isinstance(data['relatedId'], dict) and '_ref' in data['relatedId']:
                transformed_data['related_id'] = data['relatedId']['_ref']
            else:
                transformed_data['related_id'] = data['relatedId']

        # Type/label mapping
        if 'typeLabel' in data:
            type_map = {
                'Type One': 'type_one',
                'Type Two': 'type_two',
            }
            transformed_data['type_label'] = type_map.get(data['typeLabel'], data['typeLabel'])

        # Pass through to parent for standard DRF validation
        return super().to_internal_value(transformed_data)

    def validate_some_field(self, value):
        """
        Field-level validation example.

        Args:
            value: Field value after to_internal_value transformation

        Returns:
            Validated value

        Raises:
            serializers.ValidationError: If validation fails
        """
        if not value:
            raise serializers.ValidationError("Field cannot be empty")

        # Normalization
        normalized_value = value.strip().upper()

        # Business rule validation
        if len(normalized_value) < 3:
            raise serializers.ValidationError("Must be at least 3 characters")

        return normalized_value

    def validate(self, attrs):
        """
        Object-level validation (cross-field validation).

        Args:
            attrs: Dictionary of validated field values

        Returns:
            Validated attrs

        Raises:
            serializers.ValidationError: If cross-field validation fails
        """
        # Example: Date range validation
        if 'start_date' in attrs and 'end_date' in attrs:
            if attrs['start_date'] >= attrs['end_date']:
                raise serializers.ValidationError({
                    'end_date': 'End date must be after start date'
                })

        return attrs

    def create(self, validated_data):
        """
        Create new instance from webhook payload.

        Note: Called within SyncContext by webhook receiver,
        so post_save signal will skip outbound sync.

        Args:
            validated_data: Validated and transformed data

        Returns:
            Created MyModel instance
        """
        instance = MyModel.objects.create(**validated_data)
        logger.info(f"Created MyModel from webhook: {instance.pk}")
        return instance

    def update(self, instance, validated_data):
        """
        Update existing instance from webhook payload.

        Only updates fields that were present in webhook payload.

        Args:
            instance: Existing MyModel instance
            validated_data: Validated and transformed data

        Returns:
            Updated MyModel instance
        """
        for field, value in validated_data.items():
            setattr(instance, field, value)

        instance.save()
        logger.info(f"Updated MyModel from webhook: {instance.pk}")
        return instance

Key Serializer Patterns

1. Conditional Field Processing

Problem: Webhooks only send fields that changed, but DRF serializers expect all required fields.

Solution: Only process fields present in webhook payload

def to_internal_value(self, data):
    transformed_data = {}

    # Only process if field is present
    if 'title' in data:
        transformed_data['title'] = data['title']

    if 'description' in data:
        transformed_data['description'] = data['description']

    # Required field with default
    if 'status' in data:
        transformed_data['status'] = data['status']
    else:
        transformed_data['status'] = 'draft'  # Default value

    return super().to_internal_value(transformed_data)

2. Reference Extraction

Problem: Sanity sends references as objects: {_ref: "test-123", _type: "reference"}

Solution: Extract the _ref value

def to_internal_value(self, data):
    transformed_data = {}

    # Extract reference ID
    if 'storeId' in data:
        if isinstance(data['storeId'], dict) and '_ref' in data['storeId']:
            transformed_data['store_id'] = data['storeId']['_ref']
        else:
            # Already a string ID (from API or manual creation)
            transformed_data['store_id'] = data['storeId']

    return super().to_internal_value(transformed_data)

3. Type/Label Mapping

Problem: Sanity stores human-readable labels ("Seasonal Sale"), Django stores database values ("seasonal_sale")

Solution: Map labels to database values

CAMPAIGN_TYPE_MAP = {
    'seasonal_sale': 'Seasonal Sale',
    'flash_sale': 'Flash Sale',
    'loyalty_reward': 'Loyalty Reward',
    'new_customer': 'New Customer',
    'clearance': 'Clearance',
}

# Reverse map for webhook processing
CAMPAIGN_TYPE_REVERSE_MAP = {v: k for k, v in CAMPAIGN_TYPE_MAP.items()}

def to_internal_value(self, data):
    transformed_data = {}

    if 'campaignType' in data:
        label = data['campaignType']
        # Map label → database value
        transformed_data['campaign_type'] = CAMPAIGN_TYPE_REVERSE_MAP.get(
            label,
            label.lower().replace(' ', '_')  # Fallback transformation
        )

    return super().to_internal_value(transformed_data)

4. JSONField Validation

Problem: JSONFields need structure validation beyond basic JSON syntax

Solution: Type-specific validation methods

def validate_discount_effect(self, value):
    """
    Validate discountEffect JSONField structure.

    Expected structure:
    {
        "type": "percentage" | "fixedAmount" | "bogo" | "freeShipping",
        "value": <number>  # Percentage (0-100) or cents
    }
    """
    if not isinstance(value, dict):
        raise serializers.ValidationError("Must be an object")

    # Required fields
    if 'type' not in value:
        raise serializers.ValidationError("Missing required field: type")

    discount_type = value['type']

    # Type-specific validation
    if discount_type == 'percentage':
        if 'value' not in value:
            raise serializers.ValidationError("Percentage discount requires 'value'")

        percentage = value['value']
        if not (0 <= percentage <= 100):
            raise serializers.ValidationError("Percentage must be 0-100")

    elif discount_type == 'fixedAmount':
        if 'value' not in value:
            raise serializers.ValidationError("Fixed amount discount requires 'value'")

        amount_cents = value['value']
        if amount_cents < 0:
            raise serializers.ValidationError("Amount cannot be negative")

    elif discount_type in ['bogo', 'freeShipping']:
        # No value needed
        pass

    else:
        raise serializers.ValidationError(f"Invalid discount type: {discount_type}")

    return value

Integration Points

From Integrations Domain

SanityWebhookReceiver routes to domain serializers:

  • Location: api/nextango/apps/integrations/webhooks.py
  • Method: route_to_serializer(document_type, payload)
  • Uses: SyncContext to prevent outbound sync loops

Documentation: docs/overview/integrations/views.md

To Business Domains

Domain serializers are called by webhook receiver:

  • Users: UserSerializer for user webhooks
  • Products: ProductSerializer, ProductVariantSerializer, CategorySerializer
  • Stores: StoreSerializer for store webhooks
  • Promotions: PromotionalCampaignSerializer, PromoCodeSerializer, ABTestSerializer
  • Transactions: SaleTransactionSerializer, TransactionLineItemSerializer

With SyncContext

All webhook processing uses SyncContext:

with SyncContext(source='sanity_webhook'):
    serializer = DomainSerializer(data=payload)
    serializer.is_valid(raise_exception=True)
    instance = serializer.save()
    # instance._sync_source = 'sanity_webhook'
    # post_save signal checks this and skips outbound sync

Documentation: docs/overview/integrations/sync_context.md

Performance Characteristics

Webhook Processing Time

Target: < 200ms per webhook (p95)

Breakdown:

  • Signature verification: ~5-10ms
  • Ping-pong check: ~2-5ms (Redis)
  • Serializer routing: ~1ms
  • Domain serializer validation: ~20-50ms
  • Database save: ~30-100ms
  • Signal processing (skipped due to SyncContext): ~0ms
  • Total: ~60-170ms

Validation Performance

Field-level validation: ~1-5ms per field Object-level validation: ~5-20ms JSONField validation: ~10-30ms (depends on complexity)

Database Impact

Create: 1 INSERT query Update: 1 UPDATE query Foreign key lookups: 0-3 SELECT queries (cached after first access)

Error Handling

Validation Errors

try:
    serializer = DomainSerializer(data=payload)
    serializer.is_valid(raise_exception=True)
    instance = serializer.save()

except serializers.ValidationError as e:
    # DRF validation error
    logger.error(f"Webhook validation failed: {e.detail}")
    return Response(
        {'error': 'Validation failed', 'details': e.detail},
        status=status.HTTP_400_BAD_REQUEST
    )

except Exception as e:
    # Unexpected error
    logger.error(f"Webhook processing failed: {str(e)}", exc_info=True)
    return Response(
        {'error': 'Internal server error'},
        status=status.HTTP_500_INTERNAL_SERVER_ERROR
    )

Common Validation Errors

  1. Missing required field:

    • Error: {"field_name": ["This field is required."]}
    • Cause: Field not in webhook payload and no default provided
  2. Invalid reference:

    • Error: {"related_id": ["Object with id=... does not exist."]}
    • Cause: Referenced object not in database yet
  3. Type validation:

    • Error: {"discount_type": ["Invalid discount type: unknown_type"]}
    • Cause: Value not in allowed choices
  4. Cross-field validation:

    • Error: {"end_date": ["End date must be after start date"]}
    • Cause: Object-level validation failed

Testing

Example Test: Webhook Payload Transformation

from django.test import TestCase
from nextango.apps.promotions.serializers import PromoCodeSerializer


class PromoCodeSerializerTest(TestCase):
    def test_webhook_payload_transformation(self):
        """Test camelCase → snake_case and reference extraction."""

        # Webhook payload (from Sanity)
        webhook_data = {
            '_id': 'promo-summer20',
            '_type': 'promoCode',
            'code': 'SUMMER20',
            'discountEffect': {
                'type': 'percentage',
                'value': 20
            },
            'appliesTo': {
                'scope': 'order',
                'minPurchase': 50.00
            },
            'campaignId': {
                '_ref': 'campaign-summer',
                '_type': 'reference'
            },
            'startDate': '2025-06-01T00:00:00Z',
            'endDate': '2025-08-31T23:59:59Z',
        }

        # Create serializer
        serializer = PromoCodeSerializer(data=webhook_data)

        # Validate
        self.assertTrue(serializer.is_valid())

        # Check transformed data
        validated_data = serializer.validated_data

        # Code normalized to uppercase
        self.assertEqual(validated_data['code'], 'SUMMER20')

        # discountEffect preserved as dict
        self.assertEqual(validated_data['discount_effect']['type'], 'percentage')
        self.assertEqual(validated_data['discount_effect']['value'], 20)

        # Reference extracted
        self.assertEqual(validated_data['campaign_id'], 'campaign-summer')


    def test_validation_errors(self):
        """Test field-level validation."""

        invalid_data = {
            'code': 'AB',  # Too short (< 3 characters)
            'discountEffect': {
                'type': 'percentage',
                'value': 150  # Invalid (> 100)
            }
        }

        serializer = PromoCodeSerializer(data=invalid_data)

        # Should fail validation
        self.assertFalse(serializer.is_valid())

        # Check specific errors
        self.assertIn('code', serializer.errors)
        self.assertIn('discountEffect', serializer.errors)

Example Test: Conditional Field Processing

def test_partial_update_from_webhook(self):
    """Test that only fields in webhook payload are updated."""

    # Create existing promo code
    promo = PromoCode.objects.create(
        code='SUMMER20',
        discount_effect={'type': 'percentage', 'value': 20},
        applies_to={'scope': 'order'},
        max_uses=100,
        current_uses=50
    )

    # Webhook payload with only some fields
    webhook_data = {
        '_id': promo.sanity_id,
        'discountEffect': {
            'type': 'percentage',
            'value': 25  # Changed from 20 to 25
        }
        # Note: max_uses and current_uses NOT in payload
    }

    # Update with partial data
    serializer = PromoCodeSerializer(promo, data=webhook_data, partial=True)
    self.assertTrue(serializer.is_valid())
    updated_promo = serializer.save()

    # Only discount_effect should change
    self.assertEqual(updated_promo.discount_effect['value'], 25)

    # Other fields unchanged
    self.assertEqual(updated_promo.max_uses, 100)
    self.assertEqual(updated_promo.current_uses, 50)
  • Webhook Receiver: docs/overview/integrations/views.md - SanityWebhookReceiver routing logic
  • Sync Context: docs/overview/integrations/sync_context.md - Loop prevention during webhook processing
  • Domain Signals: docs/overview/integrations/signals.md - Why signals are in business domains
  • Promotions Serializers: docs/overview/promotions/serializers.md - Complete serializer examples
  • Users Domain: docs/overview/users/ - User serializer patterns
  • Products Domain: docs/overview/products/ - Product serializer patterns

Summary

Integrations Domain Role:

  • Routes webhooks to domain serializers
  • Validates HMAC signatures
  • Prevents ping-pong loops
  • Wraps serializer calls in SyncContext
  • Does NOT define business domain serializers

Business Domain Role:

  • Defines serializers for their own models
  • Validates webhook payloads
  • Transforms camelCase → snake_case
  • Extracts Sanity references
  • Implements domain-specific validation logic

This separation keeps integration infrastructure reusable across all domains while allowing each domain to control its own data validation and transformation rules.

Was this page helpful?