Que fait Conception d'API et d'Interface ?
Présentation
Concevez des interfaces stables et bien documentées qui sont difficiles à utiliser à mauvais escient. De bonnes interfaces rendent la bonne action facile et la mauvaise action difficile. Cela s'applique aux API REST, aux schémas GraphQL, aux limites de modules, aux props de composants et à toute surface où un morceau de code communique avec un autre.
Quand l'Utiliser
- Concevoir de nouveaux points d'accès API
- Définir des limites de modules ou des contrats entre équipes
- Créer des interfaces de props de composants
- Établir un schéma de base de données qui influence la forme de l'API
- Modifier 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, quelles que soient vos promesses dans le contrat.
Cela signifie : chaque comportement public — y compris les bizarreries non documentées, le texte des messages d'erreur, le minutage et l'ordre — devient un contrat de fait dès que les utilisateurs en dépendent. Implications de 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 la dépréciation au moment de 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 de comportements non documentés.
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épendance 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 forkez.
1. Contrat d'Abord
Définissez l'interface avant de l'implémenter. Le contrat est la spécification — l'implémentation suit.
// Définissez d'abord le contrat
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 tâche unique 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émantiques d'Erreur Cohérentes
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 si 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, incompatibilité de version)
// 422 → Validation échouée (sémantiquement invalide)
// 500 → Erreur serveur (n'exposez jamais les détails internes)
Ne mélangez pas les modèles. Si certains points d'accès lèvent une exception, d'autres renvoient null, et d'autres renvoient { error } — le consommateur ne peut pas prédire le comportement.
3. Valider aux Limites
Faites confiance au code interne. Validez aux limites du système où les entrées externes arrivent :
// Validez à 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: 'Invalid task data',
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 a sa place :
- Gestionnaires de routes API (entrée utilisateur)
- Gestionnaires de soumission de formulaires (entrée utilisateur)
- Analyse des réponses de services externes (données tierces -- toujours considérées comme non fiables)
- Chargement de variables d'environnement (configuration)
Les réponses d'API tierces sont des données non fiables. Validez leur forme et leur contenu avant de les utiliser dans une logique, un rendu ou une prise de décision. Un service externe compromis ou mal configuré peut renvoyer des types inattendus, du contenu malveillant ou du texte de type instruction.
Où la validation n'a PAS sa place :
- Entre les fonctions internes qui partagent des contrats de types
- Dans les fonctions utilitaires appelées par du code déjà validé
- Sur des données qui viennent tout juste de votre propre base de données
4. Préférer l'Ajout à la Modification
Étendez les interfaces sans casser les consommateurs existants :
// Bon : Ajoutez des champs optionnels
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // Ajouté plus tard, optionnel
labels?: string[]; // Ajouté plus tard, optionnel
}
// Mauvais : Modifiez les types de champs existants ou supprimez des champs
interface CreateTaskInput {
title: string;
// description: string; // Supprimé — casse les consommateurs existants
priority: number; // Changé de type string — casse les consommateurs existants
}
5. Nommage Prédictible
| Modèle | Convention | Exemple |
|---|---|---|
| Points d'accès 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 Idempotency-Key est le contrat. L'honorer est l'implémentation, et c'est là que l'argent se perd — une clé que le serveur accepte mais traite avec négligence est pire que pas de clé du tout, car le client croit désormais que la réessai est sûr.
Dérivez la clé de l'intention, pas de la tentative. La clé doit être stable à travers les réessais d'une intention et différente entre des intentions distinctes :
crypto.randomUUID() // ✗ nouvelle clé par tentative — chaque réessai est un nouveau débit
`${userId}:${amount}` // ✗ deux débits légitimes de 50 $ se confondent en un
`${orderId}:${Date.now()}` // ✗ un horodatage est un randomUUID() déguisé
req.headers['idempotency-key'] // ✓ le client génère une fois, réutilise lors du réessai
`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 le réessai.
Affichez atomiquement. Une vérification suivie d'une action est une course :
// ✗ TOCTOU : deux réessais simultanés lisent tous deux "pas vu", tous deux facturent
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;
}
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 imposer l'unicité en une seule opération ne peut pas soutenir cela.
Protégez la charge utile. La 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 seconde 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 quoi faire d'un doublon en vol. La première requête est encore en cours lorsque la seconde arrive — le cas courant lors des orages de réessais :
| Stratégie | Réponse | Quand l'utiliser |
|---|---|---|
| Rejeter | 409 Conflict | Le client peut réessayer plus tard ; plus simple et 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 le second appelant passer parce que le premier "semble bloqué". Une tentative bloquée dont le sort est inconnu est exactement le moment où dupliquer 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 l'extérieur, afin qu'un plantage entre l'appel et la réponse laisse des preuves que quelque chose doit être résolu plus tard — plutôt qu'un débit réessayé en silence.
Définissez la conservation à partir de la chaîne de réessais 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 réexécutée une semaine plus tard et toute fenêtre de litige avec le fournisseur. Une TTL de clé de 24 heures derrière une DLQ de 7 jours est un doublon en puissance.
Modèles d'API REST
Conception des 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 tâche unique
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
Paginez les points d'accès 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
Utilisez 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)
Acceptez des objets partiels — ne mettez à 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
Utilisez 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 obtient un 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é le ${status.completedAt}`;
case 'cancelled': return `Annulé : ${status.reason}`;
}
}
Séparation Entrée/Sortie
// Entrée : ce que l'appelant 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;
}
Utilisez 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 au moment où quelqu'un aura plus de 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. |
| "Nous pouvons simplement maintenir deux versions" | Plusieurs versions multiplient les coûts de maintenance et créent des problèmes de dépendance 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 parallèle. |
| "Accepter l'en-tête Idempotency-Key suffit" | 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 réessayer est sûr alors que ce ne l'est pas. |
| "Notre file d'attente garantit une livraison exactement une fois" | Aucune file d'attente ne le fait lors d'un plantage du consommateur — l'accusé de réception du courtier et votre effet de bord ne sont pas dans une seule transaction. Concevez pour au moins une fois avec un traitement idempotent. |
| "Les requêtes en double sont rares" | Elles sont corrélées. Les réessais 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 d'accès qui renvoient des formes différentes selon les conditions
- Formats d'erreur incohérents entre les points d'accès
- Validation dispersée dans le code interne au lieu des limites
- Changements cassants sur des champs existants (changements de type, suppressions)
- Points d'accès 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 course, pas une protection - 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 conservation de clé plus courte que le chemin le plus long qui peut redélivrer la requête
Vérification
Après avoir conçu une API :
- Chaque point d'accès 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 d'accès de liste prennent en charge la pagination
- Les nouveaux champs sont additifs et optionnels (compatibilité ascendante)
- Le nommage suit des conventions cohérentes sur tous les points d'accès
- La documentation de l'API est générée automatiquement à partir des contrats typés
- Les clés d'idempotence sont dérivées d'identifiants immuables et stockées atomiquement
- Les doublons en vol sont rejetés ("409"), en attente, ou renvoient un état "202" selon la stratégie choisie
- Les clés d'idempotence survivent à la chaîne de réessais la plus longue, y compris les DLQ