Cinco patrones de URL para todo: cómo HTMX y las vistas abstractas eliminaron toda mi capa de API
¿Qué ocurre cuando llevas las vistas basadas en clases de Django hasta su extremo lógico? Obtienes cinco patrones de URL que gestionan un CRUD completo para cada modelo de tu aplicación: sin framework REST, sin serializadores, sin router del lado del cliente. Solo HTMX, convención sobre configuración y un rechazo filosófico a la complejidad accidental.
El experimento mental
Quería responder a una pregunta que me venía rondando: ¿hasta dónde puedes llevar la abstracción de Django antes de que se rompa?
No de forma académica, sino práctica. Construí una aplicación completa de gestión de currículums con más de 20 modelos distintos (experiencia, educación, habilidades, certificaciones, publicaciones, premios, voluntariado, idiomas y más) y me reté a servir todo desde un único conjunto de cinco patrones de URL.
Sin REST framework. Sin API JSON. Sin paso de build de JavaScript. Sin React. Sin gestión de estado en el cliente. Solo Django, HTMX, Alpine.js y la convicción de que la mayoría de las aplicaciones web están dramáticamente sobrediseñadas.
Los cinco patrones
Toda la aplicación se enruta a través de estas cinco entradas de URL:
# Cada modelo. Cada operación. Cinco líneas.
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: todas se resuelven a través de las mismas cinco vistas. El parámetro model_name es lo único que cambia.
Esto no es un juguete. Veinte modelos con create, read, update y delete completos: aislados por usuario, autenticados, con formularios personalizados donde se necesitan y formularios autogenerados donde no.
Cómo funciona: la convención como configuración
El truco es de una sencillez vergonzosa. Cada vista resuelve el modelo en tiempo de despacho usando el registro de aplicaciones integrado de Django:
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)
Tres convenciones hacen todo el trabajo:
- Descubrimiento de modelos:
apps.get_model("nest", model_name)convierte un segmento de URL en una clase de modelo de Django. Sin registro, sin archivo de configuración, sin decorador. Si el modelo existe en la aplicación, está disponible. - Descubrimiento de formularios: el operador morsa comprueba
globals()en busca de una clase llamada{ModelName}Form. Si encuentra una, usa el formulario personalizado. Si no, recurre a autogenerar un formulario a partir del atributofieldsdel modelo. - Aislamiento de usuarios: cada
form_validestamparequest.useren la instancia, y cadaget_querysetfiltra por él. No se necesita ningún framework de permisos: los usuarios simplemente no pueden ver los datos de los demás.
El contrato del modelo: un atributo para gobernarlos a todos
Para que un modelo participe en el sistema CRUD genérico, necesita exactamente una cosa: un atributo de clase fields que enumere los campos editables.
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"]
Ese es todo el contrato. Define tu modelo, enumera los campos y el sistema genérico se encarga del resto. Añadir una nueva entidad a la aplicación —digamos, Patent o MilitaryService— es un proceso de tres pasos:
- Escribe el modelo con un atributo
fields - Crea una plantilla de detalle (extiende la tarjeta base)
- Añádelo al bucle de secciones del panel
Sin nueva URL. Sin nueva vista. Sin nuevo serializador. Sin nuevo endpoint de API. Sin migración de la capa de enrutamiento. El sistema descubre el modelo y lo sirve.
HTMX: donde reside la magia
La razón por la que esto funciona sin dar la sensación de ser una app renderizada en el servidor de 2005 es HTMX. Cada operación CRUD es asíncrona, cada interacción es fluida y la página nunca se recarga por completo.
La técnica clave son los Out-of-Band Swaps (hx-swap-oob). Cuando haces clic en "Añadir experiencia", HTMX obtiene el formulario de creación y lo inserta en un contenedor designado; no reemplazando el botón, sino apuntando a un div en cualquier parte de la página por su ID.
<!-- El botón de crear -->
<button hx-get="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML">
Añadir experiencia
</button>
<!-- El formulario se envía mediante HTMX, sin recargar la página -->
<form method="post">
{{ form.as_p }}
<button hx-post="/experience/create"
hx-target="#experience_section"
hx-swap="innerHTML"
hx-headers='{"X-CSRFToken": "..."}'>Crear</button>
<button hx-get="/clean-div/experience_section"
hx-target="#experience_section"
hx-swap="innerHTML">Cancelar</button>
</form>
<!-- Eliminar retira la tarjeta del DOM por completo -->
<button hx-delete="/experience/42/delete"
hx-target="#experience_42"
hx-swap="delete">Eliminar</button>
El botón de cancelar es especialmente elegante: llama a un endpoint /clean-div/{id} que devuelve un <div> vacío, cerrando así el modal al reemplazar su contenido por nada. Nada de lógica de mostrar/ocultar en JavaScript. Nada de alternar clases de CSS. El servidor devuelve el vacío, y HTMX obedece.
Alpine.js: la delgada capa de orquestación
Alpine.js se encarga de exactamente una tarea: qué sección del panel está visible. Toda la gestión del estado es una sola variable:
<body x-data="{ openDiv: null }">
<!-- Alternadores de la barra lateral -->
<a @click="openDiv = openDiv === 'experience'
? null : 'experience'">Trabajo</a>
<a @click="openDiv = openDiv === 'education'
? null : 'education'">Educación</a>
<!-- Las secciones aparecen/desaparecen con transiciones -->
<div x-show="openDiv === 'experience'"
x-transition:enter="transition ease-out"
x-transition:leave="transition ease-in">
{% include "list.html" %}
</div>
</body>
Eso es todo. Una variable, unos cuantos manejadores @click y x-show con transiciones. Alpine.js no obtiene datos, no gestiona formularios ni rastrea el estado. Solo decide qué panel es visible. HTMX se encarga de todo lo demás.
Esta división del trabajo es lo que mantiene limpia la arquitectura. Alpine es dueño de la visibilidad. HTMX es dueño de los datos. Django es dueño de la verdad. Nadie pisa a nadie.
El sistema de formularios de dos niveles
No todos los modelos pueden servirse con un formulario plano autogenerado. Educación necesita un autocompletado de centros con filtrado por país. Experiencia necesita un multiselector de habilidades. El sistema gestiona esto con un elegante fallback de dos niveles:
- Nivel 1: Formularios personalizados - si existe un
EducationFormen el namespace del módulo, la vista lo usa. Estos formularios pueden tener widgets personalizados, validación personalizada, campos de autocompletado; lo que sea que el modelo necesite. - Nivel 2: Formularios autogenerados - si no existe un formulario personalizado, Django construye uno a partir de la lista de
fieldsdel modelo. Los modelos sencillos comoHobby(solo un campo de título) nunca necesitan un formulario personalizado.
El mecanismo de descubrimiento es una sola línea con el operador morsa:
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
Esto es convención sobre configuración en su forma más pura. No hay registro de formularios, ni decorador, ni diccionario de configuración. Nombra tu formulario {Model}Form, colócalo donde la vista pueda verlo, y el sistema lo detecta. No hagas nada, y aun así funciona.
El argumento filosófico: complejidad accidental frente a esencial
Fred Brooks distinguía entre la complejidad esencial (inherente al problema) y la complejidad accidental (introducida por nuestras herramientas y decisiones). La mayoría de las aplicaciones web se ahogan en complejidad accidental.
Considera lo que exige un stack moderno típico para una operación CRUD:
- Un modelo de Django
- Un serializer (DRF)
- Un viewset o APIView
- Una ruta de URL registrada en el router
- Una interfaz de TypeScript que refleje el serializer
- Un componente de React con useState, useEffect, llamadas fetch
- Una ruta del lado del cliente en React Router
- Manejo de errores en ambos lados
- Estados de carga, actualizaciones optimistas, invalidación de caché
Eso son nueve artefactos para una sola entidad. Multiplica por veinte modelos y estarás manteniendo 180 archivos que deben permanecer sincronizados entre dos lenguajes, dos entornos de ejecución y dos pipelines de despliegue.
En este proyecto, añadir un modelo requiere:
- El modelo (con un atributo
fields) - Una plantilla de detalle (10 líneas, extendiendo una base)
- Una línea en el bucle del dashboard
Tres artefactos. Un lenguaje. Un entorno de ejecución. Un despliegue. Todo lo demás se resuelve por convención.
Dónde falla esto (y dónde no)
No afirmo que este patrón reemplace a toda SPA. Se desmorona cuando:
- Se requiere un enfoque offline-first: el SSR con HTMX necesita un servidor. Las PWA con IndexedDB, no.
- Hay interacciones complejas en el lado del cliente: un tablero Kanban de arrastrar y soltar, un editor de documentos colaborativo o un juego en tiempo real necesitan un estado de cliente rico.
- La API es el producto: si estás construyendo una plataforma consumida por apps móviles y terceros, necesitas una capa de API adecuada de todos modos.
Pero aquí está la clave: la mayoría de las aplicaciones CRUD no son nada de eso. La mayoría de las aplicaciones de negocio son formularios, listas y vistas de detalle con autenticación. No necesitan un entorno de ejecución de JavaScript de 50 KB para renderizar una tabla de registros que el servidor ya sabe renderizar.
Este experimento me demostró que las vistas basadas en clases de Django, combinadas con la capacidad de HTMX de hacer que el HTML renderizado en el servidor se sienta como una SPA, pueden manejar veinte modelos con CRUD completo en aproximadamente 1.500 líneas de código. El stack equivalente de React + DRF sería de cinco a diez veces eso.
Lo que aprendí
Construir este proyecto cambió mi forma de pensar sobre la arquitectura web:
- La convención le gana a la configuración - la búsqueda de formularios con
globals()es poco convencional, pero eliminó toda una capa de registro. A veces la solución más simple es la mejor. - HTMX no es una concesión - los intercambios fuera de banda, las estrategias de intercambio (
delete,innerHTML,none) y los disparadores con debounce te dan el 90% de la interactividad de una SPA con el 10% de la complejidad. - Las vistas genéricas de Django están infravaloradas -
CreateView,UpdateView,ListView,DeleteViewestán diseñadas exactamente para este tipo de composición. El framework siempre fue capaz de esto; simplemente dejamos de recurrir a ello cuando llegó React. - El patrón del atributo
fieldses sorprendentemente potente. Actúa a la vez como lista de campos del formulario y como contrato de documentación de lo que es editable. Un atributo, dos propósitos. - Alpine.js + HTMX es un emparejamiento perfecto - Alpine se encarga de las cuestiones exclusivas del cliente (visibilidad, transiciones), HTMX se encarga de la comunicación con el servidor. Ninguno intenta hacer el trabajo del otro.
El mejor código es el que no escribes. El mejor endpoint es el que maneja todos los modelos. El mejor framework es el que se quita de en medio y deja que la convención haga el trabajo pesado.