MFormations
Modern Backend Engineering

Chapitre 16

Chapitre 16 — Projet Fil Rouge : Plateforme SaaS Enterprise

Chapitre 16 — Projet Fil Rouge : Plateforme SaaS Enterprise

Cours — Projet Fil Rouge : Plateforme SaaS Enterprise

1. Présentation du Projet

Contexte

"Enterprise Backend Platform" (EBP) est une plateforme SaaS complète permettant aux entreprises de gérer leurs opérations : utilisateurs, commandes, paiements, notifications.

Objectifs pédagogiques

  • Appliquer tous les concepts vus dans la formation
  • Travailler en architecture microservices
  • Mettre en production une application réelle
  • Être capable de présenter le projet en entretien

Stack technique

ServiceLangageFrameworkBase de données
API GatewayNode.jsExpress
Auth ServiceNode.jsFastifyPostgreSQL + Redis
User ServiceNode.jsFastifyPostgreSQL
Order ServiceNode.jsFastifyPostgreSQL
Payment ServicePythonFastAPIPostgreSQL
NotificationGoGin

Infrastructure

[CloudFront CDN]
    ↓
[API Gateway:8080] → Auth Service:3001
    ↓                    ↓
[User Service:3002]   Redis
    ↓
[Order Service:3003] → PostgreSQL
    ↓
[Payment Service:3004] → Stripe API
    ↓
[Notification Service:3005] → Kafka → Email/SMS/Push

2. Monorepo avec Nx

Structure

ebp/
├── apps/
│   ├── gateway/          # API Gateway
│   ├── auth-service/     # Authentication
│   ├── user-service/     # Users CRUD
│   ├── order-service/    # Orders
│   ├── payment-service/  # Payments (Python)
│   └── notification-service/ # Notifications (Go)
├── libs/
│   ├── shared/           # Types, utils
│   ├── database/         # Prisma schemas
│   └── messaging/        # Kafka/RabbitMQ clients
├── infra/
│   ├── terraform/        # AWS infrastructure
│   └── k8s/              # Kubernetes manifests
├── tools/
│   └── scripts/          # Dev scripts
├── nx.json
├── package.json
├── docker-compose.yml
└── tsconfig.base.json

Configuration Nx

// nx.json
{
  "extends": "nx/presets/npm.json",
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": ["build", "lint", "test"]
      }
    }
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"]
    },
    "test": {
      "inputs": ["default", "^production"]
    }
  }
}

3. API Gateway

Fonctionnalités

  • Routing vers les services
  • Authentication JWT (vérification du token)
  • Rate limiting (100 req/min/IP, 1000 req/min/user)
  • Circuit Breaker par service
  • Request/Response logging (correlation-id)
  • Health check aggregator

Implémentation

// apps/gateway/src/main.js
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { authenticate } from './middleware/auth';
import { rateLimiter } from './middleware/rate-limit';
import { circuitBreaker } from './middleware/circuit-breaker';
import { correlationId } from './middleware/correlation';
import { errorHandler } from './middleware/error-handler';
import { createLogger } from './lib/logger';

const app = express();
const logger = createLogger('gateway');

// Global middleware
app.use(correlationId);
app.use(rateLimiter);
app.use(authenticate);

// Service routes
const services = [
  { path: '/api/v1/users', target: 'http://user-service:3002' },
  { path: '/api/v1/orders', target: 'http://order-service:3003' },
  { path: '/api/v1/payments', target: 'http://payment-service:3004' },
  { path: '/api/v1/notifications', target: 'http://notification-service:3005' },
];

for (const service of services) {
  app.use(service.path, circuitBreaker(service.name), createProxyMiddleware({
    target: service.target,
    changeOrigin: true,
    timeout: 5000,
    on: {
      proxyReq: (proxyReq, req) => {
        proxyReq.setHeader('x-correlation-id', req.correlationId);
        proxyReq.setHeader('x-user-id', req.user?.id);
      },
    },
  }));
}

// Health endpoint
app.get('/health', async (req, res) => {
  const checks = await Promise.allSettled(
    services.map(async (s) => {
      const start = Date.now();
      const response = await fetch(`${s.target}/health`);
      return {
        service: s.name,
        status: response.ok ? 'healthy' : 'degraded',
        latency: Date.now() - start,
      };
    })
  );
  res.json({ status: 'ok', checks: checks.map(c => c.value) });
});

app.use(errorHandler);
app.listen(8080);

4. Auth Service

Fonctionnalités

  • Register / Login / Logout
  • JWT access + refresh tokens
  • OAuth2 (Google, GitHub)
  • MFA (TOTP)
  • Password reset
  • Session management (Redis)

Endpoints

