Construir una plataforma de recaudación de fondos en tiempo real: por qué apostamos por Django, Wagtail, Rust y SSE
Cómo diseñamos la arquitectura de una plataforma multiinquilino de eventos de recaudación de fondos que gestiona miles de flujos de donaciones simultáneos, temas por organización y actualizaciones en vivo en menos de un segundo, con Django, Wagtail CMS, un microservicio SSE en Rust y cero complejidad de WebSocket.
El problema: un evento de recaudación de fondos es un sprint de 24 horas
Imagina una universidad movilizando a 50.000 exalumnos para que donen en un solo día. Un termómetro en la página de inicio sube en tiempo real. Las clasificaciones se reordenan. Los embajadores observan cómo sus páginas personales de recaudación van subiendo. Entre bastidores, miles de pestañas del navegador mantienen conexiones abiertas, esperando el siguiente evento de donación.
Eso es un evento de recaudación de fondos, y cuando nos propusimos construir la plataforma que los impulsa, sabíamos que el stack tecnológico tenía que satisfacer tres requisitos innegociables:
- Tiempo real a escala - miles de conexiones SSE concurrentes con latencia por debajo del segundo
- Flexibilidad de contenido - cada organización necesita su propia marca, páginas, campañas y causas, gestionadas por personal no técnico
- Simplicidad operativa - un equipo pequeño debe lanzar funcionalidades rápido, no hacer de niñera de la infraestructura
Así es como elegimos - y justificamos - cada capa del stack.
Django + Wagtail: la Central de Contenido
La aplicación web principal se ejecuta sobre Django 5.x con Wagtail 7.x como capa de CMS. Nunca fue una decisión reñida. Wagtail nos ofrece:
- StreamField: las páginas de campaña se componen a partir de bloques reutilizables (banners hero, termómetros, tablas de clasificación, muros de donantes, cuadrículas de causas) que los editores de contenido arrastran y sueltan. No se necesita ningún desarrollador para cambiar el diseño de la página.
- Configuración basada en snippets: los desafíos, las reglas de las tablas de clasificación, las donaciones equivalentes y las designaciones de causas son todos snippets de Wagtail. Los editores crean un desafío, establecen una ventana de tiempo y un objetivo en dólares, y la plataforma se encarga del resto.
- Vista previa e historial de revisiones: durante un evento de recaudación de fondos en vivo, los editores pueden redactar actualizaciones de página, previsualizarlas y publicarlas al instante sin necesidad de un despliegue.
Wagtail no es simplemente "WordPress pero en Python". Es un CMS genuinamente amigable para el desarrollador que se mantiene al margen cuando necesitas lógica de negocio personalizada, pero que otorga a los equipos de contenido una autonomía real.
# Página de campaña construida a partir de bloques StreamField componibles
class CampaignPage(WagtailCacheMixin, Page):
body = StreamField([
("hero", HeroBannerBlock()),
("thermometer", ThermometerBlock()),
("leaderboard", LeaderboardBlock()),
("donor_wall", DonorWallBlock()),
("cause_grid", CauseGridBlock()),
("rich_text", blocks.RichTextBlock()),
("video_embed", EmbedBlock()),
])
campaign = models.ForeignKey(
"campaigns.Campaign", on_delete=models.PROTECT
)
Arquitectura multiinquilino: una sola base de código, datos aislados
La plataforma aísla los datos de cada organización a nivel de base de datos. Un enrutador de base de datos personalizado dirige las consultas al backend correcto según el contexto del inquilino actual, mientras que los recursos compartidos, como el contenido de las páginas y la configuración de las campañas, residen en un almacén común.
¿Por qué un aislamiento completo a nivel de base de datos en lugar de un filtrado a nivel de fila?
- Soberanía de los datos: algunas organizaciones exigen que los datos de sus donantes estén físicamente separados. Las bases de datos aisladas hacen que las auditorías de cumplimiento sean triviales.
- Aislamiento del rendimiento: un evento de recaudación de fondos que genera 10.000 donaciones en una hora no ralentiza las consultas de otras organizaciones.
# Registro dinámico de la base de datos del inquilino
def ensure_database_registered(org):
db_alias = f"tenant_{org.sanitized_id}"
if db_alias in connections.databases:
return db_alias
connections.databases[db_alias] = {
"ENGINE": "django.db.backends...",
"NAME": db_alias,
"CONN_MAX_AGE": 60,
"CONN_HEALTH_CHECKS": True,
}
return db_alias
Donaciones en tiempo real: por qué SSE supera a WebSockets aquí
La característica distintiva de un evento de recaudación de fondos es el feed de donaciones en vivo. Cada pestaña del navegador que muestra la página de la campaña mantiene una conexión abierta y recibe los eventos de donación en el instante en que se procesan.
Elegimos Server-Sent Events (SSE) en lugar de WebSockets por una razón deliberada: el flujo de datos es unidireccional. El servidor envía los eventos de donación a los clientes. Los clientes nunca devuelven datos a través del flujo de eventos. SSE nos ofrece:
- Reconexión automática: la API nativa
EventSourcedel navegador se reconecta al desconectarse con backoff exponencial. Cero lógica de reconexión en el lado del cliente. - Multiplexación HTTP/2: los flujos SSE comparten la misma conexión TCP que otras peticiones HTTP. Sin protocolo separado, sin el baile del preflight de CORS, sin configuración especial del proxy.
- Transparencia para el balanceador de carga: SSE no es más que una respuesta HTTP de larga duración. Todos los proxy inversos, CDN y balanceadores de carga ya lo entienden.
- Simplicidad: sin tramas ping/pong, sin handshake de actualización de conexión, sin framing binario. Solo texto UTF-8 sobre HTTP.
WebSockets sería la elección correcta si necesitáramos comunicación bidireccional: una función de chat, edición colaborativa o subastas interactivas. Para un feed de donaciones, SSE es la herramienta adecuada.
El microservicio SSE en Rust: ¿por qué no usar simplemente Django?
Django es síncrono. Los workers de Gunicorn atienden una petición a la vez. Mantener abiertas 5.000 conexiones SSE requeriría 5.000 workers de Gunicorn - algo inviable.
Construimos un microservicio SSE dedicado, y lo construimos dos veces: primero en Python con FastAPI + Hypercorn (ASGI asíncrono), luego en Rust con Axum + Tokio. Ambos siguen la misma arquitectura:
- El cliente abre un
EventSourcehacia el servicio SSE - El servicio SSE se suscribe a un canal pub/sub de Redis:
campaign:{id}:updates - Cuando Django procesa una donación, una tarea de Celery publica las estadísticas actualizadas en Redis
- El servicio SSE recibe el mensaje y lo distribuye a todos los clientes conectados para esa campaña
La versión en Rust existe porque queríamos elevar aún más el techo de concurrencia. Un solo proceso de Rust puede mantener cómodamente decenas de miles de conexiones concurrentes con una sobrecarga de memoria mínima, gracias al runtime asíncrono de coste cero de Tokio. En la práctica, la versión en FastAPI maneja nuestra carga actual perfectamente - el servicio en Rust es un seguro para el día en que una campaña se vuelva verdaderamente viral.
// Manejador SSE en Rust (Axum + Tokio)
pub async fn sse_handler(
State(state): State<AppState>,
Path(campaign_id): Path<i64>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = async_stream::stream! {
// Recuento atómico de conexiones mediante un script Lua
let count = state.redis
.increment_connection(campaign_id)
.await;
if count > MAX_CONNECTIONS {
yield Ok(Event::default()
.data("rate_limited"));
return;
}
// Suscripción al canal pub/sub de Redis
let mut sub = state.redis
.subscribe(campaign_id).await;
while let Some(msg) = sub.next().await {
yield Ok(Event::default()
.data(msg.get_payload::<String>()?));
}
};
Sse::new(stream)
.keep_alive(KeepAlive::default())
}
Prevención de estampida (Thundering Herd) con scripts Lua
Un problema sutil de SSE a escala: cuando se carga la página de una campaña popular, cientos de navegadores abren conexiones SSE de forma simultánea. Si comprobamos el número de conexiones en Python, lo incrementamos y luego decidimos si aceptar, hay una condición de carrera. Doscientos clientes pueden leer todos "199 conexiones" y pasar todos.
Lo resolvimos con un script Lua atómico ejecutado dentro de Redis. El script lee el recuento actual, lo compara con el límite y lo incrementa en una única operación atómica. Sin carrera TOCTOU, sin necesidad de un bloqueo distribuido.
-- Limitación atómica de conexiones (se ejecuta dentro de Redis)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
return -1 -- rechazado
end
local new_count = redis.call('INCR', key)
redis.call('EXPIRE', key, ttl)
return new_count -- aceptado
Temas por organización: variables CSS a escala
Cada organización de la plataforma tiene su propia marca: colores, logotipos, tipografías. Necesitábamos un sistema de temas que fuera:
- Configurable por personas sin conocimientos de desarrollo a través del CMS
- Eficiente: sin cálculos de color en JavaScript en tiempo de ejecución
- Consciente del modo oscuro desde el primer día
La solución: un modelo BrandColor de Wagtail que genera propiedades personalizadas de CSS en el servidor. Cada organización define su paleta (primario, secundario, acento, fondo) con anulaciones opcionales para el modo oscuro. La plataforma las renderiza como un bloque <style> en la cabecera de la página.
class BrandColor(models.Model):
color_value = models.CharField() # modo claro
color_value_dark = models.CharField() # anulación de modo oscuro
is_dark = models.BooleanField() # pista de contraste de texto
@classmethod
def get_all_css_for_organization(cls, org) -> str:
"""Genera bloques completos de variables CSS."""
colors = cls.objects.filter(organization=org)
light = "; ".join(
f"{c.css_var_name}: {c.color_value}" for c in colors
)
dark = "; ".join(
f"{c.css_var_name}: {c.color_value_dark}"
for c in colors if c.color_value_dark
)
return (
f":root {{ {light} }}\n"
f'[data-theme="dark"] {{ {dark} }}\n'
f"@media (prefers-color-scheme: dark) "
f"{{ :root:not([data-theme]) {{ {dark} }} }}"
)
Este enfoque de tres niveles cubre todos los escenarios: conmutación explícita por parte del usuario (atributo data-theme), preferencia del sistema mediante prefers-color-scheme, y un valor por defecto sensato. Las clases de Tailwind a lo largo de las plantillas referencian estas variables en lugar de colores codificados de forma fija, de modo que una organización puede ser de un azul marino profundo y otra de un carmesí brillante: el mismo HTML, distinta pintura.
HTMX: la anti-SPA
El frontend son plantillas de Django renderizadas en el servidor, potenciadas con HTMX y Alpine.js. No hay React, ni Vue, ni paso de build para JavaScript. Esta es una elección arquitectónica deliberada.
Las páginas de eventos de recaudación de fondos se cachean intensamente. El HTML de la página de campaña se genera una sola vez, se cachea en Redis y se sirve a miles de visitantes. Un SPA de React requeriría una capa de API separada, introduciría complejidad de caché del lado del cliente y duplicaría la superficie para bugs, todo para lograr el mismo resultado que una página renderizada en el servidor y cacheada.
HTMX nos da la interactividad que necesitamos con precisión quirúrgica:
- Paginación de la lista de donantes:
hx-getobtiene la siguiente página de donantes y la intercambia en el DOM. El servidor devuelve un fragmento HTML parcial, no JSON. - Secciones de carga diferida: los cálculos pesados de las tablas de clasificación se aplazan. La página carga al instante con un placeholder, y luego HTMX dispara una petición para rellenarla.
- Patrón Shell + Partial: las cargas de página completas devuelven el shell HTML completo. Las peticiones de HTMX (detectadas mediante la cabecera
HX-Request) devuelven solo el fragmento. La misma vista, la misma plantilla, cero duplicación.
<!-- Lista de donantes con paginación mediante HTMX -->
<div id="donors-list"
hx-get="?page=1"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Marcador de posición del esqueleto -->
</div>
<!-- El servidor detecta la cabecera HX-Request y devuelve un fragmento parcial -->
{% if page_obj.has_next %}
<a hx-get="?page={{ page_obj.next_page_number }}"
hx-target="#donors-list"
hx-swap="innerHTML"
class="btn-outline">Cargar más</a>
{% endif %}
Rendimiento: de más de 70 consultas a 9
La mayor mejora de rendimiento no vino del almacenamiento en caché, sino de la eliminación de las consultas N+1. Las páginas de campaña implican relaciones profundamente anidadas: las campañas tienen causas, las causas tienen tablas de clasificación, las tablas de clasificación tienen equipos, los equipos tienen miembros. Un recorrido ingenuo del ORM generaba más de 70 consultas SQL por carga de página.
Usamos tres estrategias para reducir esto a 9 consultas:
select_relatedpara claves foráneas de un solo valor (campaña → organización, donación → causa)prefetch_relatedpara relaciones inversas y de muchos a muchos (campaña → causas, tabla de clasificación → equipos)- Inyección manual de caché de prefetch para los objetos ClusterableModel de Wagtail que eluden la maquinaria de prefetch estándar de Django. Poblamos
_prefetched_objects_cachedirectamente después de una consulta masiva.
# Caché de prefetch manual para ClusterableModel
leaderboards = campaign.leaderboards.all()
leaderboard_ids = [lb.pk for lb in leaderboards]
# Una sola consulta masiva para todos los equipos de todas las tablas de clasificación
teams = FlexiLeaderboardTeam.objects.filter(
leaderboard_id__in=leaderboard_ids
).select_related("team_profile")
teams_by_lb = defaultdict(list)
for t in teams:
teams_by_lb[t.leaderboard_id].append(t)
# Inyectar en la caché de prefetch: evita N+1
for lb in leaderboards:
lb._prefetched_objects_cache = {
"leaderboard_team_links": teams_by_lb.get(lb.pk, []),
}
Precalentamiento de caché: no esperes a la primera petición
El almacenamiento en caché es solo la mitad de la historia - la primera petición tras un fallo de caché puede ser dolorosamente lenta si la página es costosa de renderizar. Resolvemos esto con un calentamiento proactivo de la caché: cuando llega una donación y se recalculan las estadísticas, una tarea de Celery renderiza la página de la campaña usando el Client de pruebas de Django y precarga la caché antes de que ningún usuario real acceda a ella.
El calentamiento del CDN es más complicado. Disparamos peticiones HTTP al borde del CDN con limitación de velocidad (2 concurrentes, 200ms entre peticiones) para evitar saturar el origen. Una clave de deduplicación SETNX en Redis evita que varios workers de Celery calienten la misma página simultáneamente.
Despliegue: Kubernetes + Helm
El stack de producción se ejecuta sobre Kubernetes orquestado con charts de Helm. Cada servicio es escalable de forma independiente:
- Django (Gunicorn): de 2 a 4 réplicas detrás de una malla de servicios
- Servicio SSE (Rust/FastAPI): escala horizontalmente según el número de conexiones
- Workers de Celery: colas separadas para el cálculo de estadísticas, el precalentamiento de caché y el procesamiento de donaciones
- Celery Beat: una única réplica para las tareas periódicas
- Nginx: proxy inverso con HTTP/2 y terminación TLS
- Redis Sentinel: clúster de Redis de alta disponibilidad
La observabilidad se gestiona con OpenTelemetry + Jaeger para el trazado distribuido, Sentry para el seguimiento de errores y Prometheus + Grafana para las métricas y las alertas. Cada donación tiene un ID de traza que la acompaña desde el endpoint de la API, a través de las tareas de Celery, hasta la difusión por SSE.
El stack, justificado
Cada tecnología de este stack se gana su lugar:
- Django: ORM probado en producción, middleware, autenticación, panel de administración. El ecosistema no tiene rival para una plataforma con mucho contenido.
- Wagtail: da a los equipos de contenido verdadero poder sin sacrificar la ergonomía para los desarrolladores. Solo StreamField ya lo justifica.
- SSE en lugar de WebSockets: más sencillo, más resiliente, y de todos modos el flujo de datos es unidireccional.
- Rust para SSE: mantiene decenas de miles de conexiones inactivas con una sobrecarga de memoria casi nula. La herramienta adecuada para el trabajo adecuado.
- Redis: pub/sub para la difusión en tiempo real, caché para el rendimiento de las páginas, scripts Lua para operaciones atómicas. Un solo servicio, tres roles críticos.
- HTMX en lugar de React: el HTML renderizado en el servidor y cacheado es más rápido, más sencillo y más accesible que una SPA renderizada en el cliente para este caso de uso.
- Bases de datos por organización: aislamiento de datos sin la complejidad de la multitenencia a nivel de fila.
- Kubernetes + Helm: escalado independiente de los servicios sin estado durante los picos de tráfico de los eventos de recaudación de fondos.
No hay una bala de plata en la arquitectura de software. Pero hay stacks que encajan con la forma del problema, y este encaja como un guante. La plataforma ha impulsado con éxito eventos de recaudación de fondos que reunieron millones de dólares, con feeds en tiempo real funcionando sin problemas a través de miles de conexiones concurrentes, y el equipo de contenido publica cambios de página sin una sola línea de código.
La mejor arquitectura es aquella en la que cada componente puede explicar por qué existe. Si no puedes justificar la presencia de una tecnología en dos frases, probablemente no debería estar ahí.