A .NET 10 Web API demonstrating EF Core SaveChangesInterceptor patterns in a clean architecture with minimal API, repository + unit of work pattern, and PostgreSQL.
This repo was created to illustrate the examples from the blog post EF Core Interceptors. It implements two interceptor patterns:
- Auditing Interceptor: Automatically stamps
CreatedDateandModifiedDateon entities implementingIAuditableduring Add/Modify operations. - Soft-Delete Interceptor: Converts physical deletions into logical updates (
IsDeleted = true) for entities implementingISoftDelete, backed by global query filters to hide them from normal queries.
src/
├── Domain/ # Core entities, interfaces, and abstractions
│ ├── Common/IAuditable.cs # Auditable interface (CreatedDate, ModifiedDate)
│ ├── Common/ISoftDelete.cs # Soft-delete interface (IsDeleted)
│ ├── Entities/Product.cs # Auditable entity
│ ├── Entities/Category.cs # Soft-delete entity
│ └── Repositories/ # Repository + UoW interfaces
├── Application/ # Business logic and services
│ ├── Products/ProductService.cs # Product CRUD service
│ ├── Categories/CategoryService.cs # Category CRUD service (includes soft-delete view)
│ └── DependencyInjection.cs # DI registration
├── Infrastructure/ # EF Core, repositories, interceptors
│ ├── Interceptors/
│ │ ├── AuditInterceptor.cs # Auditing logic
│ │ └── SoftDeleteInterceptor.cs # Soft-delete logic
│ ├── Persistence/AppDbContext.cs # DbContext
│ ├── Configurations/ # Entity configurations with query filters
│ ├── Repositories/ # Generic Repository<T>, UnitOfWork
│ ├── Migrations/ # EF Core migrations (auto-generated)
│ └── DependencyInjection.cs # DI registration
└── Api/ # Minimal API endpoints
├── Program.cs # Entry point, middleware config, auto-migrations
├── Endpoints/ # Endpoint definitions
├── efcore-interceptor.http # REST client test file
└── appsettings.Development.json # Postgres connection string
- .NET 10 SDK (installed via
dotnet --version) - Docker & Docker Compose (for PostgreSQL)
- REST Client (VS Code extension, Visual Studio, or Postman)
start.cmdThis starts PostgreSQL via Docker Compose and runs the API in one step.
docker compose up -d
# Verify: docker ps | grep efcoredotnet run --project src/Api
# Server listens on http://localhost:5080Open src/Api/efcore-interceptor.http in VS Code or Visual Studio and execute requests in order:
- Create Category (Electronics)
- Create Product (Laptop) → observe
createdDate == modifiedDate(audit on Add) - Update Product → observe
modifiedDateadvances later thancreatedDate(audit on Modify) - Soft-Delete Category → soft-delete interceptor triggers
- List Categories (default) → Electronics hidden by query filter
- List Categories (
/allwithIgnoreQueryFilters()) → Electronics still visible withisDeleted: true - Hard-Delete Product → Product fully removed (no soft-delete behavior)
- List Products → Laptop gone (contrast with soft-delete)
Overrides SaveChanges and SaveChangesAsync:
- On
EntityState.Added: sets bothCreatedDateandModifiedDatetoDateTime.UtcNow - On
EntityState.Modified: updates onlyModifiedDate
foreach (var entry in context.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedDate = now;
entry.Entity.ModifiedDate = now;
}
else if (entry.State == EntityState.Modified)
{
entry.Entity.ModifiedDate = now;
}
}Overrides SaveChanges and SaveChangesAsync:
- On
EntityState.Deleted: changes toEntityState.Modifiedand setsIsDeleted = true - Combined with
builder.HasQueryFilter(c => !c.IsDeleted)inCategoryConfiguration
foreach (var entry in context.ChangeTracker.Entries<ISoftDelete>())
{
if (entry.State == EntityState.Deleted)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
}
}Applied in CategoryConfiguration:
builder.HasQueryFilter(c => !c.IsDeleted);This filter:
- Applies automatically to
GetAllAsync()queries - Includes
Include()navigations - Can be bypassed with
.IgnoreQueryFilters()(used inGetAllIncludingDeletedAsync())
ExecuteDelete/ExecuteUpdate bulk operations bypass SaveChanges interceptors entirely.
Both interceptors only work with normal SaveChanges-based operations. If using bulk delete/update via LINQ queries:
// ❌ Bypasses soft-delete interceptor
await context.Categories.Where(...).ExecuteDeleteAsync();
// ✅ Triggers soft-delete interceptor
var categories = await context.Categories.Where(...).ToListAsync();
context.RemoveRange(categories);
await context.SaveChangesAsync();This is an EF Core design decision and is noted in both interceptor files.
- Domain layer defines entities (Product, Category), interfaces (IAuditable, ISoftDelete), and repository/UoW contracts.
- Application layer implements services (ProductService, CategoryService) with DTOs and manual mapping — no MediatR for simplicity.
- Infrastructure layer contains EF Core configuration, interceptors, repository implementations, and migrations.
- Api layer wires up minimal API endpoints, DI, and auto-applies migrations on startup in Development.
The generic IUnitOfWork.Repository<T>() accessor keeps the UoW interface stable as entities are added:
var productRepo = unitOfWork.Repository<Product>();
await productRepo.AddAsync(newProduct, ct);
await unitOfWork.SaveChangesAsync(ct);Microsoft.EntityFrameworkCore10.0.10Microsoft.EntityFrameworkCore.Design10.0.10Npgsql.EntityFrameworkCore.PostgreSQL10.0.3dotnet-ef(global tool) 10.0.10
# Stop API: Ctrl+C in terminal
# Stop PostgreSQL
docker compose down
# Remove volume (deletes DB data)
docker volume rm efcore-interceptor-data