Architecture 2026-06-18·Ievgenii Svyryd

Construire une plateforme de collecte de fonds en temps réel : pourquoi nous avons parié sur Django, Wagtail, Rust et SSE

Comment nous avons architecturé une plateforme d'événements de collecte de fonds multi-locataire qui gère des milliers de flux de dons simultanés, un thème par organisation et des mises à jour en direct en moins d'une seconde - avec Django, Wagtail CMS, un microservice SSE en Rust et zéro complexité WebSocket.

Construire une plateforme de collecte de fonds en temps réel : pourquoi nous avons parié sur Django, Wagtail, Rust et SSE

Le problème : un événement de collecte de fonds est un sprint de 24 heures

Imaginez une université mobilisant 50 000 anciens élèves pour faire un don en une seule journée. Un thermomètre sur la page d'accueil grimpe en temps réel. Les classements se réorganisent. Les ambassadeurs regardent leurs pages de collecte personnelles progresser. En coulisses, des milliers d'onglets de navigateur maintiennent des connexions ouvertes, attendant le prochain événement de don.

C'est cela, un événement de collecte de fonds - et lorsque nous avons entrepris de construire la plateforme qui les alimente, nous savions que la pile technique devait satisfaire trois exigences non négociables :

  • Temps réel à grande échelle - des milliers de connexions SSE simultanées avec une latence inférieure à la seconde
  • Flexibilité du contenu - chaque organisation a besoin de sa propre marque, ses pages, ses campagnes et ses causes, gérées par du personnel non technique
  • Simplicité opérationnelle - une petite équipe doit livrer des fonctionnalités rapidement, pas materner l'infrastructure

Voici comment nous avons choisi - et justifié - chaque couche de la pile.

Django + Wagtail : le moteur de contenu par excellence

Le cœur de l'application web repose sur Django 5.x avec Wagtail 7.x comme couche CMS. Ce n'a jamais été un choix serré. Wagtail nous apporte :

  • StreamField - les pages de campagne sont composées de blocs réutilisables (bannières hero, thermomètres, classements, murs de donateurs, grilles de causes) que les éditeurs de contenu glissent-déposent. Aucun développeur n'est nécessaire pour modifier la mise en page.
  • Configuration pilotée par des snippets - les défis, les règles de classement, les dons de contrepartie et les affectations de causes sont tous des snippets Wagtail. Les éditeurs créent un défi, définissent une fenêtre temporelle et un objectif en dollars, et la plateforme se charge du reste.
  • Aperçu et historique des révisions - pendant un événement de collecte en direct, les éditeurs peuvent rédiger des mises à jour de page, les prévisualiser et les publier instantanément sans déploiement.

Wagtail n'est pas juste « WordPress mais en Python ». C'est un CMS réellement accueillant pour les développeurs, qui se fait discret lorsque vous avez besoin d'une logique métier sur mesure tout en offrant aux équipes de contenu une véritable autonomie.

# Page de campagne construite à partir de blocs StreamField composables
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
    )

Architecture multi-locataire : une seule base de code, des données isolées

La plateforme isole les données de chaque organisation au niveau de la base de données. Un routeur de base de données personnalisé dirige les requêtes vers le bon backend en fonction du contexte du tenant courant, tandis que les ressources partagées comme le contenu des pages et la configuration des campagnes résident dans un stockage commun.

Pourquoi une isolation complète au niveau de la base de données plutôt qu'un filtrage au niveau des lignes ?

  • Souveraineté des données - certaines organisations exigent que les données de leurs donateurs soient physiquement séparées. Des bases de données isolées rendent les audits de conformité triviaux.
  • Isolation des performances - un événement de collecte de fonds qui génère 10 000 dons en une heure ne ralentit pas les requêtes des autres organisations.
# Enregistrement dynamique de base de données par locataire
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

Dons en temps réel : pourquoi les SSE l'emportent ici sur les WebSockets

La fonctionnalité emblématique d'un événement de collecte de fonds est le flux de dons en direct. Chaque onglet de navigateur affichant la page de campagne maintient une connexion ouverte et reçoit les événements de don à l'instant même où ils sont traités.

