Integrations - Event Adapters
Location: api/nextango/apps/integrations/adapters/
Last Updated: 2025-12-21
Overview
The Event Adapters implement the Adapter Pattern to provide clean separation between webhook event system plumbing and domain business logic. Adapters handle WebhookEvent loading, validation, dependency resolution, state management, error handling, and audit logging while delegating actual sync operations to domain-specific sync handlers.
Architecture Goal: Decouple event processing infrastructure from business logic, enabling consistent error handling and monitoring across all domains while keeping domain logic isolated and maintainable.
Architecture
Layered Adapter Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Celery Task Layer │
│ (tasks/webhook_processing.py routes events to adapters) │
└───────────────────┬─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ BaseEventAdapter (Abstract) │
│ • Event loading & validation │
│ • Dependency pre-check (ensures references exist) │
│ • Audit logging & state management │
│ • Standardized error handling │
│ • Ping-pong prevention checks │
└───────────────────┬─────────────────────────────────────────────┘
│
┌───────────┴───────────┬──────────────────┬──────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
│UserSync │ │ProductSync │ │StoreSync │ │Transaction │
│Adapter │ │Adapter │ │Adapter │ │SyncAdapter │
└─────┬───────┘ └────────┬─────────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐
│UserSync │ │ProductSync │ │StoreSync │ │Transaction │
│Handler │ │Handler │ │Handler │ │SyncHandler │
└─────────────┘ └──────────────────┘ └──────────────┘ └──────────────┘
Adapter Responsibilities
BaseEventAdapter (Infrastructure)
- Load WebhookEvent from database
- Validate event data structure
- Pre-check dependencies (fetch if missing)
- Add processing log entries
- Standardized error handling
- Return consistent result format
- Does NOT contain business logic
Domain Adapters (Routing)
- Domain-specific validation rules
- Map document_type → model_class_key
- Ping-pong prevention logic
- Rate limiting (for high-traffic domains)
- Delegate to domain sync handlers
- Does NOT implement sync logic
Sync Handlers (Business Logic)
- Transform Sanity payload → Django model
- Handle create/update/delete operations
- Manage database transactions
- Business rule validation
- See:
docs/overview/integrations/services.md
BaseEventAdapter
File: api/nextango/apps/integrations/adapters/__init__.py
Class Definition
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
class BaseEventAdapter(ABC):
"""
Base adapter for event system integration with domain handlers.
Handles WebhookEvent lifecycle management and error handling.
"""
def __init__(self, domain_name: str):
self.domain_name = domain_name
self.logger = logging.getLogger(f"{__name__}.{domain_name}")
Source: api/nextango/apps/integrations/adapters/init.py:21-29
Main Processing Flow
process_event() - Entry Point
def process_event(self, webhook_event_id: int, priority: str = 'normal') -> Dict[str, Any]:
"""
Main adapter interface for processing WebhookEvent.
Handles event loading, business logic delegation, and state management.
Args:
webhook_event_id: WebhookEvent primary key
priority: Processing priority level ('normal', 'high', 'critical')
Returns:
Standardized result dictionary:
{
'success': bool,
'domain': str,
'document_type': str,
'business_result': dict,
'error': str (if failed),
'stage': str (where error occurred)
}
"""
Source: api/nextango/apps/integrations/adapters/init.py:31-43
Processing Pipeline (7 Stages)
1. Event Loading
├─ Load WebhookEvent by ID
└─ Return error if not found
2. Add Processing Log
├─ Log: adapter_processing_started
└─ Include: domain, priority, document_type
3. Validation
├─ Call _validate_event_data()
├─ Check payload structure
├─ Domain-specific validation
└─ Log: adapter_validation_failed (if errors)
4. Dependency Pre-Check CRITICAL
├─ Call _ensure_dependencies()
├─ Fetch missing references from Sanity
├─ Atomic locking to prevent race conditions
├─ Log: adapter_dependency_check_failed (if missing)
└─ Log: adapter_dependencies_fetched (if fetched)
5. Business Logic Delegation
├─ Call _process_business_logic() (abstract method)
├─ Domain adapter implements actual logic
├─ Wrapped in disable_sync_signals() context
└─ Calls domain sync handler
6. Success Logging
├─ Log: adapter_processing_completed
└─ Return standardized success result
7. Error Handling
├─ Catch all exceptions
├─ Log: adapter_exception
└─ Return standardized error result
Source: api/nextango/apps/integrations/adapters/init.py:44-143
Dependency Pre-Check (Critical Innovation)
The _ensure_dependencies() method is a critical innovation that prevents sync failures:
def _ensure_dependencies(self, webhook_event) -> Dict[str, Any]:
"""
PRE-CHECK: Ensure all required dependencies exist before business logic runs.
This is like ping-pong prevention - runs BEFORE sync logic.
Only fetches dependencies when they're actually missing.
Uses atomic locking to prevent race conditions.
Returns:
Dict with success status and details
"""
try:
from ..tasks.dependency_fetcher import ensure_dependencies_exist, MissingReferenceError
# Run the pre-check
result = ensure_dependencies_exist(webhook_event.payload)
# All statuses except explicit failure are success
return {
'success': True,
'status': result.get('status'),
'fetched_count': result.get('fetched_count', 0),
'fetch_results': result.get('fetch_results', [])
}
except MissingReferenceError as e:
# Dependency could not be resolved - FAIL EXPLICITLY
self.logger.error(f"Failed to resolve dependencies: {e}")
return {
'success': False,
'error': str(e),
'missing_dependencies': [str(e)]
}
except Exception as e:
# Unexpected error in dependency resolution
self.logger.error(f"Unexpected error in dependency resolution: {e}", exc_info=True)
return {
'success': False,
'error': f"Dependency resolution error: {str(e)}"
}
Source: api/nextango/apps/integrations/adapters/init.py:176-215
Why This Matters:
- Order matters: Dependencies are fetched BEFORE business logic runs
- Race condition prevention: Uses atomic locking (see
tasks/dependency_fetcher.py) - Explicit failures: Fails fast if dependencies can't be resolved
- Audit trail: Logs all fetched dependencies for debugging
Validation Method
def _validate_event_data(self, webhook_event) -> Dict[str, Any]:
"""
Validate webhook event data before processing.
Override in domain adapters for specific validation rules.
"""
errors = []
# Basic validation
if not webhook_event.payload:
errors.append("Empty payload")
if not webhook_event.document_type:
errors.append("Missing document type")
if not webhook_event.sanity_id:
errors.append("Missing sanity ID")
return {
'valid': len(errors) == 0,
'errors': errors
}
Source: api/nextango/apps/integrations/adapters/init.py:154-174
Abstract Method (Must Override)
@abstractmethod
def _process_business_logic(self, webhook_event, priority: str) -> Dict[str, Any]:
"""
Process domain-specific business logic.
Must be implemented by domain adapters.
Args:
webhook_event: Loaded WebhookEvent instance
priority: Processing priority level
Returns:
Business processing result dictionary:
{
'success': bool,
'sync_operation': str ('sync' | 'skip'),
'model_type': str (e.g., 'users.User'),
'instance_id': str (Django model PK),
'sanity_id': str,
'error': str (if failed)
}
"""
pass
Source: api/nextango/apps/integrations/adapters/init.py:217-230
Adapter Registry
Dynamic Adapter Loading
_DOMAIN_ADAPTERS = {}
def register_domain_adapter(adapter_name: str, adapter_class):
"""Register a domain adapter for dynamic loading."""
_DOMAIN_ADAPTERS[adapter_name] = adapter_class
def get_domain_adapter(adapter_name: str) -> Optional[BaseEventAdapter]:
"""Get domain adapter instance by name."""
adapter_class = _DOMAIN_ADAPTERS.get(adapter_name)
if adapter_class:
return adapter_class()
# Try dynamic import if not registered
try:
module_name = f".{adapter_name}"
from importlib import import_module
module = import_module(module_name, package=__name__)
# Convert "user_sync_adapter" -> "UserSyncAdapter"
class_name_parts = adapter_name.replace('_adapter', '').split('_')
class_name = ''.join(word.capitalize() for word in class_name_parts) + 'Adapter'
adapter_class = getattr(module, class_name, None)
if adapter_class:
instance = adapter_class()
register_domain_adapter(adapter_name, adapter_class)
return instance
except ImportError as e:
logger.warning(f"Could not dynamically import adapter {adapter_name}: {e}")
return None
def list_registered_adapters() -> Dict[str, str]:
"""List all registered domain adapters."""
return {name: cls.__name__ for name, cls in _DOMAIN_ADAPTERS.items()}
Source: api/nextango/apps/integrations/adapters/init.py:233-272
Built-in Adapter Registration
def _register_builtin_adapters():
"""Register all built-in domain adapters."""
from .user_sync_adapter import UserSyncAdapter
register_domain_adapter('user_sync_adapter', UserSyncAdapter)
from .product_sync_adapter import ProductSyncAdapter
register_domain_adapter('product_sync_adapter', ProductSyncAdapter)
from .store_sync_adapter import StoreSyncAdapter
register_domain_adapter('store_sync_adapter', StoreSyncAdapter)
from .transaction_sync_adapter import TransactionSyncAdapter
register_domain_adapter('transaction_sync_adapter', TransactionSyncAdapter)
from .promotional_campaign_sync_adapter import PromotionalCampaignSyncAdapter
register_domain_adapter('promotional_campaign_sync_adapter', PromotionalCampaignSyncAdapter)
# ... and 7 more adapters ...
logger.info(f"Domain adapters registered: {len(_DOMAIN_ADAPTERS)} adapters loaded")
Source: api/nextango/apps/integrations/adapters/init.py:276-345
Registered Adapters:
- user_sync_adapter
- product_sync_adapter
- store_sync_adapter
- transaction_sync_adapter
- content_sync_adapter
- promotional_campaign_sync_adapter
- zone_sync_adapter
- inventory_sync_adapter
- attribute_sync_adapter
- task_sync_adapter
- payment_sync_adapter
- hardware_sync_adapter (if exists)
Domain Adapters
UserSyncAdapter
File: api/nextango/apps/integrations/adapters/user_sync_adapter.py
Supported Document Types
def get_supported_document_types(self) -> list:
"""Return list of document types this adapter can handle."""
return [
'user',
'userCustomer',
'userEmployee',
'userManager',
'userInfluencer',
'userVip',
'userRole'
]
Source: api/nextango/apps/integrations/adapters/user_sync_adapter.py:145-150
Document Type → Model Mapping
def _get_model_class_key(self, document_type: str) -> str:
"""Map document type to Django model class key."""
model_mapping = {
'user': 'users.User',
'userCustomer': 'users.Customer',
'userEmployee': 'users.Employee',
'userManager': 'users.Manager',
'userInfluencer': 'users.Influencer',
'userVip': 'users.Vip',
'userRole': 'users.UserRole',
}
return model_mapping.get(document_type)
Source: api/nextango/apps/integrations/adapters/user_sync_adapter.py:127-143
Domain-Specific Validation
def _validate_event_data(self, webhook_event) -> Dict[str, Any]:
"""User-specific event validation."""
# Start with base validation
result = super()._validate_event_data(webhook_event)
errors = result['errors']
# User domain-specific validation
document_type = webhook_event.document_type
valid_user_types = {
'user', 'userCustomer', 'userEmployee', 'userManager',
'userInfluencer', 'userVip', 'userRole'
}
if document_type not in valid_user_types:
errors.append(f"Invalid user document type: {document_type}")
# Validate essential user fields exist
payload = webhook_event.payload or {}
if document_type != 'userRole': # Roles don't require email/name
if not payload.get('name') and not payload.get('firstName'):
errors.append("Missing user name information")
return {
'valid': len(errors) == 0,
'errors': errors
}
Source: api/nextango/apps/integrations/adapters/user_sync_adapter.py:36-61
Business Logic Processing
def _process_business_logic(self, webhook_event, priority: str) -> Dict[str, Any]:
"""
Delegate user business logic processing to UserSyncHandler.
Maintains clean separation between event handling and business logic.
"""
document_type = webhook_event.document_type
payload = webhook_event.payload
# Check for ping-pong prevention
if self._should_skip_inbound_sync(webhook_event):
self.logger.info(f"Skipping inbound sync - Django-originated change detected")
return {
'success': True,
'skipped': True,
'reason': 'ping_pong_prevention_django_originated',
'sync_operation': 'skip',
'model_type': self._get_model_class_key(document_type),
'sanity_id': webhook_event.sanity_id
}
try:
# Get model class key for the document type
model_class_key = self._get_model_class_key(document_type)
if not model_class_key:
return {
'success': False,
'error': f'No model mapping for document type: {document_type}'
}
# Call generic sync handler with signal isolation
# WEBHOOK PROCESSING: This is INBOUND ONLY - no outbound sync
with disable_sync_signals():
django_instance = self.user_sync_handler.sync_from_sanity(payload, model_class_key)
if django_instance:
return {
'success': True,
'sync_operation': 'sync',
'model_type': model_class_key,
'instance_id': str(django_instance.pk), # Convert UUID to string
'sanity_id': webhook_event.sanity_id,
'processing_time_ms': 0
}
else:
return {
'success': False,
'error': 'Sync handler returned None - sync failed',
'sync_operation': 'sync',
'model_type': model_class_key
}
except Exception as e:
self.logger.error(f"User business logic processing failed: {e}")
return {
'success': False,
'error': str(e),
'processing_stage': 'business_logic_exception'
}
Source: api/nextango/apps/integrations/adapters/user_sync_adapter.py:63-125
Ping-Pong Prevention
def _should_skip_inbound_sync(self, webhook_event) -> bool:
"""
Implement ping-pong prevention for user webhooks.
Skip inbound webhook processing if the document was recently synced outbound from Django.
"""
from ..sync.sync_context import SyncContext
from django.core.cache import cache
# Check 1: Currently in a sync context?
current_sync_source = SyncContext.get_sync_source()
if current_sync_source and 'django' in current_sync_source.lower():
self.logger.debug(f"Skipping - in Django sync context: {current_sync_source}")
return True
# Check 2: Webhook payload has transaction ID?
payload = webhook_event.payload or {}
transaction_id = payload.get('_transaction_id')
if transaction_id:
cache_key = f"django_transaction:{transaction_id}"
django_originated = cache.get(cache_key)
if django_originated:
self.logger.info(f"PING-PONG PREVENTED: Skipping {transaction_id} - originated from Django")
return True
# Check 3: Webhook metadata indicates Django source?
webhook_metadata = payload.get('_metadata', {})
if webhook_metadata.get('sync_source') == 'django':
self.logger.debug(f"Skipping - webhook originated from Django")
return True
# Check 4: Recent outbound sync timestamp?
document_type = webhook_event.document_type
sanity_id = webhook_event.sanity_id
import time
cache_key = f"outbound_sync:{document_type}:{sanity_id}"
recent_outbound_sync = cache.get(cache_key)
if recent_outbound_sync:
time_since_sync = time.time() - recent_outbound_sync
if time_since_sync < 30: # 30 second window
self.logger.debug(f"Skipping - recent outbound sync {time_since_sync:.1f}s ago")
return True
return False
Source: api/nextango/apps/integrations/adapters/user_sync_adapter.py:152-203
ProductSyncAdapter
File: api/nextango/apps/integrations/adapters/product_sync_adapter.py
Supported Document Types
def get_supported_document_types(self) -> list:
return ['product', 'productVariant', 'productType', 'brand', 'category', 'collection']
Key Differences from UserSyncAdapter
- Rate Limiting - Products can have high update volume
# Implement rate limiting for product updates
from django.core.cache import cache
rate_limit_key = f"product_sync_rate_limit_{document_type}"
current_requests = cache.get(rate_limit_key, 0)
# Allow max 50 product syncs per minute per document type
if current_requests >= 50:
self.logger.warning(f"Rate limit exceeded - queuing for later processing")
from ...tasks.product_tasks import queue_delayed_product_sync_task
queue_delayed_product_sync_task.apply_async(
args=[webhook_event.pk, priority],
countdown=60 # Delay by 1 minute
)
return {
'success': True,
'rate_limited': True,
'queued_for_delayed_processing': True,
'retry_after_seconds': 60
}
# Increment rate limit counter
cache.set(rate_limit_key, current_requests + 1, 60) # 1-minute window
- Complex Validation - Products have many required relationships
def _validate_event_data(self, webhook_event) -> Dict[str, Any]:
"""Product-specific event validation."""
result = super()._validate_event_data(webhook_event)
errors = result['errors']
document_type = webhook_event.document_type
valid_product_types = {'product', 'productVariant', 'productType', 'brand', 'category', 'collection'}
if document_type not in valid_product_types:
errors.append(f"Invalid product document type: {document_type}")
payload = webhook_event.payload or {}
if document_type in ['product', 'productVariant']:
if not payload.get('name') and not payload.get('title'):
errors.append("Missing product name/title")
if document_type == 'productVariant' and not payload.get('product'):
errors.append("ProductVariant missing product reference")
return {
'valid': len(errors) == 0,
'errors': errors
}
StoreSyncAdapter
File: api/nextango/apps/integrations/adapters/store_sync_adapter.py
Supported Document Types
def get_supported_document_types(self) -> list:
return ['store', 'storeLocation', 'storeConfig']
Geo-Location Validation
def _validate_event_data(self, webhook_event) -> Dict[str, Any]:
"""Store-specific event validation including geo-location."""
result = super()._validate_event_data(webhook_event)
errors = result['errors']
payload = webhook_event.payload or {}
document_type = webhook_event.document_type
# Validate store fields
if document_type == 'store':
if not payload.get('name'):
errors.append("Missing store name")
# Validate geo-location if present
if 'geoLocation' in payload:
geo = payload['geoLocation']
if not isinstance(geo, dict):
errors.append("Invalid geoLocation format")
elif 'lat' not in geo or 'lng' not in geo:
errors.append("geoLocation missing lat/lng")
else:
lat = geo.get('lat')
lng = geo.get('lng')
if not (-90 <= lat <= 90):
errors.append(f"Invalid latitude: {lat}")
if not (-180 <= lng <= 180):
errors.append(f"Invalid longitude: {lng}")
return {
'valid': len(errors) == 0,
'errors': errors
}
TransactionSyncAdapter
File: api/nextango/apps/integrations/adapters/transaction_sync_adapter.py
Supported Document Types
def get_supported_document_types(self) -> list:
return ['saleTransaction', 'transactionLineItem', 'refund', 'payment']
Financial Validation
def _validate_event_data(self, webhook_event) -> Dict[str, Any]:
"""Transaction-specific validation including financial integrity."""
result = super()._validate_event_data(webhook_event)
errors = result['errors']
payload = webhook_event.payload or {}
document_type = webhook_event.document_type
if document_type == 'saleTransaction':
# Required transaction fields
if not payload.get('store'):
errors.append("Missing store reference")
if not payload.get('cashier'):
errors.append("Missing cashier reference")
# Financial validation
if 'totalAmount' in payload:
total = payload['totalAmount']
if not isinstance(total, (int, float)) or total < 0:
errors.append(f"Invalid totalAmount: {total}")
if 'taxAmount' in payload:
tax = payload['taxAmount']
if not isinstance(tax, (int, float)) or tax < 0:
errors.append(f"Invalid taxAmount: {tax}")
elif document_type == 'transactionLineItem':
if not payload.get('transaction'):
errors.append("Missing transaction reference")
if not payload.get('product'):
errors.append("Missing product reference")
if 'quantity' in payload:
qty = payload['quantity']
if not isinstance(qty, int) or qty <= 0:
errors.append(f"Invalid quantity: {qty}")
return {
'valid': len(errors) == 0,
'errors': errors
}
PromotionalCampaignSyncAdapter
File: api/nextango/apps/integrations/adapters/promotional_campaign_sync_adapter.py
Supported Document Types
def get_supported_document_types(self) -> list:
return ['promotionalCampaign', 'promoCode', 'abTest']
Discount Validation
def _validate_event_data(self, webhook_event) -> Dict[str, Any]:
"""Promotional campaign validation including discount rules."""
result = super()._validate_event_data(webhook_event)
errors = result['errors']
payload = webhook_event.payload or {}
document_type = webhook_event.document_type
if document_type == 'promoCode':
if not payload.get('code'):
errors.append("Missing promo code")
# Validate discount effect structure
if 'discountEffect' in payload:
discount = payload['discountEffect']
if not isinstance(discount, dict):
errors.append("discountEffect must be an object")
elif 'type' not in discount:
errors.append("discountEffect missing type")
else:
discount_type = discount['type']
if discount_type == 'percentage':
if 'value' not in discount:
errors.append("Percentage discount missing value")
elif not (0 <= discount.get('value', 0) <= 100):
errors.append(f"Invalid percentage: {discount.get('value')}")
elif discount_type == 'fixedAmount':
if 'value' not in discount:
errors.append("Fixed amount discount missing value")
elif discount.get('value', 0) < 0:
errors.append("Fixed amount cannot be negative")
return {
'valid': len(errors) == 0,
'errors': errors
}
Other Domain Adapters
ZoneSyncAdapter
File: zone_sync_adapter.py
Document Types: ['zone', 'zoneRegion']
Special Logic: Geographic boundary validation
InventorySyncAdapter
File: inventory_sync_adapter.py
Document Types: ['productInventoryItem', 'inventoryAdjustment']
Special Logic: Stock level validation, negative quantity prevention
AttributeSyncAdapter
File: attribute_sync_adapter.py
Document Types: ['productAttribute', 'attributeValue']
Special Logic: Attribute type validation (color, size, material, etc.)
TaskSyncAdapter
File: task_sync_adapter.py
Document Types: ['task', 'taskComment']
Special Logic: Task assignment validation, deadline checks
PaymentSyncAdapter
File: payment_sync_adapter.py
Document Types: ['payment', 'paymentMethod', 'paymentRefund']
Special Logic: Payment amount validation, refund amount capping
ContentSyncAdapter
File: content_sync_adapter.py
Document Types: ['blogPost', 'page', 'article']
Special Logic: Content type validation, slug uniqueness
HardwareSyncAdapter
File: hardware_sync_adapter.py
Document Types: ['posTerminal', 'receipt Printer', 'barcodeScanner']
Special Logic: Hardware serial number validation, connection status
Delete Webhook Handling (Added December 2025)
Adapters now support handling Sanity delete webhooks through the handle_delete() method. This enables proper soft-deletion of Django records when documents are deleted in Sanity CMS.
Delete Detection
Delete events are detected using the Sanity-Operation HTTP header:
# In webhooks.py
sanity_operation = request.headers.get('Sanity-Operation')
is_delete_event = sanity_operation == 'delete'
if is_delete_event:
webhook_data['_is_delete'] = True
The deduplication signature includes the operation type to prevent delete webhooks from being flagged as duplicates of prior update webhooks:
webhook_signature = hashlib.md5(
f"{sanity_id}:{revision}:{document_type}:{sanity_operation or 'update'}".encode()
).hexdigest()
BaseEventAdapter.handle_delete()
Base implementation provides a default soft-delete behavior:
def handle_delete(self, instance, webhook_event) -> Dict[str, Any]:
"""
Default delete handling - marks record as deleted from Sanity.
Domain adapters can override for custom behavior.
"""
if hasattr(instance, 'mark_deleted_from_sanity'):
instance.mark_deleted_from_sanity()
return {'success': True, 'action': 'marked_deleted'}
elif hasattr(instance, 'sanity_id'):
instance.sanity_id = None
instance.save(update_fields=['sanity_id'])
return {'success': True, 'action': 'cleared_sanity_id'}
return {'success': True, 'action': 'no_action'}
ProductSyncAdapter Delete Handling
The product domain implements transaction-aware soft-delete to preserve data integrity:
def handle_delete(self, instance, webhook_event) -> Dict[str, Any]:
"""
Product domain uses soft-delete with transaction history awareness.
- ProductVariant: Uses soft_delete_if_needed() which checks transaction history
- Product: Marks deleted and cascades to variants
- InventoryLevel: Sets is_active=False before marking deleted
- Other models: Uses base mark_deleted_from_sanity()
"""
model_name = type(instance).__name__
if model_name == 'ProductVariant':
# Check transaction history before deciding delete strategy
if hasattr(instance, 'soft_delete_if_needed'):
was_soft_deleted = instance.soft_delete_if_needed()
if was_soft_deleted:
action = 'soft_deleted_with_history'
else:
instance.mark_deleted_from_sanity()
action = 'marked_deleted_no_history'
elif model_name == 'Product':
instance.mark_deleted_from_sanity()
# Cascade soft-delete to variants
for variant in instance.variant_records.all():
variant.soft_delete_if_needed()
action = f'marked_deleted_with_{variants_count}_variants'
elif model_name == 'InventoryLevel':
instance.is_active = False
instance.save(update_fields=['is_active'])
instance.mark_deleted_from_sanity()
action = 'deactivated_and_marked_deleted'
return {'success': True, 'action': action}
Delete Result Format
{
'success': True,
'action': 'soft_deleted_with_history', # or 'marked_deleted', 'deactivated_and_marked_deleted'
'model_name': 'ProductVariant',
'instance_pk': 'uuid-string'
}
Transaction-Aware Soft Delete
The soft_delete_if_needed() method on ProductVariant checks for associated transaction line items:
- If transaction history exists: Performs soft-delete (marks
is_deleted=True, clearssanity_id) to preserve historical records - If no transaction history: Allows standard
mark_deleted_from_sanity()behavior
This ensures transaction reports and historical data remain accurate while still reflecting the deletion from Sanity.
Adapter Integration Points
From Webhook Processing Tasks
# tasks/webhook_processing.py
from ..adapters import get_domain_adapter
def process_webhook_event_via_adapter(webhook_event_id: int, priority: str = 'normal'):
"""
Route webhook event to appropriate domain adapter for processing.
"""
# Load webhook event
webhook_event = WebhookEvent.objects.get(pk=webhook_event_id)
# Determine adapter name from document type
adapter_name = determine_adapter_for_document_type(webhook_event.document_type)
# Get adapter instance
adapter = get_domain_adapter(adapter_name)
if not adapter:
raise ValueError(f"No adapter found for: {adapter_name}")
# Process event through adapter
result = adapter.process_event(webhook_event_id, priority)
return result
To Domain Sync Handlers
# Each adapter delegates to its domain sync handler
class UserSyncAdapter(BaseEventAdapter):
@property
def user_sync_handler(self):
"""Lazy load UserSyncHandler to avoid circular imports."""
if self._user_sync_handler is None:
from ..sync.user_sync import UserSyncHandler
self._user_sync_handler = UserSyncHandler()
return self._user_sync_handler
def _process_business_logic(self, webhook_event, priority: str):
# Delegate to sync handler
with disable_sync_signals():
django_instance = self.user_sync_handler.sync_from_sanity(
webhook_event.payload,
model_class_key
)
Adapter Result Format
Success Result
{
'success': True,
'domain': 'user',
'document_type': 'userCustomer',
'business_result': {
'sync_operation': 'sync',
'model_type': 'users.Customer',
'instance_id': 'uuid-string',
'sanity_id': 'customer-123',
'processing_time_ms': 0
}
}
Skip Result (Ping-Pong Prevented)
{
'success': True,
'skipped': True,
'reason': 'ping_pong_prevention_django_originated',
'sync_operation': 'skip',
'model_type': 'users.User',
'sanity_id': 'user-456'
}
Failure Result
{
'success': False,
'error': 'Dependency check failed: Missing product reference',
'stage': 'dependency_resolution',
'domain': 'transaction',
'missing_dependencies': ['product-789']
}
Rate Limited Result
{
'success': True,
'rate_limited': True,
'queued_for_delayed_processing': True,
'retry_after_seconds': 60
}
Performance Characteristics
Adapter Overhead
Typical adapter processing time: ~10-30ms
- Event loading: 2-5ms
- Validation: 1-3ms
- Dependency pre-check: 5-15ms (if dependencies need fetching)
- Business logic delegation: Variable (see sync handlers)
- Logging: 2-5ms
Rate Limiting
Product domain: Max 50 syncs/minute per document type Transaction domain: Max 100 syncs/minute User domain: No rate limiting Store domain: Max 20 syncs/minute
Dependency Fetching
Average dependency fetch time: 100-300ms per reference Atomic locking prevents: Duplicate fetches (race condition) Cache duration: 5 minutes after successful fetch
Error Handling Patterns
Validation Failures
# Validation errors prevent business logic from running
{
'success': False,
'error': "Event validation failed: ['Missing store reference', 'Invalid totalAmount']",
'stage': 'validation'
}
Dependency Resolution Failures
# Missing dependencies prevent sync
{
'success': False,
'error': 'Dependency check failed: Product product-123 not found in Sanity',
'stage': 'dependency_resolution',
'missing_dependencies': ['product-123']
}
Business Logic Exceptions
# Unexpected errors during sync
{
'success': False,
'error': 'IntegrityError: duplicate key value violates unique constraint',
'stage': 'business_logic',
'processing_stage': 'business_logic_exception'
}
Adapter Exceptions
# Critical adapter infrastructure failures
{
'success': False,
'error': 'WebhookEvent 12345 not found',
'stage': 'adapter_exception',
'domain': 'user'
}
Testing
Example Test: Adapter Processing Flow
from django.test import TestCase
from nextango.apps.integrations.adapters import get_domain_adapter
from nextango.apps.integrations.models import WebhookEvent
class UserSyncAdapterTest(TestCase):
def test_successful_user_sync(self):
"""Test successful user webhook processing through adapter."""
# Create webhook event
webhook_event = WebhookEvent.objects.create(
document_type='userCustomer',
sanity_id='customer-test-123',
operation='update',
payload={
'_id': 'customer-test-123',
'_type': 'userCustomer',
'firstName': 'John',
'lastName': 'Doe',
'email': '[email protected]'
}
)
# Get adapter
adapter = get_domain_adapter('user_sync_adapter')
self.assertIsNotNone(adapter)
# Process event
result = adapter.process_event(webhook_event.pk, priority='normal')
# Verify success
self.assertTrue(result['success'])
self.assertEqual(result['domain'], 'user')
self.assertEqual(result['document_type'], 'userCustomer')
# Verify Django instance was created
from nextango.apps.users.models import Customer
customer = Customer.objects.get(sanity_id='customer-test-123')
self.assertEqual(customer.first_name, 'John')
self.assertEqual(customer.last_name, 'Doe')
Example Test: Validation Failure
def test_validation_failure_empty_payload(self):
"""Test that empty payload fails validation."""
webhook_event = WebhookEvent.objects.create(
document_type='userCustomer',
sanity_id='customer-invalid',
operation='update',
payload=None # Empty payload
)
adapter = get_domain_adapter('user_sync_adapter')
result = adapter.process_event(webhook_event.pk)
self.assertFalse(result['success'])
self.assertEqual(result['stage'], 'validation')
self.assertIn('Empty payload', result['error'])
Example Test: Ping-Pong Prevention
def test_ping_pong_prevention(self):
"""Test that Django-originated webhooks are skipped."""
from django.core.cache import cache
# Create webhook with transaction ID
webhook_event = WebhookEvent.objects.create(
document_type='user',
sanity_id='user-123',
operation='update',
payload={
'_id': 'user-123',
'_type': 'user',
'_transaction_id': 'txn-django-456',
'name': 'Test User'
}
)
# Mark transaction as Django-originated
cache.set('django_transaction:txn-django-456', True, 300)
adapter = get_domain_adapter('user_sync_adapter')
result = adapter.process_event(webhook_event.pk)
# Should skip sync
self.assertTrue(result['success'])
self.assertTrue(result.get('skipped'))
self.assertEqual(result['reason'], 'ping_pong_prevention_django_originated')
Related Documentation
- Sync Handlers:
docs/overview/integrations/services.md- Domain sync handler implementations - Webhook Processing:
docs/overview/integrations/tasks.md- Celery task orchestration (when created) - Dependency Fetching:
docs/overview/integrations/tasks.md- Cross-domain reference resolution (when created) - Signal Management:
docs/overview/integrations/tasks.md- Signal disable/enable context (when created) - Webhook Models:
docs/overview/integrations/models.md- WebhookEvent model structure
Summary
Adapter Pattern Benefits:
- Clean separation: infrastructure vs business logic
- Standardized error handling across all domains
- Consistent audit trail and logging
- Dependency pre-checking prevents sync failures
- Ping-pong prevention at adapter level
- Rate limiting for high-volume domains
- Dynamic adapter registration and loading
Key Innovations:
- Dependency Pre-Check: Fetch missing references BEFORE business logic runs
- Multi-Layer Ping-Pong Prevention: Transaction ID, metadata, cache timestamp checks
- Standardized Result Format: Consistent success/failure responses for monitoring
- Lazy Handler Loading: Avoid circular imports with property-based lazy loading
- Domain-Specific Validation: Override validation in domain adapters
This architecture enables reliable, maintainable webhook processing at scale while keeping domain logic clean and testable.