IronAlpine.Framework.Data 3.0.6

There is a newer version of this package available.
See the version list below for details.
dotnet add package IronAlpine.Framework.Data --version 3.0.6
                    
NuGet\Install-Package IronAlpine.Framework.Data -Version 3.0.6
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="IronAlpine.Framework.Data" Version="3.0.6" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="IronAlpine.Framework.Data" Version="3.0.6" />
                    
Directory.Packages.props
<PackageReference Include="IronAlpine.Framework.Data" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add IronAlpine.Framework.Data --version 3.0.6
                    
#r "nuget: IronAlpine.Framework.Data, 3.0.6"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package IronAlpine.Framework.Data@3.0.6
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=IronAlpine.Framework.Data&version=3.0.6
                    
Install as a Cake Addin
#tool nuget:?package=IronAlpine.Framework.Data&version=3.0.6
                    
Install as a Cake Tool

IronAlpine.Framework.Data

EF Core data runtime for IronAlpine services: generic repositories, unit of work, SaveChanges interceptors (audit, soft delete, versioning, occurred-on, domain event dispatch), model conventions, paging/sorting, smart enum conversion, and the auditing / transaction mediator behaviours.

  • Package id: IronAlpine.Framework.Data (3.0.6, net10.0, EF Core 10)
  • Depends on: Kernel, Mediator, Microsoft.EntityFrameworkCore(.Relational)
  • The EF provider (SQL Server, PostgreSQL, ...) is added by the consuming service.

Installation

<PackageReference Include="IronAlpine.Framework.Data" Version="3.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.*" />

Registration

using IronAlpine.Data;

services.AddIronAlpineData<OrdersDbContext>(configuration, cfg =>
{
    cfg.ConfigureEfCore(options =>
    {
        options.Auditing.DefaultActorValue = "00000000-0000-0000-0000-000000000001"; // system actor
    });

    cfg.ConfigureDbContext((sp, options) =>
        options.UseSqlServer(
            configuration.GetConnectionString("OrdersDb"),
            sql => sql.MigrationsAssembly(typeof(OrdersDbContext).Assembly.FullName)));
});

AddIronAlpineData<TDbContext> is the single public entry point. It registers:

Service Lifetime / notes
TDbContext (AddDbContext) and IDbContextFactory<TDbContext> (scoped) only when ConfigureDbContext is called; the IronAlpine model customizer and interceptors are added to the options
DbContext scoped alias for TDbContext (used by behaviours, stores and repositories)
IReadRepository<T>, IWriteRepository<T>, IRepository<T> (+ <T, TId>) scoped, open generic
IAggregateReadRepository<T>, IAggregateWriteRepository<T>, IAggregateRepository<T> (+ <T, TId>) scoped, T : IAggregateRoot
IProjectionReadRepository<T>, IProjectionWriteRepository<T>, IProjectionRepository<T> (+ <T, TId>) scoped, T : IProjectionEntity
IUnitOfWork → EfUnitOfWork<TDbContext> scoped
IClock → SystemClock singleton (TryAdd)
IAuditUserIdConverter (from options), IAuditLogWriter scoped
interceptors: domain events, soft delete, auditable, version, occurred-on scoped
model contributors: soft delete filter, concurrency tokens, version defaults, AuditLogs table singleton

All repository and unit of work registrations use TryAdd; register your own implementation first to replace one.

IronAlpineDataConfiguration<TDbContext>:

Method Purpose
ConfigureDbContext(Action<IServiceProvider, DbContextOptionsBuilder>) / ConfigureDbContext(Action<DbContextOptionsBuilder>) provider, connection string, migrations assembly, retry strategy
ConfigureEfCore(Action<DataEfCoreOptions>) auditing options (applied after configuration binding)

Configuration

Section IronAlpine:Data:EFCore (DataEfCoreOptions, validated on start):

{
  "IronAlpine": {
    "Data": {
      "EFCore": {
        "Auditing": {
          "Mode": "ActorPreferred",
          "MissingActorBehavior": "UseDefault",
          "DefaultActorValue": "00000000-0000-0000-0000-000000000001"
        }
      }
    }
  }
}
Option Default Meaning
Auditing.Mode ActorPreferred Disabled (no stamping), TimestampOnly (dates only), ActorPreferred (dates + user when known), ActorRequired (throw without a user; requires a registered IAuditUser)
Auditing.MissingActorBehavior UseDefault when no authenticated IAuditUser: UseDefault (use DefaultActorValue), SkipUserFields, Throw
Auditing.DefaultActorValue null required with ActorPreferred + UseDefault (start-up validation fails otherwise). Must be a Guid when audit log rows are written
Auditing.UserIdTargetType typeof(Guid) type the raw user id must convert to (code only)
Auditing.UserIdConverterType DefaultAuditUserIdConverter IAuditUserIdConverter implementation (string, Guid, int, long, Convert.ChangeType) (code only)

