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:#fff

Le 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:#fff

Concrè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é par

5. 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 AuditStockDbContext et ajoute les configurations propres au provider (PRAGMA SQLite, conversion UTC pour PostgreSQL, syntaxe des filtres d'index…)
  • Une factory qui implémente IAuditStockDbContextFactory et appelle le bon provider EF Core (UseSqlite() ou UseNpgsql())

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, DbFileName pour les fichiers .db locaux
  • PostgreSQL : ConnectionString, AdminConnectionString pour 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:#fff

Les 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:#fff

7. 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ésultat

9. 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
  1. Nouveau projet référençant AuditStock.EntityFramework et le package Microsoft.EntityFrameworkCore.SqlServer
  2. Un DbContext spécialisé avec les conventions SQL Server
  3. Une factory appelant UseSqlServer()
  4. Une DbConfiguration avec les propriétés spécifiques
  5. Une méthode d'extension AddAuditStockSqlServer()
  6. 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 .db sur 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 :

  1. IAuditStockDbContextFactory — un contrat unique, des implémentations spécialisées
  2. Le sélecteur de provider — piloté par un simple mot-clé Provider= dans la chaîne de connexion
  3. La résolution de secrets par tenantDbConnectionString-{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.