Skip to content

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

CategoryMethodNotes
ReadGetByIdAsync(id)With/without tracking
ReadGetAllAsync(spec)Specification pattern support
ReadGetPagedAsync(pageReq)Built-in pagination
ReadExistsAsync(predicate)Optimized count check
WriteAddAsync(entity)Fires AuditInterceptor
WriteUpdateAsync(entity)Marks as Modified
WriteDeleteAsync(id)Soft-delete (sets IsDeleted=true)
WriteBulkInsertAsync(entities)EF Core bulk extension
ProjectionProjectToAsync<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

FieldOn CreateOn Update
CreatedByCurrent user ID— (immutable)
CreatedOnIClockService.Now— (immutable)
UpdatedByCurrent user IDCurrent user ID
UpdatedOnIClockService.NowIClockService.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 unbounded ToListAsync() 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 default decimal(18,2) columns

Anti-Patterns to Avoid

Anti-PatternProblemCorrect Approach
DateTime.NowNot mockable, wrong timezoneIClockService.Now
Multiple SaveChanges without transactionPartial writes on failureUse BeginTransactionAsync()
ToListAsync() without paginationMemory exhaustion on large tablesGetPagedAsync()
Raw EF Core in controllersBypasses repository abstractionUse handler → repository
Tracked entities in read handlersMemory/CPU overheadAsNoTracking()
IgnoreQueryFilters() in app handlersCross-tenant data leakAdmin services only

Internal Documentation — Microtec Platform Team