Introduction : Le défi de l'abstraction des couches d'accès aux données dans le noyau .NET

Les applications de base de .NET modernes interagissent souvent avec les données à travers plusieurs backends de stockage – bases de données relatives, stockage NoSQL, caches en mémoire ou API tiers. À mesure que l'application se développe, un couplage étroit entre la logique d'entreprise et les implémentations spécifiques d'accès aux données devient un fardeau de maintenance. Modifier le fournisseur de base de données sous-jacent ou ajouter un nouveau mécanisme de stockage peut s'écouler sur l'ensemble de la base de données, forçant la recompilation et des tests de régression étendus. Le Factory Pattern[ offre une solution éprouvée en en capturant la création d'objets, vous permettant de changer les implémentations d'accès aux données sans modifier le code de consommation.

Comprendre le modèle d'usine : au-delà de la création d'objets simples

Au cœur de ce modèle, le Factory Pattern est un modèle de conception créative qui délègue la responsabilité d'injecter des objets à une classe d'usine dédiée. Ce modèle se situe sous trois variantes communes : Simple Factory, Factory Method et Abstract Factory. Pour l'abstraction des couches d'accès aux données, le Simple Factory (ou Static Factory) est souvent le point de départ le plus pragmatique, mais nous allons aussi explorer comment l'évoluer en une usine abstraite plus flexible lorsque plusieurs familles de produits existent.

La principale motivation pour utiliser une usine dans .NET Core data access est de maintenir le Principe ouvert/fermé: les classes doivent être ouvertes pour l'extension mais fermées pour modification. En canalisant toute création d'objets d'accès aux données par une usine, vous pouvez introduire de nouvelles implémentations (p. ex., passer du noyau de cadre d'entité au pilon) sans toucher la logique d'entreprise.

Le modèle d'usine n'est pas une balle d'argent. Il est plus efficace lorsque vous avez un ensemble bien défini de stratégies d'accès aux données interchangeables et que vous devez isoler la logique de création du reste de l'application. Dans des applications monolithiques simples avec une seule source de données, le coût supérieur d'une usine peut ne pas être justifié.

Conception de l'abstraction : le contrat d'interface

La première étape de l'utilisation du modèle Factory pour l'accès aux données est de définir une interface commune que tous les dépôts concrets doivent implémenter. Cette interface sert de contrat entre votre logique d'affaires et la couche de données. Dans .NET Core, une telle interface se map souvent aux opérations CRUD standard, mais vous pouvez l'adapter aux besoins de votre domaine.

Exemple : Une interface de dépôt générique

public interface IDataRepository<TKey, TEntity> where TEntity : class
{
 Task<IEnumerable<TEntity>> GetAllAsync();
 Task<TEntity?> GetByIdAsync(TKey id);
 Task AddAsync(TEntity entity);
 Task UpdateAsync(TEntity entity);
 Task DeleteAsync(TKey id);
}

Cette interface générique fonctionne bien lorsque vous avez besoin d'opérations de données cohérentes pour différents types d'entités. Cependant, pour simplifier cet article, nous nous en tenirons à une interface non générique qui fonctionne sur un seul type d'entité. Les principes restent identiques.

Interfaces spécialisées pour les scénarios avancés

Dans les projets réels, vous pouvez avoir besoin de méthodes de dépôt qui vont au-delà du CRUD de base, comme les requêtes paginées, le filtrage ou l'agrégation. Envisagez de définir des interfaces distinctes pour les opérations en lecture seule et en écriture seulement pour suivre le principe de séparation de l'interface.

public interface IDataReader<TEntity>
{
 Task<IEnumerable<TEntity>> QueryAsync(Expression<Func<TEntity, bool>> predicate);
 Task<TEntity?> GetByIdAsync(int id);
}

public interface IDataWriter<TEntity>
{
 Task InsertAsync(TEntity entity);
 Task UpdateAsync(TEntity entity);
 Task DeleteAsync(int id);
}

Le Factory Pattern peut alors produire une implémentation combinée qui satisfait les deux interfaces au besoin, ou retourner des objets séparés pour lecture et écriture si vous choisissez une approche CQRS.

