Appearance
EF Core Patterns
Section: 13 — Data
Last Updated: 2026-05-30
Scope: Repository pattern, Unit of Work, interceptors, global query filters, transactions
Overview
All EF Core interactions in Microtec ERP follow a strict set of patterns enforced via the Microtec.Persistence NuGet package. Services do not write raw EF Core queries — they use the repository abstraction and unit-of-work interfaces.
RepositoryBase<T>
Every aggregate root repository inherits RepositoryBase<T>, which ships 20+ pre-built methods via Microtec.Persistence.
Key Methods
| Category | Method | Notes |
|---|---|---|
| Read | GetByIdAsync(id) | With/without tracking |
| Read | GetAllAsync(spec) | Specification pattern support |
| Read | GetPagedAsync(pageReq) | Built-in pagination |
| Read | ExistsAsync(predicate) | Optimized count check |
| Write | AddAsync(entity) | Fires AuditInterceptor |
| Write | UpdateAsync(entity) | Marks as Modified |
| Write | DeleteAsync(id) | Soft-delete (sets IsDeleted=true) |
| Write | BulkInsertAsync(entities) | EF Core bulk extension |
| Projection | ProjectToAsync<TDto>(spec) | AutoMapper projection |
Implementation Pattern
csharp
// Concrete repository in a service
public class EmployeeRepository : RepositoryBase<Employee>, IEmployeeRepository
{
public EmployeeRepository(HrDbContext context) : base(context) { }
// Only override methods that need business-specific logic
public async Task<Employee?> GetWithContractsAsync(Guid employeeId)
=> await DbSet
.Include(e => e.Contracts)
.AsNoTracking() // Always for reads
.FirstOrDefaultAsync(e => e.Id == employeeId);
}IMPORTANT
Always call AsNoTracking() for read-only queries. EF Core change tracking has significant memory and CPU overhead. Omitting it in list queries is one of the most common performance regressions.
IUnitOfWork<TContext>
Interface Contract
csharp
public interface IUnitOfWork<TContext> where TContext : DbContext
{
TContext Context { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken ct = default);
Task CommitTransactionAsync(IDbContextTransaction transaction);
Task RollbackTransactionAsync(IDbContextTransaction transaction);
}Transaction Pattern
csharp
// ALWAYS use this pattern for multi-step writes
public async Task<Result> HandleAsync(CreateSalesOrderCommand cmd)
{
await using var scope = await _unitOfWork.BeginTransactionAsync();
try
{
var order = SalesOrder.Create(cmd.CustomerId, cmd.Lines);
await _orderRepo.AddAsync(order);
var inventory = await _inventoryRepo.GetByIdAsync(cmd.ItemId);
inventory.Reserve(cmd.Quantity);
await _inventoryRepo.UpdateAsync(inventory);
await _unitOfWork.SaveChangesAsync();
await _unitOfWork.CommitTransactionAsync(scope);
return Result.Success(order.Id);
}
catch
{
await _unitOfWork.RollbackTransactionAsync(scope);
throw;
}
}WARNING
Never call SaveChangesAsync() multiple times within a single handler without a transaction. If the second save fails, the first write is already committed.
AuditInterceptor
The AuditInterceptor is registered globally in Microtec.Persistence and automatically populates audit fields on every SaveChanges call.
Auto-Populated Fields
| Field | On Create | On Update |
|---|---|---|
CreatedBy | Current user ID | — (immutable) |
CreatedOn | IClockService.Now | — (immutable) |
UpdatedBy | Current user ID | Current user ID |
UpdatedOn | IClockService.Now | IClockService.Now |
Entity Requirement
Entities must implement IAuditableEntity (from Microtec.Domain):
csharp
public interface IAuditableEntity
{
string? CreatedBy { get; set; }
DateTime CreatedOn { get; set; }
string? UpdatedBy { get; set; }
DateTime? UpdatedOn { get; set; }
}
// Convenience base class
public abstract class AuditableEntity : BaseEntity, IAuditableEntity
{
public string? CreatedBy { get; set; }
public DateTime CreatedOn { get; set; }
public string? UpdatedBy { get; set; }
public DateTime? UpdatedOn { get; set; }
}IClockService
CAUTION
Never use DateTime.Now or DateTime.UtcNow directly. Always inject IClockService.
csharp
public interface IClockService
{
DateTime Now { get; } // UTC time
DateTime LocalNow { get; } // Tenant-local time
}
// Correct usage
public class CreateEmployeeHandler
{
private readonly IClockService _clock;
public Employee Handle(CreateEmployeeCommand cmd)
=> new Employee
{
HireDate = _clock.Now, // Correct
// HireDate = DateTime.Now // WRONG — breaks tests and timezone handling
};
}Rationale: IClockService is mockable (deterministic tests), timezone-aware (tenant-local time), and injectable.
Global Query Filters
Two global filters apply to every query automatically — no explicit .Where() required.
Soft Delete Filter
csharp
// Applied in OnModelCreating via ConfigureSoftDelete()
modelBuilder.Entity<T>().HasQueryFilter(e => !e.IsDeleted);Multi-Tenancy Filter
csharp
// Applied in OnModelCreating via ConfigureMultiTenancy()
modelBuilder.Entity<T>().HasQueryFilter(
e => e.TenantId == _tenantProvider.TenantId
);Bypassing Filters
Only bypass filters when there is a justified business need (e.g., admin operations):
csharp
// Include soft-deleted records (admin view)
var allEmployees = await _context.Employees
.IgnoreQueryFilters()
.AsNoTracking()
.ToListAsync();
// Cross-tenant query (system admin only)
var allOrders = await _context.Orders
.IgnoreQueryFilters()
.Where(o => o.TenantId == targetTenantId)
.AsNoTracking()
.ToListAsync();WARNING
Never call IgnoreQueryFilters() in application-layer handlers. It is reserved for admin/system services only.
EF Core Extension Methods
All registered via the Microtec.Persistence AddPersistence() extension or called from OnModelCreating.
ConfigureAuditFields<T>()
csharp
// Configures column types and required constraints for audit fields
modelBuilder.Entity<Employee>().ConfigureAuditFields();
// Equivalent to:
// .Property(e => e.CreatedBy).HasMaxLength(100)
// .Property(e => e.UpdatedBy).HasMaxLength(100)
// .Property(e => e.CreatedOn).IsRequired()ConfigureSoftDelete<T>()
csharp
// Adds HasQueryFilter(e => !e.IsDeleted) + index on IsDeleted
modelBuilder.Entity<Employee>().ConfigureSoftDelete();ConfigureMultiTenancy<T>()
csharp
// Adds HasQueryFilter + index on TenantId
modelBuilder.Entity<Employee>().ConfigureMultiTenancy(_tenantProvider);ConfigureDecimalPrecision()
csharp
// Applies to ALL decimal properties in the model
modelBuilder.ConfigureDecimalPrecision(precision: 18, scale: 4);NOTE
Decimal precision 18,4 is the ERP standard. Financial calculations that require higher precision must specify their column precision explicitly via [Column(TypeName = "decimal(28,8)")].
ApplyGlobalQueryFilters()
csharp
// Called at the end of OnModelCreating — applies soft-delete + tenant filters
// to all entities that implement the relevant interfaces
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
modelBuilder.ConfigureDecimalPrecision(18, 4);
modelBuilder.ApplyGlobalQueryFilters(_tenantProvider);
}Admin Unit of Work
For admin/BusinessOwner operations (cross-tenant, not scoped to a single tenant DB):
csharp
public interface IAdminUnitOfWork
{
AdminDbContext Context { get; }
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken ct = default);
}Admin context connects to the mic_admin database and does not apply tenant query filters.
Performance Checklist
Before submitting any EF Core query for review:
- [ ]
AsNoTracking()on all read-only queries - [ ] Pagination via
GetPagedAsync()— no unboundedToListAsync()on large tables - [ ]
Include()only what is needed — no blanket eager loading - [ ] No N+1 queries — use
.Include()or explicit batch loads - [ ]
Select()projection for large DTOs — do not load entire entities when only 3 fields are needed - [ ] Indexes exist for every filter column (check migration)
- [ ]
ConfigureDecimalPrecision()applied — no defaultdecimal(18,2)columns
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Correct Approach |
|---|---|---|
DateTime.Now | Not mockable, wrong timezone | IClockService.Now |
Multiple SaveChanges without transaction | Partial writes on failure | Use BeginTransactionAsync() |
ToListAsync() without pagination | Memory exhaustion on large tables | GetPagedAsync() |
| Raw EF Core in controllers | Bypasses repository abstraction | Use handler → repository |
| Tracked entities in read handlers | Memory/CPU overhead | AsNoTracking() |
IgnoreQueryFilters() in app handlers | Cross-tenant data leak | Admin services only |