The current actor comes from IAuditUser (IronAlpine.Kernel.Primitives). IronAlpine.Framework.Security registers an HTTP implementation; background jobs and message handlers have no authenticated user and fall back to MissingActorBehavior.

SQL Server connection strings with MultipleActiveResultSets=true are rejected when the DbContext is configured (savepoint-based rollback semantics of the transaction behaviour).

Entities and marker interfaces

Implement the Kernel marker interfaces; the interceptors do the rest.

public sealed class Invoice : AggregateRoot, ISoftDeletable, IDeletionAuditableEntity, IActivatable, IConcurrencyTracked
{
    public bool IsDeleted { get; private set; }
    public Guid? DeletedBy { get; private set; }
    public DateTime? DeletedOnUtc { get; private set; }
    public bool IsActive { get; private set; } = true;
    public byte[] RowVersion { get; private set; } = [];
}
Interface Behaviour
IAuditableEntity (AggregateRoot implements it) CreatedOnUtc (+ CreatedBy) on insert, ModifiedOnUtc (+ ModifiedBy) on update, both with the save instant
ISoftDeletable see Soft delete
IDeletionAuditableEntity DeletedOnUtc / DeletedBy on soft delete, cleared on restore
IActivatable IsActive false on soft delete, true on restore
IConcurrencyTracked RowVersion configured IsRowVersion().IsConcurrencyToken()
IVersioned Version default 1, set to 1 on insert when <= 0, +1 on every update
IOccurredOnUtc (ProjectionEntity implements it) OccurredOnUtc = the save instant on insert or update only when the value is null/default; a value you set is kept
IHasDomainEvents pending domain events dispatched by SaveChangesAsync

Do not assign these fields in handlers; any value you set is overwritten. The exception is IOccurredOnUtc: the interceptor only fills a missing value, so projection handlers can (and should) store the applied event's occurrence time there.

Repositories

public sealed class GetInvoiceQueryHandler(IReadRepository<Invoice> invoices)
    : IRequestHandler<GetInvoiceQuery, Result<InvoiceDto>>
{
    public async Task<Result<InvoiceDto>> Handle(GetInvoiceQuery request, CancellationToken ct)
    {
        var dto = await invoices.GetWhereQuery(x => x.Id == request.Id, trackChanges: false)
            .Select(x => new InvoiceDto(x.Id, x.Number, x.Total))
            .FirstOrDefaultAsync(ct);

        return dto is null ? Result.Failure<InvoiceDto>(InvoiceErrors.NotFound) : Result.Success(dto);
    }
}

IReadRepository<TEntity, TId>:

Method Notes
GetByIdAsync(id) DbSet.FindAsync (tracked; returns an already tracked instance without a query)
GetByIdAsync(id, queryBuilder) Where(Id == id) + queryBuilder (includes, IncludeDeleted(), ...), tracked
GetFirstOrDefaultAsync(predicate[, queryBuilder]) tracked
GetListAsync(), GetListAsync(predicate[, queryBuilder]), GetListAsync(ISpecification<T>) tracked
GetAllAsync(queryBuilder, trackChanges = true), GetAllAsync(predicate, queryBuilder, trackChanges = true) tracked by default
GetAllQuery(trackChanges = true), GetWhereQuery(predicate[, queryBuilder], trackChanges = true) IQueryable, tracked by default
AnyAsync(predicate), GetCountAsync(predicate?)
GetPagedListAsync(page, pageSize, predicate?, queryBuilder?), GetPagedListAsync(PagedRequest, ...) no tracking; a valid PagedRequest.SortBy (scalar property name) / SortDirection is applied after queryBuilder with Id as tie-breaker; otherwise an order set in queryBuilder is kept, else rows are ordered by Id

IWriteRepository<TEntity, TId>: Add, AddRange, Update (sets the entry Modified), UpdateRange, Remove, RemoveRange. Changes are written by IUnitOfWork.SaveChangesAsync.

Custom repositories derive from the public base classes (EfRepository<T, TId>, EfReadRepository<T, TId>, EfAggregateRepository<T, TId>, EfProjectionRepository<T, TId>, ...; the DbContext and DbSet are available as Context / DbSet, and every method is virtual):