Nous avons choisi les Server-Sent Events (SSE) plutôt que les WebSockets pour une raison délibérée : le flux de données est unidirectionnel. Le serveur pousse les événements de don vers les clients. Les clients ne renvoient jamais de données par le flux d'événements. Les SSE nous offrent :

  • Reconnexion automatique - l'API native EventSource du navigateur se reconnecte en cas de déconnexion avec un backoff exponentiel. Aucune logique de reconnexion côté client.
  • Multiplexage HTTP/2 - les flux SSE partagent la même connexion TCP que les autres requêtes HTTP. Pas de protocole distinct, pas de danse de preflight CORS, pas de configuration de proxy spéciale.
  • Transparence pour les load balancers - un SSE n'est qu'une réponse HTTP à longue durée de vie. Tout reverse proxy, CDN et load balancer la comprend déjà.
  • Simplicité - pas de frames ping/pong, pas de handshake de mise à niveau de connexion, pas de framing binaire. Juste du texte UTF-8 sur HTTP.

Les WebSockets seraient le bon choix s'il nous fallait une communication bidirectionnelle - une fonctionnalité de chat, de l'édition collaborative ou des enchères interactives. Pour un flux de dons, les SSE sont le bon outil.

Le microservice SSE en Rust : pourquoi ne pas simplement utiliser Django ?

Django est synchrone. Les workers Gunicorn traitent une requête à la fois. Maintenir ouvertes 5 000 connexions SSE nécessiterait 5 000 workers Gunicorn - un non-sens.

Nous avons construit un microservice SSE dédié, et nous l'avons construit deux fois : d'abord en Python avec FastAPI + Hypercorn (ASGI asynchrone), puis en Rust avec Axum + Tokio. Les deux suivent la même architecture :

  1. Le client ouvre un EventSource vers le service SSE
  2. Le service SSE s'abonne à un canal pub/sub Redis : campaign:{id}:updates
  3. Lorsque Django traite un don, une tâche Celery publie les statistiques mises à jour dans Redis
  4. Le service SSE reçoit le message et le diffuse à tous les clients connectés pour cette campagne

La version Rust existe parce que nous voulions repousser encore plus haut le plafond de concurrence. Un seul processus Rust peut aisément maintenir des dizaines de milliers de connexions simultanées avec une surcharge mémoire minimale, grâce au runtime asynchrone sans coût de Tokio. En pratique, la version FastAPI gère parfaitement notre charge actuelle - le service Rust est une assurance pour le jour où une campagne deviendra vraiment virale.

// Rust SSE handler (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! {
        // Atomic connection count via Lua script
        let count = state.redis
            .increment_connection(campaign_id)
            .await;

        if count > MAX_CONNECTIONS {
            yield Ok(Event::default()
                .data("rate_limited"));
            return;
        }

        // Subscribe to Redis pub/sub channel
        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())
}

Prévention du thundering herd avec des scripts Lua

Un problème subtil des SSE à grande échelle : lorsqu'une page de campagne populaire se charge, des centaines de navigateurs ouvrent simultanément des connexions SSE. Si l'on vérifie le nombre de connexions en Python, qu'on l'incrémente, puis qu'on décide d'accepter ou non - il y a une condition de concurrence. Deux cents clients peuvent tous lire « 199 connexions » et tous passer.

Nous avons résolu cela avec un script Lua atomique exécuté à l'intérieur de Redis. Le script lit le compteur actuel, le compare à la limite et l'incrémente en une seule opération atomique. Aucune course TOCTOU, aucun verrou distribué nécessaire.

