AgileTech.EfCore.Base 1.0.0

dotnet add package AgileTech.EfCore.Base --version 1.0.0
                    
NuGet\Install-Package AgileTech.EfCore.Base -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="AgileTech.EfCore.Base" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AgileTech.EfCore.Base" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="AgileTech.EfCore.Base" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add AgileTech.EfCore.Base --version 1.0.0
                    
#r "nuget: AgileTech.EfCore.Base, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package AgileTech.EfCore.Base@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=AgileTech.EfCore.Base&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=AgileTech.EfCore.Base&version=1.0.0
                    
Install as a Cake Tool

AgileTech.EfCore.Base

A small EF Core base library that gives you, for free:

  • Auditable entitiesCreatedAt/CreatedBy/ModifiedAt/ModifiedBy stamped automatically, plus a full AuditLogs change history.
  • Soft deleteRemove() becomes a soft delete automatically; deleted rows are hidden from every query.
  • DbSet query helpersGetAllAsync, GetPagedAsync, FindOneAsync, ExistsAsync, Save (upsert) — no repository layer needed.
  • Automatic caching — flip one setting and GetAllAsync / GetPagedAsync / FindOneAsync are transparently cached, with the cache kept correct on every write. No cache parameter to pass anywhere.

Targets net10.0.


Install

Add a project/package reference, then:

dotnet add package Microsoft.AspNetCore.Http.Abstractions
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.Extensions.Caching.Hybrid
dotnet add package Microsoft.Extensions.Hosting.Abstractions

(These are pulled in transitively when you reference the library as a project/package — listed here so dotnet restore has everything it needs.)


1. Entities

Inherit BaseAuditableEntity and you're done — no boilerplate:

public class Product : BaseAuditableEntity
{
    public string Name { get; set; } = default!;
    public decimal Price { get; set; }
}

You get: Id (int), CreatedAt, CreatedBy, ModifiedAt, ModifiedBy, IsDeleted, DeletedAt, DeletedBy.

Other base types available:

Base type Use when
BaseEntity / BaseEntity<TKey> You just want an Id, no audit fields
BaseAuditableEntity / BaseAuditableEntity<TKey> You want audit fields and soft delete (the common case)
BaseAuditableEntityHardDelete / BaseAuditableEntityHardDelete<TKey> You want audit fields but Remove() should be a real (hard) delete

TKey defaults to int on the non-generic versions; use the generic version for Guid, long, string, etc.


2. DbContext

Inherit AuditableDbContext (or AuditableIdentityDbContext<TUser> if you also need ASP.NET Core Identity):

public class AppDbContext : AuditableDbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options, ICurrentUserService currentUser)
        : base(options, currentUser) { }

    public DbSet<Product> Products => Set<Product>();
}

ICurrentUserService is how the library reads "who made this change" without depending on ASP.NET Core directly. Register the built-in implementation, which reads the logged-in user from HttpContext:

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();

That's it — every SaveChanges/SaveChangesAsync now:

  • Stamps CreatedAt/CreatedBy or ModifiedAt/ModifiedBy on any changed IAuditableEntity.
  • Converts deletes on ISoftDeletable entities into a soft delete (IsDeleted = true, DeletedAt, DeletedBy) instead of a real DELETE.
  • Writes one AuditLog row per changed entity, with old/new values and changed columns as JSON.
  • Invalidates the cache for anything just written (see Caching below), if caching is enabled.

Soft-deleted rows are automatically excluded from every query (Products.Where(...), GetAllAsync(), etc.) — you never need to write !IsDeleted yourself.


3. Query extensions

Available directly on any DbSet<T> / IQueryable<T>:

var all = await db.Products.GetAllAsync();
var active = await db.Products.GetAllAsync(p => p.IsActive);

var page = await db.Products.GetPagedAsync(
    pageNumber: 1,
    pageSize: 20,
    filter: p => p.IsActive,
    orderBy: q => q.OrderByDescending(p => p.CreatedAt));

