Architecture and Design

This page covers how django-stratagem works internally and why it was designed this way.

Architecture Overview

django-stratagem uses Python metaclasses and __init_subclass__ to build a registration system that requires no manual wiring:

  1. Metaclass-based registry - RegistryMeta enables in, iter, and len on Registry classes directly (e.g., EmailNotification in NotificationRegistry).

  2. Auto-registration via __init_subclass__ - When you define a class that subclasses Interface with both registry and slug set, Python’s __init_subclass__ hook fires and calls Registry.register() automatically.

  3. Fully qualified name (FQN) storage - Model fields store FQNs (e.g., "myapp.notifications.EmailNotification") as plain CharField values. This avoids foreign keys, content types, or any schema coupling.

  4. Descriptor-based access - Each field type has a paired descriptor that handles conversion between the stored string and the Python class or instance.

The register() Decorator

As an alternative to auto-registration via Interface.__init_subclass__, you can use the register() decorator for explicit registration:

from django_stratagem import register

@register(NotificationRegistry)
class WebhookNotification:
    slug = "webhook"
    description = "Send via webhook"

    def send(self, message, recipient):
        ...

Use this when you don’t want to subclass Interface or when registering third-party classes.

How Auto-Discovery Works

The following diagram shows the startup lifecycle:

        flowchart TD
    A[Django starts] --> B[AppConfig.ready]
    B --> C{Running migrations?}
    C -->|Yes| D[Skip all registry ops]
    C -->|No| E[discover_registries]
    E --> F[autodiscover_modules 'registry']
    F --> G[For each registry in django_stratagem_registry]
    G --> H[clear_cache]
    H --> I[discover_implementations]
    I --> J[autodiscover_modules implementations_module]
    J --> K[Interface.__init_subclass__ triggers register]
    K --> L[PluginLoader.load_plugin_implementations]
    L --> M[Send registry_reloaded signal]
    M --> N[update_choices_fields]
    N --> O[Set model field choices from registry]
    

Step by Step

The same sequence in detail:

  1. Django starts - DjangoStratagemAppConfig.ready() is called.

  2. Migration check - is_running_migrations() checks sys.argv for migrate/makemigrations. If running migrations, all registry operations are skipped to avoid import errors.

  3. discover_registries() - Calls autodiscover_modules("registry"), then for each registry class in django_stratagem_registry:

    • Clears the cache

    • Calls discover_implementations(), which runs autodiscover_modules(implementations_module) to import implementation modules

    • When implementation modules are imported, Interface.__init_subclass__ fires and calls Registry.register() for each subclass that has both registry and slug set

    • PluginLoader.load_plugin_implementations() loads any plugin-provided implementations

    • Sends the registry_reloaded signal

  4. update_choices_fields() - For each registry, sets the choices attribute on any model fields registered via choices_fields.

Migration Safety

The is_running_migrations() function in utils.py detects migration commands and caches the result for the process lifetime. The skip_during_migrations decorator and direct checks in fields.py and apps.py prevent:

  • Auto-discovery from running during migrations

  • Choice population from running during migrations

  • Class imports in field descriptors from failing during migrations

Design Decisions

Why fully qualified names in the database?

Storing FQNs like "myapp.notifications.EmailNotification" as plain strings:

  • No schema coupling - No foreign keys, content types, or extra tables. Adding or removing implementations never requires a migration.

  • Human-readable - You can read the database value and immediately know what class it refers to.

  • Portable - Values are self-contained strings that work across environments without needing matching database records.

The trade-off is that renaming or moving a class requires updating stored values. In practice, implementation classes are rarely renamed.

Why autodiscovery?

Autodiscovery (importing modules matching implementations_module from all installed apps) follows the pattern established by Django’s admin autodiscovery. It means:

  • Adding a new implementation is one file, zero configuration

  • No manual registration lists to maintain

  • No settings to update

The register() decorator provides an opt-out for cases where autodiscovery doesn’t fit.

Why classmethods on Registry?

All registry operations (get(), get_choices(), register(), etc.) are classmethods rather than instance methods. This means you never need to instantiate a registry - you interact with it as a class:

NotificationRegistry.get(slug="email")
NotificationRegistry.get_choices()
"email" in NotificationRegistry

A registry is a global, singleton-like container - there’s exactly one NotificationRegistry for the application, so there’s nothing to instantiate.

Management Commands

Three management commands help you inspect and manage registries at runtime.

list_registries

List all registered registries and their implementations.

python manage.py list_registries
python manage.py list_registries --format json

Shows: registry name, module, implementation count, slugs, classes, descriptions, priorities, conditions, and parent requirements.

clear_registries_cache

Clear cache for all registries.

python manage.py clear_registries_cache

initialize_registries

Re-discover and initialize all registries, then update model field choices.

python manage.py initialize_registries
python manage.py initialize_registries --force --clear-cache
python manage.py initialize_registries -v 2  # Show health checks

Options:

  • --force - Force initialization even if already initialized

  • --clear-cache - Clear all caches before initialization