Mise en œuvre de classes d'accès aux données concrètes

Une fois l'interface définie, vous créez des implémentations concrètes pour chaque technologie d'accès aux données. Voici des exemples utilisant [Dapper, deux des cadres d'accès aux données de base .NET les plus courants.

Cadre de l ' entité

public class EfDataRepository : IDataRepository
{
 private readonly AppDbContext _context;

 public EfDataRepository(AppDbContext context)
 {
 _context = context;
 }

 public async Task<IEnumerable<DataItem>> GetAllAsync()
 {
 return await _context.Set<DataItem>().AsNoTracking().ToListAsync();
 }

 public async Task<DataItem?> GetByIdAsync(int id)
 {
 return await _context.Set<DataItem>().FindAsync(id);
 }

 // Additional methods omitted for brevity
}

Notez que s'attend à une instance qui, dans une application .NET Core, est généralement injectée via le conteneur DI. Cela s'harmonise avec le modèle Factory : l'usine aura besoin d'accéder au conteneur DI pour résoudre ces dépendances.

Mise en œuvre de l'approche de l'évaluation

public class DapperDataRepository : IDataRepository
{
 private readonly IDbConnection _connection;
 private readonly string _connectionString;

 public DapperDataRepository(IConfiguration configuration)
 {
 _connectionString = configuration.GetConnectionString("DefaultConnection");
 _connection = new SqlConnection(_connectionString);
 }

 public async Task<IEnumerable<DataItem>> GetAllAsync()
 {
 var sql = "SELECT * FROM DataItems";
 return await _connection.QueryAsync<DataItem>(sql);
 }

 public async Task<DataItem?> GetByIdAsync(int id)
 {
 var sql = "SELECT * FROM DataItems WHERE Id = @Id";
 return await _connection.QueryFirstOrDefaultAsync<DataItem>(sql, new { Id = id });
 }
}

Les deux implémentations remplissent le même contrat mais utilisent des mécaniques entièrement différentes. L'usine décidera de l'instantané en fonction des conditions d'exécution.

Construction de l'usine: De Simple à Résumé

L'usine elle-même encapsule la logique de décision. Une usine statique est suffisante lorsque le choix de l'implémentation dépend uniquement d'une valeur de configuration (par exemple, une clé de paramètres d'application). Cependant, lorsque la décision nécessite un contexte d'exécution (rôle utilisateur, locataire, drapeau de fonctionnalité), une usine non statique qui accepte des paramètres supplémentaires est plus appropriée.

Simplifier l'usine statique (configuration-conduite)

public static class DataRepositoryFactory
{
 public static IDataRepository Create(IServiceProvider serviceProvider, string provider)
 {
 return provider switch
 {
 "EntityFramework" => serviceProvider.GetRequiredService<EfDataRepository>(),
 "Dapper" => ActivatorUtilities.CreateInstance<DapperDataRepository>(serviceProvider),
 _ => throw new NotSupportedException($"Data provider '{provider}' is not supported.")
 };
 }
}

Cette usine utilise les pour injecter des types qui ont des dépendances enregistrées dans le conteneur DI. est résolu directement parce qu'il est déjà enregistré (avec . ] est créé en utilisant qui gère l'injection du constructeur sans exiger l'enregistrement manuel du dépôt lui-même – seulement ses dépendances (par exemple, ]) doivent être dans le conteneur.

Fabrique abstraite pour plusieurs familles de produits