public interface IInvoiceRepository : IAggregateRepository<Invoice>
{
    Task<Invoice?> GetByNumberAsync(string number, CancellationToken ct);
}

public sealed class InvoiceRepository(OrdersDbContext context)
    : EfAggregateRepository<Invoice, Guid>(context), IInvoiceRepository
{
    public Task<Invoice?> GetByNumberAsync(string number, CancellationToken ct)
        => DbSet.FirstOrDefaultAsync(x => x.Number == number, ct);
}

Paging and sorting

using IronAlpine.Data.Extensions;

var sortMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
    ["customer"] = "Customer.Name",   // nested paths only through the map
    ["total"] = "Total"
};

PagedResult<InvoiceListItem> page = await invoices.GetAllQuery(trackChanges: false)
    .Select(x => new InvoiceListItem { Id = x.Id, Total = x.Total, Customer = new() { Name = x.Customer.Name } })
    .ToPagedResultAsync(request, defaultSortBy: "Total", allowedSortMap: sortMap, ct);

ToPagedResultAsync(PagedRequest, defaultSortBy[, allowedSortMap]):

  1. counts the query, then sorts and pages it;
  2. sort column: request.SortBy resolved through allowedSortMap (case-insensitive) or as a top-level property name; dotted user input without a map entry is ignored; otherwise defaultSortBy; otherwise Id; otherwise the first sortable public property;
  3. direction from request.SortDirection (desc or ascending) - the default sort also follows it;
  4. adds ThenBy(Id) when the type has an Id property, for stable pages.

Sorting builds member-access expressions; project to a class with settable properties or an anonymous type before paging. A positional record projection (new Dto(a, b)) followed by a dynamic OrderBy cannot be translated by EF Core.

Unit of work

await unitOfWork.SaveChangesAsync(ct);            // EF SaveChangesAsync through all interceptors
await unitOfWork.SaveChangesResilientAsync(ct);   // inside the provider execution strategy + exception mapping

SaveChangesResilientAsync (IronAlpine.Data.Extensions) runs the save inside Database.CreateExecutionStrategy() and maps failures:

EF / SQL failure Thrown
DbUpdateConcurrencyException PersistenceException, code Persistence.ConcurrencyConflict
SQL error 2627 / 2601 PersistenceException, code Persistence.UniqueConstraintViolation
transient SQL errors (-2, 20, 64, 233, 1205, 4060, 10053, 10054, 10060, 10928, 10929, 40197, 40501, 40613) TransientException
other DbUpdateException PersistenceException, code Persistence.DbUpdateFailed

It does not open a transaction; inside TransactionBehavior it joins the existing one. dbContext.ClearTracking() clears the change tracker.

There is no unit-of-work behaviour: handlers call SaveChangesAsync themselves, once, after all mutations.

Mediator behaviours

using IronAlpine.Data;

services.AddIronAlpineMediator(cfg =>
{
    cfg.RegisterServicesFromAssembly(applicationAssembly);
    cfg.UseAuditingBehavior();
    cfg.UseResultAwareTransactionBehavior();     // or UseTransactionBehavior(TransactionMode.Auto) / UseClassicTransactionBehavior()
});

TransactionBehavior<TRequest, TResponse> wraps every request that reaches it (queries included) in Database.BeginTransactionAsync inside the execution strategy. If a transaction is already open it just calls the handler. Modes (DataEfCoreMediatorOptions.Transaction.Mode):

Mode Commit rule
Auto (default of UseTransactionBehavior()) ResultAware when the response is a Result, else Classic
ResultAware rolls back when the handler returns a failed Result (even after SaveChangesAsync), commits otherwise
Classic commits unless an exception is thrown

To keep queries out of the transaction, write your own behaviour constrained to a command marker interface and register it instead.

AuditingBehavior<TRequest, TResponse> (requires TRequest : IAuditableCommand and TResponse : Result): when an authenticated IAuditUser with a Guid id exists, adds an AuditLog row (ActionType = request type name, EntityName = type name without Command, EntityId = GetAuditableEntityId(), ChangesJson = serialized request). The row is written by the handler's SaveChangesAsync.

public sealed record CancelInvoiceCommand(Guid InvoiceId) : IRequest<Result>, IAuditableCommand
{
    public Guid GetAuditableEntityId() => InvoiceId;
}

SaveChanges pipeline

