Guide technique

Conception d'API pour des systemes qui survivent a ton equipe

Comment concevoir des API qui survivent au turnover. Protobuf comme source de verite, tRPC pour les stacks TypeScript, GraphQL en production, gestion des breaking changes et contrats d'erreur.

5 avril 202614 min de lectureÉquipe d'Ingénierie Oronts

L'API qui survit a son createur

Chaque API demarre avec une equipe, un client et un cas d'usage. Deux ans plus tard, 5 equipes en dependent, 3 partenaires externes s'y integrent, et les developpeurs originaux sont partis. Les decisions de conception de l'API sont devenues permanentes.

Nous avons construit des API avec trois strategies differentes : Protobuf comme source de verite pour les systemes multi-langages, tRPC pour les stacks 100% TypeScript, et GraphQL pour les besoins clients complexes. Chacune etait le bon choix dans son contexte. Cet article couvre quand utiliser chacune et comment concevoir des API qui survivent au turnover.

Pour comprendre comment ces API s'integrent dans une architecture plus large, consulte notre guide d'architecture systeme et notre guide d'ingenierie logicielle.

Protobuf : source de verite pour les systemes multi-langages

Quand ton systeme couvre plusieurs langages (API TypeScript, service de recherche Go, pipeline ML Python), tu as besoin d'un format de schema qui genere des types pour chacun d'eux. Protobuf fait exactement ca.

// proto/product.proto
syntax = "proto3";
package commerce.v1;

message Product {
    string id = 1;
    string name = 2;
    string description = 3;
    int32 price_cents = 4;
    string currency = 5;
    repeated string category_ids = 6;
    ProductStatus status = 7;
    google.protobuf.Timestamp created_at = 8;
}

enum ProductStatus {
    PRODUCT_STATUS_UNSPECIFIED = 0;
    PRODUCT_STATUS_DRAFT = 1;
    PRODUCT_STATUS_ACTIVE = 2;
    PRODUCT_STATUS_ARCHIVED = 3;
}

service ProductService {
    rpc GetProduct(GetProductRequest) returns (Product);
    rpc ListProducts(ListProductsRequest) returns (ListProductsResponse);
    rpc CreateProduct(CreateProductRequest) returns (Product);
}

Pourquoi les tags comptent pour l'evolution

Les champs Protobuf sont identifies par des numeros de tag (1, 2, 3...), pas par leur nom. Ca veut dire que tu peux renommer un champ sans casser les clients existants. Tu peux ajouter de nouveaux champs (nouveaux numeros de tag) sans casser les anciens clients. Tu peux deprecier des champs sans les supprimer.

// Evolution securisee : ajout d'un champ
message Product {
    string id = 1;
    string name = 2;
    string description = 3;
    int32 price_cents = 4;
    string currency = 5;
    repeated string category_ids = 6;
    ProductStatus status = 7;
    google.protobuf.Timestamp created_at = 8;
    string sku = 9;          // NOUVEAU : ajoute sans casser les clients existants
    string brand = 10;       // NOUVEAU : les anciens clients ignorent les champs inconnus
}

Regles pour une evolution Protobuf securisee :

  • Ne reutilise jamais un numero de tag (meme apres avoir supprime un champ)
  • Ne change jamais le type d'un champ
  • Ajoute les nouveaux champs avec de nouveaux numeros de tag
  • Marque les champs deprecies avec reserved pour eviter la reutilisation accidentelle

Generation de code

