IronAlpine.Framework.Data
3.0.6
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
<PackageReference Include="IronAlpine.Framework.Data" Version="3.0.6" />
<PackageVersion Include="IronAlpine.Framework.Data" Version="3.0.6" />
<PackageReference Include="IronAlpine.Framework.Data" />
paket add IronAlpine.Framework.Data --version 3.0.6
#r "nuget: IronAlpine.Framework.Data, 3.0.6"
#:package IronAlpine.Framework.Data@3.0.6
#addin nuget:?package=IronAlpine.Framework.Data&version=3.0.6
#tool nuget:?package=IronAlpine.Framework.Data&version=3.0.6
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]):
- counts the query, then sorts and pages it;
- sort column:
request.SortByresolved throughallowedSortMap(case-insensitive) or as a top-level property name; dotted user input without a map entry is ignored; otherwisedefaultSortBy; otherwiseId; otherwise the first sortable public property; - direction from
request.SortDirection(descor ascending) - the default sort also follows it; - adds
ThenBy(Id)when the type has anIdproperty, 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):
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.SoftDeleteInterceptor— turns deletes ofISoftDeletableentities into updates (IsDeleted = true).AuditableEntityInterceptor— created/modified stamps and audit log entries.VersionInterceptorOccurredOnInterceptor
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
IClockon the first interceptor that needs it, kept perDbContextinstance for the pending change set and released after a successful save (also bydbContext.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 asOccurredOnUtc. A handler that saves anotherDbContextgets that context's own instant; the outer value is restored afterwards. - Synchronous
SaveChangesuses 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 insideDomainEventDispatchScope(IronAlpine.Data.Runtime); they must not callSaveChanges- 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)orentity.IsDeleted = true→ soft delete.IDeletionAuditableEntity.DeletedBy/DeletedOnUtcare filled,IActivatable.IsActivebecomesfalse,ModifiedBy/ModifiedOnUtcare stamped and the audit log action isEntityDeleted.entity.IsDeleted = false→ restore. Deletion audit is cleared,IsActivebecomestrue, audit actionEntityRestored.- Do not declare
HasQueryFilter(x => !x.IsDeleted)in entity configurations. A legacy anonymous filter is moved underIronAlpineQueryFilters.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,GetAllAsyncandGetListAsynctrack by default. PasstrackChanges: falsefor read-only queries.GetPagedListAsynchonorsPagedRequest.SortByand 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 byToPagedResultAsync.- Child entities derived from
Entityare not stamped unless they implementIAuditableEntity. IOccurredOnUtc.OccurredOnUtcis filled with the save instant only when it isnull/default. Projection handlers should set it to the applied integration event'sOccurredOnUtcand 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.
ResultAwarerollback also discards rows added by domain event handlers (for example outbox messages) - this is the intended atomicity.- Synchronous
SaveChangesdoes not dispatch domain events. - After a failed save the
DbContextkeeps its save instant until the next successful save orClearTracking(). Do not reuse a context whose save failed for unrelated work much later; its stamps would carry the old instant. - With
ActorPreferred+UseDefault,DefaultActorValueis mandatory, and it must be a Guid as soon as an auditable entity is saved without an authenticated user.
| 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
- IronAlpine.Framework.Kernel (>= 3.0.6)
- IronAlpine.Framework.Mediator (>= 3.0.6)
- Microsoft.EntityFrameworkCore (>= 10.0.10)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.10)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.10)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.10)
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 |
IronAlpine framework release. See the package README and repository documentation for details.