architecting-django

star 0

Apply Django Active Record architecture with hexagonal adapter patterns, service layers, and settings-based dependency injection for Django and NetBox plugin projects. Use when designing Django models, structuring Django apps, creating service layers, implementing adapter patterns, or when the user asks about Django code organization. Activates for phrases like "Django architecture", "Django model", "service layer", "adapter pattern", "Django structure", "NetBox plugin", or when writing significant new Django code.

nicolaei By nicolaei schedule Updated 2/12/2026

name: architecting-django description: Apply Django Active Record architecture with hexagonal adapter patterns, service layers, and settings-based dependency injection for Django and NetBox plugin projects. Use when designing Django models, structuring Django apps, creating service layers, implementing adapter patterns, or when the user asks about Django code organization. Activates for phrases like "Django architecture", "Django model", "service layer", "adapter pattern", "Django structure", "NetBox plugin", or when writing significant new Django code.

Django Architecture

Core Philosophy

Django embraces Active Record: each model instance is a database row, each model class is a table. Work with this pattern, not against it.

  • Thin models, thin views: Models hold state, basic validation, and single-instance or same-model row-level logic. Cross-model logic and multi-instance coordination lives in services
  • Hexagonal adapters for external boundaries: Isolate cross-plugin and third-party dependencies behind Protocol adapters
  • Settings-based DI: Use Django's own import_string pattern (like EMAIL_BACKEND, STORAGES) for adapter injection
  • Query/command separation: Model methods are either queries (no side effects) or commands (change state)
  • Explicit over implicit: Service calls over signals, adapter injection over hard-coded imports

This skill complements architecting-python (functional core / imperative shell). Django's Active Record replaces the pure-function core with rich model objects, but the adapter and service patterns still apply at the boundaries.

              ┌──────────────────────────────────┐
              │  Views / ViewSets (thin)         │  Validate, delegate, respond
              └──────────────┬───────────────────┘
              ┌──────────────▼───────────────────┐
              │  Serializers                      │  Parse input, enrich output
              └──────────────┬───────────────────┘
              ┌──────────────▼───────────────────┐
              │  Service Layer                    │  transaction.atomic, adapters
              └────────┬─────────────────┬───────┘
            ┌──────────▼──────┐ ┌────────▼────────────┐
            │  Models (Active │ │  Adapters (Protocol) │
            │  Record)        │ │                      │
            │  State, rules,  │ │  Cross-plugin data,  │
            │  validation,    │ │  external APIs,      │
            │  query methods  │ │  notifications       │
            └────────┬────────┘ └────────┬────────────┘
                     ▼                   ▼
                 Database          Other plugins / External systems

      Adapter loading: settings.PLUGINS_CONFIG → import_string()

Project Structure

plugin_name/
├── __init__.py          # PluginConfig with adapter validation in ready()
├── models/              # Active Record models (aggregate roots)
│   ├── __init__.py
│   └── order.py
├── services/            # Cross-model orchestration, workflows
│   ├── __init__.py
│   └── provisioning.py
├── adapters.py          # Ports (Protocol) + Adapters + Loaders
├── api/                 # DRF serializers, viewsets, urls
│   ├── serializers.py
│   ├── viewsets.py
│   └── urls.py
├── views/               # Django/NetBox UI views (thin)
├── forms/               # Django forms (UI rendering only)
├── tables/              # NetBox tables
├── filtersets.py        # Django-filter querysets
├── choices/             # Enums (TextChoices)
├── validators.py        # Field validators with normalization
├── jobs.py              # Async/background jobs
├── navigation.py        # NetBox navigation config
├── templates/
├── migrations/
└── tests/

Model Patterns

Field Design

from django.db import models
from netbox.models import NetBoxModel

class ServiceOrder(NetBoxModel):
    """Aggregate root for service provisioning."""

    # Core data
    name = models.CharField(max_length=100)
    order_type = models.CharField(max_length=50, choices=OrderTypeChoices)
    status = models.CharField(
        max_length=30,
        choices=StatusChoices,
        default=StatusChoices.DRAFT,
    )

    # Cross-plugin reference (ID, not ForeignKey)
    circuit_id = models.PositiveBigIntegerField(null=True, blank=True)

    # Timestamps
    submitted_at = models.DateTimeField(null=True, blank=True)
    completed_at = models.DateTimeField(null=True, blank=True)