Built-in interceptors implement IOrderedInterceptor and run in this order (IronAlpineInterceptorOrder):

  1. DispatchDomainEventsInterceptor — publishes pending domain events of tracked aggregates before the write, in the same transaction, and clears them. Changes made by event handlers are written by the same save.
  2. SoftDeleteInterceptor — turns deletes of ISoftDeletable entities into updates (IsDeleted = true).
  3. AuditableEntityInterceptor — created/modified stamps and audit log entries.
  4. VersionInterceptor
  5. OccurredOnInterceptor

Custom interceptors without IOrderedInterceptor run after the built-ins. Auditing, soft delete and versioning also run for synchronous SaveChanges; domain events are dispatched only by SaveChangesAsync.

Save instant

Every time written by one save is the same DateTime (since 3.0.6): CreatedOnUtc/ModifiedOnUtc, DeletedOnUtc, a filled IOccurredOnUtc, AuditLog.Timestamp and the OccurredOnUtc of every integration event created while the save dispatches domain events (IntegrationEventBase, EventBus package).

  • The instant is read once from IClock on the first interceptor that needs it, kept per DbContext instance for the pending change set and released after a successful save (also by dbContext.ClearTracking()).
  • A failed save keeps it. When the save is retried (for example by SaveChangesResilientAsync / an execution strategy) the domain events are not dispatched again and the outbox rows already carry the old instant, so the audit stamps are written with the same value again.
  • During domain event dispatch it is exposed as the ambient SaveInstantScope.Current (IronAlpine.Kernel.Time); integration events created there use it as OccurredOnUtc. A handler that saves another DbContext gets that context's own instant; the outer value is restored afterwards.
  • Synchronous SaveChanges uses the same instant for its stamps (it does not dispatch domain events).

Why it matters: an aggregate's ModifiedOnUtc ?? CreatedOnUtc equals the OccurredOnUtc of the full-state event its last save published. A consumer that stores the applied event's OccurredOnUtc on its projection can compare the two values exactly to tell whether the projection is in sync with the source (up to 3.0.5 the event time was taken a few milliseconds before the audit stamp, so the values never matched). For this to hold, create the full-state integration event in a domain event handler (inside SaveChanges), not before or after the save; an explicit SetOccurredOnUtc always wins over the save instant.

Domain events

var invoice = Invoice.Create(customerId);   // AddDomainEvent(new InvoiceCreatedDomainEvent(...))
invoices.Add(invoice);
await unitOfWork.SaveChangesAsync(ct);      // handlers of InvoiceCreatedDomainEvent run here
  • Only events of tracked entities are dispatched.
  • Handlers (INotificationHandler<TEvent>) run inside DomainEventDispatchScope (IronAlpine.Data.Runtime); they must not call SaveChanges - their changes are saved by the outer save. An exception in a handler aborts the save.
  • Do not publish GetDomainEvents() manually after saving: the list has already been cleared.

Audit log

When at least one IAuditableEntity changes and an actor id is resolved, the auditable interceptor adds one AuditLog row per changed entity (table AuditLogs): UserId, ActionType (EntityCreated, EntityUpdated, EntityDeleted, EntityRestored), EntityName, EntityId (Guid ids only), Timestamp, and ChangesJson (State, Entity, changed properties with original/current values). Write additional rows with IAuditLogWriter.

Soft delete

Entities implementing ISoftDeletable are hidden from every query by the named query filter IronAlpineQueryFilters.SoftDelete. Opt in explicitly:

using IronAlpine.Data.QueryFilters;

query.IncludeDeleted();   // active + soft-deleted rows (only the soft delete filter is disabled)
query.OnlyDeleted();      // soft-deleted rows only
  • repository.Remove(entity) or entity.IsDeleted = true → soft delete. IDeletionAuditableEntity.DeletedBy / DeletedOnUtc are filled, IActivatable.IsActive becomes false, ModifiedBy/ModifiedOnUtc are stamped and the audit log action is EntityDeleted.
  • entity.IsDeleted = false → restore. Deletion audit is cleared, IsActive becomes true, audit action EntityRestored.
  • Do not declare HasQueryFilter(x => !x.IsDeleted) in entity configurations. A legacy anonymous filter is moved under IronAlpineQueryFilters.Application; IncludeDeleted() does not disable it.
  • Filters are declared on hierarchy roots; derived types inherit them. Unique indexes must account for soft-deleted rows (use a filtered index or check IncludeDeleted() before inserting).

Smart enum conversion

using IronAlpine.Data.Extensions;

builder.Property(x => x.Status).HasEnumerationConversion();               // Enumeration<T> -> int (Id)
builder.Property(x => x.Priority).HasNullableEnumerationConversion();     // nullable
builder.Property(x => x.Status).HasConversion(new EnumerationConverter<InvoiceStatus>()); // converter only