Lorsque votre application nécessite différents types d'objets d'accès aux données (par exemple, un pour les commandes, un autre pour l'inventaire, chacun utilisant un moteur de stockage différent), la Simple Factory devient un peu difficile. Une Abstract Factory[ définit une interface pour créer des familles d'objets liés sans spécifier leurs classes de béton. Chaque usine de béton produit un ensemble complet d'objets d'accès aux données pour une pile technologique particulière.

public interface IDataAccessFactory
{
 IDataRepository CreateOrderRepository();
 IDataRepository CreateInventoryRepository();
 // etc.
}

public class EfDataAccessFactory : IDataAccessFactory
{
 private readonly AppDbContext _context;
 public EfDataAccessFactory(AppDbContext context) => _context = context;

 public IDataRepository CreateOrderRepository() => new EfOrderRepository(_context);
 public IDataRepository CreateInventoryRepository() => new EfInventoryRepository(_context);
}

public class DapperDataAccessFactory : IDataAccessFactory
{
 private readonly string _connectionString;
 public DapperDataAccessFactory(IConfiguration configuration) => _connectionString = configuration.GetConnectionString("DefaultConnection");

 public IDataRepository CreateOrderRepository() => new DapperOrderRepository(_connectionString);
 public IDataRepository CreateInventoryRepository() => new DapperInventoryRepository(_connectionString);
}

La Fabrique Abstract est plus puissante mais aussi plus lourde. Réservez-la pour les applications où vous devez échanger des piles d'accès aux données entières (par exemple, remplacer tous les dépôts de Cadre d'entités par des dépôts de Dopper) à la fois, plutôt que de sélectionner des implémentations individuelles.

Intégration de l'usine avec l'injection de dépendance de base .NET

La force réelle du modèle Factory dans .NET Core émerge lorsque vous le combinez avec le conteneur DI. Au lieu d'enregistrer les types de dépôt béton, enregistrez l'usine et laissez-le produire la mise en œuvre appropriée sur demande.

Inscription à Program.cs (ou Startup.cs)

builder.Services.AddTransient<EfDataRepository>();
builder.Services.AddTransient<IDataRepository>(sp =>
{
 var config = sp.GetRequiredService<IConfiguration>();
 var provider = config.GetValue<string>("DataProvider");
 return DataRepositoryFactory.Create(sp, provider);
});

Dans cet enregistrement, le béton est enregistré de façon transitoire (de sorte que le conteneur DI puisse le résoudre à l'intérieur de l'usine). L'enregistrement utilise un délégué d'usine qui lit la valeur de et les délégués à l'usine statique. Maintenant, tout consommateur qui injecte obtiendra la mise en œuvre correcte sans savoir lequel il est.

Utilisation de services nommés pour le support multi-fournisseur

