Dans cet article, je vais vous montrer comment j'ai architecturé la couche de persistance du projet AuditStock pour qu'elle soit totalement interchangeable. AuditStock est une application multi-tenant : chaque client dispose de son propre environnement, de sa propre base de données et de ses propres secrets stockés dans Azure Key Vault. L'application s'adapte au client courant dès le démarrage et résout dynamiquement la chaîne de connexion dédiée à ce tenant.
Mes handlers Mediator, mes validators FluentValidation et l'ensemble de mon domaine métier ne savent absolument pas quel SGBD tourne derrière, ni pour quel client. Un simple changement dans la chaîne de connexion suffit à basculer de SQLite (dev / mono-utilisateur) vers PostgreSQL (production / multi-tenant) — et inversement.
1. Vue d'ensemble de l'architecture
AuditStock est déployé pour plusieurs clients simultanément — chacun avec sa propre base de données, son propre provider (SQLite ou PostgreSQL) et ses propres secrets. Voici comment les projets de la solution s'articulent autour de la persistance :
graph TB
subgraph "Domaine (aucune dépendance)"
DATAS["AuditStock.Datas<br/>(POCO / Enums)"]
end
subgraph "Abstraction EF Core"
EF["AuditStock.EntityFramework<br/>(DbContext de base + IAuditStockDbContextFactory)"]
end
subgraph "Implémentations concrètes"
SQLITE["AuditStock.Sqlite<br/>(SqliteDbContext + SqliteDbContextFactory)"]
PGSQL["AuditStock.PostgreSql<br/>(PostgreSqlDbContext + PostgreSqlDbContextFactory)"]
end
subgraph "Logique métier"
CORE["AuditStock.Core<br/>(Handlers Mediator + Validators)"]
end
subgraph "Présentation"
WEB["AuditStock.AdminWebApp<br/>AuditStock.BlazorWebApp"]
end
EF -->|référence| DATAS
SQLITE -->|référence| EF
PGSQL -->|référence| EF
CORE -->|référence| EF
WEB -->|référence| CORE
style DATAS fill:#2d6a4f,color:#fff
style EF fill:#1b4332,color:#fff
style SQLITE fill:#005f73,color:#fff
style PGSQL fill:#005f73,color:#fff
style CORE fill:#9b2226,color:#fff
style WEB fill:#ae2012,color:#fffLe point clé : AuditStock.Core ne dépend que de l'interface IAuditStockDbContextFactory. Il ne sait jamais quel provider concret est câblé, ni pour quel client l'application tourne.
2. Le multi-tenant : un client, une base, un secret
Chaque client (tenant) dispose de son propre environnement isolé. Le CustomerName, passé en argument CLI ou via une variable d'environnement CLIENT_NAME, est la clé de voûte de cette isolation. Au démarrage, il sert à résoudre dynamiquement la chaîne de connexion depuis Azure Key Vault :
// Résolution du secret par client au démarrage
if (await secretClient.IsSecretExists($"DbConnectionString-{env.CustomerName}"))
{
settings.DbConnectionString = await secretClient
.GetSecretValue($"DbConnectionString-{env.CustomerName}");
}
Pour un client nommé acme, le secret DbConnectionString-acme contient la chaîne de connexion complète, y compris le Provider qui déterminera si on utilise SQLite ou PostgreSQL.
flowchart TD
START["Démarrage de l'application"] --> ENV["Résolution du CustomerName<br/>(args CLI / variable d'env / env.txt)"]
ENV --> KV{"Secret existe dans Key Vault ?<br/>DbConnectionString-{CustomerName}"}
KV -->|Oui| RESOLVE["Chaîne de connexion résolue<br/>depuis le Key Vault"]
KV -->|Non| DEFAULT["Chaîne de connexion par défaut<br/>depuis appsettings.json"]
RESOLVE --> PROVIDER["Extraction du Provider<br/>et sélection de la factory"]
DEFAULT --> PROVIDER
PROVIDER --> TENANT_DB["✅ Tenant isolé<br/>Base dédiée + Provider adapté"]
style START fill:#264653,color:#fff
style KV fill:#e9c46a,color:#000
style TENANT_DB fill:#2a9d8f,color:#fffConcrètement :
- Le client Acme peut tourner sur PostgreSQL avec
Provider=pgsql;Host=pg-acme.example.com;Database=auditstock_acme;... - Le client Beta peut tourner sur SQLite avec
Provider=sqlite;Data Source=beta.db - Les deux utilisent exactement le même code applicatif
Les handlers Mediator, les plugins — personne ne sait quel client est connecté, ni quel SGBD est utilisé.
3. Le domaine pur : AuditStock.Datas
Ce projet contient les classes POCO, les enums et les interfaces de marquage (IPrimaryKey, ILastUpdatable, IArchivable…). Il n'a aucune dépendance vers Entity Framework — c'est la règle d'or.
// Exemple : une entité du domaine
public class Product : IPrimaryKey, ILastUpdatable, IArchivable, ICodable, ITaggable
{
public Guid Id { get; set; }
public string Code { get; set; } = null!;
public string Designation { get; set; } = null!;
public DateTime CreationDate { get; set; }
public DateTime LastUpdate { get; set; }
public DateTime? ArchivedDate { get; set; }
public Price SalePrice { get; set; } = new();
// ...
}
Ces classes vivent dans un monde purement C#. Elles ne savent rien de SQLite, PostgreSQL, ni même d'EF Core.
4. Le contrat d'abstraction : AuditStock.EntityFramework
Ce projet est le pivot de l'architecture. Il référence AuditStock.Datas et définit les éléments essentiels :
L'interface IAuditStockDbContextFactory
C'est le cœur du mécanisme — le seul contrat que les handlers Mediator connaissent :
public interface IAuditStockDbContextFactory
{
AuditStockDbContext CreateDbContext();
Task<AuditStockDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default);
Task<bool> CreateBackupAsync(CancellationToken cancellationToken = default);
Task MigrateAsync(CancellationToken cancellationToken = default);
Task EnsureCreatedAsync(CancellationToken cancellationToken = default);
Task OptimizeDatabase(CancellationToken cancellationToken = default);
}
| Méthode | Rôle |
|---|---|
CreateDbContextAsync() |
Création async du contexte — utilisée par 99 % des handlers |
CreateBackupAsync() |
Backup natif (SQLite : BackupDatabase / PostgreSQL : pg_dump) |
MigrateAsync() |
Migrations (scripts SQL embarqués pour SQLite, EF Migrations pour PostgreSQL) |
EnsureCreatedAsync() |
Provisioning initial (création de la base, du rôle…) |
OptimizeDatabase() |
Optimisation (VACUUM pour SQLite, ANALYZE pour PostgreSQL) |
Le AuditStockDbContext de base
Un DbContext générique qui déclare tous les DbSet<T> du domaine, sans configuration spécifique à un provider. Il définit aussi une convention globale : toute propriété nommée Id devient automatiquement clé primaire.
Les extensions partagées ModelBuilderExtensions
La configuration du modèle EF (index, tables, propriétés complexes) est mutualisée dans des méthodes d'extension partagées entre les deux providers. Le point subtil : certaines méthodes acceptent des filtres SQL en paramètre car la syntaxe diffère entre providers (SupplierId is not null en SQLite vs "SupplierId" IS NOT NULL en PostgreSQL).
classDiagram
class IAuditStockDbContextFactory {
<<interface>>
+CreateDbContext() AuditStockDbContext
+CreateDbContextAsync(CancellationToken) Task~AuditStockDbContext~
+CreateBackupAsync(CancellationToken) Task~bool~
+MigrateAsync(CancellationToken) Task
+EnsureCreatedAsync(CancellationToken) Task
+OptimizeDatabase(CancellationToken) Task
}
class AuditStockDbContext {
+Products DbSet~Product~
+Suppliers DbSet~Supplier~
+StockItems DbSet~StockItem~
+OnModelCreating(ModelBuilder)
}
class IDbConfiguration {
<<interface>>
+ConnectionString string
+PluginAssemblyList IEnumerable~Assembly~
}
IAuditStockDbContextFactory ..> AuditStockDbContext : crée
AuditStockDbContext --> IDbConfiguration : configuré par5. Les implémentations concrètes : SQLite et PostgreSQL
Chaque provider se matérialise par un projet dédié contenant deux classes :
- Un DbContext spécialisé qui hérite de
AuditStockDbContextet ajoute les configurations propres au provider (PRAGMA SQLite, conversion UTC pour PostgreSQL, syntaxe des filtres d'index…) - Une factory qui implémente
IAuditStockDbContextFactoryet appelle le bon provider EF Core (UseSqlite()ouUseNpgsql())
Les deux DbContext spécialisés appellent les mêmes méthodes d'extension ModelBuilderExtensions pour la configuration du modèle — seuls les filtres SQL dépendants du dialecte sont passés en paramètre.
Chaque provider a aussi sa propre DbConfiguration avec des propriétés adaptées :
- SQLite :
RepositoryFolder,DbFileNamepour les fichiers.dblocaux - PostgreSQL :
ConnectionString,AdminConnectionStringpour le provisioning de rôle et de base
graph LR
subgraph "Interface commune"
I["IAuditStockDbContextFactory"]
end
subgraph "SQLite"
SF["SqliteDbContextFactory"]
SC["SqliteDbContext"]
SD["DbConfiguration<br/>RepositoryFolder / DbFileName"]
SF --> SC
SF --> SD
end
subgraph "PostgreSQL"
PF["PostgreSqlDbContextFactory"]
PC["PostgreSqlDbContext"]
PD["DbConfiguration<br/>ConnectionString / AdminConnectionString"]
PF --> PC
PF --> PD
end
I -.->|implémente| SF
I -.->|implémente| PF
SC -->|hérite| BASE["AuditStockDbContext"]
PC -->|hérite| BASE
style I fill:#e9c46a,color:#000
style SF fill:#005f73,color:#fff
style PF fill:#005f73,color:#fff
style BASE fill:#1b4332,color:#fffLes différences principales entre les deux implémentations :
| Aspect | SQLite | PostgreSQL |
|---|---|---|
| Provider EF Core | UseSqlite() |
UseNpgsql() |
| Migrations | Scripts SQL embarqués (EmbeddedResource) | EF Core Migrations classiques |
| Backup | API native SqliteConnection.BackupDatabase() |
Délègue à pg_dump |
| Optimisation | VACUUM + ANALYZE |
ANALYZE |
| Provisioning | Création du fichier .db |
Création du rôle, de la base, des droits |
| Conventions | PRAGMA WAL, busy_timeout… | Conversion UTC des DateTime |
| Syntaxe filtre index | SupplierId is not null |
"SupplierId" IS NOT NULL |
6. Le sélecteur de provider au démarrage
Après la résolution du secret spécifique au tenant (cf. section 2), le choix du provider se fait uniquement sur la base d'un mot-clé Provider dans la chaîne de connexion. Ce mot-clé est extrait puis retiré via un DbConnectionStringBuilder, et la factory correspondante est enregistrée dans le conteneur DI :
// SQLite (développement)
"DbConnectionString": "Provider=sqlite;Data Source=auditstock.db"
// PostgreSQL (production, résolu depuis Key Vault)
"DbConnectionString": "Provider=pgsql;Host=db.example.com;Database=auditstock;..."
Chaque provider expose une méthode d'extension (AddAuditStockSqlite() / AddAuditStockPostgreSql()) qui enregistre sa DbConfiguration en singleton et sa factory en tant que IAuditStockDbContextFactory dans le conteneur.
flowchart TD
START["Démarrage de l'application"] --> READ["Lecture de la chaîne de connexion<br/>(appsettings ou Key Vault par tenant)"]
READ --> PARSE["Extraction du mot-clé Provider"]
PARSE --> CHECK{Provider = ?}
CHECK -->|"sqlite (ou absent)"| SQLITE_REG["AddAuditStockSqlite()<br/>→ SqliteDbContextFactory"]
CHECK -->|"pgsql"| PGSQL_REG["AddAuditStockPostgreSql()<br/>→ PostgreSqlDbContextFactory"]
CHECK -->|"autre"| ERROR["❌ OperationCanceledException"]
SQLITE_REG --> READY["✅ Application prête<br/>Handlers injectés avec la bonne factory"]
PGSQL_REG --> READY
style START fill:#264653,color:#fff
style READY fill:#2a9d8f,color:#fff
style ERROR fill:#e76f51,color:#fff7. Comment les handlers consomment la factory
Tous les handlers, qu'ils soient dans AuditStock.Core ou dans un plugin, injectent simplement IAuditStockDbContextFactory et appellent CreateDbContextAsync(). Ils ne mentionnent jamais SQLite ni PostgreSQL :
public class SaveProductRequestHandler(
IValidator<Product> validator,
IAuditStockDbContextFactory dbContextFactory,
IMediator mediator)
: IRequestHandler<SaveProductRequest, SaveProductResult>
{
public async Task<SaveProductResult> Handle(
SaveProductRequest request, CancellationToken cancellationToken)
{
// 1. Validation
var validationResult = await validator.ValidateAsync(request.Entity, cancellationToken);
// ...
// 2. Persistance — provider-agnostic
var db = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var saveEntity = await mediator.Send(
new SaveEntityRequest(db, request.Entity), cancellationToken);
// 3. Notification
await mediator.Notify(new ProductChangedNotification { /* ... */ });
return result;
}
}
Le db retourné est toujours un AuditStockDbContext, qu'il soit en réalité un SqliteDbContext ou un PostgreSqlDbContext. Le LINQ-to-Entities est traduit en SQL natif par EF Core selon le provider configuré.
8. Le flux complet : de la requête HTTP à la base de données
sequenceDiagram
participant UI as Blazor UI
participant MR as Mediator
participant H as SaveProductRequestHandler
participant V as FluentValidation
participant F as IAuditStockDbContextFactory
participant DB as AuditStockDbContext
participant SQL as SQLite / PostgreSQL
UI->>MR: Send(SaveProductRequest)
MR->>H: Handle(request, ct)
H->>V: ValidateAsync(product)
V-->>H: ValidationResult
H->>F: CreateDbContextAsync(ct)
F-->>H: AuditStockDbContext
H->>DB: SaveEntityRequest(db, entity)
DB->>SQL: INSERT / UPDATE (SQL natif)
SQL-->>DB: OK
DB-->>H: PersistResult
H->>MR: Notify(ProductChangedNotification)
H-->>MR: SaveProductResult
MR-->>UI: Résultat9. Ajouter un nouveau provider — le guide express
Imaginons que je veuille ajouter SQL Server. Voici les étapes :
flowchart LR
A["1. Créer le projet<br/>AuditStock.SqlServer"] --> B["2. Hériter de<br/>AuditStockDbContext"]
B --> C["3. Implémenter<br/>IAuditStockDbContextFactory"]
C --> D["4. Créer<br/>DbConfiguration"]
D --> E["5. Ajouter<br/>AddAuditStockSqlServer()"]
E --> F["6. Ajouter le cas<br/>'mssql' dans le switch"]
style A fill:#264653,color:#fff
style F fill:#2a9d8f,color:#fff- Nouveau projet référençant
AuditStock.EntityFrameworket le packageMicrosoft.EntityFrameworkCore.SqlServer - Un DbContext spécialisé avec les conventions SQL Server
- Une factory appelant
UseSqlServer() - Une
DbConfigurationavec les propriétés spécifiques - Une méthode d'extension
AddAuditStockSqlServer() - Un nouveau cas
"mssql"dans le sélecteur de provider
Le domaine, les handlers, les validators, les notifications — rien ne change.
10. Ce que j'y gagne au quotidien
En développement
- SQLite me donne un démarrage instantané, sans infrastructure. Un simple fichier
.dbsur le disque. - Les migrations sont des scripts SQL embarqués versionnés et exécutés séquentiellement.
En production
- PostgreSQL offre la concurrence, les transactions MVCC, la scalabilité, et le provisioning automatique piloté par
EnsureCreatedAsync. - Chaque tenant dispose de sa propre base, provisionnée automatiquement avec son rôle dédié et ses droits.
- Les migrations sont les EF Core Migrations classiques.
En architecture
graph TD
subgraph "SOLID en action"
D["D — Dependency Inversion<br/>Core → IAuditStockDbContextFactory ← Sqlite / PostgreSql"]
O["O — Open/Closed<br/>Nouveau provider = nouveau projet, zéro modif métier"]
L["L — Liskov Substitution<br/>SqliteDbContext et PostgreSqlDbContext<br/>sont interchangeables via AuditStockDbContext"]
end
style D fill:#2d6a4f,color:#fff
style O fill:#005f73,color:#fff
style L fill:#9b2226,color:#fff- Le Dependency Inversion Principle est appliqué à la lettre : les modules de haut niveau dépendent de l'abstraction, pas du concret.
- L'Open/Closed Principle permet d'ajouter un provider sans modifier le code métier.
- Le Liskov Substitution Principle garantit que les deux DbContext sont interchangeables.
Conclusion
Cette architecture me permet de travailler sereinement avec SQLite en local pour itérer rapidement, puis de déployer exactement le même code applicatif sur PostgreSQL en production, le tout en mode multi-tenant où chaque client dispose de sa propre base isolée. La clé, c'est la combinaison de trois mécanismes :
IAuditStockDbContextFactory— un contrat unique, des implémentations spécialisées- Le sélecteur de provider — piloté par un simple mot-clé
Provider=dans la chaîne de connexion - La résolution de secrets par tenant —
DbConnectionString-{CustomerName}dans Azure Key Vault
Le domaine ne sait rien de la base. Les handlers ne savent rien du provider. Le code applicatif ne sait rien du client courant. Et c'est exactement comme ça que j'aime mes architectures.