Skip to content
Zeid Youssefzadeh edited this page Nov 7, 2025 · 3 revisions

EasyPersistence.EFCore — Developer Guide

A single, practical guide for adding searching, paging, repositories, and a unit of work to EF Core apps with EasyPersistence.EFCore.

Table of Contents


1) What this library provides

  • 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


2) Installation & Requirements

  1. Install the NuGet package (e.g., ZeidLab.ToolBox.EasyPersistence.EFCore).
  2. Target .NET with Entity Framework Core.
  3. For server‑side fuzzy search via SQL‑CLR, your database must allow CLR assemblies (e.g., SQL Server with CLR enabled).

2.0 Ways to use this library

  1. Extensions only (no repository required)
    Use the provided LINQ extensions directly on IQueryable<T> from your EF Core DbContext — 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 the FuzzySearch(...) 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.
  2. Repository & Unit of Work
    Inherit from RepositoryBase<TEntity,TEntityId> and UnitOfWorkBase<TContext> and consume the interfaces IRepositoryBase<,> and IUnitOfWorkBase. This gives you in‑DB bulk update/delete helpers and a consistent data access pattern. (Extensions still work inside repositories.)

  3. 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).

2.1 Register fuzzy search in the EF model

// OnModelCreating in your DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
    modelBuilder.RegisterFuzzySearchMethods();
}

2.2 Ensure CLR assembly is registered at startup (required for FuzzySearch)

// 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();

2.3 Basic text search across properties

var people = db.Set<Person>()
    .ApplySearch("john", x => x.FirstName, x => x.LastName)
    .ToList();

2.4 Fuzzy search with scoring and ordering

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();

2.5 Conditional filters and paging

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

2.6 In‑DB bulk updates and deletes (via repository)

// 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);

2.7 FuzzySearch setup rules (multi‑DbContext & databases)

  • Single database, multiple DbContext types: call RegisterFuzzySearchAssemblyAsync<OneOfYourDbContexts>() at least once during startup. Calling it multiple times is safe and idempotent (it will no‑op if already registered).
  • Multiple databases (each DbContext points to a different DB): you must register the assembly once per database. Invoking RegisterFuzzySearchAssemblyAsync<ThatDbContext>() for each database at startup is recommended.
  • Always add modelBuilder.RegisterFuzzySearchMethods() inside every DbContext that 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.

3) Key extension methods

Search & Fuzzy Search (ZeidLab.ToolBox.EasyPersistence.EFCore)

  • 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)

Query helpers

  • WhereIf<T>(IQueryable<T> queryable, bool condition, Expression<Func<T,bool>> predicate)
  • GetPagedResultsAsync<TEntity>(IQueryable<TEntity> query, int page, int pageSize)PagedResult<TEntity>

SQL‑CLR bootstrap

  • InitializeSqlClrAsync(DbContext context, CancellationToken cancellationToken = default)
  • RemoveSqlClrAssemblyAsync(DbContext context, string assemblyName)

EF model/host integration (Microsoft.Extensions.DependencyInjection)

  • ModelBuilder RegisterFuzzySearchMethods(this ModelBuilder modelBuilder)
  • Task RegisterFuzzySearchAssemblyAsync<TDbContext>(this IHost app)

4) Repository & Unit of Work

4.1 Reference implementation (clean example)

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 get SaveChangesAsync, transactions, and state helpers.

4.2 Registering in DI

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();

4.3 Using the pattern

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 SaveChangesAsync on the Unit of Work to persist changes.
  • For transactions and DDD factory‑created entities, see sections 4.5 and 4.6.

4.5 Transactions with the Unit of Work

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;
    }
}

4.6 Tracking entities created via factories (DDD)

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();

5) Aggregates & IAggregateRoot (DDD)

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 IAggregateRoot should be saved directly or exposed via repositories. Only entities implementing IHaveDomainEvents can raise domain events.

Practical guidance

  • Design aggregates around business invariants (e.g., an Order must have at least one OrderItem).
  • Favor small aggregates that can be updated atomically within a single transaction.
  • Compose larger workflows with domain events or application services.

6) Domain events

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 for IAppEvent, and publishing can be strongly‑typed (no reflection in your handlers).
  • MediatR (section 6.2): events implement IApplicationEvent : IDomainEvent, INotification so 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.

Registering domain event interceptors

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.

6.1 Handling Domain Events with EventBus

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.

6.2 Handling Domain Events with MediatR (Mediator)

Unify domain and notification by having events implement IApplicationEvent : IDomainEvent, INotification, then publish after saving with an interceptor.

Why IApplicationEvent and not IAppEvent here? IAppEvent belongs to the EventBus package and is used in section 6.1. To prevent name collisions and clarify intent, we define IApplicationEvent for 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;
    }
}

7) Domain model primitives

  • EntityBase<TId>
    • TId Id (getter)
    • IList<IDomainEvent> DomainEvents
    • Equality overrides (==, !=, Equals, GetHashCode)
    • bool IsTransient()
  • IAggregateRoot (marker)
  • IDomainEvent (marker)
  • IHaveDomainEventsIList<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 new up entities.
  • With the parameterless ctor, Id stays 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)); }
}

8) Types returned by helpers

  • PagedResult<T>{ IReadOnlyCollection<T> Items, long TotalItems } (+ static Empty).
  • ScoredRecord<TEntity>{ TEntity Entity, double Score, IEnumerable<PropertyScore> Scores }.
  • PropertyScore{ string Name, double Score }.

9) Tips & patterns

  • Use ApplySearch for contains‑style filtering across string properties.
  • Use ApplyFuzzySearch when misspellings are likely; order by Score descending.
  • Always place WhereIf before paging to keep TotalItems correct.
  • Prefer repo methods InDbUpdatePropertyAsync / InDbDeleteAsync for bulk changes.
  • After SaveChangesAsync, publish domain events, then clear the collection.
  • For DDD factories, remember to set state with MarkAsAdded/Modified/Deleted/... before saving.

10) API surface (at a glance)

Classes

  • EF integration: EFCoreDependencyInjection
  • Extensions: FuzzySearchExtensions, HelperExtensions, SearchExtensions, SqlClrHelperExtensions
  • Primitives: EntityBase<TId>, PagedResult<T>, PropertyScore, ScoredRecord<TEntity>
  • Base infra: RepositoryBase<TEntity,TEntityId>, UnitOfWorkBase<TContext>

Interfaces

  • IMarker, IAggregateRoot, IDomainEvent, IHaveDomainEvents
  • IRepositoryBase<TEntity,TEntityId>, IUnitOfWorkBase

11) FAQ

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.