PersistenceToolkit 10.0.2
dotnet add package PersistenceToolkit --version 10.0.2
NuGet\Install-Package PersistenceToolkit -Version 10.0.2
<PackageReference Include="PersistenceToolkit" Version="10.0.2" />
<PackageVersion Include="PersistenceToolkit" Version="10.0.2" />
<PackageReference Include="PersistenceToolkit" />
paket add PersistenceToolkit --version 10.0.2
#r "nuget: PersistenceToolkit, 10.0.2"
#:package PersistenceToolkit@10.0.2
#addin nuget:?package=PersistenceToolkit&version=10.0.2
#tool nuget:?package=PersistenceToolkit&version=10.0.2
π PersistenceToolkit β DDD-Friendly Persistence Orchestration
π What is PersistenceToolkit?
PersistenceToolkit is your reusable, modular orchestration layer for safe, consistent, Domain-Driven Design (DDD)-first persistence with EF Core.
It gives you:
β
True aggregate root boundaries
β
Smart state management & tracking
β
Clean separation of read/write responsibilities
β
Soft deletes, tenant isolation, and audit logging β all automated
β
Full snapshot-based change detection
β
Navigation ignore rules for safe partial updates
β
Consistent detach after save β zero stale tracking
It turns plain EF Core into a domain-safe store.
π₯ Who Should Use This?
PersistenceToolkit is ideal for:
- β DDD Practitioners β Teams building domain-driven applications who need aggregate root enforcement
- β Multi-tenant Applications β Systems requiring automatic tenant isolation and audit logging
- β Enterprise Applications β Projects needing consistent state management, soft deletes, and change tracking
- β
Clean Architecture Teams β Developers following layered architecture who want to keep
DbContextout of application/domain layers - β EF Core Users β Teams using Entity Framework Core who want safer, more predictable persistence behavior
- β Teams with Complex Aggregates β Applications with nested entity graphs that need whole-aggregate persistence
Not ideal for:
- β Simple CRUD applications without DDD requirements
- β Projects that don't use Entity Framework Core
- β Applications that need direct
DbContextaccess in business logic
π Prerequisites
Before integrating PersistenceToolkit, ensure you have:
- .NET 6.0, .NET 8.0, or .NET 9.0
- Entity Framework Core (version 6.0.33+ for .NET 6.0, 8.0.11+ for .NET 8.0, or 9.0.7+ for .NET 9.0)
- SQL Server (or compatible database via EF Core provider)
- A layered architecture with separate Domain, Application, and Infrastructure layers
- Understanding of Domain-Driven Design (DDD) concepts, especially aggregate roots
π¦ Installation
Via NuGet
Install the required packages based on your layer:
# For Domain layer
dotnet add package PersistenceToolkit.Domain
# For Application layer
dotnet add package PersistenceToolkit.Abstractions
# For Infrastructure layer
dotnet add package PersistenceToolkit
Package References
<ItemGroup>
<PackageReference Include="PersistenceToolkit.Domain" Version="10.0.2" />
<PackageReference Include="PersistenceToolkit.Abstractions" Version="10.0.2" />
<PackageReference Include="PersistenceToolkit" Version="10.0.2" />
</ItemGroup>
π Quick Start / Integration Guide
Step 1: Implement ISystemUser
Create an implementation of ISystemUser to provide current user and tenant context:
public class SystemUser : ISystemUser
{
public int UserId { get; set; }
public int TenantId { get; set; }
}
Step 2: Create Your DbContext
Inherit from BaseContext and implement required methods:
public class MyDbContext : BaseContext
{
public MyDbContext(DbContextOptions<BaseContext> options) : base(options) { }
public MyDbContext(string connectionString) : base(connectionString) { }
public DbSet<MyAggregate> MyAggregates { get; set; }
protected override void ApplyConfiguration(ModelBuilder modelBuilder)
{
// Apply your entity configurations
modelBuilder.ApplyConfiguration(new MyAggregateConfiguration());
}
protected override void DefineManualConfiguration(ModelBuilder modelBuilder)
{
// Define any manual configurations
// Optionally mark navigation properties to ignore on update:
// modelBuilder.Entity<MyAggregate>()
// .IgnoreOnUpdate(x => x.SomeNavigationProperty);
}
}
Step 3: Define Your Domain Entities
Your entities must inherit from Entity:
public class MyAggregate : Entity, IAggregateRoot
{
public string Name { get; set; }
public virtual ICollection<ChildEntity> Children { get; set; }
}
public class ChildEntity : Entity
{
public int MyAggregateId { get; set; }
public string Title { get; set; }
}
Step 4: Register Services
In your Program.cs or Startup.cs:
using PersistenceToolkit.Persistence;
using PersistenceToolkit.Abstractions;
var builder = WebApplication.CreateBuilder(args);
// Register your DbContext
builder.Services.AddScoped<BaseContext>(serviceProvider =>
{
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
return new MyDbContext(connectionString);
});
// Register ISystemUser (implement based on your auth system)
builder.Services.AddScoped<ISystemUser>(serviceProvider =>
{
// Example: Get from current HTTP context, claims, etc.
var httpContext = serviceProvider.GetRequiredService<IHttpContextAccessor>().HttpContext;
return new SystemUser
{
UserId = GetUserIdFromContext(httpContext),
TenantId = GetTenantIdFromContext(httpContext)
};
});
// Register PersistenceToolkit repositories
builder.Services.AddPersistenceToolkit();
var app = builder.Build();
Step 5: Use Repositories in Your Application Layer
public class MyApplicationService
{
private readonly IAggregateRepository<MyAggregate> _repository;
public MyApplicationService(IAggregateRepository<MyAggregate> repository)
{
_repository = repository;
}
public async Task<MyAggregate> CreateAggregateAsync(string name)
{
var aggregate = new MyAggregate { Name = name };
await _repository.Save(aggregate);
return aggregate;
}
public async Task<MyAggregate?> GetByIdAsync(int id)
{
return await _repository.GetByIdAsync(id);
}
public async Task<PaginatedResult<MyAggregate>> SearchAsync(string searchTerm)
{
var spec = new Specification<MyAggregate>()
.Where(x => x.Name.Contains(searchTerm))
.OrderBy(x => x.Name)
.Take(10);
return await _repository.GetPaginatedAsync(spec);
}
}
π― The main goal
PersistenceToolkitβs main goal is to encapsulate all EF Core behavior behind proper DDD guardrails, so:
- Your Domain layer stays pure.
- Your Application layer depends only on clean repository contracts β never
DbContext. - Your Infrastructure handles orchestration, specs, audit logic, and tracking rules.
- Your aggregates are always saved as a whole, with no orphaned child updates.
ποΈ Project structure
| Package | What it does | Used by |
|---|---|---|
| PersistenceToolkit.Domain | Entity, IAggregateRoot, AggregateWalker |
Domain only |
| PersistenceToolkit.Abstractions | IAggregateRepository<T>, IEntityRepository<T>, IGenericRepository<T>, ISystemUser, BaseSpecification<T> |
Application |
| PersistenceToolkit.Persistence | EF Core implementations: BaseContext, EntityStateProcessor, NavigationIgnoreTracker, config extensions, repositories |
Infrastructure |
β Key features that make this DDD-safe
π’ 1οΈβ£ Aggregate root enforcement
Your IAggregateRepository<T>:
- Works only with
where T : Entity, IAggregateRoot. - Enforces that you never persist a child entity in isolation.
- You must go through the aggregate root β always.
β Compile-time safety for your domain model.
π’ 2οΈβ£ Smart EntityStateProcessor
Your orchestration brain:
- Uses
HasChange()snapshot comparison. - Walks the entire aggregate graph recursively.
- Sets
AddedorModifiedcorrectly. - Skips navigations flagged with
.IgnoreOnUpdate()viaNavigationIgnoreTracker. - Calls
EntityAuditLogSetterto applyTenantId,CreatedBy,UpdatedByon each node. - Detaches all tracked entries after
SaveChanges.
β EF Core never leaves stale tracked entities hanging in memory.
π’ 3οΈβ£ Graph traversal done right
Your AggregateWalker:
- Walks your root and all nested children.
- Runs any action you want β snapshotting, audit, state setting.
- Prevents missing nested entities when persisting.
π’ 4οΈβ£ Detect changes with snapshots
Your Entity base:
- Automatically snapshots state when loaded.
- Uses
HasChange()before update. - Your
DetectChangemethod generates a detailed diff of which properties changed, with old & new values. - Works across the entire graph.
β Makes your audit logs bulletproof.
π’ 5οΈβ£ Navigation ignore support
You can safely exclude nav properties from updates:
// In your BaseContext.DefineManualConfiguration method
protected override void DefineManualConfiguration(ModelBuilder modelBuilder)
{
modelBuilder.Entity<MyAggregate>()
.IgnoreOnUpdate(x => x.SomeNavigationProperty);
}
This ensures that when updating an aggregate, the specified navigation properties are not touched, preventing unintended side effects.
ποΈ Architecture Layers & Dependencies
PersistenceToolkit follows Clean Architecture principles with clear layer separation:
graph TB
subgraph Domain["ποΈ Domain Layer"]
Entity["Entity<br/>(base class)"]
IAggregateRoot["IAggregateRoot<br/>(marker interface)"]
AggregateWalker["AggregateWalker"]
DomainNote["Depends on: Nothing"]
end
subgraph Application["π Application Layer"]
IAggregateRepo["IAggregateRepository<T>"]
IEntityReadRepo["IEntityReadRepository<T>"]
IGenericReadRepo["IGenericReadRepository<T>"]
ISpecification["ISpecification<T>"]
ApplicationNote["Depends on: Domain"]
end
subgraph Infrastructure["π§ Infrastructure Layer"]
BaseContext["BaseContext<br/>(your DbContext)"]
AggregateRepo["AggregateRepository<T>"]
EntityRepo["EntityRepository<T>"]
EntityStateProcessor["EntityStateProcessor"]
InfrastructureNote["Depends on: Domain, Abstractions"]
end
Domain --> Application
Application --> Infrastructure
style Domain fill:#e1f5e1,stroke:#4caf50,stroke-width:2px
style Application fill:#e3f2fd,stroke:#2196f3,stroke-width:2px
style Infrastructure fill:#fff3e0,stroke:#ff9800,stroke-width:2px
Key Rules:
- β Domain has zero dependencies on persistence
- β Application depends only on abstractions, never implementations
- β Infrastructure implements all persistence concerns
- β
Your business logic never references
DbContextdirectly
π Repository Types
PersistenceToolkit provides three repository interfaces for different use cases:
1. IAggregateRepository<T> β For Aggregate Roots
Use when: You need to save/update/delete aggregate roots with their entire graph.
public interface IAggregateRepository<T> where T : Entity, IAggregateRoot
{
// Read operations
Task<T?> GetByIdAsync(int id);
Task<PaginatedResult<T>> GetPaginatedAsync(ISpecification<T> spec);
Task<List<T>> GetListAsync(ISpecification<T> spec);
// Write operations
Task<bool> Save(T entity);
Task<bool> SaveRange(IEnumerable<T> entities);
Task<bool> DeleteAsync(T entity);
Task<bool> DeleteRangeAsync(IEnumerable<T> entities);
}
2. IEntityReadRepository<T> β For Read-Only Entity Access
Use when: You only need to read entities (not aggregates).
public interface IEntityReadRepository<T> where T : Entity
{
Task<T?> GetByIdAsync(int id);
Task<PaginatedResult<T>> GetPaginatedAsync(ISpecification<T> spec);
Task<List<T>> GetListAsync(ISpecification<T> spec);
}
3. IGenericReadRepository<T> β For Generic Queries
Use when: You need flexible querying with specifications.
public interface IGenericReadRepository<T>
{
Task<PaginatedResult<T>> GetPaginatedAsync(ISpecification<T> spec);
Task<List<T>> GetListAsync(ISpecification<T> spec);
Task<T?> FirstOrDefaultAsync(ISpecification<T> spec);
Task<bool> AnyAsync(ISpecification<T> spec);
Task<int> CountAsync(ISpecification<T> spec);
}
π‘ Common Use Cases
Use Case 1: Creating a New Aggregate
var order = new Order
{
CustomerId = 1,
Items = new List<OrderItem>
{
new OrderItem { ProductId = 1, Quantity = 2 },
new OrderItem { ProductId = 2, Quantity = 1 }
}
};
await _orderRepository.Save(order);
// β
Entire aggregate (Order + OrderItems) saved atomically
// β
Audit fields (CreatedBy, TenantId) set automatically
Use Case 2: Updating an Aggregate
var order = await _orderRepository.GetByIdAsync(orderId);
order.AddItem(new OrderItem { ProductId = 3, Quantity = 1 });
order.UpdateStatus(OrderStatus.Confirmed);
await _orderRepository.Save(order);
// β
Only changed entities marked as Modified (via snapshot comparison)
// β
UpdatedBy and UpdatedOn set automatically
// β
All tracked entities detached after save
Use Case 3: Complex Queries with Specifications
var spec = new Specification<Order>()
.Where(o => o.Status == OrderStatus.Pending)
.Where(o => o.CreatedOn >= DateTime.UtcNow.AddDays(-7))
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.OrderByDescending(o => o.CreatedOn)
.Take(20);
var results = await _orderRepository.GetPaginatedAsync(spec);
Use Case 4: Soft Delete
var order = await _orderRepository.GetByIdAsync(orderId);
order.MarkAsDeleted(currentUserId, DateTime.UtcNow);
await _orderRepository.Save(order);
// β
IsDeleted, DeletedBy, DeletedOn set automatically
π§ Configuration Examples
Entity Configuration with Navigation Ignore
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.OrderNumber).IsRequired();
// Mark navigation to ignore during updates
builder.IgnoreOnUpdate(o => o.Customer); // Customer won't be updated when Order is saved
}
}
Multi-tenant Context Setup
// In your ISystemUser implementation
public class SystemUser : ISystemUser
{
public int UserId { get; set; }
public int TenantId { get; set; }
// Example: Extract from JWT claims or session
public static SystemUser FromHttpContext(HttpContext context)
{
var userId = int.Parse(context.User.FindFirst("UserId")?.Value ?? "0");
var tenantId = int.Parse(context.User.FindFirst("TenantId")?.Value ?? "0");
return new SystemUser { UserId = userId, TenantId = tenantId };
}
}
π Credits & Acknowledgments
PersistenceToolkit uses and extends the Specification Pattern implementation inspired by Ardalis.Specification by Steve Smith (Ardalis).
The specification pattern implementation in PersistenceToolkit is heavily inspired by Ardalis's excellent work on creating a robust, flexible specification pattern for .NET applications. While PersistenceToolkit has been adapted and extended to fit our DDD-first persistence orchestration needs, the core concepts and design patterns owe much to Ardalis's original implementation.
Thank you, Ardalis, for your contributions to the .NET community!
π License
This project is licensed under the MIT License - see the LICENSE file for details.
π Additional Resources
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net6.0 is compatible. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. 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. |
-
net6.0
- Microsoft.EntityFrameworkCore (>= 6.0.33)
- Microsoft.EntityFrameworkCore.SqlServer (>= 6.0.33)
- PersistenceToolkit.Abstractions (>= 10.0.2)
-
net8.0
- Microsoft.EntityFrameworkCore (>= 8.0.11)
- Microsoft.EntityFrameworkCore.SqlServer (>= 8.0.11)
- PersistenceToolkit.Abstractions (>= 10.0.2)
-
net9.0
- Microsoft.EntityFrameworkCore (>= 9.0.7)
- Microsoft.EntityFrameworkCore.SqlServer (>= 9.0.7)
- PersistenceToolkit.Abstractions (>= 10.0.2)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|