HATEOAS en .NET : laisse ton API dire au front quels boutons afficher
HATEOAS expliqué par la pratique : l’API expose les actions possibles et le front arrête de recopier les règles métier. Exemples ASP.NET Core et React.
Tu as sûrement déjà vu ce bug. Un utilisateur clique sur « Annuler la commande », et l’API lui répond que non, cette commande ne peut plus être annulée. Le bouton était là, pourtant.
En creusant, tu trouves toujours la même chose. Le backend connaît la règle : il sait si une commande peut être annulée. Et côté front, quelqu’un l’a réécrite avec un if sur le statut, les droits de l’utilisateur et deux exceptions métier. Le jour où la règle a changé, on a pensé à modifier le backend. Pas le front.
REST prévoit un mécanisme pour éviter ça. Il s’appelle HATEOAS. Oui, le nom est dégueulasse. Mais l’idée est plutôt utile, et je t’explique comment je la mets en place en .NET.
Le problème : la même règle écrite deux fois
Prenons une commande. Côté domaine, la règle d’annulation est claire :
public sealed class Order
{
public Guid Id { get; private set; }
public string Number { get; private set; } = "";
public OrderStatus Status { get; private set; }
public Guid CustomerId { get; private set; }
public bool IsCustomMade { get; private set; }
public bool HasPartialShipment { get; private set; }
// La règle, écrite à un seul endroit
public bool CanBeCancelledBy(CurrentUser user) =>
Status is OrderStatus.Draft or OrderStatus.Confirmed
&& !IsCustomMade
&& !HasPartialShipment
&& (user.IsSales || user.CustomerId == CustomerId);
public void Cancel(CurrentUser user)
{
if (!CanBeCancelledBy(user))
throw new InvalidOperationException("Commande non annulable.");
Status = OrderStatus.Cancelled;
}
}Et côté front, pour savoir s’il faut afficher le bouton, on retrouve ça :
const canCancel =
(order.status === 'Draft' || order.status === 'Confirmed') &&
!order.isCustomMade &&
!order.hasPartialShipment &&
(user.roles.includes('sales') || user.customerId === order.customerId);C’est la même règle, recopiée dans un autre langage, par une autre personne, dans un autre dépôt parfois. Tant que personne n’y touche, tout va bien.
Puis le métier décide qu’une commande en préparation peut encore être annulée tant que rien n’est parti. Tu modifies CanBeCancelledBy, tu déploies, et le front continue de cacher le bouton. Ou l’inverse : la règle se durcit côté serveur, et le front propose une action que l’API refuse. Dans les deux cas, l’utilisateur voit une appli incohérente, et toi tu cherches lequel des deux if a raison.
Et une commande, c’est un exemple simple. Sur un logiciel métier avec des devis, des factures, des validations en plusieurs étapes et des droits par rôle, ces règles recopiées se comptent vite par dizaines.
HATEOAS, c’est quoi ?
Hypermedia As The Engine Of Application State. Derrière ce nom, l’idée est simple : la réponse de l’API contient la ressource, plus les liens et les actions disponibles dans son état actuel.
Une commande peut être annulée ? L’API expose l’action d’annulation, avec ce qu’il faut pour l’appeler. Elle a été expédiée et l’annulation est interdite ? L’action n’est plus dans la réponse.
{
"id": "0b6f3c1e-8a52-4d0e-9f1a-6c2d7e4b9a10",
"number": "CMD-2026-0142",
"status": "Confirmed",
"actions": {
"cancel": {
"method": "POST",
"href": "/api/orders/0b6f3c1e-8a52-4d0e-9f1a-6c2d7e4b9a10/cancel"
},
"ship": {
"method": "POST",
"href": "/api/orders/0b6f3c1e-8a52-4d0e-9f1a-6c2d7e4b9a10/ship"
}
}
}La même commande une fois expédiée :
{
"id": "0b6f3c1e-8a52-4d0e-9f1a-6c2d7e4b9a10",
"number": "CMD-2026-0142",
"status": "Shipped",
"actions": {}
}Le front connaît toujours le sens d’« annuler » : le libellé du bouton, la confirmation à demander, l’endroit où l’afficher. Ce qu’il ne fait plus, c’est recalculer si l’action est possible et construire l’URL à la main. Il le découvre dans la réponse.
Ce n’est pas une mode récente. Ça fait partie des contraintes de REST décrites par Roy Fielding dans sa thèse, en 2000. C’est aussi le dernier niveau du modèle de maturité de Richardson, celui que presque toutes les API « REST » sautent.
Côté .NET : une règle, deux usages
Le point clé, c’est que la règle reste à un seul endroit : CanBeCancelledBy dans le domaine. Elle sert deux fois. Une fois pour décider si l’action apparaît dans la réponse, une fois pour valider la demande quand elle arrive.
Je décris les actions avec un petit record :
public sealed record ApiAction(string Method, string Href);
public sealed record OrderActions(ApiAction? Cancel, ApiAction? Ship);
public sealed record OrderResponse(
Guid Id, string Number, string Status, OrderActions Actions);Tu pourrais mettre un Dictionary<string, ApiAction>, mais je préfère un objet typé : chaque action possible apparaît dans le schéma OpenAPI, et le front sait exactement à quoi s’attendre.
Pour le mapping, j’utilise LinkGenerator : les URLs viennent des routes nommées, jamais d’une chaîne écrite à la main.
public static class OrderMapping
{
public static OrderResponse ToResponse(
this Order order, CurrentUser user, LinkGenerator links)
{
ApiAction Post(string endpoint) =>
new("POST", links.GetPathByName(endpoint, new { id = order.Id })!);
var actions = new OrderActions(
Cancel: order.CanBeCancelledBy(user) ? Post("CancelOrder") : null,
Ship: order.CanBeShippedBy(user) ? Post("ShipOrder") : null);
return new OrderResponse(
order.Id, order.Number, order.Status.ToString(), actions);
}
}Pour qu’une action indisponible disparaisse vraiment du JSON au lieu d’apparaître à null, une ligne de config suffit :
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.DefaultIgnoreCondition =
JsonIgnoreCondition.WhenWritingNull);Les endpoints, en Minimal API :
var orders = app.MapGroup("/api/orders").WithTags("Orders").RequireAuthorization();
orders.MapGet("/{id:guid}", async Task<Results<Ok<OrderResponse>, NotFound>> (
Guid id, CurrentUser user, LinkGenerator links, AppDbContext db,
CancellationToken ct) =>
{
var order = await db.Orders.FindAsync([id], ct);
return order is null
? TypedResults.NotFound()
: TypedResults.Ok(order.ToResponse(user, links));
}).WithName("GetOrder");
orders.MapPost("/{id:guid}/cancel",
async Task<Results<NoContent, NotFound, Conflict<string>>> (
Guid id, CurrentUser user, AppDbContext db, CancellationToken ct) =>
{
var order = await db.Orders.FindAsync([id], ct);
if (order is null) return TypedResults.NotFound();
// Le bouton a pu s'afficher avant qu'un collègue expédie la commande
if (!order.CanBeCancelledBy(user))
return TypedResults.Conflict("Cette commande ne peut plus être annulée.");
order.Cancel(user);
await db.SaveChangesAsync(ct);
return TypedResults.NoContent();
}).WithName("CancelOrder");CurrentUser, c’est un petit service scoped maison qui lit les claims de l’utilisateur connecté. Rien de spécial.
Et regarde bien l’endpoint d’annulation : le backend vérifie toujours la demande. HATEOAS ne remplace pas la validation côté serveur. Entre le moment où le bouton s’affiche et le clic, l’état a pu changer. Un collègue a expédié la commande, un paiement est arrivé. Le serveur reste le seul juge, et il répond 409 Conflict si l’action n’est plus possible.
Côté front : afficher ce que l’API propose
Côté React, le composant devient presque bête, et c’est exactement ce qu’on veut :
type ApiAction = { method: string; href: string };
type Order = {
id: string;
number: string;
status: string;
actions: { cancel?: ApiAction; ship?: ApiAction };
};
const run = (action: ApiAction) => fetch(action.href, { method: action.method });
type Props = { order: Order; onChange: () => void };
export const OrderActions = ({ order, onChange }: Props) => {
const { cancel, ship } = order.actions;
return (
<div className="actions">
{ship && (
<button onClick={() => run(ship).then(onChange)}>
Marquer comme expédiée
</button>
)}
{cancel && (
<button onClick={() => run(cancel).then(onChange)}>
Annuler la commande
</button>
)}
</div>
);
};Plus aucune règle métier. Le composant sait afficher une annulation et une expédition, et il les affiche quand l’API les propose.
Le then(onChange) a son importance. fetch ne lève pas d’erreur sur un 409 : que l’action réussisse ou que le serveur la refuse, tu recharges la commande. Les actions à jour arrivent avec, et le bouton qui n’a plus de sens disparaît tout seul.
Ce que ça ne fait pas
Je préfère être clair, parce qu’on lit parfois des promesses un peu folles sur HATEOAS.
- Ça ne rend pas ton front générique. Le front connaît toujours la liste des actions qu’il sait afficher. Le contrat, c’est le nom de chaque action et son sens. Si l’API ajoute une action
refund, il faudra un bouton pour elle. - Ça ne remplace pas la validation serveur. Je le répète parce que c’est le piège : l’action exposée, c’est une information pour l’affichage, pas une autorisation.
- Ça a un coût sur les listes. Calculer les actions de 200 commandes, c’est évaluer les règles 200 fois. Souvent ce n’est rien, mais si une règle tape en base, fais attention. Sur une liste, je n’expose que les actions rapides dont l’écran a vraiment besoin.
- Parfois tu veux griser, pas cacher. Un bouton qui disparaît sans explication, ça peut perturber. Rien ne t’empêche d’exposer l’action avec
"enabled": falseet une raison (« La commande est déjà expédiée »). Le front affiche alors un bouton désactivé avec un message utile, et la raison vient toujours du serveur.
Pour le format, il existe des standards : HAL avec ses _links, Siren avec ses actions qui décrivent même les champs à envoyer, JSON:API. Tu peux en prendre un. Mais honnêtement, le plus important, c’est un contrat simple, documenté dans ton OpenAPI et compris par les deux équipes. Un petit format maison bien tenu vaut mieux qu’un standard que personne n’a lu.
Par où commencer
Pas besoin de tout migrer. Prends un écran avec des actions conditionnelles : une fiche commande, un devis à valider, une facture à envoyer. Regarde combien de règles métier ton front recopie pour décider quels boutons afficher.
Si tu en trouves plus de deux ou trois, c’est un bon endroit pour commencer. Ajoute les actions dans la réponse de cet endpoint, branche les boutons dessus, et supprime les if côté front. Tu verras vite si l’approche te plaît.
Le mot de la fin
HATEOAS, c’est surtout une façon de remettre chaque responsabilité à sa place : le backend décide de ce qui est possible, le front décide de comment l’afficher. Une règle métier vit à un seul endroit, et un changement de workflow ne demande plus de toucher deux dépôts en priant pour ne rien oublier.
C’est une contrainte de REST au même titre que les autres, décrite par Fielding il y a plus de vingt-cinq ans. Elle mérite au moins autant d’attention que le débat sur le pluriel dans tes URLs.
Si tu veux en parler pour ton API, ou si tu as un écran plein de if à démêler, écris-moi.