Modèle de données et mapping ORM
Modélisation des données avec Entity Framework Core, pattern Repository et architecture multi-base.
Vue d'ensemble
L'architecture de données d'APS repose sur Entity Framework Core comme ORM (Object-Relational Mapping) principal, permettant un mapping objet-relationnel performant et type-safe entre le code C# et les bases de données.
Technologies utilisées
- Entity Framework Core : ORM moderne de Microsoft
- SQL Server : Base de données relationnelle principale
- Oracle : Support optionnel pour Oracle Database
- Code First Migrations : Gestion du schéma de base de données
- LINQ : Requêtes fortement typées
Contextes de base de données
L'application utilise deux contextes EF Core principaux, séparant les données d'annuaire des données métier.
DirectoryContext
Namespace : Avanteam.Directory.Repository.EFCore
Fichier : /Directory/Repository/EFCore/Src/DirectoryContext.cs
Le DirectoryContext gère les données de l'annuaire : utilisateurs, ressources, rôles, délégations.
public class DirectoryContext : DbContext, IRawDataSourceProvider
{
public DirectoryContext(
DbContextOptions<DirectoryContext> options)
: base(options)
{
this.RawDbDataSource = options
.FindExtension<RawDbDataSourceOptionsExtension>()
?.CreateRawDbDataSource(this);
}
public IRawDbDataSource? RawDbDataSource { get; }
// DbSets
public DbSet<DirectoryResource> DirectoryResources => Set<DirectoryResource>();
public DbSet<DirectoryRelation> DirectoryRelations => Set<DirectoryRelation>();
public DbSet<Delegation> Delegations => Set<Delegation>();
public DbSet<IdentityProvider> IdentityProviders => Set<IdentityProvider>();
// ... autres entités
}
Entités principales :
DirectoryResource: Ressources de l'annuaire (utilisateurs, services, etc.)DirectoryResourceType: Types de ressourcesDirectoryRelation: Relations entre ressourcesDelegation: Délégations entre utilisateursIdentityProvider: Fournisseurs d'identité (SAML, OAuth)AuthnToken: Tokens d'authentification
Base de données : Base commune APSDirectory partagée entre applications.
ApplicationContext
Namespace : Avanteam.Application.Repository.EFCore
Fichier : /Application/Repository/EFCore/Src/ApplicationContext.cs
Le ApplicationContext gère les données métier : documents, formulaires, workflows, vues.
public class ApplicationContext : DbContext, IRawDataSourceProvider
{
public ApplicationContext(
DbContextOptions<ApplicationContext> options)
: base(options)
{
this.RawDbDataSource = options
.FindExtension<RawDbDataSourceOptionsExtension>()
?.CreateRawDbDataSource(this);
}
public IRawDbDataSource? RawDbDataSource { get; }
// DbSets - Documents
public DbSet<Document> Documents => Set<Document>();
public DbSet<DocumentObject> DocumentObjects => Set<DocumentObject>();
public DbSet<DocumentBlob> DocumentBlobs => Set<DocumentBlob>();
public DbSet<DocumentAccessRight> DocumentAccessRights => Set<DocumentAccessRight>();
// DbSets - Formulaires
public DbSet<Form> Forms => Set<Form>();
public DbSet<FormField> FormFields => Set<FormField>();
public DbSet<FieldDefinition> FieldDefinitions => Set<FieldDefinition>();
// DbSets - Vues
public DbSet<View> Views => Set<View>();
public DbSet<PersonalizedView> PersonalizedViews => Set<PersonalizedView>();
// DbSets - Agents
public DbSet<AgentSchedule> AgentSchedules => Set<AgentSchedule>();
// ... autres entités (100+ DbSets)
}
Entités principales :
Document: Documents métierForm: Définitions de formulairesView: Vues et requêtesAgentSchedule: Planifications d'agentsProcessInstance: Instances de workflowApplicationLog: Logs applicatifsArchiveProfile: Profils d'archivage
Base de données : Une base par application cliente (multi-tenant).
Configuration des DbContext
Enregistrement dans le container DI
Les contextes sont enregistrés dans le container d'injection de dépendances avec une durée de vie Scoped (une instance par requête HTTP).
DirectoryContext :
services.AddDbContext<DirectoryContext>(ConfigureDirectoryContext);
private static void ConfigureDirectoryContext(
IServiceProvider serviceProvider,
DbContextOptionsBuilder options)
{
var dbConnection = serviceProvider
.GetRequiredService<IWebSiteDescriptor>()
.DirectoryDbConnectionDescriptor;
options.WithContext<DirectoryContext>()
.UseConnectionStringAndStandardFeatures(dbConnection);
}
ApplicationContext :
services.AddDbContext<ApplicationContext>(ConfigureApplicationContext);
private static void ConfigureApplicationContext(
IServiceProvider serviceProvider,
DbContextOptionsBuilder options)
{
var profile = serviceProvider.GetRequiredService<ApplicationProfile>();
options.WithContext<ApplicationContext>()
.UseConnectionStringAndStandardFeatures(
profile.GetDbConnectionDescriptor("APSApp"));
}
Providers de base de données
L'architecture supporte deux providers de base de données via des méthodes d'extension :
SQL Server :
options.UseSqlServerDirectoryContext(connectionString);
options.UseSqlServerApplicationContext(connectionString);
Oracle :
options.UseOracleDirectoryContext(connectionString);
options.UseOracleApplicationContext(connectionString);
Les méthodes d'extension sont définies dans les projets :
Directory.Repository.EFCore.SqlServerDirectory.Repository.EFCore.OracleApplication.Repository.EFCore.SqlServerApplication.Repository.EFCore.Oracle
Entités et configuration
Configuration Fluent API
La configuration des entités utilise le pattern IEntityTypeConfiguration avec configuration automatique :
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Auto-découverte et enregistrement de toutes les configurations
var assembly = typeof(DirectoryContext).Assembly;
modelBuilder.ApplyConfigurationsFromAssembly(assembly);
// Configurations spécifiques au provider (SqlServer vs Oracle)
var migrationAssembly = this.GetInfrastructure()
.GetService<IMigrationsAssembly>();
if (migrationAssembly is not null)
{
modelBuilder.ApplyConfigurationsFromAssembly(
migrationAssembly.Assembly);
}
}
Exemple de configuration d'entité
public class DirectoryResourceConfiguration
: IEntityTypeConfiguration<DirectoryResource>
{
public void Configure(EntityTypeBuilder<DirectoryResource> builder)
{
builder.ToTable("DirectoryResources");
builder.HasKey(e => e.Id);
builder.Property(e => e.Id)
.HasColumnName("id")
.HasColumnType("char(36)")
.IsFixedLength()
.HasMaxLength(36);
builder.Property(e => e.DisplayName)
.HasColumnName("cn_name")
.HasMaxLength(255)
.IsRequired();
builder.Property(e => e.Reference)
.HasColumnName("dn_name")
.HasMaxLength(255)
.IsRequired();
}
}
Conventions de nommage
- Tables : PascalCase en anglais (ex:
DirectoryResources,Documents) - Colonnes : snake_case en anglais (ex:
cn_name,creation_date) - Clés primaires : Colonne
idde typechar(36)(GUID) - Dates : Type
datetime2sur SQL Server - Chaînes :
nvarchar(max)ounvarchar(n)selon besoin
Migrations
Gestion des migrations
Les migrations EF Core sont utilisées pour gérer l'évolution du schéma de base de données.
Structure des migrations :
Directory/Repository/EFCore/
├── SqlServer/Src/Migrations/ # Migrations SQL Server
│ ├── Initial.cs
│ ├── AddIdentityProviders.cs
│ └── DirectoryContextModelSnapshot.cs
└── Oracle/Src/Migrations/ # Migrations Oracle
├── Initial.cs
└── OracleDirectoryContextModelSnapshot.cs
Création de migrations
# Migration pour SQL Server
dotnet ef migrations add MigrationName `
--project Application/Repository/EFCore/SqlServer/Src `
--context ApplicationContext
# Migration pour Oracle
dotnet ef migrations add MigrationName `
--project Application/Repository/EFCore/Oracle/Src `
--context ApplicationContext
Application des migrations
Les migrations sont appliquées automatiquement au démarrage via les DatabaseInitializer :
public class DirectoryDatabaseInitializer :
DatabaseInitializerBase<DirectoryContext>
{
public override async Task InitializeAsync(
CancellationToken cancellationToken = default)
{
await base.InitializeAsync(cancellationToken);
// Apply pending migrations
await Context.Database.MigrateAsync(cancellationToken);
// Seed initial data
await SeedDataAsync(cancellationToken);
}
}
Pattern Repository
Abstraction Repository
Les accès aux données passent par des Stores (repositories) qui encapsulent la logique d'accès :
public interface IResourceStore
{
Task<DirectoryResource?> FindByIdAsync(
string id,
CancellationToken cancellationToken);
Task<DirectoryResource?> FindByReferenceAsync(
string reference,
CancellationToken cancellationToken);
IQueryable<DirectoryResource> GetAll();
Task CreateAsync(
DirectoryResource resource,
CancellationToken cancellationToken);
Task UpdateAsync(
DirectoryResource resource,
CancellationToken cancellationToken);
Task DeleteAsync(
DirectoryResource resource,
CancellationToken cancellationToken);
}
Implémentation Store
public class ResourceStore : IResourceStore
{
private readonly DirectoryContext _context;
public ResourceStore(DirectoryContext context)
{
_context = context;
}
public Task<DirectoryResource?> FindByIdAsync(
string id,
CancellationToken cancellationToken)
{
return _context.DirectoryResources
.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
}
public IQueryable<DirectoryResource> GetAll()
{
return _context.DirectoryResources.AsQueryable();
}
public async Task CreateAsync(
DirectoryResource resource,
CancellationToken cancellationToken)
{
_context.DirectoryResources.Add(resource);
await _context.SaveChangesAsync(cancellationToken);
}
}
Managers (Couche métier)
Les Managers utilisent les Stores et ajoutent la logique métier :
public class ResourceManager : IResourceManager
{
private readonly IResourceStore _store;
private readonly ILogger<ResourceManager> _logger;
public ResourceManager(
IResourceStore store,
ILogger<ResourceManager> logger)
{
_store = store;
_logger = logger;
}
public async Task<DirectoryResource> CreateUserAsync(
string login,
string displayName)
{
// Validation métier
if (string.IsNullOrWhiteSpace(login))
throw new ArgumentException("Login required");
// Vérification unicité
var existing = await _store.FindByReferenceAsync(login);
if (existing != null)
throw new InvalidOperationException("User already exists");
// Création
var user = new DirectoryResource
{
Id = Guid.NewGuid().ToString(),
Reference = login,
DisplayName = displayName,
Type = "User"
};
await _store.CreateAsync(user);
_logger.LogInformation("User {Login} created", login);
return user;
}
}
Modèle multi-tenant
Isolation par base de données
Chaque application cliente dispose de sa propre base de données Application, offrant :
- Isolation complète des données métier
- Performance optimale (pas de filtrage par tenant)
- Sécurité renforcée
- Sauvegarde et restauration indépendantes
ApplicationProfile
Le ApplicationProfile contient les métadonnées de connexion pour chaque tenant :
public class ApplicationProfile
{
public string ApplicationName { get; set; }
public string ConnectionString { get; set; }
public ConnectionType ConnectionType { get; set; }
public DbConnectionDescriptor GetDbConnectionDescriptor(string name)
{
return new DbConnectionDescriptor
{
ConnectionString = ConnectionString,
ConnectionType = ConnectionType
};
}
}
Résolution du contexte
À chaque requête, le bon contexte est résolu selon l'application ciblée :
// Le ApplicationProfile est injecté et résolu automatiquement
// en fonction de l'URL ou du contexte de la requête
services.AddScoped<ApplicationProfile>(sp =>
{
var httpContext = sp.GetService<IHttpContextAccessor>()?.HttpContext;
var appName = httpContext?.Request.Host.Host;
return profileRepository.GetProfileByName(appName);
});
Requêtes et performances
LINQ to Entities
Les requêtes utilisent LINQ pour une approche type-safe :
public async Task<List<Document>> GetRecentDocumentsAsync(
string userId,
int count)
{
return await _context.Documents
.Where(d => d.CreatedBy == userId)
.OrderByDescending(d => d.CreatedAt)
.Take(count)
.Include(d => d.Form)
.ToListAsync();
}
Requêtes SQL brutes
Pour des requêtes complexes, utilisation de SQL brut :
public async Task<List<DocumentStats>> GetDocumentStatsAsync()
{
return await _context.Database
.SqlQueryRaw<DocumentStats>(
@"SELECT FormId, COUNT(*) as Count,
MAX(CreatedAt) as LastCreated
FROM Documents
GROUP BY FormId")
.ToListAsync();
}
IRawDataSourceProvider
Les DbContexts implémentent IRawDataSourceProvider pour accès direct :
public interface IRawDataSourceProvider
{
IRawDbDataSource? RawDbDataSource { get; }
}
Permet des requêtes ADO.NET directes pour maximum de performance :
var dataSource = _context.RawDbDataSource;
using var connection = await dataSource.OpenConnectionAsync();
using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM Documents WHERE ...";
// ...
Mapping et AutoMapper
Configuration AutoMapper
AutoMapper est utilisé pour mapper entités ↔ DTOs :
public class DirectoryMapperProfile : Profile
{
public DirectoryMapperProfile()
{
CreateMap<DirectoryResource, ResourceDto>()
.ForMember(d => d.Name,
opt => opt.MapFrom(s => s.DisplayName));
CreateMap<ResourceDto, DirectoryResource>()
.ForMember(d => d.DisplayName,
opt => opt.MapFrom(s => s.Name));
}
}
Enregistrement
services.AddAutoMapper(typeof(DirectoryMapperProfile));
Gestion des transactions
Transactions implicites
SaveChangesAsync() gère automatiquement les transactions :
public async Task TransferDocumentAsync(string docId, string newOwnerId)
{
var doc = await _context.Documents.FindAsync(docId);
doc.OwnerId = newOwnerId;
var log = new ApplicationLog { Action = "Transfer", DocumentId = docId };
_context.ApplicationLogs.Add(log);
// Transaction implicite autour de SaveChanges
await _context.SaveChangesAsync();
}
Transactions explicites
Pour transactions complexes multi-contextes :
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Opérations...
await _context.SaveChangesAsync();
// Autres opérations...
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
Tests unitaires
Contextes en mémoire
Pour les tests, utilisation de SQLite en mémoire :
public class FakeDirectoryDbContext : DirectoryContext
{
public FakeDirectoryDbContext()
: base(BuildOptions())
{
Database.EnsureCreated();
}
private static DbContextOptions<DirectoryContext> BuildOptions()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return new DbContextOptionsBuilder<DirectoryContext>()
.UseSqlite(connection)
.Options;
}
}
Utilisation dans les tests
[Test]
public async Task Can_Create_User()
{
// Arrange
using var context = new FakeDirectoryDbContext();
var store = new ResourceStore(context);
var manager = new ResourceManager(store, logger);
// Act
var user = await manager.CreateUserAsync("jdoe", "John Doe");
// Assert
Assert.That(user.Reference, Is.EqualTo("jdoe"));
}
Bonnes pratiques
Requêtes asynchrones
Toujours utiliser les méthodes asynchrones :
// ✅ Bon
await _context.Documents.ToListAsync();
// ❌ Mauvais
_context.Documents.ToList();
Inclusions explicites
Charger explicitement les relations nécessaires :
var doc = await _context.Documents
.Include(d => d.Form)
.Include(d => d.DocumentObjects)
.FirstAsync(d => d.Id == id);
Projections
Utiliser Select pour ne charger que les données nécessaires :
var summaries = await _context.Documents
.Where(d => d.FormId == formId)
.Select(d => new { d.Id, d.Reference, d.CreatedAt })
.ToListAsync();
Désactivation du tracking
Pour requêtes en lecture seule :
var docs = await _context.Documents
.AsNoTracking()
.ToListAsync();