Le cours de qualité de développement suit un TD fil rouge : une API REST de gestion de produits en ASP.NET Core, enrichie séance après séance jusqu’à une architecture propre et testée.
Ce que j’y ai appris
Une architecture en couches
Contrôleurs, dépôts et accès aux données séparés, reliés par injection de dépendances.
Les principes SOLID
Des interfaces petites et ciblées, des dépendances vers des abstractions.
DTO et AutoMapper
Ne jamais exposer les entités : des objets de transfert calculés directement par la base.
Tester pour de vrai
Tests unitaires avec Moq, tests du mapping et test d’intégration sur PostgreSQL.
La suite
Un contrôleur générique pour factoriser le code, puis un client Blazor Server en MVVM qui consommera cette API.
Detailed write-ups and revision notes are in French: they are my own study notes.
Le TD fil rouge : TPRevision
Une API REST de gestion de produits, marques et types de produits, construite au fil des séances : EF Core et PostgreSQL, dépôts, DTO, AutoMapper et tests. Tout le code vient de mon dépôt public Qualite_de_dev ↗.
Architecture
Le contrôleur reçoit ses dépendances par injection (constructeur principal C# 12) et ne dépend que d’interfaces. Les interfaces sont découpées par rôle : lire, écrire, lire avec les détails, chercher, et produire des DTO. ProduitRepository les implémente toutes ; les tests, eux, remplacent chaque interface par un faux objet (Moq).
La chaîne de connexion de développement est masquée ici.
L’API en fonctionnement
J’ai lancé l’API (dotnet run, .NET 8) sur une base PostgreSQL locale créée par mes migrations EF Core, remplie avec un petit jeu de données de démonstration (6 produits, 4 marques, 4 types). Les réponses ci-dessous sont celles qu’elle a réellement renvoyées.
Les tests
Trois familles : tests unitaires des contrôleurs avec des dépôts simulés (Moq), tests du profil AutoMapper, et un test d’intégration qui crée puis relit un produit dans une vraie base PostgreSQL. Relancés pour cette page sur le dernier commit qui compile :
Ce que l’exécution a révélé
GET /Product/GetById/6 plante : le mapping EnReappro = stockReel < stockMin est traduit en SQL par ProjectTo, et une comparaison avec NULL donne NULL, impossible à mettre dans un bool. Le test EnReappro_FalseWhenStockIsNull passe pourtant : il teste le mapping en mémoire, sans la base. Correctif possible : src.stockReel != null && src.stockMin != null && src.stockReel < src.stockMin.
POST /Product/AddProduct prend l’entité Produit en entrée ; ses navigations non nullables (MarqueNavigation, TypeProduitNavigation) deviennent obligatoires pour la validation. Un DTO d’entrée ne contenant que les identifiants règle le problème et évite d’exposer l’entité.
- Routes incohérentes :
/Product/…mais/api/marque/…, et des actions nomméesAddProductdans le contrôleur des marques (copier-coller). C’est ce que vise le contrôleur générique en cours. - PATCH est nommé
ModifyProductet PUTModifyProductPartially: en REST, c’est l’inverse (PUT remplace, PATCH modifie partiellement). UpdateAsyncetDeleteAsyncne sont pas attendus (await) dans le contrôleur, etSaveAsyncn’est pas appelé après la mise à jour.DELETErenvoie 204 même si l’identifiant n’existe pas ; chaque action déclare les 8 codes de retour possibles.
En cours : le contrôleur générique
Le dernier commit (« a finir chez moi ») amorce un CrudController<T> pour factoriser les trois contrôleurs ; il ne compile pas encore (les dépendances ne sont pas injectées). Les tests ci-dessus tournent donc sur le commit précédent. La suite du module : un client Blazor Server en MVVM qui consommera l’API (projet ClientBlazor, pour l’instant le modèle de départ).
Detailed write-ups and revision notes are in French: they are my own study notes.
Fiche de révision
1. REST, verbes et codes HTTP
| Verbe | Rôle | Idempotent | Réponse type |
|---|---|---|---|
GET | Lire | oui | 200 OK, 404 |
POST | Créer | non | 201 Created + en-tête Location (CreatedAtAction) |
PUT | Remplacer entièrement | oui | 200 ou 204 |
PATCH | Modifier partiellement | pas forcément | 200 ou 204 |
DELETE | Supprimer | oui | 204 No Content, 404 |
| 200 OK | 201 Created | 204 No Content |
| 400 Bad Request (entrée invalide) | 401 non authentifié | 403 authentifié mais interdit |
| 404 Not Found | 409 Conflict | 500 erreur serveur |
- Une ressource = un nom au pluriel :
GET /api/produits/2plutôt que/Product/GetById/2. - Sans état (stateless) : chaque requête porte tout ce qu’il faut pour être traitée.
- Idempotent : répéter la requête donne le même état final.
2. ASP.NET Core Web API
builder.Services.AddDbContext<DbProduitsContext>(o => o.UseNpgsql(cs)); builder.Services.AddScoped<IProduitRepository, ProduitRepository>(); builder.Services.AddScoped<IQueryService<ProduitDto, ProduitDetailDto>, ProduitRepository>(); builder.Services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies()); builder.Services.AddControllers();
| Durée de vie | Une instance par… | Exemple |
|---|---|---|
AddTransient | injection | service léger sans état |
AddScoped | requête HTTP | DbContext, dépôts |
AddSingleton | application | cache, configuration |
[ApiController]: validation automatique du modèle (400 si invalide), binding intelligent.[Route("api/[controller]")],[HttpGet("{id}")]: routage par attributs.ActionResult<T>permet de renvoyer soit un T (200), soit un résultat (NotFound(),BadRequest()).CreatedAtAction(nameof(GetById), new { id }, obj): 201 + URL de la nouvelle ressource.- Toujours
awaitles méthodes asynchrones, sinon l’action peut répondre avant la fin de l’opération.
3. Entity Framework Core
- DbContext = session avec la base ; DbSet<T> = une table. Approche « database first » (scaffold) ou « code first » (migrations).
- Annotations :
[Table],[Column],[Key],[StringLength],[ForeignKey],[InverseProperty]; ou configuration fluente dansOnModelCreating. - Propriétés de navigation :
MarqueNavigation(vers un) etproduits(vers plusieurs). Chargement explicite avec.Include(p => p.MarqueNavigation). - Migrations :
dotnet ef migrations add Nom,dotnet ef database update. SaveChangesAsync()envoie les ajouts, modifications et suppressions suivis par le contexte.ExecuteDeleteAsync()supprime directement en SQL, sans suivi.AsNoTracking()pour la lecture seule ;ProjectTo<Dto>()fait calculer la projection par la base (seules les colonnes utiles sont lues).- Cycles de sérialisation (produit → marque → produits…) :
ReferenceHandler.IgnoreCyclesou, mieux, renvoyer des DTO.
4. Les principes SOLID, illustrés par TPRevision
| Principe | Idée | Dans mon TD |
|---|---|---|
| S responsabilité unique | Une classe, une raison de changer | Le contrôleur gère HTTP, le dépôt l’accès aux données, le profil le mapping |
| O ouvert/fermé | Étendre sans modifier | Nouvelle entité = nouvelle implémentation des mêmes interfaces génériques |
| L substitution de Liskov | Une implémentation remplace son abstraction sans surprise | Le faux dépôt Moq remplace ProduitRepository dans les tests |
| I ségrégation des interfaces | Des petites interfaces ciblées | IReadable, IWritable, IDetailReadable, ISearchable, IQueryService |
| D inversion des dépendances | Dépendre d’abstractions, injectées | Le contrôleur reçoit des interfaces par son constructeur |
Isoler l’accès aux données derrière une interface : le reste de l’application ne sait pas qu’il y a EF Core et PostgreSQL derrière, et on peut le simuler dans les tests.
5. DTO et AutoMapper
- DTO (Data Transfer Object) : l’objet qui sort (ou entre) par l’API. On n’expose pas l’entité : pas de cycles, pas de colonnes internes, un contrat stable même si la base change.
- DTO de liste (léger) et DTO de détail :
ProduitDto/ProduitDetailDto. Un DTO d’entrée pour POST/PUT. - AutoMapper :
CreateMap<Source, Dest>().ForMember(d => d.Nom, o => o.MapFrom(s => s.NomProduit)). mapper.Map<Dto>(entite)en mémoire ;query.ProjectTo<Dto>(config)traduit le mapping en SQL (attention aux expressions qui se comportent différemment en SQL, comme la comparaison avec NULL).- Tester la configuration :
config.AssertConfigurationIsValid()(mon testConfiguration_IsValid).
6. Tests avec MSTest et Moq
[TestMethod]
public async Task GetProductById_ReturnsNotFound_WhenIdDoesNotExist()
{
// Given : le service simulé ne trouve rien
_queryServiceMock.Setup(qs => qs.GetByIdDtoAsync(999))
.ReturnsAsync((ProduitDetailDto)null);
// When
var actionResult = await _controller.GetProductById(999);
// Then
Assert.IsInstanceOfType(actionResult.Result, typeof(NotFoundResult));
}
- MSTest :
[TestClass],[TestMethod],[TestInitialize](avant chaque test),[TestCleanup],Assert.AreEqual / IsNotNull / IsInstanceOfType. - Moq :
new Mock<IInterface>(),.Setup(...).ReturnsAsync(...),.Verify(m => m.SaveAsync(), Times.Once),mock.Objectpour l’injecter. - Unitaire = une classe isolée, dépendances simulées, rapide. Intégration = plusieurs couches réelles (ici une vraie base PostgreSQL recréée avec
EnsureDeleted/EnsureCreated). - Nommer un test :
Methode_Resultat_Condition. - Base InMemory : pratique mais ne reproduit pas le SQL (contraintes, NULL) ; mon bug du stock NULL ne se voit qu’avec une vraie base.
7. Ce qui arrive : Blazor Server et MVVM
- Blazor Server : des composants
.razor(HTML + C#) exécutés sur le serveur ; l’interface est mise à jour dans le navigateur via une connexion SignalR. - MVVM : la Vue (composant) s’affiche à partir d’un ViewModel qui expose l’état et les commandes ; le Modèle et les services (appels à l’API par
HttpClient) restent à part. - CommunityToolkit.Mvvm :
[ObservableProperty]génère la propriété notifiante,[RelayCommand]la commande.