var product = await db.Products.FindOneAsync(p => p.Sku == sku);
var exists = await db.Products.ExistsAsync(p => p.Sku == sku);

// Upsert: updates if a row matches the predicate, otherwise adds
await db.Products.Save(product, p => p.Sku == product.Sku, db);
await db.SaveChangesAsync();

Add / Update / Remove are EF Core's own DbSet<T> methods — not re-wrapped here.


4. Caching

GetAllAsync, GetPagedAsync, and FindOneAsync can be transparently cached — same method calls, no new API, no cache object to pass around.

Turn it on

// Program.cs
builder.Services.AddCaching(o =>
{
    o.Enabled = true;                          // the one switch — off by default
    o.DefaultExpiration = TimeSpan.FromMinutes(5); // optional, this is the default
});

That's the entire setup. From this point on:

var product = await db.Products.FindOneAsync(p => p.Id == id); // served from cache on repeat calls
var all = await db.Products.GetAllAsync();                      // cached
var page = await db.Products.GetPagedAsync(1, 20);              // cached

db.Products.Update(product);
await db.SaveChangesAsync(); // automatically evicts the cache for Product

Nothing else changes — no constructor changes, no cache parameter, no separate "cached" method names.

How it works

  • Built on HybridCache — a fast in-process (L1) cache by default, with built-in protection against cache stampedes (100 concurrent requests for the same cold key trigger exactly 1 database query, not 100).
  • The cache key is generated automatically from the query's own SQL (IQueryable.ToQueryString(), hashed) — it already encodes the entity type, filter, ordering, and paging, so you never construct a key and two different queries can never collide.
  • Every write through AuditableDbContext / AuditableIdentityDbContext already knows which entity types changed (for the audit log); SaveChanges/SaveChangesAsync reuses that to evict the cache for exactly those types — nothing else is touched.
  • If caching is disabled (default), or a provider can't produce a query string, calls fall straight through to the database — this never breaks a query that used to work.

Scaling across multiple instances (Redis)

Out of the box the cache is in-process only — fastest option, but each instance/container has its own copy. If you run more than one instance (e.g. several ECS tasks behind a load balancer) and need writes on one instance to invalidate the cache on all of them, register Redis before AddAgileTechCaching:

builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "your-redis-endpoint:6379");
builder.Services.AddCaching(o => o.Enabled = true);

HybridCache automatically uses Redis as an L2 behind the in-process L1 — no other code changes, and invalidation now propagates to every instance sharing that Redis.

When to use it, and when not to

Good fit: reference/lookup tables, dashboards, lists that get read far more often than they change.

Skip it (or accept the small, harmless overhead) for tables that are written to almost as often as they're read — you'll pay a tiny invalidation cost on every save without getting much benefit from the cache.

If running multiple instances without Redis: a write on instance A won't invalidate instance B's cache — B can serve slightly stale data (up to DefaultExpiration) until its own entry expires or is naturally refreshed. Add Redis (above) if that window matters for a given table.


Project layout

Entities/     BaseEntity, BaseAuditableEntity, AuditLog
Interfaces/   IAuditableEntity, ISoftDeletable, ICurrentUserService
Context/      AuditableDbContext, AuditableIdentityDbContext<TUser>, soft-delete query filter, audit interceptor
Extensions/   DbSet query helpers (GetAllAsync, GetPagedAsync, FindOneAsync, ExistsAsync, Save) + CurrentUserService
Repository/   PagedResult<T>
Caching/      HybridCache wiring, automatic caching, cache invalidation on save
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 115 7/15/2026

Initial release: AuditableDbContext / AuditableIdentityDbContext with automatic audit logging and soft delete, DbSet query extensions (GetAllAsync, GetPagedAsync, FindOneAsync, ExistsAsync, Save), and opt-in automatic caching via HybridCache with SaveChanges-triggered invalidation.