Skip to content

Design Decisions and Trade-offs

This document explains the key design decisions made in django-admin-deux, the rationale behind them, and the trade-offs involved.

Decision 1: Action-Centric Over View-Centric

The Decision

Make actions first-class objects that generate views, rather than views that have actions attached.

Rationale

Traditional approach (Django admin):

class ProductAdmin(admin.ModelAdmin):
    def export_csv(self, request, queryset):
        # Export logic
        pass
    export_csv.short_description = "Export to CSV"

    actions = [export_csv]

Problems: 1. Actions are functions with magical attributes 2. No clear distinction between "main views" and "operations" 3. Cannot compose or extend actions easily 4. Actions have no access to admin configuration 5. Limited reusability across models

Our approach:

class ProductAdmin(ModelAdmin):
    general_actions = [ListAction, AddAction]  # Main entry points
    bulk_actions = [ExportCSVAction]               # Multi-record operations
    record_actions = [EditAction]            # Single-record operations

Benefits: 1. Clear separation: Three action types with distinct purposes 2. Rich context: Actions carry model, model_admin, admin_site 3. Composability: Actions built from mixins (view-type + behavior) 4. Extensibility: Plugins provide actions without modifying code 5. Reusability: Generic actions work across models

Trade-offs

Pro: - Superior extensibility and composition - Plugin-friendly architecture - Clear mental model (everything is an action) - Testable in isolation - Reusable across projects

Con: - More complex than simple function-based actions - Requires understanding action hierarchy - Slight learning curve for Django admin users - Additional abstraction layer

Verdict: Worth it. The improved extensibility and clarity outweigh the added complexity, especially for medium-to-large admin interfaces.

Decision 2: djp (Django Plugin System) Over Alternatives

The Decision

Use djp (built on pluggy) for the plugin system rather than Django signals, app hooks, or custom solutions.

Alternatives Considered

Alternative 1: Django Signals

# Using signals
admin_view_rendering.send(
    sender=ProductAdmin,
    view=view_instance,
    context=context
)

