Back to Blog
February 22, 20261,000 views

Architecture orientée événements : comment nous avons remplacé un système monolithique qui coûtait 50 000 dollars par mois

Un guide pratique sur l'architecture orientée événements, illustré par des exemples concrets de code « avant/après », qui montre comment nous avons décomposé un système de traitement des commandes étroitement couplé en un pipeline d'événements résilient et évolutif.

Le problème : un système monolithique incapable de s'adapter à la croissance

Notre client disposait d'une application monolithique Django chargée du traitement des commandes. Chaque commande déclenchait une chaîne d'opérations synchrones : validation des stocks → prélèvement du paiement → envoi d'un e-mail de confirmation → mise à jour des statistiques → notification à l'entrepôt. Si le service de messagerie était lent, l'ensemble du processus de paiement se bloquait. Si le système d'analyse était hors service, les commandes échouaient.

Le système traitait 200 commandes par minute en temps normal, mais tombait en panne lors des ventes flash, où le volume atteignait 2 000 commandes par minute. Pour pallier ce problème, l'entreprise dépensait 50 000 dollars par mois en serveurs surdimensionnés.

Avant : La chaîne synchrone de la douleur

# The old way: everything synchronous, everything coupled
def process_order(request):
    order = Order.objects.create(**request.data)
    
    # If ANY of these fail, the order fails
    inventory_service.reserve(order)        # 200ms
    payment_service.charge(order)           # 800ms
    email_service.send_confirmation(order)  # 500ms
    analytics_service.track(order)          # 300ms
    warehouse_service.notify(order)         # 400ms
    
    # Total: 2200ms minimum response time
    # If email is down: order fails
    # If analytics is slow: checkout is slow
    return Response({'order_id': order.id})

L'architecture : événements et commandes

Nous avons divisé le système en deux concepts :

Commandes (opérations qui DOIVENT aboutir) : réservation de stock + paiement. Ces opérations restent synchrones car la commande en dépend.

Événements (actions qui DOIVENT se produire) : e-mails, analyses, notifications de l'entrepôt. Ceux-ci sont publiés sous forme d'événements et traités de manière asynchrone. En cas d'échec, ils sont relancés — le client n'a pas à attendre.

Après : Approche orientée événements avec Celery + Redis

# The new way: synchronous for critical path, events for the rest
def process_order(request):
    order = Order.objects.create(**request.data)
    
    # Critical path: must succeed (synchronous)
    inventory_service.reserve(order)   # 200ms
    payment_service.charge(order)      # 800ms
    
    # Publish event: fire-and-forget (async)
    publish_event('order.completed', {
        'order_id': order.id,
        'customer_email': order.customer.email,
        'total': str(order.total),
    })
    
    # Total: 1000ms response time
    # Email down? Order still succeeds. Retried later.
    return Response({'order_id': order.id})


# Event handlers (separate workers)
@celery_app.task(bind=True, max_retries=5, default_retry_delay=60)
def handle_order_completed(self, event_data):
    """Each handler is independent and retriable"""
    try:
        email_service.send_confirmation(event_data)
    except Exception as exc:
        self.retry(exc=exc, countdown=2 ** self.request.retries * 60)

Le modèle de bus d'événements

Nous avons mis au point un bus d'événements léger à l'aide de Redis Streams (il ne s'agit pas simplement d'un système pub/sub : les flux offrent une meilleure durabilité et permettent de créer des groupes de consommateurs) :

import redis
import json
from datetime import datetime

class EventBus:
    def __init__(self):
        self.redis = redis.Redis(host='redis', port=6379, db=0)
    
    def publish(self, event_type: str, data: dict):
        """Publish event to Redis Stream"""
        event = {
            'type': event_type,
            'data': json.dumps(data),
            'timestamp': datetime.utcnow().isoformat(),
        }
        self.redis.xadd(f'events:{event_type}', event)
    
    def subscribe(self, event_type: str, group: str, consumer: str):
        """Read events with consumer group (each event processed once)"""
        try:
            self.redis.xgroup_create(
                f'events:{event_type}', group, id='0', mkstream=True
            )
        except redis.ResponseError:
            pass  # Group already exists
        
        while True:
            events = self.redis.xreadgroup(
                group, consumer,
                {f'events:{event_type}': '>'},
                count=10, block=5000
            )
            for stream, messages in events:
                for msg_id, fields in messages:
                    yield msg_id, json.loads(fields[b'data'])
                    self.redis.xack(stream, group, msg_id)

Résultats après 3 mois

Système métriqueAvantAprès
Temps de réponse lors du paiement2 200 ms1 000 ms
Débit maximal200 commandes par minute5 000 commandes par minute
Coût mensuel du serveur50 000 $12 000 $
Conséquences de la panne du service de messagerie électroniqueToutes les commandes échouentLes commandes s'enchaînent, les e-mails s'accumulent
Risque lié au déploiementTout ou rienDéployer les services de manière indépendante

Quand NE PAS utiliser une architecture orientée événements

L'approche orientée événements n'est pas toujours la solution. Évitez-la dans les cas suivants :

1. Vous devez respecter un ordre strict. Les événements peuvent se produire dans le désordre. Si l'étape B doit suivre l'étape A, veillez à ce qu'elles restent synchronisées.

2. Vous avez besoin d'une cohérence immédiate. Les événements sont cohérents à terme. Si l'utilisateur doit voir le résultat immédiatement, n'utilisez pas d'événements asynchrones.

3. Votre équipe est réduite. L'architecture orientée événements ajoute à la complexité opérationnelle. Une équipe de deux ou trois développeurs a tout intérêt à opter pour une architecture monolithique bien structurée.

4. Vous recevez moins de 100 requêtes par minute. Une architecture monolithique synchrone suffit amplement. N'en faites pas trop.

Commencez par un monolithe. Définissez des événements lorsque vous rencontrez des difficultés. La pire architecture est celle conçue pour résoudre des problèmes que vous n'avez pas encore.

— alokknight Ingénierie