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
- Domain : créer
src/Domain/Entities/<Entity>.csavec[Key] Guid Id, hériter deBaseEntity/BaseAuditableEntity/ implémenterISoftDeleteselon besoin. - DbSet : ajouter
DbSet<Entity>dansApplicationDbContext. - Migration :
cd src/Persistence && dotnet ef migrations add Add<Entity>(⚠ ne pas lancerdatabase update, l'utilisateur s'en charge). - Application :
- DTO :
src/Application/Dtos/<Entity>Dto.cs(parfois sousBooking/,Travel/,Api/). - Service : créer
src/Application/Services/Entities/<Entity>Service.cs+ interfaceI<Entity>Service. Pour les CRUD simples, instancierEntityService<T>ouEntityService<T, TModel>à la place. - Profile : ajouter
CreateMap<Entity, EntityDto>().ReverseMap()dansApplication/Profiles/Profiles.cs. - DI : enregistrer dans
Application/ApplicationModule.ConfigureServices:services.AddScoped<I<Entity>Service, <Entity>Service>();.
- DTO :
- Web :
- Razor pages : créer
src/Web/Pages/<Entities>/avecIndex.cshtml(.cs),Create.cshtml(.cs),Update.cshtml(.cs),Delete.cshtml(.cs),Form.cshtml(partial),_ListActions.cshtml(partial). - API : ajouter
Areas/Api/<Entity>Controller.csavec[Authorize(Policy = "Api")]si l'entité doit être exposée. - Navigation : ajouter le lien dans
Pages/Shared/_Layout.cshtml(sidebar Metronic).
- Razor pages : créer
- Tests : créer
tests/Application/Services/Entities/<Entity>ServiceTest.cs+ Faker danstests/HelpersouDomain/Fakers.
⚠ Le CodeGenerator peut produire le scaffolding Razor à partir de templates Liquid (src/CodeGenerator/Templates/Crud/).
Ajouter un endpoint API
- Identifier le
Areas/Api/<Entity>Controller.cscible (créer si nouveau). - Décorer la classe avec
[ApiController],[Route("api/[controller]")],[Authorize(Policy = "Api")]. - Ajouter la méthode
[HttpGet]/[HttpPost]/...qui retourneTask<IActionResult>ouTask<EntityDto>. - Désérialisation :
[FromBody] Dtoutilise Newtonsoft.Json (case-insensitive par défaut). - Sérialisation : retourner DTO direct → JSON camelCase.
ReferenceLoopHandling.Ignoreest posé globalement. - 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 :
| Cas | Outil | Pourquoi |
|---|---|---|
| Mapping DTO ↔ Entity avec règles explicites (calcul, formatage, projection partielle) | AutoMapper Profile | Profile 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 :
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 :
- Créer le template dans
src/Application/TemplatesPdf/<Name>.liquid(et marquerEmbeddedResourcedans le.csproj— déjà géré pattern*.liquid). - Si on expose un nouveau type / nouvelle propriété au template, enregistrer dans
MemberAccessStrategydansPdfGeneratorService.cs:csharpoptions.MemberAccessStrategy.Register<MyType>("Prop1", "Prop2"); - Le template est compilé avec
FluidParser. - 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 :
- Hériter de la base de test existante (à confirmer — voir un fichier existant comme
BookingServiceTest.cs). - Utiliser les fakers de
tests/HelpersouDomain/Fakers/. - Mocker via
Moq(déjà en dépendance d'Application). - Ne pas mocker la DB quand le test couvre un calcul EF complexe — utiliser un
ApplicationDbContextin-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 leserrorsdansModelState(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.
@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 :
- Créer dans
Application/Hubs/<Name>Hub.cshéritant deHub. - Mapper la route dans
Startup.cs > Configure > endpoints.MapHub<XxxHub>("/hubs/xxx"). - Côté front :
new HubConnectionBuilder().withUrl(...).build()(cf. mini-SPAVue/src/booking/).
Mini-SPA Vue 3
Convention pour ajouter une nouvelle SPA :
src/Web/Frontend/Vue/src/<spa-name>/avecApp.vue,store.js(Vuex),library.js(helpers),main.js(mount).- Référencer la nouvelle SPA dans
src/Web/Frontend/Vue/src/main.jspour le mount sur sélecteur DOM. - Build :
yarn build→wwwroot/assets/vue. La page Razor inclut alors<script src="/assets/vue/<spa>.js">et un sélecteur DOM cible (souventid="app"). - Tests Vitest dans
<spa>/tests/ouVue/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
ApplicationDbContextestScoped(par requête HTTP / par job worker scope).- Les services sont
Scopedaussi saufCacheBusterServicequi estSingleton. - Worker : chaque tick crée un
IServiceProvider.CreateScope()pour avoir unDbContextpropre par job. IBackgroundTaskQueueestSingleton(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 :
_backgroundTaskQueue.Enqueue((provider, _) => DoWork(provider.GetRequiredService<IXxxService>(), ids));Trois règles :
- Ne capturer que des valeurs (ids, chaînes) — le scope DI de la requête est disposé avant l'exécution, donc jamais de
DbContextni d'entité trackée. Résoudre les services depuis leproviderfourni. - Uniquement pour du travail idempotent et non critique : un redémarrage perd la file. Facturation, email transactionnel, écriture NAV/BC → surtout pas.
- 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 standardOnGetList()qui retourneJsonResult. - 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 :
: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.
Deep-link vers la modale d'update d'une entité
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 cetid(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)—.pdfseulement, max 20 Mo.- Contrôles : taille, extension déclarée dans l'allowlist, et magic bytes (JPEG
FFD8FF, PNG89504E47, WEBPRIFF…WEBP, PDF%PDF-).ContentTypeest ignoré (fourni par le client). - Le nom stocké est toujours
RandomString.Get(30) + extension détectée— jamaisPath.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à traversgrep(Windows).