// apps/auth-service/src/routes/auth.js
router.post('/auth/register', validate(registerSchema), async (req, res) => {
  const { email, password, name } = req.body;

  const existing = await db.users.findByEmail(email);
  if (existing) throw new ConflictError('Email already exists');

  const hashedPassword = await argon2.hash(password);
  const user = await db.users.create({ email, password: hashedPassword, name });

  const tokens = generateTokens(user);
  await redis.set(`session:${user.id}`, tokens.refreshToken, 'EX', 7 * 86400);

  res.status(201).json({ user: sanitizeUser(user), ...tokens });
});

router.post('/auth/login', validate(loginSchema), async (req, res) => {
  const { email, password } = req.body;

  const user = await db.users.findByEmail(email);
  if (!user) throw new UnauthorizedError('Invalid credentials');

  const valid = await argon2.verify(user.password, password);
  if (!valid) throw new UnauthorizedError('Invalid credentials');

  const tokens = generateTokens(user);
  res.json({ user: sanitizeUser(user), ...tokens });
});

router.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  const payload = jwt.verify(refreshToken, REFRESH_SECRET);
  const stored = await redis.get(`session:${payload.userId}`);

  if (stored !== refreshToken) throw new UnauthorizedError('Invalid refresh token');

  const user = await db.users.findById(payload.userId);
  const tokens = generateTokens(user);

  // Token rotation
  await redis.set(`session:${user.id}`, tokens.refreshToken, 'EX', 7 * 86400);

  res.json(tokens);
});

5. User Service

Endpoints REST

// apps/user-service/src/routes/users.js
router.get('/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id, {
    select: { id: true, name: true, email: true, role: true, createdAt: true },
  });
  if (!user) throw new NotFoundError('User not found');
  res.json(user);
});

router.put('/users/:id', validate(updateUserSchema), async (req, res) => {
  const user = await db.users.update(req.params.id, req.body);
  // Invalidate cache
  await cache.del(`user:${req.params.id}`);
  res.json(user);
});

router.get('/users', async (req, res) => {
  const { page = 1, limit = 20, role, search } = req.query;
  const users = await db.users.findMany({
    where: { role, OR: search ? [{ name: { contains: search } }, { email: { contains: search } }] : undefined },
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: 'desc' },
  });
  const total = await db.users.count({ where: { role } });
  res.json({ data: users, meta: { page, limit, total } });
});

6. Order Service

Modèle de données

model Order {
  id          String   @id @default(cuid())
  userId      String
  status      OrderStatus @default(PENDING)
  total       Decimal
  currency    String   @default("EUR")
  items       OrderItem[]
  shipping    Shipping?
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  @@index([userId])
  @@index([status, createdAt])
}

model OrderItem {
  id        String @id @default(cuid())
  orderId   String
  productId String
  name      String
  quantity  Int
  price     Decimal
  order     Order  @relation(fields: [orderId], references: [id])
}

Saga pour la création de commande

// apps/order-service/src/saga/create-order.saga.js
export async function createOrderSaga(orderData) {
  const saga = new Saga('create-order');

  saga.step('validate-stock')
    .invoke(async (ctx) => {
      const result = await fetch('http://inventory-service/validate', {
        method: 'POST',
        body: JSON.stringify({ items: orderData.items }),
      });
      if (!result.ok) throw new Error('Insufficient stock');
    })
    .withCompensation(async (ctx) => {
      // No compensation needed for read-only validation
    });

  saga.step('process-payment')
    .invoke(async (ctx) => {
      const payment = await fetch('http://payment-service/charge', {
        method: 'POST',
        body: JSON.stringify({
          userId: orderData.userId,
          amount: orderData.total,
          currency: orderData.currency,
        }),
      });
      ctx.paymentId = payment.id;
    })
    .withCompensation(async (ctx) => {
      await fetch('http://payment-service/refund', {
        method: 'POST',
        body: JSON.stringify({ paymentId: ctx.paymentId }),
      });
    });

  saga.step('reserve-inventory')
    .invoke(async (ctx) => {
      await fetch('http://inventory-service/reserve', {
        method: 'POST',
        body: JSON.stringify({
          orderId: ctx.orderId,
          items: orderData.items,
        }),
      });
    })
    .withCompensation(async (ctx) => {
      await fetch('http://inventory-service/release', {
        method: 'POST',
        body: JSON.stringify({ orderId: ctx.orderId }),
      });
    });

  return saga.execute({ orderId: generatedId });
}

7. Payment Service (Python)

# apps/payment-service/src/main.py
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
import stripe
import structlog

app = FastAPI(title="Payment Service")
logger = structlog.get_logger()

class ChargeRequest(BaseModel):
    userId: str
    amount: float
    currency: str = "EUR"

class RefundRequest(BaseModel):
    paymentId: str

