Skip to main content

RA.Utilities.Data.EntityFramework

NuGet version NuGet Downloads Codecov Publish NuGet GitHub license

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>, and RepositoryBase<T, TKey> cover queries and commands without per-entity boilerplate.
  • CQS by construction — inject IReadRepositoryBase<T, TKey> where only queries are allowed and IWriteRepositoryBase<T, TKey> where only commands are allowed.
  • No-tracking reads — ListAsync calls AsNoTracking(), 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 — BaseEntitySaveChangesInterceptor maintains CreatedAt and LastModifiedAt on 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 — ListAsync supports filtering, ordering, skip/take pagination, and Include for navigation properties
  • Mark-then-save deletes — deletes participate in the same SaveChanges transaction as the rest of the unit of work
  • EF Core value converters — EmailConverter, CurrencyConverter, SsnConverter, IbanConverter, and MoneyConverter
  • Two-column Money mapping — HasMoney() stores a Money as a decimal Amount plus a string Currency column
  • 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
Version 10.2.0

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.Abstractions and RA.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​

TypeNamespaceRole
ReadRepositoryBase<T, TKey>RA.Utilities.Data.EntityFrameworkRead-only queries (GetByIdAsync, ListAsync) with AsNoTracking()
WriteRepositoryBase<T, TKey>RA.Utilities.Data.EntityFrameworkCommands (Add, Update, Delete, SaveChanges)
RepositoryBase<T, TKey>RA.Utilities.Data.EntityFrameworkFull CRUD: inherits reads, composes writes
ServiceCollectionExtensions…Data.EntityFramework.ExtensionsAddReadRepositoryBase(), AddWriteRepositoryBase(), AddRepositoryBase()
MoneyConventionExtensions…Data.EntityFramework.ExtensionsHasMoney() — two-column Money mapping
Value object converters…Data.EntityFramework.ConventionsEmailConverter, CurrencyConverter, SsnConverter, IbanConverter, MoneyConverter
BaseEntitySaveChangesInterceptor…Data.EntityFramework.InterceptorsSets 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:

ServiceLifetimeRegistered by
DbContext / AppDbContextScopedAddDbContext(...)
IReadRepositoryBase<,>ScopedAddReadRepositoryBase()
IWriteRepositoryBase<,>ScopedAddWriteRepositoryBase()
IRepositoryBase<,>ScopedAddRepositoryBase()

Register the interceptor as scoped as well when it depends on scoped services.

Value object persistence​

Value objectConverterColumn
EmailEmailConverterNormalized string, e.g. user@example.com
CurrencyCurrencyConverterISO code, e.g. EUR
SSNSsnConverterNormalized string
IBANIbanConverterNormalized IBAN, e.g. GB82WEST12345698765432
MoneyMoneyConverterJSON object in one string column
MoneyHasMoney()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 DbContext change tracker. SaveChangesAsync commits 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 SaveChangesAsync is called.
  • No-tracking query: An EF Core query that does not add results to the change tracker. ListAsync uses it; GetByIdAsync uses FindAsync, 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-column Money storage.

📖 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:

SettingTypeDefaultWhere
DbContext lifetimeServiceLifetimeScopedAddDbContext<…>(…, ServiceLifetime.Scoped)
Repository lifetimeServiceLifetimeScoped (fixed)Add*RepositoryBase()
Interceptor lifetimeServiceLifetime— (you choose)AddScoped<BaseEntitySaveChangesInterceptor>()
Database providerDbContextOptionsBuilder— (required)options.UseSqlServer/UseSqlite/UseNpgsql(…)
Connection stringstring—builder.Configuration.GetConnectionString(…)
Value object mappingHasConversion(…) / HasMoney(…)No converter is applied automaticallyOnModelCreating / IEntityTypeConfiguration<T>
Amount column typestringProvider default for decimal (decimal(18,2) on SQL Server, TEXT on SQLite).Property(m => m.Amount).HasColumnType(…)
Money column namesstring{Property}_Amount, {Property}_Currency.HasMoney(…).Property(…)
Converters are opt-in

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​


🙌 Contributing & Support​

Found a bug or have a suggestion?​

Contributing​

  1. Fork the RA.Utilities repository
  2. Create a branch from main with a descriptive name (e.g., feature/ef-specification-support)
  3. Make your changes — add XML doc comments for new public APIs and tests for new behavior
  4. Submit a pull request to the main branch with a clear description
ResourceURL
NuGetRA.Utilities.Data.EntityFramework
SourceGitHub — RedonAlla/RA.Utilities
LicenseMIT
Build StatusGitHub Actions