A thin, opinionated layer on top of EF Core that removes the boilerplate most data-access code repeats:
- Queries as objects. Filtering lives in typed
*Queryclasses instead ofWhere(...)calls scattered across services. Queries are serializable, composable and reusable — send one from an API controller straight to a domain service. - Repository + domain service. CRUD, paging, projections, soft delete, transactions and detached-graph saves are already implemented — you only add the logic that is actually yours.
- Field-level audit. Writes who / what / when / old → new into your own audit tables, including bulk
ExecuteUpdate/ExecuteDeleteoperations, which EF Core normally performs without touching the change tracker. - Automatic audit stamps.
Created,Modified,CreatedById,ModifiedByIdare filled on save, without a single line in your service.
- Requirements & install
- Quick start
- Building blocks
- Querying
- Services & repositories
- Saving and deleting
- Transactions
- Bulk operations
- Auditing
- Sample project
- Repository layout
- License
| Target framework | .NET 10 |
| EF Core | 10.x |
| Databases | anything EF Core supports (samples ship SQL Server + PostgreSQL) |
dotnet add package EFDM.CoreThe single EFDM.Core package ships three assemblies:
| Assembly | What is inside |
|---|---|
EFDM.Abstractions |
Interfaces only: IRepository<,>, IDomainService<,,>, IDataQuery<,>, IAuditSettings, … |
EFDM.Core |
Entity base classes, query base classes, QueryFilter, DomainServiceBase, AuditSettings |
EFDM.DAL |
EFDMDatabaseContext, Repository<,>, DBContextAuditor — the EF Core-facing part |
Six short steps take you from an empty project to a working data layer. Auditing is optional and has its own section.
Pick a base class that matches the shape of your table (full list). DictDeletableEntityBase<int> gives you Id, Title, IsDeleted and the audit stamps.
using EFDM.Core.Models.Domain;
public class Group : DictDeletableEntityBase<int>
{
public int TypeId { get; set; }
public virtual GroupType Type { get; set; } = default!;
public virtual ICollection<GroupUser> Users { get; set; } = default!;
}Map it with a regular EF Core IEntityTypeConfiguration<Group> — EFDM does not interfere with your mapping.
Everything a caller may filter by becomes a property. ToFilter() turns the properties that were actually set into an expression tree.
using EFDM.Abstractions.DataQueries;
using EFDM.Core.DataQueries;
public class GroupQuery : DictIntDeletableDataQuery<Group> // Ids, IsDeleted, Title*, paging, sorting
{
public int[]? TypeIds { get; set; }
public int[]? UserIds { get; set; }
public override IQueryFilter<Group> ToFilter()
{
var and = new QueryFilter<Group>();
if (TypeIds?.Any() == true)
and.Add(x => TypeIds.Contains(x.TypeId));
if (UserIds?.Any() == true)
and.Add(x => x.Users.Any(gu => UserIds.Contains(gu.UserId)));
return base.ToFilter().Add(and); // never drop the inherited filters
}
}DomainServiceBase already implements the full CRUD surface, so an empty class is a perfectly valid service. Add methods only when you have real domain logic.
public interface IGroupService : IDomainService<Group, GroupQuery, int>
{
Task AddUserAsync(int groupId, int userId, CancellationToken ct = default);
}
public class GroupService
: DomainServiceBase<Group, GroupQuery, int, IRepository<Group, int>>, IGroupService
{
private readonly IRepository<User, int> _userRepo;
public GroupService(IRepository<Group, int> repository, IRepository<User, int> userRepo, ILogger logger)
: base(repository, logger)
{
_userRepo = userRepo ?? throw new ArgumentNullException(nameof(userRepo));
}
public async Task AddUserAsync(int groupId, int userId, CancellationToken ct = default)
{
var user = (await _userRepo.FetchAsync(new UserQuery
{
Ids = [userId],
IsDeleted = false,
Includes = [nameof(User.Groups)],
Take = 1
}, tracking: true, ct)).FirstOrDefault()
?? throw new InvalidOperationException($"User '{userId}' not found");
if (user.Groups.Any(g => g.GroupId == groupId))
return;
user.Groups.Add(new GroupUser { GroupId = groupId, UserId = userId });
await _userRepo.SaveAsync(user, ct);
}
}Inherit from EFDMDatabaseContext. Two members are required: ExecutorId (the user id stamped into CreatedById / ModifiedById) and InitAuditMapping() (leave it empty until you need audit).
using EFDM.DAL.Providers;
public class AppDbContext : EFDMDatabaseContext
{
public override int ExecutorId { get; protected set; } = UserValues.SystemId;
public DbSet<Group> Groups { get; set; }
public DbSet<User> Users { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options, ILoggerFactory? factory = null,
IAuditSettings? auditSettings = null, Action<ModelConfigurationBuilder>? conventionsAction = null)
: base(options, factory, auditSettings, conventionsAction) { }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(Assembly.GetAssembly(GetType())!);
}
public override void InitAuditMapping() { } // see the Auditing section
}Call SetExecutor(currentUserId) per request (e.g. in middleware) so audit stamps point at the real user.
services.AddScoped(sp => new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(configuration.GetConnectionString("app"))
.Options,
sp.GetService<ILoggerFactory>()));
// the repository resolves EFDMDatabaseContext, so expose your context as the base type too
services.AddScoped<EFDMDatabaseContext>(sp => sp.GetRequiredService<AppDbContext>());
services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>));
services.AddScoped<IGroupService, GroupService>();var groups = await groupSvc.FetchAsync(new GroupQuery
{
TypeIds = [GroupTypeValues.Users],
IsDeleted = false,
Includes = [nameof(Group.Type), $"{nameof(Group.Users)}.{nameof(GroupUser.User)}"],
Sorts = [new Sort { Field = nameof(Group.Title) }],
Take = 20
});
var group = new Group { Title = "Analysts", TypeId = GroupTypeValues.Users };
await groupSvc.SaveAsync(group); // Created/Modified/…ById are stamped automatically
Console.WriteLine(group.Id); // identity is back on the instance
await groupSvc.DeleteAsync(group.Id); // IsDeleted = true (soft delete), row is keptAll of them live in EFDM.Core.Models.Domain and are generic over the key type (int, long, Guid, …).
| Base class | Id |
Created/Modified + …ById |
IsDeleted |
Title |
|---|---|---|---|---|
IdKeyEntityBase<TKey> |
✔ | |||
IdKeyDeletableEntityBase<TKey> |
✔ | ✔ | ||
EntityBase<TKey> |
✔ | ✔ | ||
DeletableEntityBase<TKey> |
✔ | ✔ | ✔ | |
DictEntityBase<TKey> |
✔ | ✔ | ✔ | |
DictDeletableEntityBase<TKey> |
✔ | ✔ | ✔ | ✔ |
Extras you get for free:
IsNew—truewhileIdstill holds its default value.PreserveLastModified,PreserveLastModifiedBy,PreserveLastModifiedFields—[NotMapped]flags. Set one before saving and EFDM will not overwrite the correspondingModified*value (useful for imports and migrations that must keep the original timestamps).- On update,
CreatedandCreatedByIdare automatically marked as not modified, so they can never be overwritten by a stale value carried on a detached entity.
Prefer to define one project-local base class per key type (public abstract class DictIntDeletableEntity : DictDeletableEntityBase<int>) — the sample does exactly that, and it keeps entity declarations short.
The base classes expose CreatedById / ModifiedById as plain columns; CreatedBy / ModifiedBy are declared through IAuditablePrincipalEntity and typed as IUser, which EF Core cannot map. If you want real navigation properties (so Includes = [nameof(Group.CreatedBy)] and Sorts = [new Sort { Field = "CreatedBy.Title" }] work), re-declare them on your own base class with your concrete user type:
public abstract class DomainEntityBase<TKey> : EntityBase<TKey>, IAuditableUserEntity
where TKey : IComparable, IEquatable<TKey>
{
public User CreatedBy { get; set; } = default!;
public User ModifiedBy { get; set; } = default!;
}The sample then maps both relations for every entity implementing that interface in one pass — see AuditableUserEntityConfig<T> and the builder.ApplyConfiguration<IAuditableUserEntity>(...) loop in TestDatabaseContext.OnModelCreating.
All of them live in EFDM.Core.DataQueries. Each level adds filter properties that are applied by its own ToFilter():
| Base class | Adds |
|---|---|
DataQueryBase<TModel> |
Take, Skip, Sorts, Includes, SplitQuery |
IdKeyDataQueryBase<TModel, TKey> |
Ids, NotIds |
EntityDataQueryBase<TModel, TKey> |
CreatedByIds, ModifiedByIds, CreatedQueryParams, ModifiedQueryParams |
DeletableEntityDataQueryBase<TModel, TKey> |
IsDeleted |
DictDataQueryBase<TModel, TKey> |
Title, TitleContains, TitleSplitContains |
DictDeletableDataQueryBase<TModel, TKey> |
IsDeleted, Title, TitleContains, TitleSplitContains |
DictIntDataQuery<TModel> / DictIntDeletableDataQuery<TModel> |
ready-made int-keyed shortcuts |
Created/Modified are filtered through DateOffsetPeriodQueryParams (MoreOrEquals, LessOrEquals, OrIsNull):
var query = new GroupQuery
{
CreatedQueryParams = new DateOffsetPeriodQueryParams
{
MoreOrEquals = DateTimeOffset.Now.AddDays(-7)
}
};TitleContains matches the whole string; TitleSplitContains splits the input on spaces and requires every word to be present — a cheap "search box" behaviour.
Two rules cover almost everything:
- Only add a condition when the property is set. An unset property must not narrow the result.
- Always chain onto
base.ToFilter(), otherwiseIds,IsDeleted,Title, … silently stop working.
public override IQueryFilter<User> ToFilter()
{
var and = new QueryFilter<User>();
if (Logins?.Any() == true)
{
var lcLogins = Logins.Select(x => x.ToLower()).ToArray();
and.Add(x => lcLogins.Contains(x.Login.ToLower()));
}
if (GroupId.HasValue)
and.Add(x => x.Groups.Any(g => g.GroupId == GroupId));
return base.ToFilter().Add(and);
}QueryFilter<T> is a small tree. Expressions inside one node are combined with its own operator; child nodes are nested as brackets.
| Call | Meaning |
|---|---|
new QueryFilter<T>() |
AND node (default) |
new QueryFilter<T>(or: true) |
OR node |
.Add(x => …) |
add a condition to the current node |
.Add(childFilter) |
nest another filter |
.And(node => …) |
nest an AND group |
.Or(node => …) |
nest an OR group |
A full-text-ish search — every word must match, but each word may match any of three columns, i.e. (login~w1 OR title~w1 OR email~w1) AND (login~w2 OR …):
if (!string.IsNullOrEmpty(Text))
foreach (var word in GetWords(Text))
and.Or(or => or
.Add(x => x.Login.Contains(word))
.Add(x => x.Title.Contains(word))
.Add(x => x.Email.Contains(word))
);Queries are executed through LinqKit's AsExpandable(), so PredicateBuilder and Invoke(...) compose exactly as expected and still translate to SQL:
Expression<Func<TaskAnswer, bool>> predicate = PredicateBuilder.New<TaskAnswer>(true);
predicate = predicate.And(x => x.ValidFrom >= from);
predicate = predicate.Or(x => x.ValidFrom == null);
and.Add(x => predicate.Invoke(x));var query = new GroupQuery
{
Includes = [
nameof(Group.Type), // navigation
$"{nameof(Group.Users)}.{nameof(GroupUser.User)}" // nested navigation
],
Sorts = [
new Sort { Field = "CreatedBy.Title" }, // nested path, ascending
new Sort { Field = nameof(Group.Id), Desc = true } // then by Id descending
],
SplitQuery = true, // AsSplitQuery() for collection includes
Skip = 40,
Take = 20
};- Default sorting is
Id DESCwhenSortsis empty — results are always deterministic. Take = 0(the default) means no limit;Skip = 0means no offset.- Tracking is off by default. Pass
tracking: truewhen you intend to modify what you fetched. SplitQuery = trueavoids the cartesian explosion when several collection navigations are included.
Paged results come back with the total count attached:
IPagedList<Group> page = await groupSvc.FetchPagedAsync(new GroupQuery { Skip = 40, Take = 20 });
// page.Items, page.TotalCount, page.SkippedFetchPaged derives TotalCount from the page itself whenever the page proves it (no paging at all, or a partially filled last page) and only then falls back to a second COUNT query — one round-trip fewer, and TotalCount can never contradict Items.
When you only need a few columns, *Lite methods project inside SQL instead of materialising whole rows:
// explicit selector
var titles = await groupSvc.FetchLiteAsync(query, x => new Group { Id = x.Id, Title = x.Title });
// or the service default (LiteSelector — Id only unless you override it)
var refs = await groupSvc.FetchLiteAsync(query);
// just the keys
IEnumerable<int> ids = await groupSvc.FetchIdsAsync(query);Override LiteSelector in your service to define the "reference shape" of the entity once:
public class GroupService : DomainServiceBase<Group, GroupQuery, int, IRepository<Group, int>>
{
public GroupService(IRepository<Group, int> repository, ILogger logger) : base(repository, logger)
{
LiteSelector = x => new Group { Id = x.Id, Title = x.Title };
}
}Projected instances are not tracked by EF; when you pass tracking: true, EFDM attaches them for you (unless an entity with the same key is already tracked).
IDomainService<TModel, TQuery, TKey> is what your application code talks to; IRepository<TEntity, TKey> is the lower-level API underneath it (and is what you inject when a service needs to touch a different entity).
| Category | Members |
|---|---|
| Read many | FetchAsync, FetchPagedAsync, FetchLiteAsync, FetchIdsAsync, CountAsync |
| Read one | GetAsync, GetByIdAsync, GetByIdsAsync, GetLiteAsync, GetLiteByIdAsync, ExistsAsync |
| Write | AddAsync, SaveAsync, SaveChangesAsync |
| Delete | DeleteAsync(id | entity | entities, forceDeleteEvenDeletable) |
| Bulk | ExecuteUpdateAsync, ExecuteDeleteAsync |
| Plumbing | BeginTransaction(Async), ClearChangeTracker, AutoDetectChangesEnabled, ExecutorId, QueryableSql (repository) |
Every method has a synchronous twin (Fetch, Save, Delete, …) and every async one accepts a CancellationToken.
Two conventions worth knowing:
TQueryis instantiated internally byGetByIdAsync/ExistsAsync, so query classes need a public parameterless constructor.DeleteValidation(model)andIsCreator(model, userId)arevirtualhooks — override them to plug in your own rules.
await svc.SaveAsync(entity); // insert when Id is default, update otherwise
await svc.AddAsync(e1, e2, e3); // stage several inserts…
await svc.SaveChangesAsync(); // …and flush them in one round-tripDetached graphs just work. Repository.SaveAsync detects a detached instance, loads the stored row together with its collection navigations, applies scalar values, and synchronises collections (adds new items, updates matched ones, removes the missing ones) — so an entity coming from an HTTP request body can be saved directly.
Partial updates — write exactly the listed properties and nothing else:
await userRepo.SaveAsync(user, ct, x => x.Email, x => x.Phone);Soft delete. DeleteAsync sets IsDeleted = true for anything implementing IDeletableEntity and issues a physical DELETE for everything else. Pass forceDeleteEvenDeletable: true to delete the row for real:
await svc.DeleteAsync(id); // UPDATE … SET IsDeleted = 1
await svc.DeleteAsync(id, forceDeleteEvenDeletable: true); // DELETE FROM …Selective commit. When one context holds pending changes for several entity types and you need to persist just one of them, EFDMDatabaseContext.SaveChangesOnlyAsync<T>() temporarily freezes every other tracked entry, saves (and audits) only T, and then restores the frozen states so their changes stay pending.
BeginTransaction / BeginTransactionAsync are available on both the context and the repository. isolationLevel is optional (IsolationLevel.Unspecified by default) and comes from System.Transactions; ReadUncommitted, ReadCommitted, RepeatableRead, Serializable, Snapshot and Chaos are mapped through.
using System.Transactions;
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await using var tx = await dbContext.BeginTransactionAsync(IsolationLevel.ReadCommitted);
try
{
var groupSvc = scope.ServiceProvider.GetRequiredService<IGroupService>();
var groups = await groupSvc.FetchAsync(new GroupQuery { Take = 2 }, tracking: true);
foreach (var group in groups)
group.Title = $"Group_{Guid.NewGuid()}";
await groupSvc.SaveChangesAsync();
await tx.CommitAsync();
}
catch (Exception ex)
{
await tx.RollbackAsync();
Console.WriteLine($"Transaction rolled back: {ex.Message}");
}Audit records are written inside the same transaction, so a rollback discards them together with the data.
Set-based operations run in the database, without loading entities into the change tracker:
var query = new GroupQuery { TypeIds = [GroupTypeValues.Users] };
var updated = await groupSvc.ExecuteUpdateAsync(query,
s => s.SetProperty(x => x.TextField1, x => "after_bulk_update")
.SetProperty(x => x.TextField2, x => "new_field2"));
var deleted = await groupSvc.ExecuteDeleteAsync(query);Both honour the whole ToFilter() of the query, so the same query object works for reading and for bulk writing.
They are audited too. EF Core cannot audit these operations on its own — EFDM does it explicitly when auditing is on and the entity type has an audit mapping:
- one audit event per affected row (not one per statement);
ExecuteDeleterecords every column value asOldValue;ExecuteUpdaterecordsOldValue→NewValue, taking new values straight out of theSetPropertycalls;- rows are snapshotted in batches of
Repository.AuditBulkBatchSize(default100) so memory stays bounded, and multi-batch operations are wrapped in one transaction started through the EF execution strategy; - the audit snapshot and the statement target the same fixed id set, so exactly the rows that were audited are the rows that were changed — even under concurrent writes.
Without an audit mapping the call degrades to a plain EF Core ExecuteUpdate / ExecuteDelete, at full speed.
The auditor sits in SaveChanges: it inspects the change tracker, decides what is auditable, builds an audit event per entity, hands it to your mapping code, and persists the resulting rows in the same transaction as the data.
You stay in control of the storage — EFDM never invents tables. Setting it up is four steps.
Derive an event entity (one row per changed entity) and a property entity (one row per changed column) and map them with regular EF configurations. You may keep one pair for the whole database or a dedicated pair per subsystem:
using EFDM.Core.Models.Audit;
// Id, Created, CreatedById, ObjectType, ObjectId, ActionId
public class AuditGroupEvent : AuditEventBase<long> { }
// Id, AuditId, Name, OldValue, NewValue
public class AuditGroupProperty : AuditPropertyBase<long, long> { }ActionId values come from EFDM.Core.Constants.AuditStateActionVals: Insert = 1, Update = 2, Delete = 3, Unknown = 4.
AuditSettings decides what is audited. Build it once and pass it into the context:
| Property | Effect |
|---|---|
Enabled |
master switch |
IncludedTypes |
allow-list — only these entity types are audited (an empty list audits nothing) |
ExcludedTypeStateActions |
skip specific actions per type (e.g. don't audit inserts of Group) |
IgnoredTypeProperties |
deny-list of properties per type |
OnlyIncludedTypeProperties |
allow-list of properties per type |
GlobalIgnoredProperties |
properties ignored for every type |
var auditSettings = new AuditSettings
{
Enabled = true,
IncludedTypes = new ConcurrentDictionary<Type, byte>
{
[typeof(Group)] = 1,
[typeof(GroupUser)] = 1,
[typeof(TaskAnswer)] = 1
},
ExcludedTypeStateActions = new ConcurrentDictionary<Type, List<int>>
{
[typeof(Group)] = [AuditStateActionVals.Insert] // updates and deletes only
}
};
// don't store this column's values for Group
auditSettings.IgnoredTypeProperties.TryAdd(typeof(Group), [nameof(Group.TextField1)]);
// for TaskAnswer, store this column and nothing else
auditSettings.OnlyIncludedTypeProperties.TryAdd(typeof(TaskAnswer), [nameof(TaskAnswer.TextField1)]);
services.AddScoped(sp => new AppDbContext(GetDbOptions(sp, configuration),
sp.GetService<ILoggerFactory>(), auditSettings));
services.AddScoped<EFDMDatabaseContext>(sp => sp.GetRequiredService<AppDbContext>());The audit stamps themselves (Created, Modified, CreatedBy(Id), ModifiedBy(Id)) are excluded by default — they carry no information the audit event does not already have. Each context copies the settings on creation, so per-context tweaks never leak into the shared instance.
Keep the include/exclude lists tight: fewer audited types and columns mean fewer audit rows per save.
InitAuditMapping() connects entity types to audit tables and describes how one audit event turns into rows.
Auditor.Map<TEntity, TEvent, TProperty>(...)— which tables an entity is audited into, plus a per-entity action (typically fillingObjectId).Auditor.SetEventCommonAction<T>(...)— the shared logic that builds the event row and its property rows.Auditor.EnqueueAuditEntity(entity, parent)— hand an audit entity over to EFDM. It is persisted after the mainSaveChangescompletes, and theparentargument wires up the foreign key (AuditId) automatically. Use this instead of callingSaveChangesfrom inside the mapping code.
public override void InitAuditMapping()
{
// GroupUser rows are audited under their parent group's id
Auditor.Map<GroupUser, AuditGroupEvent, AuditGroupProperty>((auditEvent, entry, eventEntity) =>
eventEntity.ObjectId = GetObjectId(entry, nameof(GroupUser.GroupId)));
Auditor.Map<Group, AuditGroupEvent, AuditGroupProperty>((auditEvent, entry, eventEntity) =>
eventEntity.ObjectId = GetObjectId(entry, "Id"));
Auditor.Map<TaskAnswer, AuditTaskAnswerEvent, AuditTaskAnswerProperty>((auditEvent, entry, eventEntity) =>
eventEntity.ObjectId = GetObjectId(entry, "Id"));
Auditor.SetEventCommonAction<IAuditEventBase<long>>((auditEvent, entry, eventEntity) =>
{
eventEntity.ActionId = entry.Action;
eventEntity.CreatedById = ExecutorId;
eventEntity.ObjectType = entry.EntityType.Name;
eventEntity.Created = DateTimeOffset.Now;
Auditor.EnqueueAuditEntity(eventEntity);
IAuditPropertyBase<long, long> NewProperty() =>
(IAuditPropertyBase<long, long>)Activator.CreateInstance(Auditor.GetPropertyType(entry.EntityType))!;
void Enqueue(IAuditPropertyBase<long, long> property)
{
if (!string.IsNullOrEmpty(property.Name))
Auditor.EnqueueAuditEntity(property, eventEntity); // AuditId is set from the parent
}
switch (entry.Action)
{
case AuditStateActionVals.Insert:
foreach (var (name, value) in entry.ColumnValues)
{
var property = NewProperty();
property.Name = name;
property.NewValue = Convert.ToString(value);
Enqueue(property);
}
break;
case AuditStateActionVals.Delete:
foreach (var (name, value) in entry.ColumnValues)
{
var property = NewProperty();
property.Name = name;
property.OldValue = Convert.ToString(value);
Enqueue(property);
}
break;
case AuditStateActionVals.Update:
foreach (var change in entry.Changes)
{
var property = NewProperty();
property.Name = change.ColumnName;
property.OldValue = Convert.ToString(change.OriginalValue);
property.NewValue = Convert.ToString(change.NewValue);
Enqueue(property);
}
break;
}
return Task.CompletedTask;
});
}
// entry.GetEntry() is null for bulk operations — fall back to the snapshotted column values
private static string? GetObjectId(IEventEntry entry, string propertyName)
{
var entityEntry = entry.GetEntry();
if (entityEntry != null)
return entityEntry.Entity.GetPropValue(propertyName)?.ToString();
return entry.ColumnValues?.TryGetValue(propertyName, out var value) == true ? value?.ToString() : null;
}
entry.GetEntry()returns the EFEntityEntryfor change-tracker saves andnullfor audited bulk operations, where onlyColumnValuesare available. Handling both — asGetObjectIdabove does — keeps one mapping working for every path.
When a foreign key changes, the audit stores a readable value instead of a bare number. By default it is built by reflection from Id and Title ("42: Administrators"); all FKs of one audit event are resolved with a single query per related type, not one query per value.
Register a resolver to change the format for a type:
public override void InitAuditMapping()
{
Auditor.RegisterLookupResolver<User>(user => $"{user.Id}: {user.LastName} {user.FirstName}");
// non-generic overload, when the type is known only at runtime
Auditor.RegisterLookupResolver(typeof(Group), obj => $"{((Group)obj).Id}: {((Group)obj).Title}");
// or one resolver for everything; returning null falls through to the per-type logic
Auditor.LookupValueResolver = obj => obj is IDictEntityBase<int> dict ? dict.Title : null;
// … the rest of the mapping
}Registered resolvers take precedence over the reflection-based default. If a resolver throws, the failure is logged (when a ILoggerFactory was supplied) and the default behaviour is used, so audit writes never fail because of formatting code.
dbContext.Auditor.Enabled = false; // e.g. around a large import
// … bulk load …
dbContext.Auditor.Enabled = true;Other useful knobs: Auditor.ExcludeProperty<T>(x => x.SomeProperty) to drop a property globally, and Auditor.ExcludeTypeStateActions<T>([AuditStateActionVals.Insert]) to skip an action for a type — both available from InitAuditMapping().
Sample/ is a runnable end-to-end application (users, groups, task answers, audit tables, migrations for SQL Server and PostgreSQL):
| Project | Role |
|---|---|
EFDM.Sample.Core |
Entities, queries, domain services, audit models |
EFDM.Sample.DAL |
TestDatabaseContext, EF configurations, repository |
EFDM.Sample.Mssql / EFDM.Sample.Npgs |
Provider-specific migrations and design-time factories |
EFDM.Sample.IOC |
DI wiring, including AuditSettings |
EFDM.Sample.TestConsole |
Console entry point — uncomment a scenario in Program.cs and run |
To run it:
-
Choose the provider in
Sample/EFDM.Sample.IOC/Managers/IoCManagerCommon.cs(SQL Server is enabled, PostgreSQL is commented out right below it). -
Set the connection string in
appsettings.json— or, better, in a machine-localappsettings.{MachineName}.json, which every sample project loads and git ignores. -
Create the database from the provider project:
dotnet tool restore dotnet ef database update --context TestDatabaseContext --project Sample/EFDM.Sample.Mssql --startup-project Sample/EFDM.Sample.TestConsole
-
Run the console:
dotnet run --project Sample/EFDM.Sample.TestConsole
Program.cs is a menu of small scenarios — paging and sorting, split queries, transactions, detached saves, audited bulk ExecuteUpdate / ExecuteDelete with the resulting audit rows printed to the console. Uncomment the one you want to look at.
EFDM.Abstractions/ interfaces (no EF Core dependency beyond the model)
EFDM.Core/ entity & query base classes, QueryFilter, DomainServiceBase, AuditSettings
EFDM.DAL/ EFDMDatabaseContext, Repository, DBContextAuditor
Sample/ runnable sample application (see above)
MIT © Pavel Kambalin