RA.Utilities.Data.EntityFramework
RA.Utilities.Data.EntityFramework is the Entity Framework Core implementation of the data access abstractions in RA.Utilities.Data.Abstractions. It provides generic repositories, a SaveChanges interceptor for audit timestamps, and value converters that persist the value objects from RA.Utilities.Core.ValueObjects.
It lets your business logic depend on IRepositoryBase<T, TKey> (or the read/write split interfaces) while the EF Core details — DbContext, tracking, SQL, converters — stay in the data layer.
🎯 Purpose
Writing the same CRUD repository for every entity is repetitive, and mixing DbContext calls into business logic couples your application to EF Core. This package solves both problems:
- One generic implementation per concern —
ReadRepositoryBase<T, TKey>,WriteRepositoryBase<T, TKey>, andRepositoryBase<T, TKey>cover queries and commands without per-entity boilerplate. - CQS by construction — inject
IReadRepositoryBase<T, TKey>where only queries are allowed andIWriteRepositoryBase<T, TKey>where only commands are allowed. - No-tracking reads —
ListAsynccallsAsNoTracking(), reducing memory and change-tracker overhead for query paths. - Value objects persist without losing validation — converters store the normalized value and re-run the constructor when materializing, so invalid data fails loudly.
- Audit timestamps for free —
BaseEntitySaveChangesInterceptormaintainsCreatedAtandLastModifiedAton entities that expose them. - Clean composition over inheritance —
RepositoryBase<T, TKey>inherits reads and composes writes, so each concern remains independently testable.
✨ Key Features
- Three repository implementations — full, read-only, and write-only, all generic over the primary key (
Guid,int,long,string, …) - Open-generic DI registration — one line registers a repository for every
CoreEntity<TKey>in your model - Query flexibility —
ListAsyncsupports filtering, ordering, skip/take pagination, andIncludefor navigation properties - Mark-then-save deletes — deletes participate in the same
SaveChangestransaction as the rest of the unit of work - EF Core value converters —
EmailConverter,CurrencyConverter,SsnConverter,IbanConverter, andMoneyConverter - Two-column
Moneymapping —HasMoney()stores aMoneyas a decimalAmountplus a stringCurrencycolumn - Timestamp interceptor — metadata-based detection, so it works with any entity that exposes
CreatedAt/LastModifiedAt - C# 14 extension members — modern registration APIs with unchanged call sites
This release is additive. It adds IbanConverter to the Conventions namespace so the IBAN value object can be persisted alongside the other value objects. No existing type, signature, or registration changed. See the Migration Guides.
🛠️ Getting Started
Prerequisites
- .NET 10.0 or later
- Microsoft.EntityFrameworkCore 10.0.12 or later (installed as a dependency)
- An EF Core database provider — SQL Server, SQLite, PostgreSQL, etc. The package references only the EF Core core library, so add the provider your application uses
RA.Utilities.Data.AbstractionsandRA.Utilities.Core.ValueObjects— installed automatically as dependencies
Installation
.NET CLI
dotnet add package RA.Utilities.Data.EntityFramework
Package Manager Console
Install-Package RA.Utilities.Data.EntityFramework
PackageReference (csproj)
<PackageReference Include="RA.Utilities.Data.EntityFramework" Version="10.2.0" />
Hello World
A complete, working setup: an entity, a DbContext, the interceptor, one repository registration, and a minimal API endpoint.
using Microsoft.EntityFrameworkCore;
using RA.Utilities.Data.Abstractions;
using RA.Utilities.Data.Entities;
using RA.Utilities.Data.EntityFramework.Extensions;
using RA.Utilities.Data.EntityFramework.Interceptors;
// 1. Entity: CoreEntity<Guid> supplies Id; BaseEntity<Guid> adds CreatedAt.
public sealed class Product : BaseEntity<Guid>
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
// 2. DbContext
public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
}
// 3. Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<BaseEntitySaveChangesInterceptor>();
// Registers AppDbContext under the DbContext service type, which is what the
// open-generic repositories ask for. See "Resolving the DbContext" below.
builder.Services.AddDbContext<DbContext, AppDbContext>((provider, options) =>
options.UseSqlite(builder.Configuration.GetConnectionString("Default"))
.AddInterceptors(provider.GetRequiredService<BaseEntitySaveChangesInterceptor>()));
builder.Services.AddRepositoryBase();
var app = builder.Build();
// 4. Inject the repository interface
app.MapGet("/products/{id:guid}", async (Guid id, IRepositoryBase<Product, Guid> repository) =>
await repository.GetByIdAsync(id) is { } product
? Results.Ok(product)
: Results.NotFound());
app.Run();
No custom repository is required for standard CRUD. Add one only when you need entity-specific queries (see Custom repositories).
🧠 Core Concepts
Components
| Type | Namespace | Role |
|---|---|---|
ReadRepositoryBase<T, TKey> | RA.Utilities.Data.EntityFramework | Read-only queries (GetByIdAsync, ListAsync) with AsNoTracking() |
WriteRepositoryBase<T, TKey> | RA.Utilities.Data.EntityFramework | Commands (Add, Update, Delete, SaveChanges) |
RepositoryBase<T, TKey> | RA.Utilities.Data.EntityFramework | Full CRUD: inherits reads, composes writes |
ServiceCollectionExtensions | …Data.EntityFramework.Extensions | AddReadRepositoryBase(), AddWriteRepositoryBase(), AddRepositoryBase() |
MoneyConventionExtensions | …Data.EntityFramework.Extensions | HasMoney() — two-column Money mapping |
| Value object converters | …Data.EntityFramework.Conventions | EmailConverter, CurrencyConverter, SsnConverter, IbanConverter, MoneyConverter |
BaseEntitySaveChangesInterceptor | …Data.EntityFramework.Interceptors | Sets CreatedAt / LastModifiedAt before saving |
How the repositories fit together
RepositoryBase<T, TKey> uses composition over inheritance for writes:
RepositoryBase<T, TKey>
├─ inherits ReadRepositoryBase<T, TKey> → GetByIdAsync, ListAsync
└─ composes WriteRepositoryBase<T, TKey> → AddAsync, UpdateAsync,
DeleteAsync, SaveChangesAsync
Both share the same DbContext instance, so a read followed by an update participates in one change tracker and one unit of work.
The read and write interfaces come from RA.Utilities.Data.Abstractions:
IRepositoryBase<T, TKey>
├─ IReadRepositoryBase<T, TKey>
└─ IWriteRepositoryBase<T, TKey>
Entity and key constraints
Every repository is constrained by:
where T : CoreEntity<TKey>
where TKey : notnull
T must derive from CoreEntity<TKey>, which supplies Id. Any non-nullable key type works: Guid, int, long, string, or a strongly typed ID. The write implementation reads e.Id, so the key property must be the one declared by CoreEntity<TKey>.
Use BaseEntity<TKey> when you need CreatedAt, or WriteEntity<TKey> when you need CreatedAt and LastModifiedAt. AuditableBaseEntity<TKey> and SoftDeleteEntity<TKey> derive from WriteEntity<TKey>.
Resolving the DbContext
The repository constructors take a DbContext parameter, not your concrete context type. AddDbContext<TContext>() registers the context under TContext only, so an open-generic IRepositoryBase<Product, Guid> cannot be constructed unless DbContext itself is resolvable.
Use the two-generic overload:
// ✅ AppDbContext is registered as the implementation of DbContext
builder.Services.AddDbContext<DbContext, AppDbContext>(options =>
options.UseSqlServer(connectionString));
or alias an existing registration:
// ✅ Alternative when you already registered the concrete context
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(connectionString));
builder.Services.AddScoped<DbContext>(sp => sp.GetRequiredService<AppDbContext>());
If you only register AppDbContext and rely on custom repositories whose constructors accept AppDbContext, the generic registrations are not required. See Troubleshooting.
Lifetimes
All three registration methods use scoped lifetimes, matching the default scoped lifetime of AddDbContext:
| Service | Lifetime | Registered by |
|---|---|---|
DbContext / AppDbContext | Scoped | AddDbContext(...) |
IReadRepositoryBase<,> | Scoped | AddReadRepositoryBase() |
IWriteRepositoryBase<,> | Scoped | AddWriteRepositoryBase() |
IRepositoryBase<,> | Scoped | AddRepositoryBase() |
Register the interceptor as scoped as well when it depends on scoped services.
Value object persistence
| Value object | Converter | Column |
|---|---|---|
Email | EmailConverter | Normalized string, e.g. user@example.com |
Currency | CurrencyConverter | ISO code, e.g. EUR |
SSN | SsnConverter | Normalized string |
IBAN | IbanConverter | Normalized IBAN, e.g. GB82WEST12345698765432 |
Money | MoneyConverter | JSON object in one string column |
Money | HasMoney() | Two columns: {Property}_Amount, {Property}_Currency |
The string converters read through the value object constructor, so data that no longer validates fails on materialization rather than entering your domain.
How a request flows
Endpoint / handler
│ injects IRepositoryBase<Product, Guid>
▼
RepositoryBase<Product, Guid> (scoped)
├─ ReadRepositoryBase ──► DbSet<Product>.AsNoTracking() / FindAsync
└─ WriteRepositoryBase ──► Add/Update/Remove + SaveChangesAsync
│
▼
BaseEntitySaveChangesInterceptor
sets CreatedAt / LastModifiedAt
│
▼
Database
Terminology
- Repository: A class that exposes data access for one entity behind an interface.
- CQS / CQRS: Command Query Separation — reads and writes have separate contracts. The package offers separate read and write repositories for this.
- Unit of work: The
DbContextchange tracker.SaveChangesAsynccommits everything tracked in the current scope, not just the last operation. - Mark-then-save: Deletes and updates change the entity state; nothing reaches the database until
SaveChangesAsyncis called. - No-tracking query: An EF Core query that does not add results to the change tracker.
ListAsyncuses it;GetByIdAsyncusesFindAsync, which may return a tracked instance. - Complex property: An EF Core mapping that flattens an owned type into columns of the same table.
HasMoney()uses it for two-columnMoneystorage.
📖 How-To Guides
Register all repositories
using RA.Utilities.Data.EntityFramework.Extensions;
builder.Services.AddRepositoryBase(); // IRepositoryBase<T, TKey>
builder.Services.AddReadRepositoryBase(); // IReadRepositoryBase<T, TKey>
builder.Services.AddWriteRepositoryBase(); // IWriteRepositoryBase<T, TKey>
Register only the interfaces you inject. Registering all three is harmless but unnecessary.
Query with filtering, ordering, and pagination
using RA.Utilities.Data.Abstractions;
using RA.Utilities.Data.Entities;
public sealed class ProductReader(IReadRepositoryBase<Product, Guid> repository)
{
public Task<IReadOnlyList<Product>> GetPageAsync(int page, int size) =>
repository.ListAsync(
filter: p => p.Price > 0,
orderBy: q => q.OrderBy(p => p.Name),
skip: (page - 1) * size,
take: size);
}
Include navigation properties
includeProperties is a params array, so pass one expression per navigation path:
IReadOnlyList<Order> orders = await repository.ListAsync(
filter: o => o.CustomerId == customerId,
includeProperties: [o => o.Lines, o => o.ShippingAddress]);
Collection expressions ([...]) work for the params array in C# 12+.
Write and save
public sealed class ProductWriter(IWriteRepositoryBase<Product, Guid> repository)
{
public async Task AddAsync(string name, decimal price, CancellationToken cancellationToken)
{
var product = new Product { Name = name, Price = price };
await repository.AddAsync(product, cancellationToken);
// Nothing is persisted until SaveChangesAsync runs.
await repository.SaveChangesAsync(cancellationToken);
}
}
SaveChangesAsync commits all pending changes tracked by the scoped DbContext, not only the entity passed to the repository.
Delete by key
// Loads the entity and marks it Deleted; no-op when the key does not exist.
await repository.DeleteAsync(productId);
// Returns the number of entities that were found and marked for deletion.
int marked = await repository.DeleteRangeAsync([id1, id2, id3]);
await repository.SaveChangesAsync();
Create a custom repository
Add entity-specific queries by deriving from RepositoryBase<T, TKey> (or ReadRepositoryBase<T, TKey> when the repository is read-only). The derived constructor accepts your concrete context, which keeps DI simple:
public interface IProductRepository : IRepositoryBase<Product, Guid>
{
Task<Product?> FindByNameAsync(string name, CancellationToken cancellationToken = default);
}
public sealed class ProductRepository : RepositoryBase<Product, Guid>, IProductRepository
{
private readonly AppDbContext _dbContext;
public ProductRepository(AppDbContext dbContext) : base(dbContext) => _dbContext = dbContext;
public Task<Product?> FindByNameAsync(string name, CancellationToken cancellationToken = default) =>
_dbContext.Products.FirstOrDefaultAsync(p => p.Name == name, cancellationToken);
}
builder.Services.AddScoped<IProductRepository, ProductRepository>();
Map a value object to a column
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using RA.Utilities.Data.EntityFramework.Conventions;
public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer>
{
public void Configure(EntityTypeBuilder<Customer> builder)
{
builder.Property(c => c.Email).HasConversion(new EmailConverter());
builder.Property(c => c.Iban).HasConversion(new IbanConverter());
builder.Property(c => c.PreferredCurrency).HasConversion(new CurrencyConverter());
}
}
Store Money across two columns
using RA.Utilities.Data.EntityFramework.Extensions;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Balance_Amount (decimal) + Balance_Currency (string)
modelBuilder.Entity<Wallet>().HasMoney(w => w.Balance);
}
The method returns a ComplexPropertyBuilder<Money>, so you can keep configuring facets:
modelBuilder.Entity<Wallet>()
.HasMoney(w => w.Balance)
.Property(m => m.Amount)
.HasColumnType("decimal(18,4)");
Maintain timestamps automatically
builder.Services.AddScoped<BaseEntitySaveChangesInterceptor>();
builder.Services.AddDbContext<DbContext, AppDbContext>((provider, options) =>
options.UseSqlServer(connectionString)
.AddInterceptors(provider.GetRequiredService<BaseEntitySaveChangesInterceptor>()));
CreatedAt is set on Added entities and LastModifiedAt on Modified entities, provided the entity type exposes those properties. CreatedBy / LastModifiedBy on AuditableBaseEntity<TKey> are not set automatically.
Separate read and write services
public sealed class ProductQueries(IReadRepositoryBase<Product, Guid> reads)
{
public Task<Product?> GetAsync(Guid id, CancellationToken cancellationToken = default) =>
reads.GetByIdAsync(id, cancellationToken);
}
public sealed class ProductCommands(
IReadRepositoryBase<Product, Guid> reads,
IWriteRepositoryBase<Product, Guid> writes)
{
public async Task RenameAsync(Guid id, string name, CancellationToken cancellationToken = default)
{
Product? product = await reads.GetByIdAsync(id, cancellationToken);
if (product is null)
{
return;
}
product.Name = name;
await writes.UpdateAsync(product, cancellationToken);
await writes.SaveChangesAsync(cancellationToken);
}
}
IWriteRepositoryBase<T, TKey> has no query methods. When a command needs to load an entity first, inject IReadRepositoryBase<T, TKey> alongside it, or load it through your concrete DbContext in a custom repository.
⚙️ Configuration
This package has no options class and no appsettings.json section of its own. Configuration happens where you register EF Core and build the model:
| Setting | Type | Default | Where |
|---|---|---|---|
DbContext lifetime | ServiceLifetime | Scoped | AddDbContext<…>(…, ServiceLifetime.Scoped) |
| Repository lifetime | ServiceLifetime | Scoped (fixed) | Add*RepositoryBase() |
| Interceptor lifetime | ServiceLifetime | — (you choose) | AddScoped<BaseEntitySaveChangesInterceptor>() |
| Database provider | DbContextOptionsBuilder | — (required) | options.UseSqlServer/UseSqlite/UseNpgsql(…) |
| Connection string | string | — | builder.Configuration.GetConnectionString(…) |
| Value object mapping | HasConversion(…) / HasMoney(…) | No converter is applied automatically | OnModelCreating / IEntityTypeConfiguration<T> |
Amount column type | string | Provider default for decimal (decimal(18,2) on SQL Server, TEXT on SQLite) | .Property(m => m.Amount).HasColumnType(…) |
Money column names | string | {Property}_Amount, {Property}_Currency | .HasMoney(…).Property(…) |
Nothing is mapped automatically. EF Core has no built-in mapping for a Money, Email, IBAN, or other value object, so a property of one of those types without HasConversion or HasMoney fails model building (or is mapped as an owned type by convention). Configure each value object property explicitly.
✨ Available Components
ReadRepositoryBase
ReadRepositoryBase is the EF Core implementation of IReadRepositoryBase. It provides read-only queries for any entity derived from CoreEntity and never exposes a method that changes the database.
WriteRepositoryBase
WriteRepositoryBase is the EF Core implementation of IWriteRepositoryBase. It provides commands for any entity derived from CoreEntity and exposes no query methods.
RepositoryBase
RepositoryBase is the full-featured repository: it implements IRepositoryBase, which combines the read and write contracts.
Conventions
1 item
Migration Guides
v10.1.0 → v10.2.0
Troubleshooting & FAQ
Common Issues
Extensions
2 items
Interceptors
1 item
🙌 Contributing & Support
Found a bug or have a suggestion?
- GitHub Issues: Report a bug or request a feature
- Discussions: Ask questions or start a discussion
Contributing
- Fork the RA.Utilities repository
- Create a branch from
mainwith a descriptive name (e.g.,feature/ef-specification-support) - Make your changes — add XML doc comments for new public APIs and tests for new behavior
- Submit a pull request to the
mainbranch with a clear description
Links
| Resource | URL |
|---|---|
| NuGet | RA.Utilities.Data.EntityFramework |
| Source | GitHub — RedonAlla/RA.Utilities |
| License | MIT |
| Build Status | GitHub Actions |