Aller au contenu

Conçu par des ingénieurs, pour des ingénieurs.

L'architecture, la sécurité et l'exploitation, détaillées à l'intention des personnes qui intégreront cette plateforme, y bâtiront des modules et valideront la revue d'architecture. Rien ici n'est édulcoré pour le marketing.

dotnet build CleverInit.slnx

0 Warning(s) · 0 Error(s)

dotnet test CleverInit.slnx

4,028 passed · 0 failed

4 028
Tests automatisés, host-solution
0
Avertissements de build tolérés
5
Behaviors sur chaque command
5
Étapes pour résoudre un tenant

Chaque décision d'architecture est délibérée. Rien n'y figure par hasard.

Dix décisions qui ont façonné tout le reste.

Chacune est imposée par le code ou vérifiée dans la CI — pas consignée dans un wiki en espérant que ça tienne.

  1. 01

    Une Clean Architecture que le compilateur impose

    Domain ← Application ← Infrastructure ← API sont des références de projet, pas des conventions. Un handler qui va puiser dans l'infrastructure fait échouer le build.

  2. 02

    Un seul pipeline pour chaque requête

    Logging, tracing, validation, invalidation de cache, transaction — le même ordre à chaque fois, pour que le timing, la validation et la sémantique des erreurs restent uniformes d'un endpoint à l'autre.

  3. 03

    Une base de données par client inscrit

    Un compte créé via l'inscription reçoit sa propre base de données SQL, et sa connection string est chiffrée par ASP.NET Core Data Protection avant d'être stockée.

  4. 04

    Les lectures sont des projections avec un contrat de pagination

    Chaque lecture est une projection no-tracking vers un DTO, et chaque endpoint de liste renvoie un résultat paginé avec un plafond strict. Un jeu de résultats sans limite n'est pas une option que le code propose.

  5. 05

    Pas de SQL brut, nulle part

    Uniquement des requêtes EF Core paramétrées. Les entrées utilisateur ne sont jamais interpolées dans du SQL, parce que le chemin qui construirait cette chaîne n'existe tout simplement pas.

  6. 06

    Des events qui survivent à un crash — avec une sémantique honnête

    Ce qui doit être annoncé est stocké dans la base de données, à côté du travail lui-même, puis publié par un seul sweeper. La livraison a lieu au moins une fois, le nombre de retries est fini, et les dead-letters sont annoncées, pas dissimulées.

  7. 07

    Les migrations sont append-only

    Une migration livrée n'est jamais modifiée. Chaque changement de schéma est une nouvelle migration vers l'avant, dans le même commit que le changement d'entité qui l'exige.

  8. 08

    Les API sont versionnées

    La surface HTTP repose sur Asp.Versioning, si bien qu'un breaking change est une nouvelle version explicite plutôt qu'une modification silencieuse sous votre intégration.

  9. 09

    Les erreurs sont en RFC 7807, en langage clair

    La validation devient un 400 avec une carte d'erreurs par champ, l'introuvable un 404, les doublons un 409, les règles métier un 422 — et le detail est toujours une phrase complète, jamais un nom de classe ni un fragment de SQL.

  10. 10

    Les modules doivent prouver qu'ils se déchargent

    Chargez un module, libérez chaque référence, forcez un garbage collection, vérifiez que la référence faible vers son load context est morte. Si un module ne peut pas réellement se décharger, ce contrôle échoue.

Quatre couches, une seule direction.

Les flèches de dépendance ci-dessous sont des références de projet. Les franchir ne produit pas un commentaire de revue de code — cela fait échouer le build.

Domain
La plus interne · zéro dépendance
Entités, value objects, domain events, domain exceptions, constantes.
Application
Cas d'usage
Dépend uniquement de Domain. Commands, queries, handlers, validators, mappers, pipeline behaviors.
Infrastructure
Adaptateurs
Dépend d'Application et de Domain. EF Core, Redis, MassTransit, JWT, BCrypt, repositories.
API
Composition root
Dépend des trois autres. Controllers, middleware, dependency injection, OpenAPI, mapping des problem-details.

Les couches internes ne référencent jamais vers l'extérieur. La direction est imposée là où elle est incontestable : dans les fichiers de projet.

Diagramme : quatre anneaux concentriques — API tout à l'extérieur, puis Infrastructure, puis Application, avec Domain au cœur. Les références de dépendance ne pointent que vers l'intérieur ; une référence qui pointe vers l'extérieur fait échouer le build.

Le pipeline de requêtes.