Si votre application a besoin de dépôts multiples utilisant différents fournisseurs simultanément (p. ex., un pour les données historiques utilisant Dapper, et un pour les données en temps réel utilisant le cadre d'entité), vous pouvez enregistrer des méthodes d'usine nommées ou utiliser un modèle de dictionnaire.

builder.Services.AddSingleton<IDataRepositoryFactory>(sp =>
{
 var config = sp.GetRequiredService<IConfiguration>();
 var providers = config.GetSection("DataProviders").Get<Dictionary<string, string>>();
 return new DataRepositoryFactory(sp, providers);
});

L'usine peut alors exposer une méthode qui renvoie l'implémentation appropriée basée sur le paramètre , que vous pouvez passer comme dépendance en utilisant ou un modèle d'injection personnalisé.

Avantages et scénarios réels

Laissez-nous examiner les situations concrètes où le modèle d'usine brille dans les couches d'accès de données de base .NET.

1. Essais et abattage

La logique d'exploitation des tests unitaires devient banale lorsque vous pouvez remplacer un dépôt simulé. L'usine peut être configurée lors de la configuration des tests pour retourner une implémentation simulée ou in-memory. Par exemple, lors des tests d'intégration, définissez la touche de configuration à et avez une usine qui retourne une soutenue par un .

2. Demandes multi-tenants

Chaque locataire peut avoir besoin d'une technologie de stockage de données différente en raison de licences, contraintes héritées ou distribution géographique. Une usine peut examiner les métadonnées du locataire à l'exécution et mettre en place le dépôt approprié – peut-être un locataire utilise SQL Server via Entity Framework, une autre utilise PostgreSQL via Dapper, et une troisième utilise Azure Cosmos DB.

3. Tombeaux de la fonction et migration progressive

Lorsque vous migrez d'un ORM à un autre (par exemple, de Entity Framework à Dapper pour des requêtes critiques en matière de performance), l'usine vous permet de diriger le trafic progressivement. Vous pouvez construire un système de flag qui, pour un pourcentage d'utilisateurs ou de paramètres spécifiques, retourne le nouveau dépôt Dapper alors que la plupart de l'application utilise toujours Entity Framework.

Tester le calque d'accès aux données abstraites

Une usine bien conçue rend les tests simples. Vous pouvez créer une usine de test qui retourne des implémentations simulées ou des versions légères en mémoire de vos dépôts de données. Par exemple:

public class TestDataRepositoryFactory
{
 public static IDataRepository CreateInMemory()
 {
 return new InMemoryDataRepository();
 }
}

Les détiennent simplement un et implémentent l'interface en utilisant des opérations en mémoire. Les tests unitaires peuvent alors inocactualiser des services commerciaux avec cette usine de test sans nécessiter une connexion de base de données ou de cadres de simulation.

Meilleures pratiques et pièges communs

Ne pas trop abstractionner

Si votre application ne changera jamais de fournisseur d'accès aux données, l'abstraction n'augmentera la complexité que pour aucun avantage. Utilisez-la seulement lorsque vous avez une exigence claire et actuelle pour plusieurs implémentations interchangeables.

Éviter les fournisseurs à chaîne

Utiliser des chaînes magiques pour les noms de fournisseurs (comme ou ) est fragile. Au lieu de cela, définir une énumération ou utiliser un objet de configuration avec des types forts. Par exemple:

public enum DataProvider
{
 EntityFramework,
 Dapper,
 InMemory
}

Gérer soigneusement la vie

Pour les applications web, une durée de vie étendue (par demande) est généralement appropriée pour EF Core DbContext, mais les connexions Dapper peuvent avoir besoin de transitoires ou de scopes en fonction de la stratégie de mise en commun de la connexion. L'usine ne devrait pas mettre en cache indéfiniment sauf s'ils sont apatrides.

Envisager d'utiliser une usine avec un registre

Pour les applications importantes, envisagez de mettre en œuvre un Emplacement Pattern[ à côté de l'usine. Un registre stocke des usines préconfigurées avec un identifiant (p. ex., numéro de locataire, nom logique). Le client demande au registre de l'usine appropriée, qui crée ensuite le dépôt. Ce modèle est particulièrement utile dans les architectures multi-locataires où chaque locataire peut avoir une stratégie d'accès aux données différente.

Comparaison avec les modèles alternatifs

Le modèle Factory n'est pas le seul moyen d'abstractionner l'accès aux données. Voici comment il se compare à d'autres approches communes dans .NET Core:

  • Stratégie Pattern – Similaire à Factory, mais l'accent est mis sur les algorithmes encapsulant (par exemple, différentes stratégies de tri ou de filtrage) plutôt que sur la création d'objets.Le Factory Pattern est un modèle de création; le modèle de stratégie est comportemental. Ils peuvent se compléter: une usine peut retourner une stratégie.
  • Modèle de décorateur – Utile pour ajouter des préoccupations transversales (cache, log, réessayer) à un dépôt sans modifier son code. L'usine peut décorer le dépôt qu'elle crée, combinant les deux motifs.
  • Repository Pattern (sans usine)[ – Injecter directement un dépôt de béton via DI fonctionne pour des applications simples. L'usine ajoute de la flexibilité lorsque le type de béton varie.

Ressources externes et lectures complémentaires

Pour approfondir votre compréhension de l'accès aux données de base de l'usine et du .NET, consultez les sources faisant autorité suivantes :

Conclusion : Construire un changement

Le Factory Pattern offre une façon disciplinée de gérer la variation des couches d'accès aux données, vous permettant d'échanger des backends de stockage, d'adopter de nouvelles technologies et de tester la logique d'affaires en isolation. Dans .NET Core, combiner le Factory Pattern avec le conteneur DI intégré donne une architecture propre et durable qui respecte les principes SOLID. Commencez petit : définissez une interface, implémentez deux classes de béton, créez une usine statique et filez-la à travers DI. À mesure que votre application grandit, transformez l'usine en une usine abstraite ou une usine axée sur le registre pour gérer plusieurs familles de dépôts.