AiTech.Anvil.Data.EntityFrameworkCore
1.0.0
See the version list below for details.
dotnet add package AiTech.Anvil.Data.EntityFrameworkCore --version 1.0.0
NuGet\Install-Package AiTech.Anvil.Data.EntityFrameworkCore -Version 1.0.0
<PackageReference Include="AiTech.Anvil.Data.EntityFrameworkCore" Version="1.0.0" />
<PackageVersion Include="AiTech.Anvil.Data.EntityFrameworkCore" Version="1.0.0" />
<PackageReference Include="AiTech.Anvil.Data.EntityFrameworkCore" />
paket add AiTech.Anvil.Data.EntityFrameworkCore --version 1.0.0
#r "nuget: AiTech.Anvil.Data.EntityFrameworkCore, 1.0.0"
#:package AiTech.Anvil.Data.EntityFrameworkCore@1.0.0
#addin nuget:?package=AiTech.Anvil.Data.EntityFrameworkCore&version=1.0.0
#tool nuget:?package=AiTech.Anvil.Data.EntityFrameworkCore&version=1.0.0
AiTech.Anvil
Enterprise application foundation for .NET 10 (data providers: EF Core 10 + MongoDB) — async repository + unit of work, automatic soft‑delete, centralized audit, atomic transactions, connection‑string encryption, and ASP.NET Core integration.
AiTech.Anvil is a small, opinionated foundation for building layered enterprise applications. It provides the cross‑cutting plumbing (data access, transactions, auditing, security, web tracing) so your application code stays focused on domain and business logic.
Packages
| Package | What it gives you |
|---|---|
| AiTech.Anvil | Provider-neutral foundation: entities, async IRepository + IUnitOfWork contracts, neutral commit exceptions (AnvilCommitException/AnvilConcurrencyException/AnvilDuplicateKeyException), optimistic concurrency (IConcurrencyTracked + SetExpectedVersion), BaseAppService (atomic transactions), AES connection‑string encryption, PredicateBuilder, in‑process event bus, typed HTTP client, persistence seeding, AddAnvilCore |
| AiTech.Anvil.Data.EntityFrameworkCore | EF Core provider for the Anvil data contracts: BaseDbContext (automatic soft‑delete filters), EF repository + unit of work, audit interceptor, declared relational escape hatches (IEfRepository, IEfUnitOfWork, IQueryService), AddAnvilEntityFrameworkCore<TDbContext> — the only package that references Microsoft.EntityFrameworkCore |
| AiTech.Anvil.Data.MongoDB | MongoDB provider (native MongoDB.Driver) for the Anvil data contracts: buffered-write repository + unit of work, sessions/transactions, audit, provider-rotated optimistic concurrency, declared document escape hatch (IMongoQueryService), AddAnvilMongoDB — the only package that references MongoDB.Driver |
| AiTech.Anvil.Console | Testable IConsole, interactive ConsoleMenu, connection‑string encryption / AES key‑generation command |
| AiTech.Anvil.AspNetCore | [TraceOnErrorFile] action filter (writes a self‑contained error log), HttpContext‑based current user, minimal client detection + ApiUtils, AddAnvilAspNetCore |
| AiTech.Anvil.Messaging.RabbitMQ | RabbitMQ transport for the cross‑process integration bus (topic exchange + per‑service queue, publisher + hosted consumer), AddAnvilRabbitMq — the only package that references RabbitMQ.Client |
All packages target net10.0, ship XML docs + symbols (snupkg), and build warning‑free.
Install
dotnet add package AiTech.Anvil
Then add what you need — each one pulls the core in automatically:
dotnet add package AiTech.Anvil.Data.EntityFrameworkCore # EF Core provider
dotnet add package AiTech.Anvil.Data.MongoDB # MongoDB provider
dotnet add package AiTech.Anvil.AspNetCore # ASP.NET Core integration
dotnet add package AiTech.Anvil.Console # console helpers
dotnet add package AiTech.Anvil.Messaging.RabbitMQ # integration bus transport
Pick one data provider per application: AddAnvilEntityFrameworkCore<TDbContext> and AddAnvilMongoDB both register the same IRepository/IUnitOfWork contracts.
Quick start
1. Define entities
using AiTech.Anvil.Entities;
// String/GUID key + audit fields (CreationTime/CreatedBy/LastUpdateTime/LastUpdateBy) + soft delete (IsDeleted)
public sealed class Product : ModernRichEntityBase
{
public string Name { get; set; } = "";
public decimal Price { get; set; }
}
Base classes: EntityBase<TKey> (struct key), ModernEntityBase (string key), RichEntityBase<TKey> / ModernRichEntityBase (add audit + soft delete via IRichEntity).
2. Define your DbContext
using AiTech.Anvil.Data.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : BaseDbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
BaseDbContext automatically applies a global query filter e => !EF.Property<bool>(e, "IsDeleted") to every IRichEntity.
If you override OnModelCreating, call base.OnModelCreating(modelBuilder).
3. Register the pipeline
using AiTech.Anvil.Data.EntityFrameworkCore;
builder.Services.AddAnvilEntityFrameworkCore<AppDbContext>(
builder.Configuration,
(options, connectionString) => options.UseSqlServer(connectionString)); // you pick the EF provider
AddAnvilEntityFrameworkCore registers: the DbContext (with the resolved connection string + audit interceptor), IRepository<> + IEfRepository<>, IUnitOfWork + IEfUnitOfWork, IQueryService<TDbContext>, the crypto helper, and a default current‑user accessor.
4. Use it from an application service
using AiTech.Anvil.Data;
using AiTech.Anvil.Services;
public sealed class CatalogService(IUnitOfWork uow, IRepository<Product> products)
: BaseAppService(uow)
{
public Task<IList<Product>> SearchAsync(string term) =>
products.FetchAsync(p => p.Name.Contains(term), take: 50, sort: p => p.Name);
public Task<IList<ValidationResult>> CreateAsync(Product product) =>
SaveEntityAsync(product, products).AsTask(); // validates, tracks, commits in one transaction
}
Reads are async (GetSingleAsync, FetchAsync, FetchWithProjectionAsync, CountAsync, AnyAsync).
Writes (Save, Delete, HardDelete) only touch the change tracker — the DB write happens at the unit‑of‑work commit.
Atomic multi‑table operations
Wrap multiple writes in a single ExecuteInTransactionAsync; only the outermost call commits (one SaveChanges), and any exception rolls back everything:
public Task TransferAsync(Product a, Product b) =>
ExecuteInTransactionAsync(async () =>
{
products.Save(a);
products.Save(b);
await Task.CompletedTask; // one commit, atomic across both rows
});
Soft vs hard delete
Delete(entity)→ soft delete forIRichEntity(setsIsDeleted, hidden by the global filter); physical otherwise.HardDelete(entity)→ always physical.Query()/QueryIgnoringFilters("...")→ relational escape hatches onIEfRepository<T>(e.g. to read soft‑deleted rows); injectingIEfRepositorydeclares that the consumer is bound to a relational provider.
Provider-agnostic discipline
The neutral contract (IRepository<T>, IUnitOfWork) is implemented by both the relational
(EF Core) and document (MongoDB) providers. An app that only injects the neutral contract can swap
providers via DI registration — AddAnvilMongoDB(configuration, o => o.DatabaseName = "...") in place
of AddAnvilEntityFrameworkCore<TDbContext>(...). To stay swappable:
- No custom methods inside predicates (
r => r.IsExpired()) — no LINQ provider can translate them (EF already rejects these); fetch first, filter in memory. - No
StringComparison/culture overloads in predicates — use theToLower()pattern instead. - No navigation-property traversal in predicates — use scalar FK columns (
g.AudienceId == id). - Cross-entity reads (joins/aggregations) are not part of the neutral contract: define a query interface in your application layer and implement it per provider in your infrastructure layer.
- Injecting
IEfRepository<T>,IEfUnitOfWorkorIQueryService<TDbContext>declares a relational binding — legitimate, but that consumer is no longer provider-neutral. - Injecting
IMongoQueryServicedeclares a document-store binding — the symmetric Mongo escape hatch. - MongoDB swappability constraints: use a string key (
ModernEntityBase) — Mongo has no auto-increment, so saving a new struct-keyed entity throwsNotSupportedException. Multi-document transactions require a replica set (single-node is fine for dev).DateTime.UtcNowinside a predicate is evaluated client-side by the driver (EF translates it server-side) — a negligible clock difference.
Connection‑string encryption
AddAnvilEntityFrameworkCore resolves the connection string by name: it prefers ConnectionStrings:{name}Encrypted (AES‑decrypted) and falls back to ConnectionStrings:{name} in clear text.
// appsettings.json
{
"ConnectionStrings": {
"DefaultEncrypted": "<base64-ciphertext>"
}
}
⚠️ Key storage: the AES key (
Crypto:KeyBase64) must live outsideappsettings.json— in an environment variable, user‑secrets, or a Key Vault. Storing the key next to the ciphertext defeats the purpose. AES‑GCM here provides authenticated encryption (confidentiality + integrity): a tampered ciphertext fails to decrypt.
Generate a key and encrypt a string with the AiTech.Anvil.Console package:
using AiTech.Anvil.Console;
using AiTech.Anvil.Console.Commands;
var console = new SystemConsole();
var key = ConnectionStringCryptoCommand.GenerateKey(console); // prints a base64 AES‑256 key
ConnectionStringCryptoCommand.EncryptConnectionString(console, key, "Server=...;"); // prints the ciphertext
ASP.NET Core integration
using AiTech.Anvil.Data.EntityFrameworkCore;
using AiTech.Anvil.AspNetCore.DependencyInjection;
builder.Services.AddAnvilEntityFrameworkCore<AppDbContext>(builder.Configuration, (o, cs) => o.UseSqlServer(cs));
builder.Services.AddAnvilAspNetCore(); // HttpContext-based current user (so audit captures the request user)
Error tracing to file
Apply [TraceOnErrorFile] to a controller or action. When the action throws, a self‑contained log file is written under App_Data/traced-errors/<date>/ containing the request (id, authenticated user, verb, controller/action, JSON parameters), the response, the duration and the full stack trace:
using AiTech.Anvil.AspNetCore.Filters;
[ApiController, Route("api/orders")]
[TraceOnErrorFile] // optional: [TraceOnErrorFile(TargetFolder = "logs/errors")]
public sealed class OrdersController : ControllerBase { /* ... */ }
Client helpers: ApiUtils.GetClientIpAddress(HttpContext) and ApiUtils.GetClientInfo(HttpContext) (best‑effort browser/OS from the User‑Agent).
In‑process events & typed HTTP client (core)
// Events — handlers run in sequence; exceptions are aggregated (one bad handler doesn't stop the others)
services.AddAnvilEventBus();
services.AddScoped<IEventHandler<OrderPlaced>, SendConfirmationEmail>();
await eventBus.PublishAsync(new OrderPlaced(orderId));
// Typed HTTP client — wraps the outcome; never throws on non-2xx OR transport failures (inspect IsSuccess)
services.AddAnvilHttpClient(c => c.BaseAddress = new Uri("https://api.example.com/"));
// For resilience, install Microsoft.Extensions.Http.Resilience in your app and chain it on the
// returned builder (kept out of core to stay dependency-light; retries non-idempotent calls too):
// services.AddAnvilHttpClient(c => ...).AddStandardResilienceHandler();
var result = await httpClient.GetAsync<Order>("/orders/1",
configureRequest: req => req.Headers.Authorization = new("Bearer", token)); // optional per-request hook
if (result.IsSuccess) { /* use result.Data */ }
else if (result.IsTransportFailure) { /* network/timeout/circuit — result.Exception */ }
else { /* HTTP error — result.StatusCode / result.ErrorContent */ }
// Build query strings safely (encodes, skips null values):
var url = "/orders" + QueryString.Build(("status", "open"), ("q", search));
Integration bus (cross‑process)
The in‑process event bus above dispatches within one application. The integration bus carries messages between services. Its abstraction lives in the core AiTech.Anvil package and is transport‑agnostic — your application code never references a broker. Swap or upgrade the transport (today: RabbitMQ) without touching a single application project.
1. Define a message and a handler (core — no broker dependency)
using AiTech.Anvil.Messaging;
// The topic is the routing key. Without [IntegrationMessage], it defaults to the type's full name.
[IntegrationMessage("orders.placed")]
public sealed record OrderPlaced(string OrderId, decimal Total) : IIntegrationMessage;
public sealed class OnOrderPlaced(IEmailSender email) : IIntegrationEventHandler<OrderPlaced>
{
public Task HandleAsync(OrderPlaced message, CancellationToken ct = default) =>
email.SendAsync($"Order {message.OrderId} received", ct);
}
2. Register the bus + handlers
using AiTech.Anvil.Messaging;
services.AddIntegrationHandler<OrderPlaced, OnOrderPlaced>(); // register handlers (transport-independent)
Then pick a transport. RabbitMQ (separate package, binds RabbitMq from configuration):
using AiTech.Anvil.Messaging.RabbitMQ;
services.AddAnvilRabbitMq(builder.Configuration); // publisher + hosted consumer (BackgroundService)
// appsettings.json
{
"RabbitMq": {
"Host": "localhost",
"Port": 5672,
"UserName": "guest",
"Password": "guest",
"ExchangeName": "aitech.Anvil", // topic exchange (default)
"QueueName": "orders-service", // this service's durable queue
"PrefetchCount": 10,
"DeadLetterEnabled": true, // retry then dead-letter failed messages (default)
"MaxRetries": 3, // attempts before parking in the dead-letter queue
"RetryDelay": "00:00:05" // delay between retries (retry-queue TTL)
}
}
For unit tests (or a modular monolith) register the in‑memory transport instead — same abstraction, no broker:
services.AddAnvilInMemoryBus();
3. Publish
public sealed class CheckoutService(IIntegrationBus bus)
{
public Task CompleteAsync(string orderId, decimal total) =>
bus.PublishAsync(new OrderPlaced(orderId, total));
}
The publisher resolves the topic, publishes to the topic exchange with the topic as the routing key; the hosted consumer binds this service's queue, dispatches each message to all registered handlers for its topic, and acks on success. Handler exceptions propagate with their original type and stack trace and trigger the dead-letter/retry flow below.
Dead-letter & retry. A failed message is never silently dropped. The consumer retries it up to MaxRetries times (default 3), each retry delayed by RetryDelay (default 5s) via an auto-declared retry-queue (<queue>.retry); once retries are exhausted the message is parked in an auto-declared dead-letter queue (<queue>.dlq, fed by the fanout exchange <queue>.dlx) carrying x-retry-count / x-death-reason / x-exception-message headers. The retry republish is broker-confirmed before the original delivery is acked, so messages are not lost. Dead-lettering is ON by default; set "DeadLetterEnabled": false to opt out (failed messages are then nacked without requeue, i.e. dropped). Because RetryDelay is baked into the retry-queue declaration, changing it later requires deleting the existing <queue>.retry queue.
Why a separate transport package: confining
RabbitMQ.ClienttoAiTech.Anvil.Messaging.RabbitMQmeans a broker version bump, an API signature change, or a deprecated client release is isolated to one package — no application project is ever tied to RabbitMQ.
Known limitations
Savecannot insert an entity with a caller‑assigned key. Convention: a new entity has an empty/null Id andSaveassigns a GUID; a non‑empty Id is treated as an update. To insert with a known key, add via theDbContext/DbSetdirectly.- Paging requires a stable sort key.
FetchAsynctakes a singleOrderBy; paging over a non‑unique column is non‑deterministic — sort on a unique key (or add a tiebreaker viaIQueryService). - AES‑GCM provides authenticated encryption (confidentiality + integrity; a tampered ciphertext fails to decrypt) — suitable for protecting a connection string at rest with a securely managed key.
ApiUtilstrustsX‑Forwarded‑Foras‑is (no proxy allow‑list) — review for security‑sensitive deployments.TraceAttributeis not a public extension point (its trace models are internal); use the supplied[TraceOnErrorFile].
Building
dotnet build -warnaserror # 0 warnings
dotnet test # all projects
Releases are not built from this repository. It deliberately carries no packaging configuration — no
Directory.Build.props, no version, no pack script. A separate repo injects the version (from a git tag), licence, README and symbol settings at pack time. This repo has one job: its green test run is what authorises a release.
Integration tests: the OAuth API ships an end‑to‑end suite (
AiTech.OAuth.Api.IntegrationTests) that drives real HTTP throughWebApplicationFactoryagainst a local.\SqlExpressthrowaway database with real RS256 token issuance. Like the Mongo/RabbitMQ suites, it auto‑skips when the server is unavailable, sodotnet teststays green without local infra. The Chimera resource API ships a complementary suite (AiTech.Chimera.Api.IntegrationTests) covering a JWT validation matrix in isolation plus a real OAuth handshake; the handshake tests auto‑skip when SQL Express is unavailable. The OAuth primitives — password hashing (PasswordHasher), secure token generation/hashing (SecureTokenGenerator), RS256 JWT issuance/validation (RsaJwtTokenProvider), the strong‑password policy (StrongPasswordAttribute), and the generic paged search helper (OAuthAppServiceBase) — are covered by a dependency‑free unit suite (AiTech.OAuth.UnitTests) that always runs: no SQL or broker required.
License
MIT © 2026 Andrea Iridio
| Product | Versions 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. |
-
net10.0
- AiTech.Anvil (>= 1.0.0)
- Microsoft.EntityFrameworkCore (>= 10.0.10)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.10)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.