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¶
Problems: - Must implement plugin registry ourselves - No standard hook specification - No execution ordering - No testing infrastructure - Reinventing the wheel
Alternative 3: Raw Pluggy¶
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¶
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?
- Clear mental model: Every operation fits into one category
- Different UIs: Each type renders differently
- List: Toolbar buttons
- Bulk: Dropdown after checkbox selection
- Record: Row buttons or detail page actions
- Different signatures: Type determines what action receives
- List:
execute(request) - Bulk:
execute(request, queryset) - Record:
execute(request, obj) - 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¶
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?
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 |
Related Documentation¶
Conceptual: - Architecture Overview - Action System - ViewFactory - Plugin System
Implementation: - PRD - Implementation Plan
External References: - Django CBV Design - pluggy Documentation - Progressive Enhancement