Chaque command traverse les mêmes cinq behaviors, du plus externe au plus interne. Les queries traversent l'étape de transaction sans y toucher.

  1. 01

    Logging

    Nom du command et millisecondes écoulées. Les payloads ne sont jamais journalisés.

  2. 02

    Tracing

    Le cycle de vie complet du handler, enveloppé dans un span OpenTelemetry.

  3. 03

    Validation

    Chaque validator enregistré s'exécute ; un échec devient un 400 avec une carte d'erreurs par champ.

  4. 04

    Invalidation de cache

    Clés de cache marquées comme obsolètes après un command réussi.

  5. 05

    Transaction

    Les commands sont enveloppés dans une transaction EF Core. Les queries sautent cette étape.

Un client, une base de données.

Un compte créé via l'inscription reçoit sa propre base de données SQL, provisionnée et migrée au moment même où le compte est créé.

La connection string de cette base de données est chiffrée via ASP.NET Core Data Protection avant d'être stockée.

Emplacement d'image — visuel à venir

Diagramme : comment les données d'un seul compte créé via l'inscription restent séparées — sa propre base de données, avec les schémas des modules à l'intérieur.

Déterminer à quel client appartient une requête forme une chaîne de cinq étapes. Elle s'arrête à la première correspondance.

  1. 1Le claim signé du tokenLe token porte notre signature ; celle-ci est vérifiée avant que le claim qu'il contient soit tenu pour fiable.
  2. 2L'en-tête X-Tenant-IdPour les appels de serveur à serveur qui ne portent aucun token utilisateur.
  3. 3Une correspondance sur un domaine personnaliséLe domaine propre du client, associé à son compte.
  4. 4Le sous-domaine de la plateformeSon nom sur notre domaine, tant qu'il n'apporte pas le sien.
  5. 5Un paramètre de query stringPar nom : uniquement en développementLa valeur de la liste la plus facile à saisir à la main — c'est pourquoi elle vient en dernier.

Un travail qui survit à un crash.

  1. 01

    Le travail s'exécute

    L'état change et est sauvegardé. Rien n'a encore touché le réseau.

  2. 02

    L'annonce est stockée, pas envoyée

    Le message dont le reste du système a besoin est écrit dans une table de base de données, à côté du travail qui l'a produit.

  3. 03

    Un seul sweeper publie

    Un processus d'arrière-plan balaie la table et publie ce qu'il y trouve. Rien d'autre dans la codebase ne parle au broker.

  4. 04

    Les consumers s'attendent aux répétitions

    La livraison a lieu au moins une fois. Les consumers vérifient une clé d'event traité ou s'appuient sur un index unique, si bien qu'une deuxième tentative échoue sans dommage.

Le nombre de retries est fini. Après un nombre fixe d'échecs, un message est marqué dead-letter et n'est plus réessayé — nous préférons le dire clairement plutôt que de laisser croire le contraire.

Les modules sont des invités dans le processus hôte.

Installer ou retirer un module ne demande ni redémarrage ni nouveau déploiement.

Chaque module est chargé dans son propre collectible assembly load context. En installer un ne redémarre pas l'hôte, et en retirer un non plus.

L'isolation est réelle, pas seulement de façade. Les dépendances propres d'un module se chargent en privé, à l'intérieur de celui-ci. Ce qui vient de l'hôte se limite à une liste courte et explicite de contrats partagés — le SDK de modules, EF Core, MediatR, FluentValidation et les bibliothèques de base d'ASP.NET et .NET — ainsi que les contract-assemblies que les modules publient les uns pour les autres.

La vérification qui nous importe est celle qui le prouve plutôt que de l'affirmer : charger un module, relâcher chaque référence vers lui, forcer une garbage collection, puis confirmer que la weak reference vers son load context est morte. Si un module ne pouvait pas réellement être déchargé, cette vérification échouerait.

La signature est une signature RSA sur un hash SHA-256, vérifiée contre une clé publique que l'opérateur installe. Installer depuis une URL exige une chose de plus qu'une signature valide : l'hôte source doit figurer sur une allow-list, et cette liste est livrée vide — installer depuis une URL ne fait donc rien tant qu'un opérateur n'y ajoute pas de source.

Publication — un événement au niveau de l'hôte

  • La signature de l'artefact — une signature RSA sur un hash SHA-256 — est vérifiée contre une clé publique que l'opérateur installe.
  • Les assemblies se chargent dans un assembly load context privé et collectible.
  • Le manifeste enregistre le module dans la marketplace. Aucun redéploiement.

Installation — un événement au niveau du tenant

  • Les migrations s'exécutent dans le schéma propre du module, à l'intérieur de la base de données de ce client.
  • Le menu et la liste des autorisations se mettent à jour immédiatement.
  • La désinstallation coupe le module et conserve ses données — supprimer les tables est un choix distinct et délibéré.

Un module déclare ce qu'il est dans un seul fichier :