Cross-plugin references: Store integer IDs, never ForeignKey across plugin boundaries. Resolve via adapters at runtime.

Row-Level Business Logic

Model methods encapsulate state transitions and business rules. They change state but do not call save() — the caller decides when to persist. Maintain query/command separation: queries return data with no side effects (can_submit(), is_overdue()), commands change state and return None (submit(), complete()).

class ServiceOrder(NetBoxModel):

    def submit(self) -> None:
        """Command: transition from DRAFT to SUBMITTED."""
        if self.status != StatusChoices.DRAFT:
            raise ValidationError(f"Cannot submit order in status {self.status}")
        self.status = StatusChoices.SUBMITTED
        self.submitted_at = timezone.now()

    def can_submit(self) -> bool:
        """Query: check if submission is allowed."""
        return self.status == StatusChoices.DRAFT

Validation

# Field-level: validators.py (with normalization)
def validate_circuit_reference(value: str) -> str:
    """Validate and normalize circuit reference format."""
    normalized = value.strip().upper()
    if not re.match(r"^CIR-\d{6}$", normalized):
        raise ValidationError(f"Invalid circuit reference: {value}")
    return normalized

# Model-level: cross-field validation in clean()
class ServiceOrder(NetBoxModel):
    def clean(self):
        super().clean()
        if self.order_type == OrderTypeChoices.UPGRADE and not self.circuit_id:
            raise ValidationError(
                {"circuit_id": "Circuit required for upgrade orders."}
            )

Custom Managers and QuerySets

class ServiceOrderQuerySet(models.QuerySet):
    def active(self):
        return self.exclude(status=StatusChoices.CANCELLED)

    def pending_provisioning(self):
        return self.filter(status=StatusChoices.SUBMITTED)

class ServiceOrderManager(models.Manager):
    def get_queryset(self):
        return ServiceOrderQuerySet(self.model, using=self._db)

    def create_draft(self, **kwargs) -> "ServiceOrder":
        """Factory method for creating orders in DRAFT status."""
        return self.create(status=StatusChoices.DRAFT, **kwargs)

Enums with TextChoices

class StatusChoices(TextChoices):
    DRAFT = "draft", "Draft"
    SUBMITTED = "submitted", "Submitted"
    PROVISIONING = "provisioning", "Provisioning"
    COMPLETED = "completed", "Completed"

Aggregate Root with OneToOneField

Use OneToOneField for polymorphic sub-types sharing a common base:

class ServiceOrder(NetBoxModel):
    """Base aggregate root."""
    order_type = models.CharField(max_length=50, choices=OrderTypeChoices)

class NewCircuitOrder(models.Model):
    """Sub-type specific fields for new circuit orders."""
    order = models.OneToOneField(
        ServiceOrder,
        on_delete=models.CASCADE,
        related_name="%(class)s",
    )
    bandwidth = models.CharField(max_length=20)
    location_a = models.CharField(max_length=200)
    location_b = models.CharField(max_length=200)

Service Layer

When to Use Services

  • Cross-model coordination (order + line items + external provisioning)
  • Multi-instance logic (operating on multiple instances of the same model)
  • External adapter calls (anything behind a Protocol)
  • Multi-step workflows needing transaction.atomic
  • Business rules that span multiple models or plugins

What Stays on Models

  • Single-instance state transitions (order.submit())
  • Single-instance queries (order.can_submit())
  • Row-level validation (clean())
  • Custom QuerySet filters for the model's own table (.active(), .pending())

Template Method with Factory

from abc import ABC, abstractmethod
from django.db import transaction

