Architecture hexagonale
Introduction
Une application de gestion de commandes reçoit des requêtes HTTP, applique des règles métier, enregistre les commandes et peut communiquer avec un prestataire de paiement. Lorsque ces opérations sont mêlées dans les contrôleurs ou dans les classes d’accès aux données, changer de solution technique ou tester une règle métier devient plus difficile.
L’architecture hexagonale propose de positionner les fonctionnalités de l’application au centre du projet et de définir explicitement ses échanges avec l’extérieur. Le qualificatif « hexagonale » ne prescrit pas six composants, ni six couches. Cette représentation visuelle aide à représenter plusieurs points d’entrée et de sortie, comme l'explique Alistair Cockburn, qui a proposé cette architecture en la nommant aussi Ports & Adapters.
Présentation
Le cœur applicatif porte les cas d’utilisation et, lorsque le domaine le justifie, les règles métier. Il définit les opérations qu’il propose et les services externes dont il a besoin. Ces frontières sont les ports. Les adaptateurs traduisent les échanges avec HTTP, une base de données, une file de messages ou une solution de tests unitaires.
Un adaptateur entrant déclenche un cas d’utilisation pris en charge par un composant tel qu'une action d'un contrôleur ASP.NET. Un adaptateur sortant fournit au cœur un service dont il a besoin : persistance des données avec EF Core, envoi de mails ou appel HTTP. Dans les deux cas, les règles du cœur restent indépendantes des détails techniques.
Le principe central porte sur le sens des dépendances du code : l’infrastructure peut référencer le cœur, mais le cœur ne doit pas référencer ASP.NET Core, EF Core, etc. Au démarrage de l'application, la configuration des injections de dépendances relient les interfaces aux implémentations, en enregistrant les services dans le conteneur d'injection de dépendances. Le flux d’exécution peut alors aller de l’API vers le cœur puis vers la base de données, même si les dépendances de compilation pointent vers le cœur.
Un simple exemple en C#
Dans une applications, nous devons prévoir le cas d’utilisation suivant : pour pouvoir enregistrer une commande, l'ensemble de ses lignes doit être valide.
Les ports et le cœur applicatif
public sealed record OrderLine(string Product, int Quantity);
public sealed record Order(Guid Id, IReadOnlyList<OrderLine> Lines);
// Port sortant : contrat défini du point de vue du cas d’utilisation.
public interface IOrderRepository
{
Task SaveAsync(Order order, CancellationToken cancellationToken);
}
// Port entrant : opération offerte aux adaptateurs entrants.
public interface ICreateOrder
{
Task<Guid> ExecuteAsync(
IReadOnlyList<OrderLine> lines,
CancellationToken cancellationToken);
}
public class CreateOrder(IOrderRepository repository)
: ICreateOrder
{
public async Task<Guid> ExecuteAsync(
IReadOnlyList<OrderLine> lines,
CancellationToken cancellationToken)
{
if (lines.Count == 0 ||
lines.Any(line => string.IsNullOrWhiteSpace(line.Product) || line.Quantity <= 0))
throw new ArgumentException("Les lignes de commande sont invalides.");
var order = new Order(Guid.NewGuid(), lines.ToArray());
await repository.SaveAsync(order, cancellationToken);
return order.Id;
}
}
Les interfaces ICreateOrder et IOrderRepository décrivent les échanges du cœur. Les classes les implémentant ne font référence pas à la classe HttpClient permettant de consommer un service Web API, ni à la classe de contexte de données DbContext d'Entity Framework Core (EF Core), permettant d'accéder au contenu de la base de données.
L’adaptateur sortant avec EF Core
public class EfOrderRepository(OrdersDbContext context)
: IOrderRepository
{
public async Task SaveAsync(
Order order,
CancellationToken cancellationToken)
{
var entity = new OrderEntity
{
Id = order.Id,
Lines = order.Lines.Select(line => new OrderLineEntity
{
Product = line.Product,
Quantity = line.Quantity
}).ToList()
};
context.Orders.Add(entity);
await context.SaveChangesAsync(cancellationToken);
}
}
Les classes OrdersDbContext représente une partie du modèle de persistance propre à l’infrastructure. Leur configuration EF Core est à définir dans le projet. Cette séparation évite d’utiliser des attributs ou des types d'EF Core dans le contrat du cas d’utilisation.
L’adaptateur entrant et la composition
public record OrderLineHttp(string Product, int Quantity);
public record CreateOrderHttp(IReadOnlyList<OrderLineHttp> Lines);
// Après la configuration du DbContext :
builder.Services.AddScoped<ICreateOrder, CreateOrder>();
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
app.MapPost("/commandes", async (
CreateOrderHttp request,
ICreateOrder createOrder,
CancellationToken cancellationToken) =>
{
if (request.Lines is null)
return Results.BadRequest();
try
{
var lines = request.Lines
.Select(line => new OrderLine(line.Product, line.Quantity))
.ToArray();
var id = await createOrder.ExecuteAsync(lines, cancellationToken);
return Results.Created($"/commandes/{id}", new { id });
}
catch (ArgumentException)
{
return Results.BadRequest();
}
});
L’API utilise les données de la requête HTTP dans le cas d’utilisation, puis transforme le résultat en réponse HTTP. Dans une application complète, on pourrait implémenter la gestion basée des erreurs sur une monade.
Les avantages
Des règles métier isolées
Les cas d’utilisation restent lisibles sans dépendre des contrôleurs, d’*EF Core* ou d’un autre composant externe.
Des tests ciblés
Un adaptateur permet de vérifier les actions du cœur sans exécuter l'API.
Plusieurs modes d’accès aux cas d'utilisation
Une API, un traitement planifié ou un consommateur de messages peuvent utiliser les mêmes cas d’utilisation.
Des changements techniques mieux contenus
Une intégration externe peut évoluer derrière un port stable, sous réserve qu'il reste compatible.
Les limites
Davantage d'interfaces et de classes
Les ports, adaptateurs et convertisseurs ajoutent du code supplémentaire, parfois excessif pour une simple application CRUD.
Des frontières à bien penser
Des ports trop génériques ou calqués sur *EF Core* peuvent exposer des caractéristiques techniques au cœur de l’application.
Une interchangeabilité relative
Remplacer une base de données ou un provider peut impacter les performances.
Une discipline collective nécessaire
Sans contrôle des références et des responsabilités, les dépendances techniques peuvent traverser les frontières définies.
Quand cette architecture est-elle un choix pertinent ?
Cette architecture convient particulièrement lorsque les règles métier sont significatives, que plusieurs canaux / points d'accès déclenchent les mêmes opérations, ou que l’application dépend de systèmes externes susceptibles d’évoluer, et pour lesquels il est nécessaire de limiter les impacts.
Pour une application composée de formulaires et d’opérations CRUD simples, le coût des abstractions peut dépasser les bénéfices de cette architecture. Il est aussi possible d’appliquer les ports et adaptateurs uniquement aux cas d’utilisation qu'on souhaite isoler, puis d’étendre ce découpage en fonction des besoins.
Bonnes pratiques avec .NET
Nommer les ports selon les besoins métier
Préférer un nom de contrat comme ICreateOrder à une interface générique qui gère plusieurs entités.
Orienter les références vers le cœur
Positionner les cas d’utilisation et leurs contrats dans des projets indépendants d’ASP.NET Core et d’EF Core.
Composer les adaptateurs au démarrage
Enregistrer les implémentations avec l’injection de dépendances et choisir une durée de vie compatible avec le contexte de données (DbContext).
Traduire les données aux frontières
Éviter que les entités *EF Core* deviennent les contrats implicites du domaine.
Tester le cœur et ses intégrations
Tester les règles métier, et vérifier la lecture et la persistance dans la base de données, ainsi que les échanges externes par des tests d’intégration.
Conserver les préoccupations techniques à leur place
Gérer l'implémentation des points d'accès HTTP (endpoints), l'authentification, la sérialisation et la configuration d'*EF Core* dans les adaptateurs.