{
  "slug": "chat",
  "name": "Chat",
  "version": "1.0.0",
  "sdkVersion": "1.1.0",
  "entryAssembly": "CleverInit.Module.Chat.dll",
  "schema": "chat",
  "dependencies": [],
  "frontend": {
    "remoteEntry": "panel/remoteEntry.json",
    "exposedModule": "./Routes"
  },
  "navSections": [
    { "items": [
      { "label": "Chat", "routerLink": null, "children": [
        { "label": "chat.menu.conversations",
          "routerLink": "/m/chat/conversations" }
      ] }
    ] }
  ],
  "permissions": [
    { "name": "Chat.View", "displayName": "View chat" },
    { "name": "Chat.ManageBots", "displayName": "Manage bots" }
  ],
  "dashboardWidgets": [
    { "key": "chat.unread", "requiredPermission": "Chat.View" }
  ]
}
Raccourci depuis modules/chat/module.json.
Le fichier de déclaration qui indique à l'hôte comment s'appelle un module, quelle assembly charger, quel schéma de base de données lui appartient et quelles entrées de menu, autorisations et widgets de tableau de bord il ajoute.

La suite de tests est le contrat.

4 028 tests automatisés s'exécutent sur la host-solution — les suites de modules viennent s'y ajouter et sont suivies par module. La règle est binaire : terminé signifie que la solution entière ne signale aucun échec.

Application2 213
Infrastructure754
Domain515
Intégration API312
Hôte de module173
SDK de module61

Tests de la host-solution par couche, tels que suivis dans le README du dépôt. Les suites de tests de modules sont comptées séparément.

Domain95%
Application85%
Infrastructure70%
API70%

Les objectifs de couverture par couche définis dans le charter, en pourcentage.

  • Un build avec le moindre avertissement est un build incomplet. Le seuil est de 0 avertissement, 0 erreur — à chaque commit.
  • Chaque module livre un test de parité : un controller qui demande une autorisation que son manifeste ne déclare pas fait échouer le build.
  • Chaque changement significatif atterrit dans un journal d'audit append-only — append-only via l'application, pas scellé cryptographiquement contre quelqu'un ayant un accès direct à la base de données.

Épinglé, délibéré, ennuyeux.

Les versions sont épinglées et ajouter une bibliothèque exige une approbation explicite. Voici ce qui se trouve dans la solution aujourd'hui — pas une liste de souhaits.

Backend

.NET 10 · Clean Architecture · CQRS

  • .NET 10 · C# 13 · ASP.NET Core
  • EF Core 10 · SQL Server
  • MediatR 14 · FluentValidation 12
  • Mapperly 4.3 · Ardalis.Specification 9.3
  • Redis · MassTransit + RabbitMQ 8.5
  • BCrypt · Otp.NET · Asp.Versioning
  • Scalar OpenAPI
  • xUnit · Shouldly · NSubstitute

Panel

Angular 21 · zoneless · signals

  • Angular 21, zoneless change detection
  • État conservé dans des signals
  • Frontends de modules comme remotes chargés à l'exécution
  • Vitest
  • anglais · néerlandais · allemand

Exploitation

Délibérément réduit

  • Docker · docker-compose pour le démarrage local
  • Spans OpenTelemetry dans le pipeline de requêtes
  • Logging structuré — secrets, tokens et PII n'atteignent jamais un sink
  • RFC 7807 sur toute la surface d'erreurs

Les chiffres derrière la connexion.

Un condensé pour le questionnaire — le tableau complet se trouve sur la page de sécurité.

Access tokens
Expirent au bout de 15 minutes. Les autorisations voyagent sous forme de claims, si bien que les contrôles d'autorisation sont des lookups en mémoire.
Refresh tokens
Valeurs aléatoires de 256 bits, stockées uniquement sous forme de hash, à usage unique, renouvelées à chaque refresh.
Mots de passe
BCrypt avec facteur de travail 12, fixé dans le code. Verrouillage après 5 tentatives échouées par défaut — configurable par tenant.
Codes à usage unique
Codes à 6 chiffres par e-mail ou SMS, hachés au repos, expirant au bout de 5 minutes avec un plafond de 3 tentatives.

Trois réponses franches.

  • Il existe un portail API public. Il couvre les API publiques de la plateforme — exactement celles-là, et rien de plus. Ce qui est interne reste interne.
  • Kubernetes est la direction que prend la plateforme. Le fournisseur qui l'exploitera, nous ne le publions pas volontairement — ce silence est une décision de sécurité, pas un oubli.
  • Pas de facturation à l'usage ni de calcul au prorata — ni aujourd'hui ni prévu. Une facture que vous pouvez anticiper, voilà la fonctionnalité.

Vous voulez aller plus loin ?

Trente minutes avec les personnes qui l'ont écrit. Apportez votre question d'architecture la plus difficile.

Réserver un appel