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:
- Serializers are domain-specific - Each business domain (Users, Products, Stores, Promotions, Transactions) defines its own serializers to validate and transform webhook payloads
- Integrations provides the routing - This domain provides the webhook receiver that routes to appropriate domain serializers
- 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 modelUserRoleSerializer- 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 payloadsProductVariantSerializer- Validates variant-specific fieldsCategorySerializer- 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 transformationPromoCodeSerializer- Modular discount validation with type-specific rulesABTestSerializer- 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 validationTransactionLineItemSerializer- 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:
- Receives webhook POST request
- Validates HMAC-SHA256 signature
- Checks ping-pong prevention
- Routes to domain serializer based on document type:
user→UserSerializerproduct→ProductSerializerstore→StoreSerializerpromotionalCampaign→PromotionalCampaignSerializerpromoCode→PromoCodeSerializerabTest→ABTestSerializersaleTransaction→SaleTransactionSerializer
- Domain serializer validates and transforms payload
- 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:
UserSerializerfor user webhooks - Products:
ProductSerializer,ProductVariantSerializer,CategorySerializer - Stores:
StoreSerializerfor 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
-
Missing required field:
- Error:
{"field_name": ["This field is required."]} - Cause: Field not in webhook payload and no default provided
- Error:
-
Invalid reference:
- Error:
{"related_id": ["Object with id=... does not exist."]} - Cause: Referenced object not in database yet
- Error:
-
Type validation:
- Error:
{"discount_type": ["Invalid discount type: unknown_type"]} - Cause: Value not in allowed choices
- Error:
-
Cross-field validation:
- Error:
{"end_date": ["End date must be after start date"]} - Cause: Object-level validation failed
- Error:
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)
Related Documentation
- 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.