class OrderProvisioningService(ABC):
    """Abstract workflow: validate -> create -> provision."""

    def __init__(self, **adapters):
        for name, adapter in adapters.items():
            setattr(self, name, adapter)

    @classmethod
    def for_order_type(cls, order_type: str, **adapters):
        """Factory: resolve correct service subclass from order type."""
        registry = {
            OrderTypeChoices.NEW_CIRCUIT: NewCircuitProvisioningService,
            OrderTypeChoices.UPGRADE: UpgradeProvisioningService,
        }
        service_cls = registry.get(order_type)
        if service_cls is None:
            raise ValueError(f"Unknown order type: {order_type}")
        return service_cls(**adapters)

    @transaction.atomic
    def create_order(self, data: dict) -> ServiceOrder:
        """Template method defining the workflow skeleton."""
        self.validate_references(data)
        order = ServiceOrder.objects.create_draft(
            name=data["name"], order_type=self.order_type,
        )
        self.create_sub_type_record(order, data)
        return order

    @abstractmethod
    def validate_references(self, data: dict) -> None: ...

    @abstractmethod
    def create_sub_type_record(self, order: ServiceOrder, data: dict) -> None: ...

class NewCircuitProvisioningService(OrderProvisioningService):
    order_type = OrderTypeChoices.NEW_CIRCUIT

    def validate_references(self, data: dict) -> None:
        if not self.location_adapter.location_exists(data["location_a_id"]):
            raise ValidationError("Location A not found")

    def create_sub_type_record(self, order: ServiceOrder, data: dict) -> None:
        NewCircuitOrder.objects.create(
            order=order, bandwidth=data["bandwidth"],
            location_a=data["location_a"], location_b=data["location_b"],
        )

Batch Operations for Background Jobs

class ProvisioningSync:
    @classmethod
    def sync_all_pending(cls, **adapters) -> int:
        synced = 0
        for order in ServiceOrder.objects.pending_provisioning():
            service = OrderProvisioningService.for_order_type(
                order.order_type, **adapters
            )
            service.provision(order)
            synced += 1
        return synced

Adapter Pattern with Settings-Based DI

Protocol as Port

Define what the domain needs. Keep ports focused — separate read (query) from write (command) when appropriate.

from typing import Protocol, runtime_checkable

@runtime_checkable
class CircuitAdapter(Protocol):
    """Port for circuit operations across plugin boundaries."""

    def circuit_exists(self, circuit_id: int) -> bool: ...
    def get_circuit_display(self, circuit_id: int) -> str | None: ...
    def create_circuit(self, data: dict) -> int: ...

Production Adapter (Django ORM)

Inline imports in adapters are acceptable — they isolate the dependency to the adapter boundary.

class DjangoCircuitAdapter:
    def circuit_exists(self, circuit_id: int) -> bool:
        from circuits.models import Circuit
        return Circuit.objects.filter(pk=circuit_id).exists()

    def get_circuit_display(self, circuit_id: int) -> str | None:
        from circuits.models import Circuit
        try:
            return str(Circuit.objects.get(pk=circuit_id))
        except Circuit.DoesNotExist:
            return None

In-Memory Adapter (Test Double)

class InMemoryCircuitAdapter:
    """Stateful test double — no mocking required."""
    _circuits: dict[int, dict] = {}
    _next_id: int = 1

    @classmethod
    def reset(cls) -> None:
        cls._circuits = {}
        cls._next_id = 1

    def circuit_exists(self, circuit_id: int) -> bool:
        return circuit_id in self._circuits

    def get_circuit_display(self, circuit_id: int) -> str | None:
        circuit = self._circuits.get(circuit_id)
        return circuit["name"] if circuit else None

Settings-Based Loading

# adapters.py — loader functions

from django.conf import settings
from django.utils.module_loading import import_string

def get_circuit_adapter() -> CircuitAdapter:
    """Load the circuit adapter from plugin settings."""
    config = settings.PLUGINS_CONFIG.get("noa_service_order", {})
    adapter_path = config.get(
        "CIRCUIT_ADAPTER",
        "noa_service_order.adapters.DjangoCircuitAdapter",
    )
    adapter_cls = import_string(adapter_path)
    return adapter_cls()

Configuration and Startup Validation

# NetBox configuration.py
PLUGINS_CONFIG = {
    "noa_service_order": {
        "CIRCUIT_ADAPTER": "noa_service_order.adapters.DjangoCircuitAdapter",
    },
}

Validate required adapters are importable at startup in PluginConfig.ready():

