Skip to main content

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 ressources
  • DirectoryRelation : Relations entre ressources
  • Delegation : Délégations entre utilisateurs
  • IdentityProvider : 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étier
  • Form : Définitions de formulaires
  • View : Vues et requêtes
  • AgentSchedule : Planifications d'agents
  • ProcessInstance : Instances de workflow
  • ApplicationLog : Logs applicatifs
  • ArchiveProfile : 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.SqlServer
  • Directory.Repository.EFCore.Oracle
  • Application.Repository.EFCore.SqlServer
  • Application.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 id de type char(36) (GUID)
  • Dates : Type datetime2 sur SQL Server
  • Chaînes : nvarchar(max) ou nvarchar(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();

Références