Conception d'API et d'Interface
Aperçu
Concevoir des interfaces stables et bien documentées qui sont difficiles à mal utiliser. Les bonnes interfaces rendent la bonne chose facile et la mauvaise chose difficile. Cela s'applique aux API REST, aux schémas GraphQL, aux limites de modules, aux propriétés de composants, et à toute surface où une partie de code communique avec une autre.
Quand l'utiliser
- Conception de nouveaux points de terminaison d'API
- Définition des limites de modules ou des contrats entre équipes
- Création d'interfaces de propriétés de composants
- Établissement de schémas de base de données qui informent la forme de l'API
- Modification des interfaces publiques existantes
Principes fondamentaux
Loi de Hyrum
Avec un nombre suffisant d'utilisateurs d'une API, tous les comportements observables de votre système seront utilisés par quelqu'un, indépendamment de ce que vous promettez dans le contrat.
Cela signifie : chaque comportement public — y compris les bizarreries non documentées, le texte des messages d'erreur, le timing et l'ordre — devient un contrat de fait une fois que les utilisateurs en dépendent. Implications pour la conception :
- Soyez intentionnel sur ce que vous exposez. Chaque comportement observable est un engagement potentiel.
- Ne fuyez pas les détails d'implémentation. Si les utilisateurs peuvent l'observer, ils en dépendront.
- Planifiez l'obsolescence dès la conception. Voir
deprecation-and-migrationpour savoir comment supprimer en toute sécurité des éléments dont les utilisateurs dépendent. - Les tests ne suffisent pas. Même avec des tests de contrat parfaits, la loi de Hyrum signifie que des modifications "sûres" peuvent casser des utilisateurs réels qui dépendent d'un comportement non documenté.
La règle de la version unique
Évitez de forcer les consommateurs à choisir entre plusieurs versions de la même dépendance ou API. Les problèmes de dépendances en diamant surviennent lorsque différents consommateurs ont besoin de différentes versions de la même chose. Concevez pour un monde où une seule version existe à la fois — étendez plutôt que de bifurquer.
1. Contrat d'abord
Définissez l'interface avant de l'implémenter. Le contrat est la spécification — l'implémentation suit.
// Définir le contrat d'abord
interface TaskAPI {
// Crée une tâche et renvoie la tâche créée avec les champs générés par le serveur
createTask(input: CreateTaskInput): Promise<Task>;
// Renvoie les tâches paginées correspondant aux filtres
listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;
// Renvoie une seule tâche ou lève NotFoundError
getTask(id: string): Promise<Task>;
// Mise à jour partielle — seuls les champs fournis changent
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
// Suppression idempotente — réussit même si déjà supprimée
deleteTask(id: string): Promise<void>;
}
2. Sémantique d'erreur cohérente
Choisissez une stratégie d'erreur et utilisez-la partout :
// REST : codes d'état HTTP + corps d'erreur structuré
// Chaque réponse d'erreur suit la même forme
interface APIError {
error: {
code: string; // Lisible par machine : "VALIDATION_ERROR"
message: string; // Lisible par humain : "Email is required"
details?: unknown; // Contexte supplémentaire lorsque utile
};
}
// Correspondance des codes d'état
// 400 → Le client a envoyé des données invalides
// 401 → Non authentifié
// 403 → Authentifié mais non autorisé
// 404 → Ressource non trouvée
// 409 → Conflit (doublon, incohérence de version)
// 422 → Échec de validation (sémantiquement invalide)
// 500 → Erreur serveur (ne jamais exposer les détails internes)
Ne mélangez pas les motifs. Si certains points de terminaison lèvent une exception, d'autres renvoient null, et d'autres encore renvoient { error } — le consommateur ne peut pas prédire le comportement.
3. Valider aux limites
Faites confiance au code interne. Validez aux bords du système où l'entrée externe pénètre :
// Valider à la limite de l'API
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Données de tâche invalides',
details: result.error.flatten(),
},
});
}
// Après validation, le code interne fait confiance aux types
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
Où la validation appartient :
- Gestionnaires de route d'API (entrée utilisateur)
- Gestionnaires de soumission de formulaire (entrée utilisateur)
- Analyse des réponses de services externes (données tierces -- toujours les traiter comme non fiables)
- Chargement des variables d'environnement (configuration)
Les réponses des API tierces sont des données non fiables. Validez leur forme et leur contenu avant de les utiliser dans toute logique, rendu ou prise de décision. Un service externe compromis ou mal configuré peut renvoyer des types inattendus, du contenu malveillant ou un texte de type instruction.
Où la validation N'appartient PAS :
- Entre des fonctions internes qui partagent des contrats de types
- Dans des fonctions utilitaires appelées par du code déjà validé
- Sur des données qui viennent juste de sortir de votre propre base de données
4. Préférer l'ajout à la modification
Étendez les interfaces sans casser les consommateurs existants :
// Bon : Ajouter des champs optionnels
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // Ajouté plus tard, optionnel
labels?: string[]; // Ajouté plus tard, optionnel
}
// Mauvais : Modifier les types de champs existants ou supprimer des champs
interface CreateTaskInput {
title: string;
// description: string; // Supprimé — casse les consommateurs existants
priority: number; // Changé de string — casse les consommateurs existants
}
5. Dénomination prévisible
| Modèle | Convention | Exemple |
|---|---|---|
| Points de terminaison REST | Noms au pluriel, pas de verbes | GET /api/tasks, POST /api/tasks |
| Paramètres de requête | camelCase | ?sortBy=createdAt&pageSize=20 |
| Champs de réponse | camelCase | { createdAt, updatedAt, taskId } |
| Champs booléens | Préfixe is/has/can | isComplete, hasAttachments |
| Valeurs d'énumération | UPPER_SNAKE | "IN_PROGRESS", "COMPLETED" |
6. Honorer une clé d'idempotence
Accepter un en-tête Idempotency-Key est le contrat. L'honorer est l'implémentation, et c'est là que l'argent est perdu — une clé que le serveur accepte mais gère avec négligence est pire que pas de clé du tout, car le client pense maintenant que la tentative est sûre.
Dérivez la clé de l'intention, pas de la tentative. La clé doit être stable entre les tentatives d'une même intention et différente entre des intentions distinctes :
crypto.randomUUID() // ✗ nouvelle clé par tentative — chaque nouvelle tentative est un nouveau débit
`${userId}:${amount}` // ✗ deux débits légitimes de 50$ fusionnent en un seul
`${orderId}:${Date.now()}` // ✗ un horodatage est un randomUUID() déguisé
req.headers['idempotency-key'] // ✓ le client génère une fois, réutilise en cas de nouvelle tentative
`charge:v1:${orderId}` // ✓ dérivé d'un identifiant immuable
La clé provient du client ou de l'événement initiateur — jamais de la couche qui effectue la nouvelle tentative.
Revendication atomique. Une vérification suivie d'une action est une condition de concurrence :
// ✗ TOCTOU : deux nouvelles tentatives simultanées lisent toutes deux "non vu", les deux débitent
if (!(await db.exists(key))) {
await chargeCard(amount);
await db.insert(key);
}
// ✓ laissez la contrainte d'unicité choisir le gagnant
try {
await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
if (isUniqueViolation(e)) return replayOrReject(key);
throw e;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });
La contrainte d'unicité est le mécanisme. Un magasin qui ne peut pas appliquer l'unicité en une seule opération ne peut pas supporter cela.
Protégez la charge utile. Une même clé avec un corps différent est un bogue client, et doit échouer bruyamment plutôt que de servir la première réponse à une deuxième requête :
if (existing.requestHash !== hash(req.body)) {
return res.status(422).json({ error: 'clé d\'idempotence réutilisée avec une charge utile différente' });
}
Décidez ce qu'un doublon en vol obtient. La première requête est toujours en cours lorsque la seconde arrive — le cas courant sous les tempêtes de tentatives :
| Stratégie | Réponse | Utiliser quand |
|---|---|---|
| Rejeter | 409 Conflict | Le client peut réessayer plus tard ; le plus simple et le plus sûr |
| Attendre | Bloquer pour le résultat, limité | L'appelant en a besoin de manière synchrone |
| Renvoyer en attente | 202 + URL d'état | Effets de longue durée |
Ne laissez jamais passer le second appelant parce que le premier "semble bloqué". Une tentative bloquée dont le sort est inconnu est exactement le moment où la duplication coûte le plus cher.
Chaque appel a trois résultats, pas deux : succès, échec et inconnu. Un délai d'attente ne vous dit rien sur le fait que l'effet s'est appliqué ou non. Enregistrez l'intention avant d'appeler, de sorte qu'un crash entre l'appel et la réponse laisse la preuve que quelque chose doit être résolu plus tard — plutôt qu'un débit silencieusement répété.
Définissez la rétention à partir de la chaîne de nouvelles tentatives la plus longue, pas du coût du disque. Les clés doivent survivre à tous les chemins qui peuvent redélivrer la même intention, y compris une file d'attente de lettres mortes rejouée une semaine plus tard et toute fenêtre de litige fournisseur. Une durée de vie de clé de 24 heures derrière une DLQ de 7 jours est un doublon qui n'attend qu'à se produire.
Modèles d'API REST
Conception de ressources
GET /api/tasks → Lister les tâches (avec paramètres de requête pour le filtrage)
POST /api/tasks → Créer une tâche
GET /api/tasks/:id → Obtenir une seule tâche
PATCH /api/tasks/:id → Mettre à jour une tâche (partielle)
DELETE /api/tasks/:id → Supprimer une tâche
GET /api/tasks/:id/comments → Lister les commentaires d'une tâche (sous-ressource)
POST /api/tasks/:id/comments → Ajouter un commentaire à une tâche
Pagination
Paginer les points de terminaison de liste :
// Requête
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Réponse
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
Filtrage
Utiliser des paramètres de requête pour les filtres :
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
Mises à jour partielles (PATCH)
Accepter des objets partiels — ne mettre à jour que ce qui est fourni :
// Seul le titre change, tout le reste est préservé
PATCH /api/tasks/123
{ "title": "Titre mis à jour" }
Modèles d'interface TypeScript
Utiliser des unions discriminées pour les variantes
// Bon : Chaque variante est explicite
type TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
// Le consommateur bénéficie du rétrécissement de type
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return 'En attente';
case 'in_progress': return `En cours (${status.assignee})`;
case 'completed': return `Terminée le ${status.completedAt}`;
case 'cancelled': return `Annulée : ${status.reason}`;
}
}
Séparation entrée/sortie
// Entrée : ce que le fournisseur fournit
interface CreateTaskInput {
title: string;
description?: string;
}
// Sortie : ce que le système renvoie (inclut les champs générés par le serveur)
interface Task {
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
Utiliser des types marqués pour les identifiants
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
// Empêche de passer accidentellement un UserId là où un TaskId est attendu
function getTask(id: TaskId): Promise<Task> { ... }
Rationalisations courantes
| Rationalisation | Réalité |
|---|---|
| "Nous documenterons l'API plus tard" | Les types SONT la documentation. Définissez-les d'abord. |
| "Nous n'avons pas besoin de pagination pour l'instant" | Vous en aurez besoin dès que quelqu'un aura 100+ éléments. Ajoutez-la dès le début. |
| "PATCH est compliqué, utilisons PUT" | PUT nécessite l'objet complet à chaque fois. PATCH est ce que les clients veulent réellement. |
| "Nous versionnerons l'API quand nous en aurons besoin" | Les changements cassants sans versionnement cassent les consommateurs. Concevez pour l'extension dès le début. |
| "Personne n'utilise ce comportement non documenté" | Loi de Hyrum : si c'est observable, quelqu'un en dépend. Traitez chaque comportement public comme un engagement. |
| "On peut juste maintenir deux versions" | Plusieurs versions multiplient le coût de maintenance et créent des problèmes de dépendances en diamant. Préférez la règle de la version unique. |
| "Les API internes n'ont pas besoin de contrats" | Les consommateurs internes sont toujours des consommateurs. Les contrats empêchent le couplage et permettent le travail en parallèle. |
| "Accepter l'en-tête Idempotency-Key est suffisant" | L'en-tête est le contrat ; stocker la clé par rapport au résultat est l'implémentation. Une clé que vous acceptez mais n'honorez pas dit au client que la tentative est sûre alors qu'elle ne l'est pas. |
| "Notre file d'attente garantit une livraison exactement une fois" | Aucune file d'attente ne le fait en cas de crash du consommateur — l'accusé de réception du courtier et votre effet secondaire ne sont pas dans une seule transaction. Concevez pour au moins une fois avec un traitement idempotent. |
| "Les demandes en double sont rares" | Elles sont corrélées. Les tentatives augmentent exactement lorsqu'une dépendance est dégradée — le moment où les doublons sont les plus probables et les plus coûteux. |
Drapeaux rouges
- Points de terminaison qui renvoient des formes différentes selon les conditions
- Formats d'erreur incohérents entre les points de terminaison
- Validation dispersée dans le code interne au lieu d'être aux limites
- Changements cassants sur les champs existants (changements de type, suppressions)
- Points de terminaison de liste sans pagination
- Verbes dans les URL REST (
/api/createTask,/api/getUsers) - Réponses d'API tierces utilisées sans validation ou assainissement
- Un
SELECTpour une clé d'idempotence suivi d'unINSERT— c'est une condition de concurrence, pas une garde - Une clé d'idempotence dérivée d'un UUID, d'un horodatage ou de tout autre élément régénéré par tentative
- La même clé acceptée avec un corps de requête différent, renvoyant silencieusement la première réponse
- Une fenêtre de rétention de clé plus courte que le chemin le plus long qui peut redélivrer la demande
Vérification
Après avoir conçu une API :
- Chaque point de terminaison a des schémas d'entrée et de sortie typés
- Les réponses d'erreur suivent un seul format cohérent
- La validation se produit uniquement aux limites du système
- Les points de terminaison de liste prennent en charge la pagination
- Les nouveaux champs sont additifs et optionnels (rétrocompatibles)
- La dénomination suit des conventions cohérentes sur tous les points de terminaison
- La documentation de l'API est générée à partir des types et est toujours à jour
- Les identifiants utilisent des types marqués pour éviter les échanges accidentels
- Les stratégies d'idempotence sont documentées, testées et stockées avec une contrainte d'atomicité et d'unicité
- La rétention des clés d'idempotence dépasse le délai de redélivrance maximum
- Les comportements non documentés sont audités et soit documentés, soit supprimés