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:

  1. user_sync_adapter
  2. product_sync_adapter
  3. store_sync_adapter
  4. transaction_sync_adapter
  5. content_sync_adapter
  6. promotional_campaign_sync_adapter
  7. zone_sync_adapter
  8. inventory_sync_adapter
  9. attribute_sync_adapter
  10. task_sync_adapter
  11. payment_sync_adapter
  12. 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

  1. 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
  1. 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, clears sanity_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')

  • 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:

  1. Dependency Pre-Check: Fetch missing references BEFORE business logic runs
  2. Multi-Layer Ping-Pong Prevention: Transaction ID, metadata, cache timestamp checks
  3. Standardized Result Format: Consistent success/failure responses for monitoring
  4. Lazy Handler Loading: Avoid circular imports with property-based lazy loading
  5. Domain-Specific Validation: Override validation in domain adapters

This architecture enables reliable, maintainable webhook processing at scale while keeping domain logic clean and testable.

Was this page helpful?