# Genere TypeScript, Go et Python a partir du meme fichier .proto
protoc --ts_out=./gen/ts --go_out=./gen/go --python_out=./gen/py proto/*.proto

Un seul fichier de schema genere des clients et des serveurs type-safe dans chaque langage. Les modifications de schema passent par une pull request. La generation de code est une etape CI. Les incoherences de types sont des erreurs de compilation, pas des bugs runtime.

tRPC : quand tout ton stack est TypeScript

Si ton serveur API et tous tes clients sont en TypeScript, tRPC elimine completement la couche de schema. Les types circulent du serveur au client a la compilation. Pas de generation de code, pas de fichiers de schema, pas de spec OpenAPI.

// Serveur : definition du router avec des procedures typees
import { router, publicProcedure, protectedProcedure } from './trpc';
import { z } from 'zod';

export const productRouter = router({
    list: publicProcedure
        .input(z.object({
            cursor: z.string().optional(),
            limit: z.number().min(1).max(100).default(20),
            category: z.string().optional(),
        }))
        .query(async ({ input, ctx }) => {
            return ctx.productService.list(input);
        }),

    create: protectedProcedure
        .input(z.object({
            name: z.string().min(1).max(200),
            price: z.number().positive(),
            description: z.string().optional(),
        }))
        .mutation(async ({ input, ctx }) => {
            return ctx.productService.create(ctx.tenantId, input);
        }),
});

// Client : inference de types complete, pas de generation de code
const products = await trpc.product.list.query({
    limit: 10,
    category: 'electronics',
});
// products est entierement type : { items: Product[], nextCursor?: string }

Quand tRPC fonctionne le mieux

  • Tous les clients sont en TypeScript (app web, React Native, services Node.js)
  • L'API est interne (pas exposee a des tiers)
  • L'iteration rapide compte plus que les contrats d'API formels
  • L'equipe est petite et co-localisee (les changements serveur et client se font ensemble)

Quand tRPC ne fonctionne pas

  • Des partenaires externes doivent s'integrer (ils ont besoin de docs OpenAPI/Swagger)
  • Les clients sont dans d'autres langages (mobile natif, Go, Python)
  • Tu as besoin de versionnement d'API pour la retro-compatibilite
  • L'API est un produit public

Nous utilisons tRPC avec Hono pour les API TypeScript internes. Consulte notre guide des backends TypeScript pour la comparaison complete des stacks.

GraphQL : pour les besoins clients complexes

GraphQL brille quand les clients ont besoin de requetes flexibles : differentes pages necessitent differents sous-ensembles de donnees, le mobile a besoin de moins de donnees que le web, et l'equipe frontend veut iterer sur les requetes sans modifications backend.

# Le client demande exactement ce dont il a besoin
query ProductPage($slug: String!) {
    product(slug: $slug) {
        id
        name
        price
        images { url alt }
        reviews(first: 5, status: APPROVED) {
            items { rating body customerName }
            totalItems
        }
        relatedProducts(first: 4) {
            id name price images { url }
        }
    }
}

Patterns GraphQL en production

Requetes persistees : N'autorise pas les requetes arbitraires en production. Les clients envoient un hash de requete, le serveur cherche la requete dans un registre. Ca empeche les abus de requetes et permet le caching.

Limitation de profondeur : Sans limites, un client peut envoyer une requete profondement imbriquee qui fait des jointures sur toutes les tables de ta base de donnees.

apiOptions: {
    middleware: [depthLimit(10)],
    shopApiPlayground: false,  // Desactive en production
}

Prevention du N+1 : Utilise DataLoader pour regrouper les requetes en base de donnees. Sans ca, une requete qui recupere 20 produits avec leurs categories fait 20 requetes de categories separees.

// DataLoader regroupe N requetes individuelles en 1
const categoryLoader = new DataLoader(async (ids: string[]) => {
    const categories = await categoryRepo.findByIds(ids);
    return ids.map(id => categories.find(c => c.id === id));
});

Analyse de complexite : Assigne un cout a chaque champ. Rejette les requetes qui depassent un budget de complexite.

Pour voir comment nous utilisons GraphQL dans Vendure commerce, consulte notre guide de production Vendure.

Breaking changes : evolution par tags vs versionnement par URL

StrategieComment ca fonctionneIdeal pour
Par tags (Protobuf)Ajoute des champs avec de nouveaux tags, les anciens clients les ignorentMulti-langages, gRPC
Versionnement par URL (/v1/, /v2/)Endpoints separes par versionAPI REST avec consommateurs externes
Versionnement par header (Accept: application/vnd.api.v2+json)Meme URL, version dans le headerAPI REST avec des URLs propres
GraphQL (pas de versionnement)Ajoute des champs, deprecie les anciens avec @deprecatedAPI GraphQL
tRPC (pas de versionnement)Les types evoluent avec la codebaseAPI TypeScript internes

Pour la plupart des API internes, evite le versionnement par URL. Ca double ta surface de maintenance. Ajoute de nouveaux champs, deprecie les anciens, supprime-les une fois que tous les clients ont migre.

Pour les API externes (integrations tiers, API publiques), le versionnement par URL est plus sur parce que tu ne controles pas quand les clients mettent a jour.

Contrats d'erreur

Les erreurs font partie du contrat d'API. Les clients ont besoin d'erreurs structurees sur lesquelles ils peuvent agir, pas de messages texte qu'ils parsent avec des regex.

// Reponse d'erreur structuree
interface ApiError {
    code: string;           // Lisible par machine : "PRODUCT_NOT_FOUND", "INSUFFICIENT_STOCK"
    message: string;        // Lisible par humain : "Product with ID xyz not found"
    details?: object;       // Contexte supplementaire pour le debug
    requestId: string;      // ID de correlation pour le support
}

// Exemples de reponses
// 404
{
    "code": "PRODUCT_NOT_FOUND",
    "message": "Product with ID prod_123 not found",
    "requestId": "req_abc456"
}

// 409
{
    "code": "INSUFFICIENT_STOCK",
    "message": "Only 2 units available, requested 5",
    "details": { "available": 2, "requested": 5 },
    "requestId": "req_def789"
}

// 422
{
    "code": "VALIDATION_ERROR",
    "message": "Invalid input",
    "details": {
        "fields": [
            { "field": "price", "error": "must be positive" },
            { "field": "name", "error": "must not be empty" }
        ]
    },
    "requestId": "req_ghi012"
}

Le champ code est le contrat. Les clients font un switch dessus. Le message est pour les humains. Les details fournissent du contexte. Le requestId permet au support de retrouver la requete exacte dans les logs.

Gouvernance d'API

Au fur et a mesure que l'API grandit, tu as besoin de gouvernance pour eviter les incoherences :

ReglePourquoi
Tous les endpoints necessitent une authentificationPas d'endpoints publics accidentels
Toutes les mutations necessitent des cles d'idempotenceLes retries reseau ne creent pas de doublons
Toutes les reponses incluent un requestIdChaque requete est tracable
Toutes les erreurs utilisent le format structureLes clients peuvent gerer les erreurs de maniere programmatique
Tous les endpoints de liste supportent la paginationPas de requetes non bornees
Les breaking changes necessitent une reviewUne seule personne ne peut pas casser tous les consommateurs
Les depreciations necessitent un calendrier de migration"Deprecie" sans deadline signifie "jamais supprime"

Erreurs courantes

  1. REST vs GraphQL comme identite. Choisis en fonction des besoins clients, pas de la preference de l'equipe. REST pour du CRUD simple avec des consommateurs externes. GraphQL pour des requetes flexibles avec les equipes frontend. tRPC pour du TypeScript interne. Protobuf pour du multi-langages.

  2. Pas de contrat d'erreur. Des messages d'erreur textuels qui changent a chaque release cassent chaque client qui essaie de les gerer.

  3. Endpoints de liste non bornes. Un endpoint qui retourne les 50 000 produits en une seule reponse va planter les clients et surcharger ta base de donnees. La pagination est obligatoire.

  4. Versionner les API internes. Si tu controles tous les clients, fais evoluer l'API en place. Le versionnement double la maintenance sans benefice.

  5. Pas de calendrier de depreciation. Marquer un champ @deprecated sans date de suppression signifie qu'il reste pour toujours. Fixe une date, notifie les consommateurs, supprime-le.

  6. GraphQL sans limites de profondeur. Une API GraphQL sans restrictions est un vecteur de deni de service. Limite la profondeur, la complexite et le cout des requetes.

Points cles a retenir

  • Protobuf pour les systemes multi-langages. Evolution par tags, generation de code, efficacite du format wire. Le schema est le contrat.

  • tRPC pour les stacks 100% TypeScript. Zero generation de code, inference de types complete, vitesse d'iteration maximale. Mais ca ne fonctionne que quand tous les clients sont en TypeScript.

  • GraphQL pour les besoins clients flexibles. Les clients requetent exactement ce dont ils ont besoin. Mais ajoute des limites de profondeur, des requetes persistees et une analyse de complexite pour la production.

  • Les contrats d'erreur sont aussi importants que les contrats de succes. Des codes lisibles par machine, des messages lisibles par humain, des ID de correlation et des details structures.

  • Evolue en place pour les API internes, versionne pour les externes. Ajouter des champs est safe. Supprimer des champs necessite un calendrier de migration. Le versionnement par URL est un dernier recours.

Nous concevons des API dans le cadre de nos pratiques de developpement web et de logiciel sur mesure. Si tu as besoin d'aide pour l'architecture de tes API, parle a notre equipe ou demande un devis.

Sujets couverts

conception APIversionnement APIstabilite APIbreaking changesAPI contract-firsttRPCGraphQL designProtobufgouvernance API

Prêt à construire des systèmes IA prêts pour la production ?

Notre équipe est spécialisée dans les systèmes IA prêts pour la production. Discutons de comment nous pouvons aider.

Démarrer une conversation