class NServiceOrderConfig(PluginConfig):
    name = "noa_service_order"
    required_adapters = ["CIRCUIT_ADAPTER", "LOCATION_ADAPTER"]

    def ready(self):
        super().ready()
        config = self._get_plugin_config()
        for key in self.required_adapters:
            path = config.get(key)
            if path:
                try:
                    import_string(path)
                except ImportError as e:
                    raise ImportError(f"Cannot import {key}={path}: {e}")

Serializer and ViewSet Patterns

Pass adapters via get_serializer_context() for computed fields. Use SerializerMethodField for adapter-backed output enrichment. Override to_internal_value() / to_representation() for complex bidirectional conversion.

class ServiceOrderSerializer(NetBoxModelSerializer):
    circuit_id = serializers.IntegerField(required=False, allow_null=True)
    circuit_display = serializers.SerializerMethodField()

    class Meta:
        model = ServiceOrder
        fields = ["id", "name", "order_type", "status",
                  "circuit_id", "circuit_display"]

    def get_circuit_display(self, obj) -> str | None:
        if not obj.circuit_id:
            return None
        adapter = self.context.get("circuit_adapter")
        return adapter.get_circuit_display(obj.circuit_id) if adapter else None

class ServiceOrderViewSet(NetBoxModelViewSet):
    queryset = ServiceOrder.objects.all()
    serializer_class = ServiceOrderSerializer

    def get_serializer_context(self):
        context = super().get_serializer_context()
        context["circuit_adapter"] = get_circuit_adapter()
        return context

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        service = OrderProvisioningService.for_order_type(
            serializer.validated_data["order_type"],
            circuit_adapter=get_circuit_adapter(),
            location_adapter=get_location_adapter(),
        )
        order = service.create_order(serializer.validated_data)
        output = self.get_serializer(order)
        return Response(output.data, status=status.HTTP_201_CREATED)

For UI views, use NetBox generics (ObjectView, ObjectListView, ObjectEditView, ObjectDeleteView) — they handle permissions, templates, and navigation.

Form Patterns

  • UI rendering only: No business logic in forms
  • Conditional layout: Adapt fields based on create vs edit (self.instance.pk)
  • FieldSet grouping: Organize sections with NetBox's FieldSet
  • DynamicModelChoiceField: For cross-module lookups
from netbox.forms import NetBoxModelForm
from utilities.forms.fields import DynamicModelChoiceField

class ServiceOrderForm(NetBoxModelForm):
    fieldsets = (
        FieldSet("name", "order_type", name="Order"),
        FieldSet("circuit_id", name="References"),
    )

    class Meta:
        model = ServiceOrder
        fields = ["name", "order_type", "circuit_id"]

Background Jobs

from netbox.jobs import JobRunner

class ProvisioningSyncJob(JobRunner):
    """Background job with adapter injection."""

    class Meta:
        name = "Provisioning Sync"

    def run(self, data, commit):
        adapters = {
            "circuit_adapter": get_circuit_adapter(),
            "location_adapter": get_location_adapter(),
        }
        synced = ProvisioningSync.sync_all_pending(**adapters)
        self.log_info(f"Synced {synced} orders")

What Doesn't Belong in Models

Concern Where It Goes
HTTP/Request handling Views / ViewSets
Serialization / field mapping Serializers
Cross-module coordination Service layer
External API calls Adapters (behind Protocol)
Multi-step workflows Services + transaction.atomic
Notifications Adapters (loaded from settings)
Background scheduling Jobs (inject adapters in run())
UI layout Forms + Templates

Anti-Patterns

  • Business logic in views or serializers — move to models or services
  • Direct cross-plugin model imports — use adapters with import_string
  • Mocking in tests — use in-memory adapters with reset()
  • Models calling save() in business methods — let the caller persist
  • Signals for business logic — use explicit service calls
  • Mixing query and command in the same model method
  • ForeignKey across plugin boundaries — use integer ID + adapter
  • God services — keep services focused on one workflow each

Testing

Django-specific testing patterns (test clients, in-memory adapters, fixture design, factory patterns) are covered in the companion testing-django skill.

Install via CLI
npx skills add https://github.com/nicolaei/claude-plugins --skill architecting-django
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator