DEV Community

BeardDemon
BeardDemon

Posted on

Mon architecture microservices e-commerce (et les erreurs à éviter)

L'architecture complète

4 APIs indépendantes qui communiquent entre elles:

┌──────────────┐
│   Frontend   │ (Django/port 8004)
└──────┬───────┘
       │
       ├──→ API Clients    (Django/port 8000) → MariaDB
       ├──→ API Catalogue  (Django/port 8001) → MariaDB
       ├──→ API Panier     (Flask/port 8002)  → Redis
       └──→ API Commandes  (Django/port 8003) → MariaDB
Enter fullscreen mode Exit fullscreen mode

Chaque API:

  • A sa propre base de données (MariaDB ou Redis)
  • Tourne dans son propre container Docker
  • Est déployée indépendamment sur Kubernetes
  • A son propre repo Git

API Clients : authentification centralisée

C'est le point d'entrée pour tout ce qui touche aux utilisateurs.

Endpoints principaux:

POST /api/auth/register/  → Créer un compte
POST /api/auth/login/     → Se connecter (retourne un token)
GET  /api/auth/validate-token/  → Valider un token
GET  /api/auth/profile/   → Récupérer son profil
Enter fullscreen mode Exit fullscreen mode

Modèle User:

class User(AbstractUser):
    ROLE_CHOICES = [
        ('client', 'Client'),
        ('vendeur', 'Vendeur'),
        ('admin', 'Administrateur'),
    ]

    role = models.CharField(
        max_length=10,
        choices=ROLE_CHOICES,
        default='client'
    )
Enter fullscreen mode Exit fullscreen mode

Le truc important: les autres APIs ne gèrent PAS l'auth. Elles font juste une requête à l'API Clients pour valider le token.

Authentification externe pour les autres APIs

Voici comment j'ai fait l'auth dans les autres services:

# api/authentification.py (API Catalogue/Commandes)
class ExternalUser:
    def __init__(self, user_id: int, first_name: str, last_name: str, role: str):
        self.id = user_id
        self.first_name = first_name
        self.last_name = last_name
        self.role = role

    @property
    def is_authenticated(self) -> bool:
        return True

class ExternalTokenAuthentication(authentication.BaseAuthentication):
    def authenticate(self, request):
        auth_header = request.META.get("HTTP_AUTHORIZATION", "")
        if not auth_header:
            return None

        parts = auth_header.split()
        if len(parts) != 2 or parts[0] not in {"Bearer", "Token"}:
            raise exceptions.AuthenticationFailed("Format invalide")

        # Appel à l'API Clients
        response = requests.get(
            "http://apiclients:8000/api/auth/validate-token/",
            headers={"Authorization": auth_header},
            timeout=3
        )

        if response.status_code != 200:
            raise exceptions.AuthenticationFailed("Token invalide")

        data = response.json()
        user = ExternalUser(
            user_id=data['user_id'],
            first_name=data['first_name'],
            last_name=data['last_name'],
            role=data['role']
        )

        return user, None
Enter fullscreen mode Exit fullscreen mode

Ça me permet d'utiliser les permissions DRF normalement dans toutes les APIs.

API Catalogue : produits et avis

Gère tout ce qui touche aux produits.

Modèles principaux:

class Category(models.Model):
    name = models.CharField(max_length=100, primary_key=True)
    description = models.TextField()
    enabled = models.BooleanField(default=True)

class Item(models.Model):
    name = models.CharField(max_length=100)
    description = models.TextField()
    price = models.DecimalField(max_digits=10, decimal_places=2)
    picture = models.ImageField(max_length=255)
    stock = models.IntegerField(default=0)
    average_rating = models.FloatField(default=0)
    category_name = models.ForeignKey(Category, on_delete=models.CASCADE)
    id_seller = models.IntegerField()  # Référence vers l'API Clients

class Review(models.Model):
    item = models.ForeignKey(Item, on_delete=models.CASCADE)
    rating = models.FloatField()
    comment = models.TextField()
    id_client = models.IntegerField()  # Référence vers l'API Clients
Enter fullscreen mode Exit fullscreen mode

Le truc cool: Les avis mettent à jour automatiquement la note moyenne grâce aux signals Django:

# api/signals.py
from django.db.models import Avg
from django.db.models.signals import post_save, post_delete

@receiver(post_save, sender=Review)
def on_review_save(sender, instance, **kwargs):
    agg = Review.objects.filter(item_id=instance.item_id).aggregate(avg=Avg('rating'))
    Item.objects.filter(id=instance.item_id).update(
        average_rating=round(agg['avg'] or 0.0, 2)
    )

@receiver(post_delete, sender=Review)
def on_review_delete(sender, instance, **kwargs):
    # Même chose pour la suppression
Enter fullscreen mode Exit fullscreen mode

Gestion du stock

J'ai ajouté un endpoint spécial pour réduire le stock:

def reduce_stock(self, quantity):
    if self.stock < quantity:
        raise ValueError("Stock insuffisant")
    self.stock -= quantity
    self.save()
Enter fullscreen mode Exit fullscreen mode

Cet endpoint est appelé par l'API Commandes quand une commande est validée.

API Panier : Flask + Redis

Contrairement aux autres, celle-ci est en Flask (plus léger pour un simple panier).

Stockage:

# Clé Redis: cart:<user_id>
# Valeur: JSON array d'IDs d'items

def get_cart_for_user(user_id):
    cart_key = f"cart:{user_id}"
    cart_data = redis_client.get(cart_key)
    if cart_data:
        return json.loads(cart_data)
    return []

def save_cart_for_user(user_id, items):
    cart_key = f"cart:{user_id}"
    redis_client.set(cart_key, json.dumps(items))
Enter fullscreen mode Exit fullscreen mode

Endpoints:

GET    /items          → Récupérer son panier
POST   /items          → Ajouter un item
PUT    /items          → Remplacer tout le panier
DELETE /items          → Vider le panier
DELETE /items/<id>     → Supprimer un item
Enter fullscreen mode Exit fullscreen mode

Pourquoi Redis ? Parce que c'est temporaire. Si le serveur redémarre, on s'en fout de perdre les paniers.

API Commandes : l'orchestrateur

Celle-ci coordonne les autres services.

Workflow de création de commande:

def post(self, request):
    # 1. Récupérer les infos des items depuis l'API Catalogue
    for item_input in data['items']:
        item_data = fetch_item_info(item_input['item_id'])

        # 2. Vérifier et réduire le stock
        stock_response = requests.post(
            f"http://apicatalogue:8001/api/items/{item_id}/stock/",
            json={'quantity': quantity}
        )

        if stock_response.status_code != 200:
            return Response({'error': 'Stock insuffisant'}, 400)

    # 3. Créer la commande
    order = Order.objects.create(id_buyer=data['id_buyer'])

    # 4. Sauvegarder les items (snapshot à l'instant T)
    for item_info in items_info:
        OrderItem.objects.create(
            order=order,
            item_id=item_info['item_id'],
            name=item_info['name'],      # Snapshot du nom
            price=item_info['price'],    # Snapshot du prix
            id_seller=item_info['id_seller'],
            quantity=item_info['quantity']
        )
Enter fullscreen mode Exit fullscreen mode

Pourquoi sauvegarder un snapshot ?

Parce que si le vendeur change le prix ou le nom du produit après, la commande doit refléter ce qui était affiché au moment de l'achat.

Génération de PDF

Bonus: chaque commande peut être exportée en PDF avec ReportLab:

from reportlab.platypus import SimpleDocTemplate, Table
from reportlab.lib.pagesizes import A4

def get(self, request, order_id):
    order = Order.objects.get(id=order_id)

    buffer = BytesIO()
    doc = SimpleDocTemplate(buffer, pagesize=A4)

    # Tableau des items
    items_data = [['Article', 'Prix', 'Quantité', 'Total']]
    for item in order.items.all():
        items_data.append([
            item.name,
            f"{item.price}",
            str(item.quantity),
            f"{item.price * item.quantity}"
        ])

    table = Table(items_data)
    doc.build([table])

    pdf_content = buffer.getvalue()
    response = HttpResponse(pdf_content, content_type='application/pdf')
    response['Content-Disposition'] = f'attachment; filename="commande_{order.id}.pdf"'
    return response
Enter fullscreen mode Exit fullscreen mode

Les pièges de la communication inter-services

Erreur #1: Timeout par défaut trop court

Les requests Python ont un timeout par défaut infini. J'ai ajouté des timeouts partout:

response = requests.get(url, timeout=3)  # 3 secondes max
Enter fullscreen mode Exit fullscreen mode

Erreur #2: Pas de gestion d'erreur

Si l'API Catalogue est down, l'API Commandes plantait. J'ai ajouté des try/catch:

try:
    response = requests.get(url, timeout=3)
except requests.RequestException:
    return Response({'error': 'Service indisponible'}, 502)
Enter fullscreen mode Exit fullscreen mode

Erreur #3: Circular dependencies

Au début, l'API Catalogue appelait l'API Commandes qui appelait l'API Catalogue. Mauvaise idée.

Solution: Communication unidirectionnelle:

  • Frontend → APIs
  • API Commandes → API Catalogue (pour vérifier le stock)
  • API Catalogue ← Jamais d'appel sortant

Permissions et rôles

Chaque API a ses propres permissions:

# API Catalogue: seul le vendeur peut modifier ses items
class IsItemSeller(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        return request.user.id == obj.id_seller

# API Commandes: seul l'admin peut supprimer
class UserAdminPermission(permissions.BasePermission):
    def has_permission(self, request, view):
        return request.user.role == 'admin'
Enter fullscreen mode Exit fullscreen mode

Déploiement Kubernetes

Chaque API a son deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: apicatalogue
spec:
  replicas: 1
  template:
    spec:
      initContainers:
        - name: wait-for-db
          image: busybox:1.36.1
          command: ['sh', '-c', 'until nc -z dbcatalogue 3307; do sleep 2; done;']
      containers:
        - image: ghcr.io/uha-sae53/api-catalogue:latest
          ports:
            - containerPort: 8001
          env:
            - name: DB_CATALOGUE_HOST
              value: "dbcatalogue"
Enter fullscreen mode Exit fullscreen mode

Le initContainer attend que la base soit prête avant de lancer l'API.

Conclusion

Cette archi microservices m'a appris:

  • La communication inter-services c'est chiant
  • Les timeouts sont ESSENTIELS
  • Un service = une responsabilité
  • Les snapshots de données évitent les incohérences

C'est plus complexe qu'un monolithe, mais ça scale mieux et c'est plus fun à développer.

Code complet: [GitHub]

Top comments (0)