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
| Service | Langage | Framework | Base de données |
|---|---|---|---|
| API Gateway | Node.js | Express | — |
| Auth Service | Node.js | Fastify | PostgreSQL + Redis |
| User Service | Node.js | Fastify | PostgreSQL |
| Order Service | Node.js | Fastify | PostgreSQL |
| Payment Service | Python | FastAPI | PostgreSQL |
| Notification | Go | Gin | — |
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, ¬ification)
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