Skip to content

Patterns récurrents

Comment ajouter une feature, un test, gérer le mapping et les erreurs. Mettre à jour quand un nouveau pattern apparaît.

Ajouter une nouvelle entité CRUD complète

  1. Domain : créer src/Domain/Entities/<Entity>.cs avec [Key] Guid Id, hériter de BaseEntity / BaseAuditableEntity / implémenter ISoftDelete selon besoin.
  2. DbSet : ajouter DbSet<Entity> dans ApplicationDbContext.
  3. Migration : cd src/Persistence && dotnet ef migrations add Add<Entity> (⚠ ne pas lancer database update, l'utilisateur s'en charge).
  4. Application :
    • DTO : src/Application/Dtos/<Entity>Dto.cs (parfois sous Booking/, Travel/, Api/).
    • Service : créer src/Application/Services/Entities/<Entity>Service.cs + interface I<Entity>Service. Pour les CRUD simples, instancier EntityService<T> ou EntityService<T, TModel> à la place.
    • Profile : ajouter CreateMap<Entity, EntityDto>().ReverseMap() dans Application/Profiles/Profiles.cs.
    • DI : enregistrer dans Application/ApplicationModule.ConfigureServices : services.AddScoped<I<Entity>Service, <Entity>Service>();.
  5. Web :
    • Razor pages : créer src/Web/Pages/<Entities>/ avec Index.cshtml(.cs), Create.cshtml(.cs), Update.cshtml(.cs), Delete.cshtml(.cs), Form.cshtml (partial), _ListActions.cshtml (partial).
    • API : ajouter Areas/Api/<Entity>Controller.cs avec [Authorize(Policy = "Api")] si l'entité doit être exposée.
    • Navigation : ajouter le lien dans Pages/Shared/_Layout.cshtml (sidebar Metronic).
  6. Tests : créer tests/Application/Services/Entities/<Entity>ServiceTest.cs + Faker dans tests/Helpers ou Domain/Fakers.

⚠ Le CodeGenerator peut produire le scaffolding Razor à partir de templates Liquid (src/CodeGenerator/Templates/Crud/).

Ajouter un endpoint API

  1. Identifier le Areas/Api/<Entity>Controller.cs cible (créer si nouveau).
  2. Décorer la classe avec [ApiController], [Route("api/[controller]")], [Authorize(Policy = "Api")].
  3. Ajouter la méthode [HttpGet]/[HttpPost]/... qui retourne Task<IActionResult> ou Task<EntityDto>.
  4. Désérialisation : [FromBody] Dto utilise Newtonsoft.Json (case-insensitive par défaut).
  5. Sérialisation : retourner DTO direct → JSON camelCase. ReferenceLoopHandling.Ignore est posé globalement.
  6. Documentation : annoter [SwaggerOperation(...)] si désiré (Swagger UI à /swagger).

Alléger une réponse : ?lighter-response=true — ajouté août 2026

Le site et l'app mobile consomment les mêmes endpoints ; on ne peut donc jamais retirer des champs d'une réponse existante. La convention est un opt-in : [FromQuery(Name = ApiQuery.LighterResponse)] bool lighterResponse = false (Areas/Api/ApiQuery.cs). Paramètre absent ou false ⇒ réponse historique inchangée, octet pour octet.

  • Le mode allégé garde le même type de DTO : on ne remplit qu'une partie des champs, les collections lourdes sortent à null. Un client typé continue de désérialiser la même classe.
  • Implémenté sur GET /api/customers (cf. modules/customer-membership.md). À reproduire tel quel sur les autres endpoints qui en auront besoin, plutôt que d'inventer un second nom de paramètre.

Conventions de mapping (AutoMapper vs Mapper reflection)

Deux mappers cohabitent volontairement :

CasOutilPourquoi
Mapping DTO ↔ Entity avec règles explicites (calcul, formatage, projection partielle)AutoMapper ProfileProfile centralise les transformations
Round-trip simple (Entity → DTO → Entity sans perte) sur les mêmes noms de propriétéLibrary/Mapper.cs reflection customÉvite de définir des Profiles symétriques à la chaîne — utilisé notamment dans BookingService pour Passenger ↔ BookingPassengerDto

