-
Notifications
You must be signed in to change notification settings - Fork 0
Home
A single, practical guide for adding searching, paging, repositories, and a unit of work to EF Core apps with EasyPersistence.EFCore.
- 1) What this library provides
-
2) Installation & Requirements
- 2.0 Ways to use this library
- 2.1 Register fuzzy search in the EF model
- 2.2 Ensure CLR assembly is registered at startup (required for FuzzySearch)
- 2.3 Basic text search across properties
- 2.4 Fuzzy search with scoring and ordering
- 2.5 Conditional filters and paging
- 2.6 In-DB bulk updates and deletes (via repository)
- 2.7 FuzzySearch setup rules (multi-DbContext & databases)
- 3) Key extension methods
- 4) Repository & Unit of Work
- 6) Domain events
- 7) Domain model primitives
- 8) Types returned by helpers
- 9) Tips & patterns
- 10) API surface (at a glance)
- 11) FAQ
- Repository & Unit of Work bases to standardize data access patterns.
-
Search helpers: simple multi‑field
ApplySearch(...)and fuzzy search with scoring. -
Query helpers: conditional filters (
WhereIf) and paging (GetPagedResultsAsync). - SQL‑CLR helpers (optional): bootstrap/remove the FuzzySearch SQL assembly for server‑side similarity scoring.
- Domain‑event friendly base types and interfaces.
Assembly name:
ZeidLab.ToolBox.EasyPersistence.EFCore
-
Install the NuGet package (e.g.,
ZeidLab.ToolBox.EasyPersistence.EFCore). - Target .NET with Entity Framework Core.
- For server‑side fuzzy search via SQL‑CLR, your database must allow CLR assemblies (e.g., SQL Server with CLR enabled).
-
Extensions only (no repository required)
Use the provided LINQ extensions directly onIQueryable<T>from your EF CoreDbContext— e.g.,ApplySearch,ApplyFuzzySearch,WhereIf,GetPagedResultsAsync. This works independently of any repository/unit‑of‑work pattern.-
Important: If you intend to call
ApplyFuzzySearch(or otherwise rely on theFuzzySearch(...)SQL scalar), you must complete the FuzzySearch setup (sections 2.1 and 2.2) in addition to 2.4. - If you do not need fuzzy search, none of the SQL‑CLR steps are required.
-
Important: If you intend to call
-
Repository & Unit of Work
Inherit fromRepositoryBase<TEntity,TEntityId>andUnitOfWorkBase<TContext>and consume the interfacesIRepositoryBase<,>andIUnitOfWorkBase. This gives you in‑DB bulk update/delete helpers and a consistent data access pattern. (Extensions still work inside repositories.) -
Hybrid
Combine both: use repositories for most operations and call extensions on queries when convenient. The fuzzy search requirement remains the same: if you use it, set up SQL‑CLR (2.1 + 2.2).
// OnModelCreating in your DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.RegisterFuzzySearchMethods();
}// Program.cs (after you build the host)
var host = builder.Build();
await host.RegisterFuzzySearchAssemblyAsync<MyDbContext>(); // registers the SQL-CLR assembly if needed
await host.RunAsync();var people = db.Set<Person>()
.ApplySearch("john", x => x.FirstName, x => x.LastName)
.ToList();var scored = db.Set<Person>()
.ApplyFuzzySearch("jon", x => x.FirstName, x => x.LastName)
.OrderByDescending(x => x.Score)
.Select(x => new { x.Entity.Id, x.Entity.FirstName, x.Entity.LastName, x.Score })
.ToList();var query = db.Set<Person>()
.WhereIf(!string.IsNullOrWhiteSpace(filter.City), p => p.City == filter.City);
var page0 = await query.GetPagedResultsAsync(page: 0, pageSize: 20);
// page0.Items, page0.TotalItems// Update without loading to memory
await repository.InDbUpdatePropertyAsync(
p => p.IsActive && p.LastLoginUtc < cutoff,
calls => calls.SetProperty(p => p.IsActive, false));
// Delete without loading
await repository.InDbDeleteAsync(p => !p.IsActive);-
Single database, multiple
DbContexttypes: callRegisterFuzzySearchAssemblyAsync<OneOfYourDbContexts>()at least once during startup. Calling it multiple times is safe and idempotent (it will no‑op if already registered). -
Multiple databases (each
DbContextpoints to a different DB): you must register the assembly once per database. InvokingRegisterFuzzySearchAssemblyAsync<ThatDbContext>()for each database at startup is recommended. - Always add
modelBuilder.RegisterFuzzySearchMethods()inside everyDbContextthat will use fuzzy search so the function is mapped in that model. - If you don’t use fuzzy search, you can skip both the SQL‑CLR registration and the model method registration.
ApplySearch<TEntity>(IQueryable<TEntity> query, string searchTerm, params Expression<Func<TEntity,string>>[] propertyExpressions)ApplySearch<TEntity>(IQueryable<TEntity> query, string searchTerm, params string[] propertyNames)ApplyFuzzySearch<TEntity>(IQueryable<TEntity> query, string searchTerm, params Expression<Func<TEntity,string>>[] propertyExpressions)-
FuzzySearch(string searchTerm, string comparedString)(SQL‑translatable scalar for use in LINQ‑to‑Entities) -
NgramCalculatorExtensions.Build3GramString(string searchTerm)(N‑gram utility)
WhereIf<T>(IQueryable<T> queryable, bool condition, Expression<Func<T,bool>> predicate)-
GetPagedResultsAsync<TEntity>(IQueryable<TEntity> query, int page, int pageSize)→PagedResult<TEntity>
InitializeSqlClrAsync(DbContext context, CancellationToken cancellationToken = default)RemoveSqlClrAssemblyAsync(DbContext context, string assemblyName)
ModelBuilder RegisterFuzzySearchMethods(this ModelBuilder modelBuilder)Task RegisterFuzzySearchAssemblyAsync<TDbContext>(this IHost app)
Below is a polished example based on the library’s base classes and interfaces, showing a typical setup you can mirror.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.Extensions.DependencyInjection;
using ZeidLab.ToolBox.EasyPersistence.EFCore;
// 1) Domain entity
public sealed class Person : EntityBase<int>, IAggregateRoot
{
// EF Core requires a parameterless constructor (public or private) for materialization.
// If you use this constructor (no parameters), the Id remains its default value
// and the database will generate a new Id on insert (see configuration below).
public Person() { }
// Optional: pass an Id manually. If you use this constructor, the provided Id
// is assigned and used as-is (no database generation for this row).
public Person(int id) : base(id) { }
public string FirstName { get; private set; } = string.Empty;
public string LastName { get; private set; } = string.Empty;
public string? City { get; private set; }
public void Rename(string first, string last) { FirstName = first; LastName = last; }
public void ChangeCity(string city) { City = city; }
}
// 1.1) Person configuration (standard EF Core configuration — nothing special is required
// by the library). This demonstrates that your entity mapping looks exactly the same as
// it would without EasyPersistence.
public sealed class PersonConfiguration : IEntityTypeConfiguration<Person>
{
public void Configure(EntityTypeBuilder<Person> builder)
{
builder.ToTable("People");
// Primary key: value generated by the database when not explicitly set
builder.HasKey(p => p.Id);
builder.Property(p => p.Id)
.ValueGeneratedOnAdd();
builder.Property(p => p.FirstName)
.HasMaxLength(100)
.IsRequired();
builder.Property(p => p.LastName)
.HasMaxLength(100)
.IsRequired();
builder.Property(p => p.City)
.HasMaxLength(100);
}
}
// 2) DbContext (add FuzzySearch methods if you plan to use them)
public sealed class AppDbContext : DbContext
{
public DbSet<Person> People => Set<Person>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Standard EF Core configuration — just like usual
modelBuilder.ApplyConfiguration(new PersonConfiguration());
// Required only if you intend to use ApplyFuzzySearch / FuzzySearch
modelBuilder.RegisterFuzzySearchMethods();
}
}
// 3) Repository contract and implementation
public interface IPersonRepository : IRepositoryBase<Person, int>
{
// Example: custom query API that uses the AppDbContext's DbSet
Task<Person?> GetByFullNameAsync(string firstName, string lastName, CancellationToken ct = default);
}
public sealed class PersonRepository : RepositoryBase<Person, int>, IPersonRepository
{
// Base exposes only DbContext; keep a strongly-typed AppDbContext for DbSets
private readonly AppDbContext _db;
public PersonRepository(AppDbContext context) : base(context)
{
_db = context; // use this inside repository methods to access DbSets safely
}
public Task<Person?> GetByFullNameAsync(string firstName, string lastName, CancellationToken ct = default)
=> _db.People
.AsNoTracking()
.FirstOrDefaultAsync(p => p.FirstName == firstName && p.LastName == lastName, ct);
}
// 4) Unit of Work contract and implementation (repositories exposed as read-only properties)
public interface IAppUnitOfWork : IUnitOfWorkBase
{
IPersonRepository Persons { get; }
}
public sealed class AppUnitOfWork : UnitOfWorkBase<AppDbContext>, IAppUnitOfWork
{
// Repositories are created by the UoW; consumers only inject IAppUnitOfWork
public IPersonRepository Persons { get; }
public AppUnitOfWork(AppDbContext context) : base(context)
{
// Compose repositories here (no separate DI registration is required)
Persons = new PersonRepository(context);
}
}Notes
- Inherit your repositories from
RepositoryBase<TEntity,TEntityId>to get paging, search, fuzzy search, and bulk in‑DB ops.- Inherit your UoW from
UnitOfWorkBase<TContext>to getSaveChangesAsync, transactions, and state helpers.
var builder = WebApplication.CreateBuilder(args);
// DbContext
builder.Services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
// Unit of Work only — repositories are provided by the UoW as read-only properties
builder.Services.AddScoped<IAppUnitOfWork, AppUnitOfWork>();
var app = builder.Build();
// If you need fuzzy search, ensure CLR assembly is registered at startup
await app.RegisterFuzzySearchAssemblyAsync<AppDbContext>();
app.Run();public sealed class PeopleService
{
private readonly IAppUnitOfWork _uow;
public PeopleService(IAppUnitOfWork uow) => _uow = uow;
public async Task<int> CreateAsync(string first, string last)
{
// Option A (recommended): let the DB generate Id
var person = new Person();
person.Rename(first, last);
// Option B: manually assign a known Id (uncomment if needed)
// var person = new Person(knownId);
// person.Rename(first, last);
_uow.Persons.Add(person);
return await _uow.SaveChangesAsync();
}
public async Task<IEnumerable<Person>> SearchAsync(string term)
=> (await _uow.Persons.SearchAsync(term, page: 0, pageSize: 25, nameof(Person.FirstName), nameof(Person.LastName))).Items;
public async Task<IEnumerable<object>> FuzzyAsync(string term)
=> (await _uow.Persons.FuzzySearchAsync(term, p => p.FirstName, p => p.LastName))
.OrderByDescending(x => x.Score)
.Select(x => new { x.Entity.Id, x.Entity.FirstName, x.Entity.LastName, x.Score });
public Task<int> DeactivateDormantAsync(DateTime cutoff)
=> _uow.Persons.InDbUpdatePropertyAsync(p => p.LastName == "Dormant" && p.Id > 0,
calls => calls.SetProperty(p => p.City!, null));
}Pattern notes
- Inject only the Unit of Work. Repositories are exposed as get‑only properties on the UoW; no separate DI registration is needed.
- DbSets are not exposed to the outside. All data access flows through repositories.
- Repositories do not Save. Call
SaveChangesAsyncon the Unit of Work to persist changes.- For transactions and DDD factory‑created entities, see sections 4.5 and 4.6.
Use a transaction when a group of changes must succeed or fail together. Begin the transaction, perform domain operations, mark state as needed, save, and then commit. On failure, roll back the transaction.
public async Task<int> UpdateTwoPeopleAsync(int id1, int id2, string city)
{
await _uow.BeginTransactionAsync();
try
{
var p1 = await _uow.Persons.GetByIdAsync(id1) ?? throw new InvalidOperationException("Person #1 not found");
var p2 = await _uow.Persons.GetByIdAsync(id2) ?? throw new InvalidOperationException("Person #2 not found");
// domain updates
p1.ChangeCity(city);
p2.ChangeCity(city);
_uow.MarkAsModified(p1);
_uow.MarkAsModified(p2);
await _uow.SaveChangesAsync();
await _uow.CommitTransactionAsync();
return 2;
}
catch
{
await _uow.RollbackTransactionAsync();
throw;
}
}In DDD, aggregates are often created or mutated via factory methods to enforce invariants. These instances may not be tracked by EF Core yet. Before saving, set the appropriate state through the UoW, then call SaveChangesAsync.
// Created in the domain via a factory (not tracked yet)
var order = OrderFactory.Create(number: "SO-1001");
// Ensure EF will persist it
_uow.MarkAsAdded(order);
await _uow.SaveChangesAsync();
// Later: mutate aggregate through a method
order.ChangeShippingAddress(newAddr);
_uow.MarkAsModified(order);
await _uow.SaveChangesAsync();
// Or to remove
_uow.MarkAsDeleted(order);
await _uow.SaveChangesAsync();An Aggregate is a cluster of closely related domain objects (entities and value objects) treated as a single unit for data changes. The Aggregate Root is the gatekeeper of that cluster: it enforces invariants, controls access to internal members, and is the only object external code may reference directly.
- Only Aggregate Roots have repositories. Child entities/value objects are loaded and persisted through the root.
- External code should not hold references to internal entities; interaction happens via methods on the root.
- The root defines and protects the aggregate’s consistency boundary—rules that must always hold true for the cluster.
- Keep references across aggregates pointing to other roots (not inner entities) to avoid coupling.
In this library, only entities implementing
IAggregateRootshould be saved directly or exposed via repositories. Only entities implementingIHaveDomainEventscan raise domain events.
Practical guidance
- Design aggregates around business invariants (e.g., an
Ordermust have at least oneOrderItem). - Favor small aggregates that can be updated atomically within a single transaction.
- Compose larger workflows with domain events or application services.
Domain events record things that already happened in the domain (e.g., OrderPlaced). Aggregates raise events during behavioral methods; the infrastructure publishes them after persistence, then clears the queue.
Choose one path for publishing
- EventBus (section 6.1): events implement
IAppEvent(from the EventBus package). Handlers listen forIAppEvent, and publishing can be strongly‑typed (no reflection in your handlers).- MediatR (section 6.2): events implement
IApplicationEvent : IDomainEvent, INotificationso they can be published via MediatR. MediatR locates handlers via reflection.- Register only the interceptor you use. You can register both if you intentionally publish to both paths.
You can register one or both interceptors depending on which path you use.
A) Register via AddDbContext (using the service provider overload)
builder.Services.AddScoped<EventBusPublishingInterceptor>();
builder.Services.AddScoped<MediatRDomainEventsInterceptor>();
builder.Services.AddDbContext<AppDbContext>((sp, opt) =>
{
opt.UseSqlServer(builder.Configuration.GetConnectionString("Default"));
opt.AddInterceptors(
sp.GetRequiredService<EventBusPublishingInterceptor>(),
sp.GetRequiredService<MediatRDomainEventsInterceptor>());
});B) Register via DbContext constructor injection
public sealed class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options,
EventBusPublishingInterceptor eventBusInterceptor,
MediatRDomainEventsInterceptor mediatrInterceptor)
: base(options)
{
this.AddInterceptors(eventBusInterceptor, mediatrInterceptor);
}
}Tip: If you only use one approach (EventBus or MediatR), register only the corresponding interceptor.
Wrapper & invoker (no reflection in your handlers)
public sealed class DomainEventWrapper<TEvent> : IDomainEventInvoker, IDomainEvent
where TEvent : IAppEvent
{
private readonly TEvent _domainEvent;
public DomainEventWrapper(in TEvent domainEvent)
{
_domainEvent = domainEvent ?? throw new ArgumentNullException(nameof(domainEvent));
}
public Task InvokeHandlerAsync(IEventBussService bus, CancellationToken ct)
{
if (bus is null)
throw new ArgumentNullException(nameof(bus));
if (ct.IsCancellationRequested)
return Task.FromCanceled(ct);
bus.Publish(_domainEvent);
return Task.CompletedTask;
}
}
public interface IDomainEventInvoker
{
Task InvokeHandlerAsync(IEventBussService bus, CancellationToken ct);
}Aggregate raises wrapped event
public sealed class TodoCategory : EntityBase<Guid>, IAggregateRoot, IHaveDomainEvents
{
public string Title { get; private set; } = string.Empty;
private readonly List<TodoItem> _items = new();
private TodoCategory() { }
private TodoCategory(string title) : base(Guid.CreateVersion7()) { Title = title; }
public static TodoCategory Create(string title)
{
var cat = new TodoCategory(title);
cat.DomainEvents.Add(new DomainEventWrapper<SectionIsCreated>(new SectionIsCreated(cat.Id, cat.Title)));
return cat;
}
}
public readonly record struct SectionIsCreated(Guid Id, string Name) : IAppEvent;Interceptor publishes invokers
public sealed class EventBusPublishingInterceptor : SaveChangesInterceptor
{
private readonly IEventBussService _bus;
public EventBusPublishingInterceptor(IEventBussService bus) => _bus = bus;
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken ct = default)
{
var ctx = eventData.Context;
if (ctx is null)
return result;
var invokers = ctx.ChangeTracker.Entries<IHaveDomainEvents>()
.SelectMany(e => e.Entity.DomainEvents)
.OfType<IDomainEventInvoker>()
.ToList();
foreach (var invoker in invokers)
{
await invoker.InvokeHandlerAsync(_bus, ct);
}
foreach (var e in ctx.ChangeTracker.Entries<IHaveDomainEvents>())
{
e.Entity.DomainEvents.Clear();
}
return result;
}
}Why EventBus? Strongly typed publishing without reflection in your handlers. MediatR works well too, but uses reflection internally to locate handlers.
Unify domain and notification by having events implement IApplicationEvent : IDomainEvent, INotification, then publish after saving with an interceptor.
Why
IApplicationEventand notIAppEventhere?IAppEventbelongs to the EventBus package and is used in section 6.1. To prevent name collisions and clarify intent, we defineIApplicationEventfor the MediatR path.
public interface IApplicationEvent : IDomainEvent, INotification { }
public readonly record struct OrderPlaced(Guid OrderId) : IApplicationEvent;
public sealed class MediatRDomainEventsInterceptor : SaveChangesInterceptor
{
private readonly IMediator _mediator;
public MediatRDomainEventsInterceptor(IMediator mediator) => _mediator = mediator;
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken ct = default)
{
var ctx = eventData.Context;
if (ctx is null)
return result;
var events = ctx.ChangeTracker.Entries<IHaveDomainEvents>()
.SelectMany(e => e.Entity.DomainEvents)
.OfType<IApplicationEvent>()
.ToList();
foreach (var notification in events)
{
await _mediator.Publish(notification, ct);
}
foreach (var e in ctx.ChangeTracker.Entries<IHaveDomainEvents>())
{
e.Entity.DomainEvents.Clear();
}
return result;
}
}-
EntityBase<TId>-
TId Id(getter) IList<IDomainEvent> DomainEvents- Equality overrides (
==,!=,Equals,GetHashCode) bool IsTransient()
-
-
IAggregateRoot(marker) -
IDomainEvent(marker) -
IHaveDomainEvents→IList<IDomainEvent> DomainEvents { get; }
Constructor & ID generation
- EF Core requires a parameterless constructor (public or private) for materialization. Keeping it public is convenient when you want to
newup entities.- With the parameterless ctor,
Idstays default and the database generates the key (ValueGeneratedOnAdd()).- With a parameterized ctor (e.g.,
new Person(42)), the provided value is used as‑is.
Example
public sealed class Order : EntityBase<Guid>, IAggregateRoot, IHaveDomainEvents
{
public Order() { }
public Order(Guid id) : base(id) { }
public string Status { get; private set; } = "Draft";
public void Place() { Status = "Placed"; DomainEvents.Add(new OrderPlaced(Id)); }
}-
PagedResult<T>→{ IReadOnlyCollection<T> Items, long TotalItems }(+ staticEmpty). -
ScoredRecord<TEntity>→{ TEntity Entity, double Score, IEnumerable<PropertyScore> Scores }. -
PropertyScore→{ string Name, double Score }.
- Use
ApplySearchfor contains‑style filtering across string properties. - Use
ApplyFuzzySearchwhen misspellings are likely; order byScoredescending. - Always place
WhereIfbefore paging to keepTotalItemscorrect. - Prefer repo methods
InDbUpdatePropertyAsync/InDbDeleteAsyncfor bulk changes. - After
SaveChangesAsync, publish domain events, then clear the collection. - For DDD factories, remember to set state with
MarkAsAdded/Modified/Deleted/...before saving.
- EF integration:
EFCoreDependencyInjection - Extensions:
FuzzySearchExtensions,HelperExtensions,SearchExtensions,SqlClrHelperExtensions - Primitives:
EntityBase<TId>,PagedResult<T>,PropertyScore,ScoredRecord<TEntity> - Base infra:
RepositoryBase<TEntity,TEntityId>,UnitOfWorkBase<TContext>
-
IMarker,IAggregateRoot,IDomainEvent,IHaveDomainEvents -
IRepositoryBase<TEntity,TEntityId>,IUnitOfWorkBase
Do I need a public parameterless constructor?
EF Core needs a parameterless constructor for materialization, but it can be public or private. Public often helps when you want to new the entity yourself.
DDD note: In DDD, you typically hide constructors and expose factory methods that enforce invariants. The entity may have a private parameterless constructor (for EF) and private parameterized constructors used internally. Consumers create instances via factories, not
new.
How is the primary key Id assigned?
- With the parameterless ctor → the database generates the Id (
ValueGeneratedOnAdd()). - With the parameterized ctor (e.g.,
new Person(123)) → the provided value is used as‑is.
Does fuzzy search require SQL‑CLR?
ApplyFuzzySearch works in LINQ; server‑side scoring uses FuzzySearch(...) and the CLR assembly for best performance (see 2.1–2.2).
My domain object changed via a factory but nothing saved. Why?
If EF isn’t tracking the instance, set the state via the UoW (MarkAsAdded/Modified/Deleted/...) before calling SaveChangesAsync.
How do I remove the CLR assembly?
Call RemoveSqlClrAssemblyAsync(context, assemblyName) from an admin path/migration if needed.