Comment nous avons architecturé un SaaS en production : une plongée dans les microservices
Construire une plateforme de stratégie produit signifie gérer l'auth, le roadmapping, les paiements, les notifications et la collaboration en temps réel — tous indépendamment scalables. Voici l'architecture sur laquelle nous avons atterri après plusieurs itérations dans un SaaS en production, les compromis que nous avons faits, et ce que nous reconsidérerions.
Construire des applications évolutives nécessite de dépasser les architectures monolithiques. Les microservices offrent la promesse d'un déploiement indépendant, d'une flexibilité technologique et d'une mise à l'échelle horizontale, mais ils introduisent un défi fondamental : comment les services communiquent-ils entre eux ? Dans ce guide complet, je vous guide à travers la construction d'une architecture de microservices NestJS prête pour la production en utilisant le transport TCP, couvrant tout, de la configuration de base à la gestion des erreurs et aux patterns de résilience.
Comprendre les microservices dans NestJS
Dans NestJS, un microservice est fondamentalement une application qui utilise une couche de transport différente de HTTP. Tandis que votre API Gateway parle HTTP avec le monde extérieur, les services internes communiquent via des protocoles plus efficaces comme TCP, Redis, NATS, ou des files de messages comme RabbitMQ.
NestJS abstrait ces couches de transport derrière une interface unifiée, ce qui signifie que vous pouvez passer de TCP à Redis sans modifier votre logique métier. C'est puissant : votre code reste propre tandis que le framework gère la complexité de la communication distribuée.
Installation
Pour démarrer avec les microservices NestJS, installez le package requis :
npm i @nestjs/microservices
Vue d'ensemble de l'architecture
Avant de plonger dans le code, comprenons l'architecture que nous construisons :
┌─────────────────┐
│ Next.js Client │
└────────┬────────┘
│ HTTP/REST
▼
┌─────────────────────────────────────┐
│ API Gateway (Port 3001) │
│ • Authentification & Autorisation │
│ • Validation des requêtes (DTOs) │
│ • Limitation de débit │
│ • Documentation Swagger │
└──────────┬──────────────────────────┘
│
┌─────┴─────┐
│ TCP │
▼ ▼
┌─────────────┐ ┌───────────────┐
│Microservice │ │ Microservice │
│ 1 :3002 │ │ 2 :3003 │
└─────────────┘ └───────────────┘
- API Gateway (Port 3001) : Le point d'entrée unique exposé à internet. Gère les requêtes HTTP, valide les entrées et route vers les microservices appropriés via TCP.
- Microservice 1 (Port 3002) : Gère un domaine spécifique de votre application.
- Microservice 2 (Port 3003) : Gère un autre domaine, complètement isolé du Microservice 1.
Pourquoi TCP pour la communication interne ?
Nous avons choisi TCP comme transport principal pour les opérations synchrones. Voici le cadre de décision :
- Utilisez TCP lorsque l'utilisateur attend une réponse. Connexion, récupération de données, soumissions de formulaires. Ces opérations nécessitent un retour immédiat. TCP fournit une communication bidirectionnelle à faible latence, parfaite pour les patterns requête-réponse.
- Utilisez des files de messages (RabbitMQ/Redis) lorsque l'utilisateur n'a pas besoin d'attendre. Envoi d'e-mails, génération de rapports, traitement de téléchargements. Ces opérations peuvent être mises en file d'attente et traitées de manière asynchrone.
TCP offre le bon équilibre : plus rapide que HTTP (sans surcharge des en-têtes), plus simple que les files de messages, et NestJS gère automatiquement le pool de connexions.
Mise en place de l'API Gateway
L'API Gateway est la réceptionniste de votre architecture. C'est le seul service exposé à internet public, responsable de l'authentification, de la validation et du routage des requêtes vers le microservice approprié.
Fichier principal de démarrage
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { ValidationPipe } from "@nestjs/common";
import * as bodyParser from "body-parser";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use("/webhook", bodyParser.raw({ type: "application/json" }));
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
const config = new DocumentBuilder()
.setTitle("Microservices API")
.setDescription("API Gateway for microservices architecture")
.setVersion("1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("api", app, document);
app.enableCors();
await app.listen(3001);
}
bootstrap();
Enregistrement des clients de microservices
L'API Gateway doit savoir comment atteindre chaque microservice. NestJS fournit ClientsModule à cet effet. L'utilisation de registerAsync garantit que le ConfigService est disponible et que les variables d'environnement sont correctement chargées avant la configuration du client.
import { Module } from "@nestjs/common";
import { ClientsModule, Transport } from "@nestjs/microservices";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
@Module({
imports: [
ConfigModule.forRoot(),
ClientsModule.registerAsync([
{
name: "MICROSERVICE_ONE",
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
transport: Transport.TCP,
options: {
host: configService.get("MS_ONE_HOST", "localhost"),
port: configService.get("MS_ONE_PORT", 3002),
},
}),
inject: [ConfigService],
},
{
name: "MICROSERVICE_TWO",
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
transport: Transport.TCP,
options: {
host: configService.get("MS_TWO_HOST", "localhost"),
port: configService.get("MS_TWO_PORT", 3003),
},
}),
inject: [ConfigService],
},
]),
],
controllers: [AppController],
providers: [AppService],
exports: [ClientsModule],
})
export class AppModule {}
Communication avec les microservices
Une fois le client enregistré, injectez-le dans votre service en utilisant le décorateur @Inject avec le même nom de token.
Le service Gateway
import { Injectable, Inject, HttpException, HttpStatus } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { firstValueFrom, timeout, catchError } from "rxjs";
import { CreateItemDto } from "./dto/create-item.dto";
@Injectable()
export class AppService {
constructor(
@Inject("MICROSERVICE_ONE") private microserviceOne: ClientProxy,
@Inject("MICROSERVICE_TWO") private microserviceTwo: ClientProxy,
) {}
async createItem(createItemDto: CreateItemDto) {
const pattern = { cmd: "createItem" };
try {
const result = await firstValueFrom(
this.microserviceOne.send(pattern, createItemDto).pipe(
timeout(5000),
catchError((error) => {
throw new HttpException(
error.message || "Microservice unavailable",
error.status || HttpStatus.SERVICE_UNAVAILABLE,
);
}),
),
);
return result;
} catch (error) {
if (error instanceof HttpException) throw error;
throw new HttpException(
"Failed to communicate with microservice",
HttpStatus.SERVICE_UNAVAILABLE,
);
}
}
async findAllItems() {
return firstValueFrom(
this.microserviceOne.send({ cmd: "findAllItems" }, {}).pipe(
timeout(5000),
),
);
}
}
Important : La méthode .toPromise() est dépréciée dans RxJS. Utilisez toujours firstValueFrom() ou lastValueFrom() du package rxjs à la place.
Construction du microservice
Construisons maintenant le microservice réel qui gère la logique métier. Contrairement à l'API Gateway, ce service n'utilise pas HTTP. Il écoute les messages TCP.
Démarrage du microservice
import { NestFactory } from "@nestjs/core";
import { MicroserviceOptions, Transport } from "@nestjs/microservices";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
import { AllExceptionsFilter } from "./common/filters/rpc-exception.filter";
async function bootstrap() {
const app = await NestFactory.createMicroservice(
AppModule,
{
transport: Transport.TCP,
options: {
host: process.env.MS_HOST || "0.0.0.0",
port: parseInt(process.env.MS_PORT, 10) || 3002,
retryAttempts: 5,
retryDelay: 3000,
},
},
);
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
transform: true,
}));
app.useGlobalFilters(new AllExceptionsFilter());
await app.listen();
}
bootstrap();
Contrôleurs de patterns de messages
Les contrôleurs de microservices n'utilisent pas @Get(), @Post(), etc. À la place, ils utilisent @MessagePattern() pour la communication requête-réponse et @EventPattern() pour les événements sans confirmation de réception.
import { Controller } from "@nestjs/common";
import { MessagePattern, Payload, RpcException } from "@nestjs/microservices";
import { ItemService } from "./item.service";
import { CreateItemDto } from "./dto/create-item.dto";
import { UpdateItemDto } from "./dto/update-item.dto";
@Controller()
export class ItemController {
constructor(private readonly itemService: ItemService) {}
@MessagePattern({ cmd: "createItem" })
async createItem(@Payload() createItemDto: CreateItemDto) {
try {
const item = await this.itemService.create(createItemDto);
return item;
} catch (error) {
throw new RpcException({
status: error.status || 500,
message: error.message || "Failed to create item",
});
}
}
@MessagePattern({ cmd: "findAllItems" })
async findAllItems() {
return this.itemService.findAll();
}
@MessagePattern({ cmd: "findOneItem" })
async findOneItem(@Payload() data: { id: string }) {
const item = await this.itemService.findOne(data.id);
if (!item) {
throw new RpcException({
status: 404,
message: `Item with ID ${data.id} not found`,
});
}
return item;
}
@MessagePattern({ cmd: "updateItem" })
async updateItem(@Payload() data: { id: string; updateItemDto: UpdateItemDto }) {
return this.itemService.update(data.id, data.updateItemDto);
}
@MessagePattern({ cmd: "deleteItem" })
async deleteItem(@Payload() data: { id: string }) {
return this.itemService.remove(data.id);
}
}
Conventions de nommage des patterns
Un nommage cohérent facilite le débogage. Nous suivons deux conventions :
- Patterns objet pour la clarté :
{ cmd: "createItem" } - Chaînes versionnées pour les APIs en évolution :
item.v1.create,item.v2.create
Filtre global d'exceptions RPC
La gestion des erreurs dans les microservices nécessite une attention particulière. Lorsque quelque chose tourne mal, vous devez propager des erreurs significatives vers la gateway sans exposer les détails internes.
import { Catch, ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { RpcException } from "@nestjs/microservices";
import { Observable, throwError } from "rxjs";
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): Observable {
if (exception instanceof RpcException) {
const error = exception.getError();
return throwError(() => error);
}
console.error("Unexpected microservice error:", exception);
return throwError(() => ({
status: 500,
message: "Internal microservice error",
timestamp: new Date().toISOString(),
}));
}
}
Gestion des délais d'attente et résilience
Dans les systèmes distribués, les services peuvent devenir lents ou ne plus répondre. Implémentez toujours des délais d'attente et envisagez des stratégies de réessai pour prévenir les défaillances en cascade.
import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { firstValueFrom, timeout, retry, catchError, throwError } from "rxjs";
@Injectable()
export class ResilientService {
constructor(
@Inject("MICROSERVICE_ONE") private microserviceOne: ClientProxy,
) {}
async callWithResilience(pattern: any, payload: any): Promise {
return firstValueFrom(
this.microserviceOne.send(pattern, payload).pipe(
timeout(5000),
retry({ count: 2, delay: 1000 }),
catchError((error) => {
return throwError(() =>
new ServiceUnavailableException("Service temporarily unavailable")
);
}),
),
);
}
}
Liste de contrôle pour le déploiement en production
- Configuration des réessais : Définissez
retryAttempts: 5etretryDelay: 3000pour gérer les problèmes d'ordre de démarrage dans l'orchestration de conteneurs. - Variables d'environnement : Ne codez jamais en dur les hôtes ou les ports. Utilisez ConfigService avec des valeurs par défaut sensibles.
- Vérifications de santé : Implémentez des endpoints
/healthen utilisant@nestjs/terminuspour l'orchestration de conteneurs. - Validation : Utilisez
ValidationPipeglobalement dans la gateway et les microservices. - Délais d'attente : Définissez toujours des délais d'attente sur les appels aux microservices pour prévenir les défaillances en cascade.
- Journalisation : Utilisez la journalisation structurée avec des identifiants de corrélation pour tracer les requêtes entre les services.
- Réseau Docker : Utilisez les noms de services plutôt que les IPs dans Docker Compose ou Kubernetes.
Conclusion
Construire des microservices avec NestJS combine l'excellente expérience développeur du framework avec des patterns éprouvés pour les systèmes distribués. Les points clés à retenir :
- TCP pour les opérations synchrones où les utilisateurs attendent des réponses
- Utilisez registerAsync pour une injection de configuration correcte
- Implémentez toujours des délais d'attente et des stratégies de réessai pour la résilience
- RpcException pour une propagation d'erreurs significative entre les services
- ValidationPipe globalement dans la gateway et les microservices
Cette architecture s'adapte horizontalement. Chaque microservice peut être déployé indépendamment, mis à l'échelle en fonction de la charge, et mis à jour sans affecter les autres. NestJS rend le câblage piloté par la configuration, vous permettant de vous concentrer sur la logique métier tandis que le framework gère la complexité de la communication distribuée.
Lectures complémentaires
Ces articles couvrent l'infrastructure et la couche de messagerie qui complète cette architecture de microservices :
- Comment nos services se parlent : les files de messages expliquées — comment RabbitMQ découple les microservices NestJS décrits ici, permettant la communication asynchrone entre les services Node.js et Python.
- Comment nous avons déployé et mis à l'échelle sur Azure : un guide de production — la configuration Azure Container Apps qui exécute ces microservices NestJS en production.
L'architecture complète de production est documentée dans la section projets, incluant la structure du monorepo Turborepo dans lequel ces services résident.
Écrit par

Tech Lead et Ingénieur Full Stack pilotant une équipe de 5 ingénieurs chez Fygurs (Paris, Remote) sur un SaaS cloud-native Azure. Diplômé de 1337 Coding School (42 Network / UM6P). Écrit sur l'architecture, l'infrastructure cloud et le leadership technique.