Data Model and ORM Mapping
Data modeling with Entity Framework Core, Repository pattern, and multi-database architecture.
Overviewβ
The APS data architecture relies on Entity Framework Core as the primary ORM (Object-Relational Mapping), enabling performant and type-safe object-relational mapping between C# code and databases.
Technologies Usedβ
- Entity Framework Core: Microsoft's modern ORM
- SQL Server: Primary relational database
- Oracle: Optional support for Oracle Database
- Code First Migrations: Database schema management
- LINQ: Strongly typed queries
Database Contextsβ
The application uses two main EF Core contexts, separating directory data from business data.
DirectoryContextβ
Namespace: Avanteam.Directory.Repository.EFCore
File: /Directory/Repository/EFCore/Src/DirectoryContext.cs
The DirectoryContext manages directory data: users, resources, roles, delegations.
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>();
// ... other entities
}
Main Entities:
DirectoryResource: Directory resources (users, services, etc.)DirectoryResourceType: Resource typesDirectoryRelation: Relations between resourcesDelegation: Delegations between usersIdentityProvider: Identity providers (SAML, OAuth)AuthnToken: Authentication tokens
Database: Common APSDirectory database shared between applications.
ApplicationContextβ
Namespace: Avanteam.Application.Repository.EFCore
File: /Application/Repository/EFCore/Src/ApplicationContext.cs
The ApplicationContext manages business data: documents, forms, workflows, views.
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 - Forms
public DbSet<Form> Forms => Set<Form>();
public DbSet<FormField> FormFields => Set<FormField>();
public DbSet<FieldDefinition> FieldDefinitions => Set<FieldDefinition>();
// DbSets - Views
public DbSet<View> Views => Set<View>();
public DbSet<PersonalizedView> PersonalizedViews => Set<PersonalizedView>();
// DbSets - Agents
public DbSet<AgentSchedule> AgentSchedules => Set<AgentSchedule>();
// ... other entities (100+ DbSets)
}
Main Entities:
Document: Business documentsForm: Form definitionsView: Views and queriesAgentSchedule: Agent schedulesProcessInstance: Workflow instancesApplicationLog: Application logsArchiveProfile: Archive profiles
Database: One database per client application (multi-tenant).
DbContext Configurationβ
Registration in DI Containerβ
Contexts are registered in the dependency injection container with a Scoped lifetime (one instance per HTTP request).
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"));
}
Database Providersβ
The architecture supports two database providers via extension methods:
SQL Server:
options.UseSqlServerDirectoryContext(connectionString);
options.UseSqlServerApplicationContext(connectionString);
Oracle:
options.UseOracleDirectoryContext(connectionString);
options.UseOracleApplicationContext(connectionString);
Extension methods are defined in the projects:
Directory.Repository.EFCore.SqlServerDirectory.Repository.EFCore.OracleApplication.Repository.EFCore.SqlServerApplication.Repository.EFCore.Oracle
Entities and Configurationβ
Fluent API Configurationβ
Entity configuration uses the IEntityTypeConfiguration pattern with automatic configuration:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Auto-discovery and registration of all configurations
var assembly = typeof(DirectoryContext).Assembly;
modelBuilder.ApplyConfigurationsFromAssembly(assembly);
// Provider-specific configurations (SqlServer vs Oracle)
var migrationAssembly = this.GetInfrastructure()
.GetService<IMigrationsAssembly>();
if (migrationAssembly is not null)
{
modelBuilder.ApplyConfigurationsFromAssembly(
migrationAssembly.Assembly);
}
}
Entity Configuration Exampleβ
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();
}
}
Naming Conventionsβ
- Tables: PascalCase in English (e.g.,
DirectoryResources,Documents) - Columns: snake_case in English (e.g.,
cn_name,creation_date) - Primary Keys: Column
idof typechar(36)(GUID) - Dates: Type
datetime2on SQL Server - Strings:
nvarchar(max)ornvarchar(n)as needed
Migrationsβ
Migration Managementβ
EF Core migrations are used to manage database schema evolution.
Migration Structure:
Directory/Repository/EFCore/
βββ SqlServer/Src/Migrations/ # SQL Server migrations
β βββ Initial.cs
β βββ AddIdentityProviders.cs
β βββ DirectoryContextModelSnapshot.cs
βββ Oracle/Src/Migrations/ # Oracle migrations
βββ Initial.cs
βββ OracleDirectoryContextModelSnapshot.cs
Creating Migrationsβ
# Migration for SQL Server
dotnet ef migrations add MigrationName `
--project Application/Repository/EFCore/SqlServer/Src `
--context ApplicationContext
# Migration for Oracle
dotnet ef migrations add MigrationName `
--project Application/Repository/EFCore/Oracle/Src `
--context ApplicationContext
Applying Migrationsβ
Migrations are applied automatically at startup via 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);
}
}
Repository Patternβ
Repository Abstractionβ
Data access goes through Stores (repositories) that encapsulate access logic:
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);
}
Store Implementationβ
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 (Business Layer)β
Managers use Stores and add business logic:
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)
{
// Business validation
if (string.IsNullOrWhiteSpace(login))
throw new ArgumentException("Login required");
// Uniqueness check
var existing = await _store.FindByReferenceAsync(login);
if (existing != null)
throw new InvalidOperationException("User already exists");
// Creation
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;
}
}
Multi-Tenant Modelβ
Database Isolationβ
Each client application has its own Application database, providing:
- Complete business data isolation
- Optimal performance (no tenant filtering)
- Enhanced security
- Independent backup and restore
ApplicationProfileβ
The ApplicationProfile contains connection metadata for each 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
};
}
}
Context Resolutionβ
For each request, the correct context is resolved based on the target application:
// The ApplicationProfile is injected and automatically resolved
// based on the URL or request context
services.AddScoped<ApplicationProfile>(sp =>
{
var httpContext = sp.GetService<IHttpContextAccessor>()?.HttpContext;
var appName = httpContext?.Request.Host.Host;
return profileRepository.GetProfileByName(appName);
});
Queries and Performanceβ
LINQ to Entitiesβ
Queries use LINQ for a type-safe approach:
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();
}
Raw SQL Queriesβ
For complex queries, using raw SQL:
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β
DbContexts implement IRawDataSourceProvider for direct access:
public interface IRawDataSourceProvider
{
IRawDbDataSource? RawDbDataSource { get; }
}
Enables direct ADO.NET queries for maximum performance:
var dataSource = _context.RawDbDataSource;
using var connection = await dataSource.OpenConnectionAsync();
using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM Documents WHERE ...";
// ...
Mapping and AutoMapperβ
AutoMapper Configurationβ
AutoMapper is used to map entities β 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));
}
}
Registrationβ
services.AddAutoMapper(typeof(DirectoryMapperProfile));
Transaction Managementβ
Implicit Transactionsβ
SaveChangesAsync() automatically manages 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);
// Implicit transaction around SaveChanges
await _context.SaveChangesAsync();
}
Explicit Transactionsβ
For complex multi-context transactions:
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Operations...
await _context.SaveChangesAsync();
// Other operations...
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
Unit Testingβ
In-Memory Contextsβ
For testing, using SQLite in-memory:
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;
}
}
Usage in 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"));
}
Best Practicesβ
Asynchronous Queriesβ
Always use asynchronous methods:
// β
Good
await _context.Documents.ToListAsync();
// β Bad
_context.Documents.ToList();
Explicit Includesβ
Explicitly load necessary relations:
var doc = await _context.Documents
.Include(d => d.Form)
.Include(d => d.DocumentObjects)
.FirstAsync(d => d.Id == id);
Projectionsβ
Use Select to load only necessary data:
var summaries = await _context.Documents
.Where(d => d.FormId == formId)
.Select(d => new { d.Id, d.Reference, d.CreatedAt })
.ToListAsync();
Disable Trackingβ
For read-only queries:
var docs = await _context.Documents
.AsNoTracking()
.ToListAsync();