Problems: - Return values are ignored (can't modify) - No ordering guarantees - No type safety - Not designed for feature composition - Loose coupling makes debugging hard

Alternative 2: Django App Hooks

# Using AppConfig
class MyAppConfig(AppConfig):
    def ready(self):
        register_admin_plugin(MyPlugin)

Problems: - Must implement plugin registry ourselves - No standard hook specification - No execution ordering - No testing infrastructure - Reinventing the wheel

Alternative 3: Raw Pluggy

# Direct pluggy usage
import pluggy
pm = pluggy.PluginManager("djadmin")

Problems: - Must implement Django app discovery - More boilerplate than djp - Less Django-idiomatic - Harder for Django developers

Our Choice: djp

# Simple and Django-native
from djadmin.plugins import pm, hookimpl

@hookimpl
def djadmin_get_default_general_actions():
    return [ListAction, AddAction]

Benefits: - Auto-discovers from INSTALLED_APPS - Looks for djadmin_hooks.py (convention) - Built on battle-tested pluggy (pytest uses it) - Type-safe hook specifications - Controlled execution ordering - Return value collection - Excellent debugging tools

Trade-offs

Pro: - Mature, battle-tested (pluggy powers pytest) - Django-friendly wrapper (djp) - Clear contracts (hook specifications) - Multiple plugins compose cleanly - Well-documented patterns

Con: - Additional dependency (djp + pluggy) - Learning curve for plugin authors - More complex than signals for simple cases - Requires understanding hook patterns

Verdict: Strongly worth it. The benefits of a proper plugin architecture far outweigh the dependency cost. pluggy is maintained and stable.

Decision 3: Factory Pattern Over Inheritance

The Decision

Generate views dynamically using ViewFactory rather than using inheritance hierarchies.

Alternatives Considered

Alternative 1: Deep Inheritance

class BaseAdminView(ListView):
    def get_queryset(self): ...

class PaginatedView(BaseAdminView):
    paginate_by = 100

class SearchableView(PaginatedView):
    def filter_queryset(self): ...

class ProductListView(SearchableView):
    model = Product

Problems: - Fixed hierarchy (can't skip levels) - Cannot compose features (must inherit all or nothing) - Plugins can't inject mixins - Method override chains are brittle - Hard to understand full behavior

Alternative 2: Mixin Composition (Manual)

class ProductListView(
    SearchMixin,
    PaginationMixin,
    BreadcrumbMixin,
    ListView
):
    model = Product
    paginate_by = 100

Problems: - Must write class for every action - Cannot customize per-action programmatically - Plugins can't inject mixins - Lots of boilerplate

Our Choice: Factory Pattern

# Action specifies configuration
action = ListAction(Product, product_admin, site)

# Factory generates view
view_class = factory.create_view(action)

# Result: ProductListView(Mixin1, Mixin2, ..., ListView)

How it works: 1. Action declares view type via mixin (ListActionMixin → ListView) 2. Plugins register mixins for action types 3. Factory collects mixins using isinstance() checks 4. Factory generates class: type(name, bases, attrs)

Benefits: - Zero boilerplate (no manual view classes) - Plugins inject mixins dynamically - Composition over inheritance - Flexible feature combination - Same action works for all models

Trade-offs

Pro: - Maximum flexibility - Plugin-friendly - No boilerplate - Easy to understand generated views - Clean separation of concerns

Con: - Generated classes harder to trace in debugger - Requires understanding factory pattern - Magic that may surprise developers - Dynamic nature means less IDE support

Verdict: Worth it for plugin architecture. The factory pattern enables the plugin system and eliminates boilerplate. Generated views are inspectable and debuggable.

Decision 4: Semantic HTML Over Heavy JavaScript

The Decision

Use semantic HTML with progressive enhancement rather than a JavaScript-heavy SPA approach.

Alternatives Considered

Alternative 1: React/Vue SPA

// Full SPA admin
<ProductList>
  <SearchBar onSearch={handleSearch} />
  <Table data={products} />
  <Pagination page={page} />
</ProductList>

Problems: - Requires build tooling - Large JavaScript payload - Accessibility harder - SEO concerns - CSRF handling more complex - Difficult to customize templates

Alternative 2: Heavy HTMX/Alpine

<!-- HTMX-heavy approach -->
<div hx-get="/api/products" hx-trigger="load">
  Loading...
</div>

Problems: - Still requires JavaScript - More moving parts - Harder to understand - Progressive enhancement not guaranteed

Our Choice: Semantic HTML + Progressive Enhancement

<!-- Base HTML works without JavaScript -->
<table class="djadmin-table">
  {% for object in object_list %}
    <tr>
      <td>{{ object.name }}</td>
      <td>
        <a href="{% url 'djadmin:myapp_product_edit' object.pk %}">
          Edit
        </a>
      </td>
    </tr>
  {% endfor %}
</table>

<!-- JavaScript enhances but isn't required -->
<script>
  // Add keyboard shortcuts
  // Add inline editing
  // Add client-side filtering
</script>

Benefits: - Works without JavaScript - Accessible by default - SEO-friendly - Easy to customize (Django templates) - Standard Django patterns - Progressive enhancement - Smaller payload

Trade-offs

Pro: - Accessibility out of the box - Works with NoScript - Easy to customize templates - Standard Django patterns - Smaller initial payload - SEO-friendly - No build step required

Con: - Less "app-like" feel - Full page refreshes for some actions - Less responsive for complex interactions - Cannot do everything SPAs can - More server round trips

Verdict: Right choice for admin interface. Admin UIs prioritize accessibility, customization, and reliability over "app-like" feel. Progressive enhancement allows JavaScript where it adds value.

Decision 5: Three Action Types (List, Bulk, Record)

The Decision

Categorize all actions into three types: list, bulk, and record.

Rationale

Traditional approach (Django admin): - Only bulk actions (checkbox-based) - CRUD views hardcoded - Record actions must be custom views

Our approach: - List actions: Main entry points (no selection needed) - Bulk actions: Operate on selected records - Record actions: Operate on single record

Why three types?

  1. Clear mental model: Every operation fits into one category
  2. Different UIs: Each type renders differently
  3. List: Toolbar buttons
  4. Bulk: Dropdown after checkbox selection
  5. Record: Row buttons or detail page actions
  6. Different signatures: Type determines what action receives
  7. List: execute(request)
  8. Bulk: execute(request, queryset)
  9. Record: execute(request, obj)
  10. Permission model: Each type has different permission needs

Trade-offs

Pro: - Clear categorization - Predictable behavior - Easy to understand - Natural UI mapping - Explicit signatures

Con: - Rigid structure (everything must fit) - Cannot easily have hybrid actions - More categories to learn than Django admin

Verdict: Worth it. The clarity and predictability outweigh the rigidity. Hybrid cases are rare and can be handled with custom URL patterns.

Decision 6: Registry Pattern for Plugin Hooks

The Decision

Plugins return dictionaries mapping action classes to lists/dicts, using isinstance() checks for matching.

Alternatives Considered

Alternative 1: Direct Registration

@hookimpl
def djadmin_get_list_view_mixins(action):
    # Return list directly
    return [SearchMixin, PaginationMixin]

Problems: - No way to target specific action types - Cannot distinguish ListView from CreateView - Must check in hook implementation

Alternative 2: String-Based Registration

@hookimpl
def djadmin_register_mixins():
    return {
        'list': [SearchMixin],
        'create': [FormMixin],
    }

Problems: - Strings are error-prone - Cannot target custom action classes - No inheritance support

Our Choice: Registry Pattern with isinstance()

@hookimpl
def djadmin_get_action_view_mixins(action):
    return {
        ListAction: [SearchMixin],           # Specific action
        ListActionMixin: [PaginationMixin],  # View-type (broader)
        BaseAction: [LoggingMixin],              # All actions
    }

How it works: 1. Plugin returns dict: {ActionClass: [Mixins]} 2. Factory checks isinstance(action, key) for each key 3. All matching entries collect their mixins 4. Inheritance works naturally

Benefits: - Type-safe (uses Python classes) - Supports inheritance (isinstance checks hierarchy) - Flexible targeting (specific or broad) - Explicit and clear - No magic strings

Trade-offs

Pro: - Type-safe - Inheritance-aware - Flexible granularity - Clear and explicit - Python-native

Con: - More verbose than simple list - Must understand isinstance() semantics - Order depends on plugin registration - Multiple registrations can target same action

Verdict: Best balance of type safety and flexibility. isinstance() is Python-native and well-understood.

Decision 7: Template-Per-View-Type Over Single Template

The Decision

Use separate templates for create/update rather than a single form template.

djadmin/
├─ {app}/{model}_list.html or actions/list.html       # ListView
├─ model_create.html     # CreateView (Add)
├─ model_update.html     # UpdateView (Edit)
└─ includes/
    └─ form.html         # Shared partial

Rationale

Alternative: Single template with conditionals

<!-- Single template approach -->
{% if is_create %}
  <h1>Add {{ opts.verbose_name }}</h1>
{% else %}
  <h1>Edit {{ object }}</h1>
{% endif %}

Problems: - Harder to customize create vs update - Conditional logic in templates - Cannot easily override just one

Our approach: Separate templates

<!-- djadmin/model_create.html -->
<h1>Add {{ opts.verbose_name }}</h1>
{% include 'djadmin/includes/form.html' %}

<!-- djadmin/model_update.html -->
<h1>Edit {{ object }}</h1>
{% include 'djadmin/includes/form.html' %}

Benefits: - Easy to customize one without affecting other - No conditional logic in templates - Clear separation - Shared parts in includes/ - Follows Django's pattern (CreateView vs UpdateView)

Trade-offs

Pro: - Easy to customize individually - No template conditionals - Clear separation - Follows Django patterns - Shared partials avoid duplication

Con: - More template files - Must keep templates in sync - Duplication if not using includes

Verdict: Right choice. Follows Django patterns and enables easy customization. Shared partials avoid duplication.

Decision 8: ModelAdmin as Configuration, Not Controller

The Decision

ModelAdmin is purely configuration, not a controller with business logic.

Traditional Approach (Django Admin)

class ProductAdmin(admin.ModelAdmin):
    def get_queryset(self, request):
        # Business logic in admin
        qs = super().get_queryset(request)
        if not request.user.is_superuser:
            qs = qs.filter(owner=request.user)
        return qs

    def save_model(self, request, obj, form, change):
        # Business logic in admin
        if not change:
            obj.created_by = request.user
        obj.save()

Problems: - Business logic in admin layer - Hard to test - Cannot reuse outside admin - Violates separation of concerns

Our Approach

# ModelAdmin is configuration only
class ProductAdmin(ModelAdmin):
    list_display = ['name', 'price']
    general_actions = [ListAction, AddAction]
    bulk_actions = [DeleteBulkAction]

# Business logic in model/manager
class Product(models.Model):
    def save(self, *args, **kwargs):
        # Business logic here
        super().save(*args, **kwargs)

    @classmethod
    def get_queryset_for_user(cls, user):
        # Business logic here
        qs = cls.objects.all()
        if not user.is_superuser:
            qs = qs.filter(owner=user)
        return qs

# Or use plugins for admin-specific logic
@hookimpl
def djadmin_modify_queryset(queryset, request, view):
    # Admin-specific filtering
    if not request.user.is_superuser:
        return queryset.filter(owner=request.user)
    return queryset

Benefits: - Clean separation of concerns - Business logic reusable outside admin - Testable in isolation - Plugins handle admin-specific logic - ModelAdmin is just configuration

Trade-offs

Pro: - Clean architecture - Reusable business logic - Testable - Clear responsibilities - Plugin-friendly

Con: - Cannot put logic in ModelAdmin (must use plugins or models) - More indirection - Learning curve for Django admin users

Verdict: Worth it for clean architecture. Business logic belongs in models, admin-specific logic in plugins.

Decision 9: Feature Advertising and Validation

The Decision

Validate requested features at Django startup, not runtime.

How It Works

# ModelAdmin requests features via indicators
class ProductAdmin(ModelAdmin):
    search_fields = ['name', 'description']  # Requests 'search' feature
    list_filter = ['category']               # Requests 'filter' feature

# Plugins advertise features
@hookimpl
def djadmin_provides_features():
    return ['search', 'filter']

# At startup: validate all requests are satisfied
for model, admin in site._registry.items():
    requested = admin.requested_features
    available = get_available_features()
    missing = requested - available
    if missing:
        raise ImproperlyConfigured(
            f"{admin.__class__.__name__} requests features {missing} "
            f"but no plugin provides them"
        )

Benefits: - Fail fast at startup, not in production - Clear error messages - Guides developers to install plugins - Self-documenting (see what features are needed) - Prevents misconfiguration

Trade-offs

Pro: - Fail fast (startup vs runtime) - Clear error messages - Self-documenting - Prevents misconfiguration - Guides plugin installation

Con: - Cannot conditionally use features - Requires plugin even if feature unused - More validation overhead at startup

Verdict: Right choice. Failing at startup is far better than runtime errors in production. Clear errors guide developers.

Decision 10: Action Context (model, model_admin, admin_site)

The Decision

Every action instance carries full context: model, model_admin, admin_site.

def __init__(self, model, model_admin, admin_site):
    self.model = model
    self.model_admin = model_admin
    self.admin_site = admin_site

Rationale

Why not use class attributes?

# Alternative: Class attributes
class ProductListAction(ListAction):
    model = Product  # Hard-coded

Problems: - Cannot reuse action across models - Must subclass for each model - Loses admin configuration

Our approach: Instance context

# Action is generic
class ListAction(BaseAction):
    pass

# Context injected at instantiation
action = ListAction(Product, product_admin, site)

Benefits: - Generic actions work for any model - Access to admin configuration - Access to site configuration - Actions are reusable - Rich context for decisions

Trade-offs

Pro: - Reusable across models - Access to configuration - Rich context - No subclassing needed - Flexible decision-making

Con: - More parameters to pass - Context carried through execution - Slightly more memory per action

Verdict: Essential for reusability. Generic actions are a core feature.

Performance Considerations

View Generation Cost

When: Django startup (once) Cost: ~0.1ms per view × number of actions Impact: Negligible

Views are generated once during URL pattern creation, not per-request.

Plugin Hook Execution

When: Per-request (for data hooks) or startup (for view factory hooks) Cost: ~0.01ms per hook Impact: Minimal

Hook execution is fast. Most hooks run at startup (view factory), not per-request.

Memory Overhead

Per ModelAdmin: ~10KB Per Action: ~1KB Per Generated View: ~2KB

For 100 models × 5 actions = 500 views ≈ 1-2MB total. Negligible.

Database Query Impact

No additional queries introduced by architecture. Plugins may add queries (e.g., permission checks), but core is query-neutral.

Future-Proofing Decisions

Extensibility Points

The architecture provides extension points at: 1. ModelAdmin level: Configuration 2. Action level: Behavior 3. Plugin level: System-wide 4. Template level: Presentation

This multi-level extensibility ensures almost anything can be customized.

Version Compatibility

Design decisions maintain compatibility: - Plugin API: Stable hook specifications - Action API: Clear contracts (mixins define behavior) - Template hierarchy: Specific → generic fallback - URL patterns: Action-defined, can evolve

Migration Path from Django Admin

Key compatibility features: - Familiar concepts (ModelAdmin, register, list_display) - Similar configuration patterns - Standard Django CBVs - Template override patterns - Migration guide (TBD)

Summary of Trade-offs

Decision Main Benefit Main Cost Verdict
Action-centric Extensibility Complexity ✅ Worth it
djp/pluggy Plugin architecture Dependency ✅ Worth it
Factory pattern Flexibility Magic ✅ Worth it
Semantic HTML Accessibility Less "app-like" ✅ Worth it
Three action types Clarity Rigidity ✅ Worth it
Registry pattern Type safety Verbosity ✅ Worth it
Template-per-type Customization More files ✅ Worth it
Config-only ModelAdmin Clean architecture Indirection ✅ Worth it
Feature validation Fail fast Startup overhead ✅ Worth it
Action context Reusability Memory ✅ Worth it

Conceptual: - Architecture Overview - Action System - ViewFactory - Plugin System

Implementation: - PRD - Implementation Plan

External References: - Django CBV Design - pluggy Documentation - Progressive Enhancement