-- Atomic connection limiting (runs inside 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  -- rejected
end

local new_count = redis.call('INCR', key)
redis.call('EXPIRE', key, ttl)
return new_count  -- accepted

Thématisation par organisation : variables CSS à grande échelle

Chaque organisation sur la plateforme possède sa propre identité de marque - couleurs, logos, polices. Il nous fallait un système de thématisation qui soit :

  • Configurable par des non-développeurs via le CMS
  • Performant - aucun calcul de couleur en JavaScript à l'exécution
  • Conscient du mode sombre dès le premier jour

La solution : un modèle Wagtail BrandColor qui génère des propriétés personnalisées CSS côté serveur. Chaque organisation définit sa palette (primaire, secondaire, accent, arrière-plan) avec des surcharges facultatives pour le mode sombre. La plateforme les restitue sous forme de bloc <style> dans l'en-tête de la page.

class BrandColor(models.Model):
    color_value = models.CharField()       # light mode
    color_value_dark = models.CharField()  # dark mode override
    is_dark = models.BooleanField()        # text contrast hint

    @classmethod
    def get_all_css_for_organization(cls, org) -> str:
        """Generates complete CSS variable blocks."""
        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} }} }}"
        )

Cette approche à trois niveaux couvre tous les scénarios : bascule explicite de l'utilisateur (attribut data-theme), préférence système via prefers-color-scheme, et une valeur par défaut sensée. Les classes Tailwind présentes dans tous les templates référencent ces variables plutôt que des couleurs codées en dur, si bien qu'une organisation peut être d'un bleu marine profond et une autre d'un cramoisi éclatant - même HTML, peinture différente.

HTMX : l'anti-SPA

Le frontend est constitué de templates Django rendus côté serveur, enrichis par HTMX et Alpine.js. Il n'y a ni React, ni Vue, ni étape de build pour le JavaScript. C'est un choix architectural délibéré.

Les pages d'événements de collecte sont fortement mises en cache. Le HTML de la page de campagne est généré une seule fois, mis en cache dans Redis, et servi à des milliers de visiteurs. Une SPA React exigerait une couche API distincte, introduirait une complexité de mise en cache côté client et doublerait la surface d'exposition aux bugs - le tout pour obtenir le même résultat qu'une page rendue côté serveur et mise en cache.

HTMX nous offre l'interactivité dont nous avons besoin avec une précision chirurgicale :

  • Pagination de la liste des donateurs - hx-get récupère la page suivante de donateurs et l'insère dans le DOM. Le serveur renvoie un fragment HTML partiel, pas du JSON.
  • Sections chargées en différé - les lourds calculs de classement sont différés. La page se charge instantanément avec un placeholder, puis HTMX déclenche une requête pour le remplir.
  • Motif Shell + Partial - les chargements de page complets renvoient l'intégralité du shell HTML. Les requêtes HTMX (détectées via l'en-tête HX-Request) ne renvoient que le fragment. Même vue, même template, zéro duplication.
<!-- Donor list with HTMX pagination -->
<div id="donors-list"
     hx-get="?page=1"
     hx-trigger="load"
     hx-swap="innerHTML">
    <!-- Skeleton placeholder -->
</div>

<!-- Server detects HX-Request header, returns partial -->
{% if page_obj.has_next %}
<a hx-get="?page={{ page_obj.next_page_number }}"
   hx-target="#donors-list"
   hx-swap="innerHTML"
   class="btn-outline">Charger plus</a>
{% endif %}

Performance : de plus de 70 requêtes à 9

Le plus grand gain de performance n'est pas venu de la mise en cache mais de l'élimination des requêtes N+1. Les pages de campagne impliquent des relations profondément imbriquées : les campagnes ont des causes, les causes ont des classements, les classements ont des équipes, les équipes ont des membres. Un parcours naïf de l'ORM générait plus de 70 requêtes SQL par chargement de page.

Nous avons utilisé trois stratégies pour ramener ce chiffre à 9 requêtes :

  1. select_related pour les clés étrangères à valeur unique (campaign → organisation, donation → cause)
  2. prefetch_related pour les relations inverses et plusieurs-à-plusieurs (campaign → causes, leaderboard → teams)
  3. Injection manuelle dans le cache de prefetch pour les objets ClusterableModel de Wagtail qui contournent le mécanisme de prefetch standard de Django. Nous peuplons _prefetched_objects_cache directement après une requête groupée.