HasEnumerationConversion also sets a value comparer based on Id.

Model conventions and extension points

The Data package replaces EF's IModelCustomizer; after OnModelCreating it runs every registered IModelContributor (IronAlpine.Data.Modeling). Other IronAlpine packages add their tables this way (cache settings, outbox/inbox, permission projections). Add your own conventions:

public sealed class DecimalPrecisionContributor : IModelContributor
{
    public void Apply(ModelBuilder modelBuilder)
    {
        foreach (var property in modelBuilder.Model.GetEntityTypes().SelectMany(t => t.GetProperties())
                     .Where(p => p.ClrType == typeof(decimal)))
        {
            property.SetPrecision(18);
            property.SetScale(2);
        }
    }
}

services.TryAddEnumerable(ServiceDescriptor.Singleton<IModelContributor, DecimalPrecisionContributor>());

The same conventions are available manually: modelBuilder.ApplySoftDeleteQueryFilters(), ApplyConcurrencyTokens(), ApplyVersionDefaults().

Custom SaveChanges interceptors: register them as IInterceptor (scoped) and implement IOrderedInterceptor to control their position (lower Order runs first; built-ins use -500..-100, unordered interceptors use 0).

Design-time DbContext factories must build the same service collection (including the packages that contribute tables) so migrations contain every table.

Pitfalls

  • GetWhereQuery, GetAllQuery, GetAllAsync and GetListAsync track by default. Pass trackChanges: false for read-only queries.
  • GetPagedListAsync honors PagedRequest.SortBy and always pages over a deterministic order since 3.0.5 (up to 3.0.4 it applied no order at all). Nested paths and alias maps are only supported by ToPagedResultAsync.
  • Child entities derived from Entity are not stamped unless they implement IAuditableEntity.
  • IOccurredOnUtc.OccurredOnUtc is filled with the save instant only when it is null/default. Projection handlers should set it to the applied integration event's OccurredOnUtc and use it as a stale-event guard (if (existing.OccurredOnUtc >= e.OccurredOnUtc) return;). Up to 3.0.4 the interceptor overwrote it on every save, which made that guard compare against save times and drop delayed-but-newer events.
  • The transaction behaviour opens a transaction for queries too when it is registered for all requests.
  • ResultAware rollback also discards rows added by domain event handlers (for example outbox messages) - this is the intended atomicity.
  • Synchronous SaveChanges does not dispatch domain events.
  • After a failed save the DbContext keeps its save instant until the next successful save or ClearTracking(). Do not reuse a context whose save failed for unrelated work much later; its stamps would carry the old instant.
  • With ActorPreferred + UseDefault, DefaultActorValue is mandatory, and it must be a Guid as soon as an auditable entity is saved without an authenticated user.
Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (3)

Showing the top 3 NuGet packages that depend on IronAlpine.Framework.Data:

Package Downloads
IronAlpine.Framework.EventBus

Kafka-backed event bus with outbox/inbox pattern, dead letter support, and replay functionality. Unified namespace: IronAlpine.EventBus. Kafka implementation is internal; only contracts and topology builder are public. SSL cert parameters fully configurable (SslCaLocation, SslCertLocation, SslKeyLocation, SslKeyPassword).

IronAlpine.Framework.Security

JWT authentication, claim-based authorization, and EFCore permission store for IronAlpine microservices. Provides ICurrentUser, permission policy provider, security builder, and public authorization store contracts.

IronAlpine.Framework.Caching

Distributed caching for IronAlpine: mediator caching behavior, cache contracts/DTOs, Redis-backed cache services, and EF Core-backed cache setting management. Works with IronAlpine.Mediator and IronAlpine.Data.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.1.1 215 10/21/2026
3.0.9 240 10/15/2026
3.0.8 140 10/13/2026
3.0.7 126 10/13/2026
3.0.6 131 10/9/2026
3.0.5 209 10/5/2026
3.0.4 227 10/2/2026
3.0.3 194 9/30/2026
3.0.2 1,033 7/22/2026
3.0.1 159 7/22/2026
3.0.0 370 7/20/2026
2.0.3 171 7/20/2026
2.0.2 196 7/13/2026
2.0.1 197 7/13/2026
2.0.0 191 7/13/2026
2.0.0-preview.2 90 7/13/2026
2.0.0-preview.1 93 7/6/2026
1.0.2 162 7/3/2026
1.0.1 163 7/3/2026
Loading failed

IronAlpine framework release. See the package README and repository documentation for details.