API du Mapper custom :

csharp
var dto = Mapper<EntityDto, Entity>.Map(new EntityDto(), entity);

Par convention, le destination est le 1er paramètre (instance), le source est le 2e.

Templates PDF Liquid (Fluid)

Toujours :

  1. Créer le template dans src/Application/TemplatesPdf/<Name>.liquid (et marquer EmbeddedResource dans le .csproj — déjà géré pattern *.liquid).
  2. Si on expose un nouveau type / nouvelle propriété au template, enregistrer dans MemberAccessStrategy dans PdfGeneratorService.cs :
    csharp
    options.MemberAccessStrategy.Register<MyType>("Prop1", "Prop2");
  3. Le template est compilé avec FluidParser.
  4. Le HTML rendu est passé à iText7 (HtmlConverter.ConvertToPdf) pour générer le PDF final.

Pour les helpers/filters : enregistrer dans options.Filters.AddFilter("name", ...) dans PdfGeneratorService.cs.

Tests

Convention tests/Application/Services/Entities/<Entity>ServiceTest.cs :

  1. Hériter de la base de test existante (à confirmer — voir un fichier existant comme BookingServiceTest.cs).
  2. Utiliser les fakers de tests/Helpers ou Domain/Fakers/.
  3. Mocker via Moq (déjà en dépendance d'Application).
  4. Ne pas mocker la DB quand le test couvre un calcul EF complexe — utiliser un ApplicationDbContext in-memory ou un SQL Server local.

⚠ Sortie console : dotnet test -l "console;verbosity=detailed" ; ne pas piper à travers grep (Windows).

Gestion des erreurs

Pas de pattern unifié — chaque service fait ce qu'il veut :

  • Côté API, on retourne BadRequest(...) / NotFound() / Ok(dto).
  • Côté Razor, on retourne Page() avec les errors dans ModelState (DataAnnotations) ou dans le store Vue.
  • Sentry capture les exceptions non gérées + sanitize les bodies /api.
  • Logs Serilog console (Information+) + fichier filtré pour les recherches.

Pages pleine page (erreur, 404, 403, message métier) — août 2026

Pages/Shared/_StandaloneMessage.cshtml + StandaloneMessageModel : partiel unique qui rend un document HTML complet (hors _Layout), en thème Metronic. Il porte le shell (fonts, plugins.bundle.css, style.bundle.css, Horizon.css), une illustration sketchy-1, un titre, un message, jusqu'à deux boutons et une ligne de détail discrète.

Utilisé par /Error (illustration 20), /NotFound (18), /Forbidden (17) et /Gifts/InvoiceUnavailable (17). Avant, ces pages dupliquaient chacune le même bloc HTML de 70 lignes, et /Error affichait encore le template anglais scaffoldé par défaut.

Pour une page de ce type : une Razor Page avec Layout = null qui ne fait qu'appeler le partiel.

csharp
@await Html.PartialAsync("_StandaloneMessage", new StandaloneMessageModel { Title = "…", Message = "…" })

⚠ Les URL d'assets du partiel sont absolues (/assets/…) : une URL relative casse dès que la page est servie depuis un sous-dossier (/Gifts/InvoiceUnavailable).

Quand l'utiliser : refuser une action en expliquant pourquoi, plutôt que laisser remonter une exception. Poser la garde dans le handler et rediriger vers la page de message — ne pas se contenter d'un return NotFound(), qui ne dit rien à l'utilisateur.

SignalR Hubs

2 hubs existants : BookingTrackingHub, EditFormActivityTrackingHub.

Pour ajouter un hub :

  1. Créer dans Application/Hubs/<Name>Hub.cs héritant de Hub.
  2. Mapper la route dans Startup.cs > Configure > endpoints.MapHub<XxxHub>("/hubs/xxx").
  3. Côté front : new HubConnectionBuilder().withUrl(...).build() (cf. mini-SPA Vue/src/booking/).

Mini-SPA Vue 3

Convention pour ajouter une nouvelle SPA :

  1. src/Web/Frontend/Vue/src/<spa-name>/ avec App.vue, store.js (Vuex), library.js (helpers), main.js (mount).
  2. Référencer la nouvelle SPA dans src/Web/Frontend/Vue/src/main.js pour le mount sur sélecteur DOM.
  3. Build : yarn buildwwwroot/assets/vue. La page Razor inclut alors <script src="/assets/vue/<spa>.js"> et un sélecteur DOM cible (souvent id="app").
  4. Tests Vitest dans <spa>/tests/ ou Vue/tests/.

Piège v-for sans :key + enfant qui snapshotte ses props au onMounted : beaucoup de composants (ex. CustomStops, CustomLines, Vehicles, Documents dans travel-design) copient props.xxx dans un ref local une seule fois au montage. Si le v-for parent n'a pas de :key, Vue recycle l'instance quand la liste change (ex. toggle « afficher les dates passées ») : la prop change mais le onMounted ne rejoue pas → le composant affiche les données d'une autre occurrence (souvent une liste vide), sans aucune erreur console. Toujours mettre :key="<entité>.id" sur les v-for qui rendent des composants à état. Bug historique : lieux personnalisés d'occurrences passées invisibles dans travel-design (juil. 2026).

DI / DbContext lifecycle

  • ApplicationDbContext est Scoped (par requête HTTP / par job worker scope).
  • Les services sont Scoped aussi sauf CacheBusterService qui est Singleton.
  • Worker : chaque tick crée un IServiceProvider.CreateScope() pour avoir un DbContext propre par job.
  • IBackgroundTaskQueue est Singleton (voir ci-dessous).

Sortir un travail de la requête utilisateur

Pour un appel sortant lent dont personne n'attend le résultat (typiquement une écriture vers Visual Planning), utiliser IBackgroundTaskQueue plutôt que de le laisser dans la requête :

csharp
_backgroundTaskQueue.Enqueue((provider, _) => DoWork(provider.GetRequiredService<IXxxService>(), ids));

Trois règles :

  1. Ne capturer que des valeurs (ids, chaînes) — le scope DI de la requête est disposé avant l'exécution, donc jamais de DbContext ni d'entité trackée. Résoudre les services depuis le provider fourni.
  2. Uniquement pour du travail idempotent et non critique : un redémarrage perd la file. Facturation, email transactionnel, écriture NAV/BC → surtout pas.
  3. Injecter la file en paramètre optionnel (IBackgroundTaskQueue queue = null) et prévoir l'exécution inline quand elle est absente, sinon les tests unitaires qui construisent le service à la main cassent. Modèle : LoadingTableService.PushToVisualPlanningAsync.

Détail et alternatives écartées : architecture/adr/0008-in-process-background-task-queue.md.

Requêtes EF : le piège du fixup

Une navigation peuplée « par magie » vient souvent d'une autre requête du même DbContext (navigation fixup) et non des Include de la requête qu'on lit. Avant de supprimer ou d'alléger un Get volumineux, vérifier ce que le code en aval lit indirectement — cas réel : occurrence.OneDayTravelDriveOccurrences n'arrivait que par TravelService.Get (cf. modules/loading-tables.md > Performance). Deux réflexes : ajouter l'Include explicite là où la donnée est réellement consommée, et préférer la FK (x.DriveId) à la navigation (x.Drive.Id) quand seul l'identifiant est nécessaire.

Conventions Razor Pages

  • 1 dossier par entité dans Pages/<Entities>/.
  • Pattern stable : Index/Create/Update/Delete + Form.cshtml + _ListActions.cshtml.
  • DataTable : composant maison Library/DataTable/. Endpoint AJAX standard OnGetList() qui retourne JsonResult.
  • Les modales sont fréquemment des partials _Modal*.cshtml.

Select2 dans une modale Bootstrap

Un <select2> (composant Frontend/Vue/src/common/Select2.vue, ou un .select2() jQuery côté Razor) placé dans une modale doit toujours recevoir un dropdownParent pointant sur l'élément .modal :

js
:options="{placeholder: '…', language: select2TranslateFr, dropdownParent: '#' + modalUid}"

Sans ça, le panneau déroulant est injecté dans <body>, donc hors de la modale : le piège à focus de Bootstrap rapatrie le focus dès qu'on clique dans le champ de recherche du select2, qui devient inutilisable (symptôme : « la recherche ne réagit pas, il faut scroller toute la liste »). Corollaire : l'id de la modale doit être stable (const modalUid = 'xxx-modal' + Math.floor(Math.random() * 1000000) calculé une fois dans le <script setup>) — un :id="'x' + Math.random()" recalculé à chaque rendu casse le sélecteur. Exemples : AddOrUpdateOneShotDrive.vue, AddOrUpdateOneShotActivity.vue, Pages/GlobalSupplements/Form.cshtml.

initDataTable (Frontend/Custom/assets/js/Datatables.js:129-147) auto-ouvre la modale <ref>_modal_update quand l'URL contient ?id=<guid>. L'entité ciblée est déduite du 1er segment du path (window.location.pathname.split('/')[1]), donc l'URL canonique est :

  • /<Entity>?id=<guid> → ouvre la modale Update pour cet id (charge /<Entity>/Update/<id> via AJAX dans .ajax-content).
  • /<Entity>?id=<guid>&tab=<tabname> → idem + bascule sur l'onglet nommé après le load.

À utiliser pour tout lien profond depuis une autre page (ex : (client) dans la liste des commentaires → /Customers?id=...). Ne pas linker /<Entity>/Update?id=... ni /<Entity>/Update/<id> directement — ces URLs ne routent vers rien (la modale n'est pas une page autonome).

Upload de fichiers vers wwwroot/Uploads

wwwroot/Uploads est servi par UseStaticFiles : un fichier .html/.svg déposé là devient du XSS stocké sur notre propre origine. Tout upload passe donc par Application/Library/UploadValidator.cs :

  • UploadValidator.IsAllowedImage(file, out extension, out error).jpg/.jpeg/.png/.webp, max 10 Mo.
  • UploadValidator.IsAllowedDocument(file, out extension, out error).pdf seulement, max 20 Mo.
  • Contrôles : taille, extension déclarée dans l'allowlist, et magic bytes (JPEG FFD8FF, PNG 89504E47, WEBP RIFF…WEBP, PDF %PDF-). ContentType est ignoré (fourni par le client).
  • Le nom stocké est toujours RandomString.Get(30) + extension détectée — jamais Path.GetExtension(file.FileName).

Appelants actuels : Pages/UploadPictures (images, retourne {error} + HTTP 400 pour Dropzone), Pages/Occurrences/Lists (PDF, remplit ErrorMessage), Pages/Bookings/Cancel (certificats médicaux PDF, retourne ReturnError).

Pour les images postées en base64 (deck/sièges/cabines), JsonImageUploader.SaveImage slugifie le fileName ([A-Za-z0-9._-]) et vérifie que le chemin résolu reste sous le dossier cible avant d'écrire — les appelants concatènent des noms saisis par le staff.

Conventions URL

  • Razor pages : /<Entities>/Action (ex: /Bookings/CreateUpdate?id=...).
  • API : /api/<Entity> ou /api/<Entity>/<Action>.
  • Dev tools : /.dev/db/seed, /.tools/nav/sync, /.tools/Slugify/Travels.
  • Identity : /Identity/Account/Login, /Identity/Account/Logout.

Locale

Tout est forcé en fr (ApplicationModule). En conséquence :

  • Dates parsées en dd.MM.yyyy (toujours préciser la culture quand on parse depuis ISO ou source externe).
  • Décimales avec ..
  • [Display(Name = "...")] sur les enums utilisés en UI.

Ne pas faire

  • ❌ Lancer dotnet ef database update (l'utilisateur s'en charge).
  • ❌ Modifier Application/Connected Services/Services.Nav.*/Reference.cs à la main.
  • ❌ Hardcoder un pourcentage d'acompte côté site web — toujours lire Travel.DepositPercentage.
  • ❌ Lire les champs Travel.Capacity* (vieux code mort).
  • ❌ Toucher aux WorkerGlobe, GlobeDbContext, WebSiteOldDbContext, GlobeLegacyService, WebSiteOldService.
  • ❌ Piper la sortie de dotnet test à travers grep (Windows).

Contributors

No contributors

Changelog

No recent changes