# Manual prefetch cache for ClusterableModel
leaderboards = campaign.leaderboards.all()
leaderboard_ids = [lb.pk for lb in leaderboards]

# Single bulk query for all teams across all leaderboards
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)

# Inject into prefetch cache - avoids N+1
for lb in leaderboards:
    lb._prefetched_objects_cache = {
        "leaderboard_team_links": teams_by_lb.get(lb.pk, []),
    }

Préchauffage du cache : n'attendez pas la première requête

La mise en cache ne représente que la moitié de l'histoire - la première requête après un défaut de cache peut être douloureusement lente si la page est coûteuse à rendre. Nous résolvons cela par un préchauffage proactif du cache : lorsqu'un don arrive et que les statistiques sont recalculées, une tâche Celery rend la page de campagne à l'aide du Client de test de Django et amorce le cache avant qu'un véritable utilisateur ne l'atteigne.

Le préchauffage du CDN est plus délicat. Nous envoyons des requêtes HTTP vers le point de présence du CDN avec une limitation de débit (2 requêtes simultanées, 200 ms entre chaque) pour éviter de submerger l'origine. Une clé de déduplication SETNX dans Redis empêche plusieurs workers Celery de préchauffer la même page simultanément.

Déploiement : Kubernetes + Helm

La stack de production tourne sur Kubernetes, orchestrée avec des Helm charts. Chaque service est indépendamment scalable :

  • Django (Gunicorn) - 2 à 4 replicas derrière un service mesh
  • Service SSE (Rust/FastAPI) - scale horizontalement en fonction du nombre de connexions
  • Workers Celery - files distinctes pour le calcul des statistiques, le préchauffage du cache et le traitement des dons
  • Celery Beat - replica unique pour les tâches périodiques
  • Nginx - reverse proxy avec HTTP/2 et terminaison TLS
  • Redis Sentinel - cluster Redis à haute disponibilité

L'observabilité est assurée par OpenTelemetry + Jaeger pour le traçage distribué, Sentry pour le suivi des erreurs, et Prometheus + Grafana pour les métriques et les alertes. Chaque don possède un trace ID qui le suit depuis l'endpoint de l'API, à travers les tâches Celery, jusqu'au broadcast SSE.

La stack, justifiée

Chaque technologie de cette stack mérite sa place :

  • Django - un ORM éprouvé, des middlewares, l'authentification, l'admin. L'écosystème est inégalé pour une plateforme riche en contenu.
  • Wagtail - donne aux équipes de contenu une réelle puissance sans sacrifier le confort des développeurs. StreamField à lui seul le justifie.
  • SSE plutôt que WebSockets - plus simple, plus résilient, et le flux de données est de toute façon unidirectionnel.
  • Rust pour le SSE - maintient des dizaines de milliers de connexions inactives avec une surcharge mémoire quasi nulle. Le bon outil pour la bonne tâche.
  • Redis - pub/sub pour la diffusion en temps réel, mise en cache pour la performance des pages, scripts Lua pour les opérations atomiques. Un seul service, trois rôles critiques.
  • HTMX plutôt que React - un HTML rendu côté serveur et mis en cache est plus rapide, plus simple et plus accessible qu'une SPA rendue côté client pour ce cas d'usage.
  • Une base de données par organisation - l'isolation des données sans la complexité d'un cloisonnement au niveau des lignes.
  • Kubernetes + Helm - une mise à l'échelle indépendante des services sans état pendant les pics de trafic des événements de collecte de fonds.

Il n'y a pas de solution miracle en architecture logicielle. Mais il existe des stacks qui épousent la forme du problème, et celle-ci s'y ajuste comme un gant. La plateforme a su porter avec succès des événements de collecte de fonds qui ont récolté des millions de dollars, avec des flux en temps réel fonctionnant sans accroc sur des milliers de connexions simultanées - et l'équipe de contenu déploie des modifications de pages sans écrire une seule ligne de code.

La meilleure architecture est celle où chaque composant peut expliquer pourquoi il existe. Si vous ne pouvez pas justifier la présence d'une technologie en deux phrases, elle n'a probablement pas sa place là.

Me contacter →