The Thought Experiment
I wanted to answer a question that had been nagging me: how far can you push Django's abstraction before it breaks?
Not academically - practically. I built a full resume management application with over 20 distinct models (experience, education, skills, certifications, publications, awards, volunteer work, languages, and more) and challenged myself to serve the entire thing from a single set of five URL patterns.
No REST framework. No JSON API. No JavaScript build step. No React. No client-side state management. Just Django, HTMX, Alpine.js, and the conviction that most web applications are dramatically over-engineered.
The Five Patterns
The entire application routes through these five URL entries:
# Every model. Every operation. Five lines.
urlpatterns = [
path("<str:model_name>/list",
ModelListView.as_view(), name="model_list"),
path("<str:model_name>/create",
ModelCreateView.as_view(), name="model_create"),
path("<str:model_name>/<int:pk>",
ModelDetailView.as_view(), name="model_detail"),
path("<str:model_name>/<int:pk>/update",
ModelUpdateView.as_view(), name="model_update"),
path("<str:model_name>/<int:pk>/delete",
ModelDeleteView.as_view(), name="model_delete"),
]
/experience/list, /education/create, /award/42/update, /hobby/7/delete - they all resolve through the same five views. The model_name parameter is the only thing that changes.
This is not a toy. Twenty models with full create, read, update, delete - user-isolated, authenticated, with custom forms where needed and auto-generated forms where not.
How It Works: Convention as Configuration
The trick is embarrassingly simple. Each view resolves the model at dispatch time using Django's built-in app registry:
class ModelCreateView(CreateView, LoginRequiredMixin):
template_name = "create.html"
@staticmethod
def get_model(model_name):
try:
return apps.get_model("nest", model_name)
except LookupError:
return None
def dispatch(self, request, *args, **kwargs):
self.model_name = self.kwargs.get("model_name")
self.model = self.get_model(self.model_name)
# Convention: look for ModelNameForm in globals
if form_class := globals().get(
f"{self.model.__name__}Form"
):
self.form_class = form_class
else:
self.fields = self.model.fields
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form):
form.instance.user = self.request.user
return super().form_valid(form)
Three conventions do all the work:
- Model discovery -
apps.get_model("nest", model_name)turns a URL segment into a Django model class. No registry, no configuration file, no decorator. If the model exists in the app, it is available. - Form discovery - the walrus operator checks
globals()for a class named{ModelName}Form. If it finds one, it uses the custom form. If not, it falls back to auto-generating a form from the model'sfieldsattribute. - User isolation - every
form_validstampsrequest.useronto the instance, and everyget_querysetfilters by it. No permissions framework needed - users simply cannot see each other's data.
The Model Contract: One Attribute to Rule Them All
For a model to participate in the generic CRUD system, it needs exactly one thing: a fields class attribute listing the editable fields.
class Award(BaseTimeStamp):
user = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
title = models.CharField(max_length=255)
awarded_by = models.CharField(max_length=255)
date_awarded = models.DateField()
fields = ["title", "awarded_by", "date_awarded"]
class VolunteerExperience(BaseTimeStamp):
user = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE)
organization = models.CharField(max_length=255)
role = models.CharField(max_length=255)
start_date = models.DateField()
end_date = models.DateField(null=True, blank=True)
description = models.TextField()
fields = ["organization", "role", "start_date",
"end_date", "description"]
That is the entire contract. Define your model, list the fields, and the generic system handles the rest. Adding a new entity to the application - say, Patent or MilitaryService - is a three-step process:
- Write the model with a
fieldsattribute - Create a detail template (extends the base card)
- Add it to the dashboard section loop
No new URL. No new view. No new serializer. No new API endpoint. No migration of the routing layer. The system discovers the model and serves it.
HTMX: Where the Magic Lives
The reason this works without feeling like a 2005 server-rendered app is HTMX. Every CRUD operation is asynchronous, every interaction is smooth, and the page never fully reloads.
The key technique is Out-of-Band Swaps (hx-swap-oob). When you click "Add Experience," HTMX fetches the create form and swaps it into a designated container - not by replacing the button, but by targeting a div anywhere on the page by its ID.
<!-- The create button -->
<button hx-get="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML">
Add Experience
</button>
<!-- The form submits via HTMX, not a page reload -->
<form method="post">
{{ form.as_p }}
<button hx-post="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "..."}'>Create</button>
<button hx-get="/clean-div/experience_section"
hx-target="#experience_section"
hx-swap="innerHTML">Cancel</button>
</form>
<!-- Delete removes the card from DOM entirely -->
<button hx-delete="/experience/42/delete"
hx-target="#experience_42"
hx-swap="delete">Delete</button>
The cancel button is particularly elegant - it hits a /clean-div/{id} endpoint that returns an empty <div>, effectively closing the modal by replacing its content with nothing. No JavaScript show/hide logic. No CSS class toggling. The server returns emptiness, and HTMX obeys.
Alpine.js: The Thin Orchestration Layer
Alpine.js handles exactly one job: which section of the dashboard is visible. The entire state management is a single variable:
<body x-data="{ openDiv: null }">
<!-- Sidebar toggles -->
<a @click="openDiv = openDiv === 'experience'
? null : 'experience'">Work</a>
<a @click="openDiv = openDiv === 'education'
? null : 'education'">Education</a>
<!-- Sections appear/disappear with transitions -->
<div x-show="openDiv === 'experience'"
x-transition:enter="transition ease-out"
x-transition:leave="transition ease-in">
{% include "list.html" %}
</div>
</body>
That is it. One variable, a few @click handlers, and x-show with transitions. Alpine.js does not fetch data, manage forms, or track state. It just decides which panel is visible. HTMX handles everything else.
This division of labour is what makes the architecture clean. Alpine owns visibility. HTMX owns data. Django owns truth. Nobody steps on anyone's toes.
The Two-Tier Form System
Not every model can be served with a flat auto-generated form. Education needs a school autocomplete with country filtering. Experience needs a skills multi-select. The system handles this with a graceful two-tier fallback:
- Tier 1: Custom forms - if a
EducationFormexists in the module namespace, the view uses it. These forms can have custom widgets, custom validation, autocomplete fields - whatever the model needs. - Tier 2: Auto-generated forms - if no custom form exists, Django builds one from the model's
fieldslist. Simple models likeHobby(just a title field) never need a custom form.
The discovery mechanism is a single line with the walrus operator:
if form_class := globals().get(f"{self.model.__name__}Form"):
self.form_class = form_class # custom form
else:
self.fields = self.model.fields # auto-generate
This is convention over configuration at its purest. There is no form registry, no decorator, no settings dictionary. Name your form {Model}Form, place it where the view can see it, and the system picks it up. Do nothing, and it still works.
The Philosophical Argument: Accidental vs Essential Complexity
Fred Brooks distinguished between essential complexity (inherent to the problem) and accidental complexity (introduced by our tools and choices). Most web applications are drowning in accidental complexity.
Consider what a typical modern stack demands for a CRUD operation:
- A Django model
- A serializer (DRF)
- A viewset or APIView
- A URL route registered in the router
- A TypeScript interface mirroring the serializer
- A React component with useState, useEffect, fetch calls
- A client-side route in React Router
- Error handling on both sides
- Loading states, optimistic updates, cache invalidation
That is nine artefacts for one entity. Multiply by twenty models and you are maintaining 180 files that must stay synchronised across two languages, two runtimes, and two deployment pipelines.
In this project, adding a model requires:
- The model (with a
fieldsattribute) - A detail template (10 lines, extending a base)
- One line in the dashboard loop
Three artefacts. One language. One runtime. One deployment. Everything else is handled by convention.
Where This Breaks Down (And Where It Does Not)
I am not claiming this pattern replaces every SPA. It breaks down when:
- Offline-first is required - SSR with HTMX needs a server. PWAs with IndexedDB do not.
- Complex client-side interactions - a drag-and-drop Kanban board, a collaborative document editor, or a real-time game need rich client state.
- The API is the product - if you are building a platform consumed by mobile apps and third parties, you need a proper API layer regardless.
But here is the thing: most CRUD applications are not any of those things. Most business applications are forms, lists, and detail views with authentication. They do not need a 50KB JavaScript runtime to render a table of records that the server already knows how to render.
This experiment proved to me that Django's class-based views, combined with HTMX's ability to make server-rendered HTML feel like an SPA, can handle twenty models with full CRUD in roughly 1,500 lines of code. The equivalent React + DRF stack would be five to ten times that.
What I Learned
Building this project changed how I think about web architecture:
- Convention beats configuration - the
globals()form lookup is unconventional, but it eliminated an entire registry layer. Sometimes the simplest solution is the best one. - HTMX is not a compromise - out-of-band swaps, swap strategies (
delete,innerHTML,none), and debounced triggers give you 90% of SPA interactivity with 10% of the complexity. - Django's generic views are underrated -
CreateView,UpdateView,ListView,DeleteVieware designed for exactly this kind of composition. The framework was always capable of this; we just stopped reaching for it when React arrived. - The
fieldsattribute pattern is surprisingly powerful. It acts as both a form field list and a documentation contract for what is editable. One attribute, two purposes. - Alpine.js + HTMX is a perfect pairing - Alpine handles client-only concerns (visibility, transitions), HTMX handles server communication. Neither tries to do the other's job.
The best code is the code you do not write. The best endpoint is the one that handles every model. The best framework is the one that gets out of the way and lets convention do the heavy lifting.