@app.post("/charge")
async def charge(request: ChargeRequest):
    try:
        payment = stripe.PaymentIntent.create(
            amount=int(request.amount * 100),
            currency=request.currency.lower(),
            metadata={"userId": request.userId},
        )
        logger.info("payment.created", payment_id=payment.id, amount=request.amount)
        return {"id": payment.id, "status": payment.status}
    except stripe.StripeError as e:
        logger.error("payment.failed", error=str(e))
        raise HTTPException(status_code=402, detail=str(e))

@app.post("/refund")
async def refund(request: RefundRequest):
    try:
        refund = stripe.Refund.create(payment_intent=request.paymentId)
        return {"id": refund.id, "status": refund.status}
    except stripe.StripeError as e:
        raise HTTPException(status_code=400, detail=str(e))

@app.get("/health")
async def health():
    return {"status": "healthy"}

8. Notification Service (Go)

// apps/notification-service/main.go
package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "github.com/segmentio/kafka-go"
    "github.com/gin-gonic/gin"
)

type Notification struct {
    ID      string `json:"id"`
    UserID  string `json:"userId"`
    Type    string `json:"type"`
    Channel string `json:"channel"`
    Content string `json:"content"`
}

func main() {
    r := gin.Default()

    // Kafka consumer
    go consumeNotifications()

    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "healthy"})
    })

    r.Run(":3005")
}

func consumeNotifications() {
    reader := kafka.NewReader(kafka.ReaderConfig{
        Brokers:   []string{"kafka:9092"},
        Topic:     "notifications",
        GroupID:   "notification-service",
        MinBytes:  10,
        MaxBytes:  10e6,
    })

    for {
        msg, err := reader.ReadMessage(context.Background())
        if err != nil {
            log.Printf("Error reading message: %v", err)
            continue
        }

        var notification Notification
        json.Unmarshal(msg.Value, &notification)

        switch notification.Channel {
        case "email":
            sendEmail(notification)
        case "sms":
            sendSMS(notification)
        case "push":
            sendPush(notification)
        }
    }
}

9. Infrastructure Kubernetes

# infra/k8s/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: ebp-production
---
# infra/k8s/gateway.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gateway
  namespace: ebp-production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: gateway
  template:
    metadata:
      labels:
        app: gateway
    spec:
      containers:
        - name: gateway
          image: registry.ebp.com/gateway:latest
          ports:
            - containerPort: 8080
          env:
            - name: NODE_ENV
              value: "production"
            - name: JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: jwt-secret
                  key: secret
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
  name: gateway
  namespace: ebp-production
spec:
  selector:
    app: gateway
  ports:
    - port: 80
      targetPort: 8080
  type: LoadBalancer

10. CI/CD Pipeline

# .github/workflows/ci.yml
name: CI/CD

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx nx run-many --target=lint
      - run: npx nx run-many --target=test
      - run: npx nx run-many --target=build

  docker:
    needs: quality
    runs-on: ubuntu-latest
    strategy:
      matrix:
        service: [gateway, auth-service, user-service, order-service]
    steps:
      - uses: actions/checkout@v4
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: apps/${{ matrix.service }}
          push: true
          tags: registry.ebp.com/${{ matrix.service }}:${{ github.sha }}

  deploy:
    needs: docker
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - name: Deploy to K8s
        run: |
          kubectl set image deployment/gateway \
            gateway=registry.ebp.com/gateway:${{ github.sha }} \
            -n ebp-production
          kubectl rollout status deployment/gateway -n ebp-production

11. Monitoring Stack

# infra/monitoring/values.yaml
prometheus:
  retention: 30d
  rules:
    - alert: HighErrorRate
      expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05
      for: 5m
    - alert: HighLatency
      expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 1
      for: 5m

grafana:
  dashboards:
    - name: "API Overview"
      datasource: Prometheus
      panels:
        - Requests per second
        - Error rate by service
        - P50/P95/P99 latency
        - Top slowest endpoints

jaeger:
  strategy: allInOne
  storage:
    type: elasticsearch

elasticsearch:
  volumeClaimTemplate:
    storage: 100Gi

12. Documentation

API Documentation (OpenAPI)

# docs/openapi.yaml
openapi: 3.0.0
info:
  title: Enterprise Backend Platform API
  version: 1.0.0
servers:
  - url: https://api.ebp.com/v1
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
paths:
  /users:
    get:
      summary: List users
      security:
        - bearerAuth: []
      parameters:
        - name: page
          in: query
          schema: { type: integer }
        - name: limit
          in: query
          schema: { type: integer }
      responses:
        '200':
          description: User list

Architecture Decision Records

  • ADR-001: Microservices vs Monolith
  • ADR-002: Message Broker (Kafka)
  • ADR-003: Database per Service
  • ADR-004: API Gateway pattern
  • ADR-005: Saga pattern for distributed transactions