This console application demonstrates CSharpEssentials.EntityFrameworkCore on SQLite: soft delete, the audit, domain event and slow query interceptors, offset and keyset pagination, and enum storage.
| Feature | File | Description |
|---|---|---|
| BaseDbContext | Data/ShopDbContext.cs |
ShopDbContext : BaseDbContext<ShopDbContext> logs the context lifecycle and keeps a service scope; it also applies the enum conventions and the soft-delete filter |
| Soft Delete | Data/Entities/Product.cs, Services/ProductCatalogService.cs |
Product : SoftDeletableEntityBase<Guid>; MarkAsDeleted hides the row through a query filter (HasQueryFilter(e => !e.IsDeleted), added by reflection in ShopDbContext) |
| Audit Interceptor | Program.cs |
AddAuditInterceptor(() => "demo-user"), attached with AddInterceptors; sets CreatedAt/CreatedBy and UpdatedAt/UpdatedBy on SaveChanges |
| Domain Event Interceptor | Program.cs, Services/ConsoleDomainEventPublisher.cs, Data/Events/ |
DomainEventInterceptor publishes entity events through IDomainEventPublisher; ProductCreatedEvent has [DomainEventTiming(BeforeSave)], ProductPriceChangedEvent uses the AfterSave default |
| Slow Query Interceptor | Program.cs |
AddSlowQueryInterceptor(TimeSpan.FromMilliseconds(500)) |
| Pagination | Services/ProductCatalogService.cs, Program.cs |
Offset-based PaginateAsync() and keyset KeysetPaginateAsync() |
| Enum Storage | Data/ShopDbContext.cs, Data/Entities/Product.cs |
ConfigureEnumConventions(EnumConventions.Default) stores the [StringEnum] enum ProductCategory by its wire name and adds a check constraint |
The interceptors are registered in DI and attached to the context in Program.cs. ShopDbContext does not override InterceptorsFromServices or DispatchDomainEventsOnSaveChanges, so BaseDbContext does not attach them itself. The example does not use a naming convention: the Product table is named products explicitly and the columns keep their property names.
cd examples/Examples.EntityFrameworkCore
dotnet runThe demo performs the following steps automatically:
- Database Creation: Deletes and creates a local SQLite database (
shop.db). - Seeding: Inserts 3 initial products.
- Soft Delete Demo: Soft-deletes the "Wireless Mouse" product, then shows:
- Visible products (filtered): 2
- Total products (with deleted): 3
- Offset Pagination Demo: Adds 25 more products and prints pages 1 and 2 of 5 items.
- Cursor Pagination Demo: Reads two pages of 3 products with
KeysetPaginateAsyncandKeysetPaginationRequest. - Audit Interceptor Demo: Modifies a product and shows
UpdatedAtandUpdatedBybeing set automatically. - Domain Event Interceptor Demo: Saves products that raise events; the console publisher prints
[BeforeSave]and[AfterSave]lines. TheAfterSaveevents are handled insideSavingChanges, before the database write. - Enum Demo: Reads a product by its
ProductCategory; the column holds the wire name (see below).
ShopDbContext inherits from BaseDbContext:
public class ShopDbContext : BaseDbContext<ShopDbContext>
{
public ShopDbContext(
DbContextOptions<ShopDbContext> options,
IServiceScopeFactory serviceScopeFactory) : base(options, serviceScopeFactory) { }
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
base.ConfigureConventions(configurationBuilder);
configurationBuilder.ConfigureEnumConventions(EnumConventions.Default);
}
}public class Product : SoftDeletableEntityBase<Guid>
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public ProductCategory Category { get; set; }
}Deleting a product (ProductCatalogService.DeleteProduct):
product.MarkAsDeleted(DateTimeOffset.UtcNow, "system");
_dbContext.SaveChanges();
// Product.IsDeleted = true, row remains in databaseProducts.Remove(product) has the same effect when the AuditInterceptor is attached: it turns a delete of an ISoftDeletable entity into a soft delete. The package also ships modelBuilder.ApplySoftDeleteQueryFilter(), which this example replaces with its own reflection loop.
Querying without soft-delete filter:
var all = db.Products.IgnoreQueryFilters().ToList();var request = new PaginationRequest { PageNumber = 1, PageSize = 10 };
var page = await _dbContext.Products
.OrderBy(p => p.Name)
.PaginateAsync(request);
// page.Items -> IReadOnlyList<Product> for current page
// page.TotalCount -> Total items across all pages
// page.TotalPages -> Total number of pages
// page.HasNextPage -> boolIdeal for infinite scroll or very large datasets. The key must be unique, so add the id as a tie-breaker.
var page = await _dbContext.Products.KeysetPaginateAsync(
new KeysetPaginationRequest { Limit = 20, After = nextCursor },
keys => keys.Ascending(p => p.Name).Ascending(p => p.Id));
// page.Value.Items -> rows of this page
// page.Value.NextCursor -> pass as After to get the next page
// page.Value.HasNext -> boolConfigureEnumConventions stores [StringEnum] values by their wire name through EnumWireNameConverter<TEnum>:
| Enum Value | Stored in DB |
|---|---|
ProductCategory.Electronics |
"electronics" |
ProductCategory.Clothing |
"clothing" |
ProductCategory.Food |
"food" |
This makes the database self-documenting and avoids magic numbers. The convention also adds a check constraint, so the database rejects unknown values:
CONSTRAINT "ck_products_Category_enum" CHECK ("Category" IN ('electronics', 'clothing', 'food', 'books', 'home'))The stored value is the wire name, the same spelling as JSON and query binding. For an existing database whose enum columns hold integers, keep them and opt in per column:
configurationBuilder.ConfigureEnumConventions(EnumConventions.Default, existingStorage: EnumStoredAs.Integer);