ULID ID generation extension for FS.EntityFramework.Library with full Domain-Driven Design (DDD) support. Provides chronologically sortable, human-readable unique identifiers perfect for enterprise microservice architectures, Aggregate Roots, and Domain Entities. Includes automatic generation, Entity Framework optimizations, and comprehensive DDD integration.
$ dotnet add package FS.EntityFramework.Library.UlidGeneratorA comprehensive, production-ready Entity Framework Core library providing Repository pattern, Unit of Work, Specification pattern, dynamic filtering, pagination support, Domain Events, Domain-Driven Design (DDD), Fluent Configuration API, and modular ID generation strategies for .NET applications.
This library transforms Entity Framework Core into a powerful, enterprise-ready data access layer that follows best practices and design patterns. Whether you're building a simple application or a complex domain-rich system, this library provides the tools you need to create maintainable, testable, and scalable data access code.
Get started with FS.EntityFramework.Library in just 5 steps:
dotnet add package FS.EntityFramework.Library
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
public DbSet<Product> Products { get; set; }
public DbSet<Category> Categories { get; set; }
}
// In Program.cs or Startup.cs
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
// Add FS.EntityFramework services
services.AddFSEntityFramework<ApplicationDbContext>()
.Build();
public class Product : BaseAuditableEntity<int>
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string Description { get; set; } = string.Empty;
}
public class ProductService
{
private readonly IUnitOfWork _unitOfWork;
public ProductService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<Product> CreateProductAsync(string name, decimal price)
{
var repository = _unitOfWork.GetRepository<Product, int>();
var product = new Product { Name = name, Price = price };
await repository.AddAsync(product);
await _unitOfWork.SaveChangesAsync();
return product;
}
}
# Core library with all essential features including DDD
dotnet add package FS.EntityFramework.Library
# GUID Version 7 ID generation (.NET 10+)
dotnet add package FS.EntityFramework.Library.GuidV7
# ULID ID generation
dotnet add package FS.EntityFramework.Library.UlidGenerator
Let's build a complete example from scratch, implementing all the major features of the library.
First, create a new project and organize it following clean architecture principles:
YourProject/
├── Models/ # Entity models
├── Services/ # Business logic
├── Repositories/ # Custom repositories (if needed)
└── Configuration/ # Database configuration
dotnet new webapi -n YourProject
cd YourProject
dotnet add package FS.EntityFramework.Library
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
Understanding the entity hierarchy is crucial. The library provides several base entity classes:
// Models/Category.cs
using FS.EntityFramework.Library.Common;
/// <summary>
/// Simple entity with just ID and domain events support
/// </summary>
public class Category : BaseEntity<int>
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
// Navigation property
public virtual ICollection<Product> Products { get; set; } = new List<Product>();
}
// Models/Product.cs
using FS.EntityFramework.Library.Common;
/// <summary>
/// Auditable entity with creation and modification tracking
/// </summary>
public class Product : BaseAuditableEntity<int>, ISoftDelete
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string Description { get; set; } = string.Empty;
public int CategoryId { get; set; }
// Navigation property
public virtual Category Category { get; set; } = null!;
// ISoftDelete properties (automatically implemented)
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
// Business method with domain events
public void UpdatePrice(decimal newPrice)
{
if (newPrice <= 0)
throw new ArgumentException("Price must be positive", nameof(newPrice));
var oldPrice = Price;
Price = newPrice;
// Raise domain event
AddDomainEvent(new ProductPriceChangedEvent(Id, oldPrice, newPrice));
}
}
Domain events enable loose coupling between different parts of your application:
// Models/Events/ProductPriceChangedEvent.cs
using FS.EntityFramework.Library.Common;
public class ProductPriceChangedEvent : DomainEvent
{
public ProductPriceChangedEvent(int productId, decimal oldPrice, decimal newPrice)
{
ProductId = productId;
OldPrice = oldPrice;
NewPrice = newPrice;
}
public int ProductId { get; }
public decimal OldPrice { get; }
public decimal NewPrice { get; }
}
// Services/EventHandlers/ProductPriceChangedEventHandler.cs
using FS.EntityFramework.Library.Events;
public class ProductPriceChangedEventHandler : IDomainEventHandler<ProductPriceChangedEvent>
{
private readonly ILogger<ProductPriceChangedEventHandler> _logger;
public ProductPriceChangedEventHandler(ILogger<ProductPriceChangedEventHandler> logger)
{
_logger = logger;
}
public async Task Handle(ProductPriceChangedEvent domainEvent, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Product {ProductId} price changed from {OldPrice} to {NewPrice}",
domainEvent.ProductId, domainEvent.OldPrice, domainEvent.NewPrice);
// Add your business logic here:
// - Send price change notification emails
// - Update related data
// - Trigger other business processes
await Task.CompletedTask;
}
}
You have two options for DbContext configuration:
// Data/ApplicationDbContext.cs
using FS.EntityFramework.Library.Common;
public class ApplicationDbContext : FSDbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, IServiceProvider serviceProvider)
: base(options, serviceProvider)
{
// FSDbContext automatically applies all FS.EntityFramework configurations
}
public DbSet<Product> Products { get; set; } = null!;
public DbSet<Category> Categories { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder); // This applies FS configurations
// Add your custom configurations here
modelBuilder.Entity<Product>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
entity.Property(e => e.Price).HasPrecision(18, 2);
entity.HasOne(e => e.Category)
.WithMany(c => c.Products)
.HasForeignKey(e => e.CategoryId);
});
modelBuilder.Entity<Category>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).HasMaxLength(100).IsRequired();
});
}
}
// Data/ApplicationDbContext.cs
public class ApplicationDbContext : DbContext
{
private readonly IServiceProvider? _serviceProvider;
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, IServiceProvider serviceProvider)
: base(options)
{
_serviceProvider = serviceProvider;
}
public DbSet<Product> Products { get; set; } = null!;
public DbSet<Category> Categories { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Apply FS.EntityFramework configurations manually
if (_serviceProvider != null)
{
modelBuilder.ApplyFSEntityFrameworkConfigurations(_serviceProvider);
}
// Your entity configurations...
}
}
The Fluent Configuration API provides a clean way to configure all features:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Add DbContext
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Configure FS.EntityFramework with all features
builder.Services.AddFSEntityFramework<ApplicationDbContext>()
// Enable audit tracking
.WithAudit()
.UsingHttpContext() // For web applications
// Enable domain events
.WithDomainEvents()
.UsingDefaultDispatcher()
.WithAutoHandlerDiscovery() // Automatically find event handlers
.Complete()
// Enable soft delete
.WithSoftDelete()
// Build the configuration
.Build();
var app = builder.Build();
Now create services that use the repository pattern:
// Services/ProductService.cs
public class ProductService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ProductService> _logger;
public ProductService(IUnitOfWork unitOfWork, ILogger<ProductService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
public async Task<Product> CreateProductAsync(CreateProductRequest request)
{
var repository = _unitOfWork.GetRepository<Product, int>();
var product = new Product
{
Name = request.Name,
Price = request.Price,
Description = request.Description,
CategoryId = request.CategoryId
};
await repository.AddAsync(product);
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Created product: {ProductName}", product.Name);
return product;
}
public async Task<Product?> GetProductByIdAsync(int id)
{
var repository = _unitOfWork.GetRepository<Product, int>();
return await repository.GetByIdAsync(id);
}
public async Task<IPaginate<Product>> GetProductsPagedAsync(int page, int size)
{
var repository = _unitOfWork.GetRepository<Product, int>();
return await repository.GetPagedAsync(
pageIndex: page,
pageSize: size,
includes: new List<Expression<Func<Product, object>>> { p => p.Category },
orderBy: query => query.OrderBy(p => p.Name)
);
}
public async Task UpdateProductPriceAsync(int id, decimal newPrice)
{
var repository = _unitOfWork.GetRepository<Product, int>();
var product = await repository.GetByIdAsync(id);
if (product == null)
throw new InvalidOperationException($"Product with ID {id} not found");
product.UpdatePrice(newPrice); // This will raise a domain event
await repository.UpdateAsync(product);
await _unitOfWork.SaveChangesAsync(); // Domain events will be dispatched here
}
public async Task SoftDeleteProductAsync(int id)
{
var repository = _unitOfWork.GetRepository<Product, int>();
var product = await repository.GetByIdAsync(id);
if (product != null)
{
await repository.DeleteAsync(product); // Soft delete
await _unitOfWork.SaveChangesAsync();
}
}
public async Task RestoreProductAsync(int id)
{
var repository = _unitOfWork.GetRepository<Product, int>();
await repository.RestoreAsync(id); // Restore soft deleted product
await _unitOfWork.SaveChangesAsync();
}
}
// DTOs for service methods
public record CreateProductRequest(string Name, decimal Price, string Description, int CategoryId);
The library provides a comprehensive, type-safe dynamic filtering system with fluent API, OR/AND groups, sorting, convenience methods, and reusable scopes.
The generic FilterBuilder<T> provides compile-time validated field names, IntelliSense, and automatic value conversion:
using FS.EntityFramework.Library.Models;
public class ProductSearchService
{
private readonly IUnitOfWork _unitOfWork;
public ProductSearchService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<IPaginate<Product>> SearchProductsAsync(ProductFilterRequest request)
{
var repository = _unitOfWork.GetRepository<Product, int>();
// Fully type-safe: field names, operators, and values validated at compile-time
var filter = FilterBuilder<Product>.Create()
.Search(request.SearchTerm)
.WhereIf(request.MinPrice.HasValue, p => p.Price,
FilterOperator.GreaterThanOrEqual, request.MinPrice)
.WhereIf(request.MaxPrice.HasValue, p => p.Price,
FilterOperator.LessThanOrEqual, request.MaxPrice)
.WhereIf(request.CategoryId.HasValue, p => p.CategoryId,
FilterOperator.Equals, request.CategoryId)
.WhereIsNull(p => p.DeletedAt)
.OrderByDescending(p => p.CreatedAt)
.OrderBy(p => p.Name)
.Build();
// Sorting is built into the filter model — no separate orderBy needed
return await repository.GetPagedWithFilterAsync(
filter,
request.Page,
request.PageSize,
includes: new List<Expression<Func<Product, object>>> { p => p.Category }
);
}
}
public record ProductFilterRequest(
string? SearchTerm = null,
decimal? MinPrice = null,
decimal? MaxPrice = null,
int? CategoryId = null,
int Page = 1,
int PageSize = 10);
// Price range in a single call (adds >= and <= filters)
var filter = FilterBuilder<Product>.Create()
.WhereBetween(p => p.Price, 100m, 999m)
.WhereDateRange(p => p.CreatedAt, DateTime.Today.AddDays(-30), DateTime.Today)
.Build();
Build complex logical expressions with grouped filters:
var filter = FilterBuilder<Product>.Create()
.WhereEquals(p => p.IsActive, true)
.OrGroup(g => g // AND (
.WhereGreaterThan(p => p.Price, 1000m) // Price > 1000
.WhereEquals(p => p.IsFeatured, true)) // OR IsFeatured = true )
.Build();
// SQL: WHERE IsActive = 1 AND (Price > 1000 OR IsFeatured = 1)
var filter = FilterBuilder<Product>.Create()
.WhereIn(p => p.Status, 1, 2, 3) // WHERE Status IN (1, 2, 3)
.WhereNotIn(p => p.CategoryId, 5, 10) // AND CategoryId NOT IN (5, 10)
.Build();
Extract common filter combinations into reusable scopes:
// Define a reusable scope
public class ActiveProductScope : IFilterScope<Product>
{
public void Apply(FilterBuilder<Product> builder)
{
builder
.WhereEquals(p => p.IsActive, true)
.WhereIsNull(p => p.DeletedAt);
}
}
// Apply across different queries
var filter = FilterBuilder<Product>.Create()
.ApplyScope(new ActiveProductScope())
.WhereBetween(p => p.Price, 50m, 500m)
.OrderBy(p => p.Name)
.Build();
Sorting can be defined in the filter model and is automatically applied by the repository:
var filter = FilterBuilder<Product>.Create()
.WhereGreaterThan(p => p.Price, 100m)
.OrderByDescending(p => p.CreatedAt) // Primary sort
.OrderBy(p => p.Name) // ThenBy
.Build();
// No orderBy parameter needed — sorts are applied from FilterModel.Sorts
var result = await repository.GetPagedWithFilterAsync(filter, pageIndex: 0, pageSize: 20);
// Explicit orderBy parameter still takes precedence when provided
The non-generic FilterBuilder is available for scenarios where the entity type isn't known at compile time (e.g., API controllers receiving filter JSON):
var filter = FilterBuilder.Create()
.Search("laptop")
.WhereGreaterThanOrEqual(nameof(Product.Price), "500")
.WhereBetween(nameof(Product.Price), "100", "999")
.OrGroup(g => g
.WhereEquals("CategoryId", "1")
.WhereEquals("CategoryId", "2"))
.OrderByDescending(nameof(Product.CreatedAt))
.Build();
var filter = new FilterModel
{
SearchTerm = "laptop",
Filters = new List<FilterItem>
{
new(nameof(Product.Price), FilterOperator.GreaterThanOrEqual, "500"),
new(nameof(Product.CategoryId), FilterOperator.Equals, "1"),
new(nameof(Product.Status), FilterOperator.In, "1,2,3"),
new(nameof(Product.DeletedAt), FilterOperator.IsNull)
}
};
The original string-based API still works and now supports short aliases:
// All of these are equivalent:
new FilterItem { Field = "Price", Operator = "greaterthanorequal", Value = "500" }
new FilterItem { Field = "Price", Operator = "gte", Value = "500" }
new FilterItem("Price", FilterOperator.GreaterThanOrEqual, "500")
Finally, create controllers that expose your services:
// Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly ProductService _productService;
private readonly ProductSearchService _searchService;
public ProductsController(ProductService productService, ProductSearchService searchService)
{
_productService = productService;
_searchService = searchService;
}
[HttpPost]
public async Task<ActionResult<Product>> CreateProduct(CreateProductRequest request)
{
var product = await _productService.CreateProductAsync(request);
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetProduct(int id)
{
var product = await _productService.GetProductByIdAsync(id);
return product == null ? NotFound() : Ok(product);
}
[HttpGet]
public async Task<ActionResult<IPaginate<Product>>> GetProducts(int page = 1, int size = 10)
{
var products = await _productService.GetProductsPagedAsync(page, size);
return Ok(products);
}
[HttpGet("search")]
public async Task<ActionResult<IPaginate<Product>>> SearchProducts([FromQuery] ProductFilterRequest request)
{
var products = await _searchService.SearchProductsAsync(request);
return Ok(products);
}
[HttpPut("{id}/price")]
public async Task<IActionResult> UpdateProductPrice(int id, [FromBody] decimal newPrice)
{
await _productService.UpdateProductPriceAsync(id, newPrice);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteProduct(int id)
{
await _productService.SoftDeleteProductAsync(id);
return NoContent();
}
[HttpPost("{id}/restore")]
public async Task<IActionResult> RestoreProduct(int id)
{
await _productService.RestoreProductAsync(id);
return NoContent();
}
}
Don't forget to register your custom services:
// Program.cs (continued)
builder.Services.AddScoped<ProductService>();
builder.Services.AddScoped<ProductSearchService>();
The library provides comprehensive support for Domain-Driven Design patterns.
Aggregate Roots are the entry points to your aggregates and ensure consistency boundaries:
using FS.EntityFramework.Library.Common;
using FS.EntityFramework.Library.Domain;
public class OrderAggregate : AggregateRoot<Guid>
{
private readonly List<OrderItem> _items = new();
public string OrderNumber { get; private set; } = string.Empty;
public decimal TotalAmount { get; private set; }
public DateTime OrderDate { get; private set; }
// Read-only access to items
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
// Factory method enforcing business rules
public static OrderAggregate Create(string orderNumber)
{
DomainGuard.AgainstNullOrWhiteSpace(orderNumber, nameof(orderNumber));
// AggregateRoot base class automatically generates Guid.CreateVersion7() in default constructor
var order = new OrderAggregate
{
OrderNumber = orderNumber,
OrderDate = DateTime.UtcNow,
TotalAmount = 0
};
// Raise domain event
order.RaiseDomainEvent(new OrderCreatedEvent(order.Id, orderNumber));
return order;
}
// Business method with domain logic
public void AddItem(string productName, decimal unitPrice, int quantity)
{
DomainGuard.AgainstNullOrWhiteSpace(productName, nameof(productName));
DomainGuard.AgainstNegativeOrZero(unitPrice, nameof(unitPrice));
DomainGuard.AgainstNegativeOrZero(quantity, nameof(quantity));
var item = new OrderItem(productName, unitPrice, quantity);
_items.Add(item);
RecalculateTotal();
RaiseDomainEvent(new OrderItemAddedEvent(Id, productName, quantity));
}
private void RecalculateTotal()
{
TotalAmount = _items.Sum(i => i.TotalPrice);
}
}
public class OrderItem
{
public string ProductName { get; }
public decimal UnitPrice { get; }
public int Quantity { get; }
public decimal TotalPrice => UnitPrice * Quantity;
public OrderItem(string productName, decimal unitPrice, int quantity)
{
ProductName = productName;
UnitPrice = unitPrice;
Quantity = quantity;
}
}
Value Objects encapsulate business concepts and ensure type safety:
using FS.EntityFramework.Library.Common;
using FS.EntityFramework.Library.Domain;
public class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency = "USD")
{
DomainGuard.AgainstNegative(amount, nameof(amount));
DomainGuard.AgainstNullOrWhiteSpace(currency, nameof(currency));
Amount = amount;
Currency = currency;
}
public static Money Zero => new(0);
public static Money FromDecimal(decimal amount) => new(amount);
// Value object operations
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add money with different currencies");
return new Money(Amount + other.Amount, Currency);
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
// Operators
public static Money operator +(Money left, Money right) => left.Add(right);
}
Implement business rules for comprehensive domain validation:
using FS.EntityFramework.Library.Domain;
// Simple business rule implementation
public class OrderMustHaveItemsRule : BusinessRule
{
private readonly IReadOnlyCollection<OrderItem> _items;
public OrderMustHaveItemsRule(IReadOnlyCollection<OrderItem> items)
{
_items = items;
}
public override bool IsBroken() => _items.Count == 0;
public override string Message => "Order must have at least one item";
public override string ErrorCode => "ORDER_NO_ITEMS";
}
// Complex business rule with dependencies
public class CustomerCreditLimitRule : BusinessRule
{
private readonly decimal _orderAmount;
private readonly decimal _currentCredit;
private readonly decimal _creditLimit;
public CustomerCreditLimitRule(decimal orderAmount, decimal currentCredit, decimal creditLimit)
{
_orderAmount = orderAmount;
_currentCredit = currentCredit;
_creditLimit = creditLimit;
}
public override bool IsBroken() => (_currentCredit + _orderAmount) > _creditLimit;
public override string Message =>
$"Order amount {_orderAmount:C} would exceed credit limit. Available credit: {(_creditLimit - _currentCredit):C}";
public override string ErrorCode => "CREDIT_LIMIT_EXCEEDED";
}
// Usage in aggregate with DomainGuard
public void ProcessOrder()
{
// Check multiple business rules
DomainGuard.Against(
new OrderMustHaveItemsRule(_items),
new CustomerCreditLimitRule(TotalAmount, _customer.CurrentCredit, _customer.CreditLimit)
);
// Alternative: Check individual rules
CheckRule(new OrderMustHaveItemsRule(_items));
// Process the order...
}
DomainGuard provides comprehensive validation utilities:
using FS.EntityFramework.Library.Domain;
public class OrderAggregate : AggregateRoot<Guid>
{
public void AddItem(string productName, decimal unitPrice, int quantity)
{
// Guard against null/empty values
DomainGuard.AgainstNullOrEmpty(productName, nameof(productName));
// Guard against invalid values
DomainGuard.Against(unitPrice <= 0, "Unit price must be positive", "INVALID_UNIT_PRICE");
DomainGuard.Against(quantity <= 0, "Quantity must be positive", "INVALID_QUANTITY");
// Guard against business rule violations
DomainGuard.Against(new MaxItemsPerOrderRule(_items.Count));
// Guard against null objects
var product = _productService.GetProduct(productName);
DomainGuard.AgainstNull(product, nameof(product));
// Business logic continues...
var item = new OrderItem(productName, unitPrice, quantity);
_items.Add(item);
RaiseDomainEvent(new OrderItemAddedEvent(Id, productName, quantity));
}
// Guard utilities for common scenarios
public void SetCustomerInfo(string customerId, string customerName)
{
DomainGuard.AgainstNullOrWhiteSpace(customerId, nameof(customerId));
DomainGuard.AgainstNullOrWhiteSpace(customerName, nameof(customerName));
DomainGuard.Against(customerId.Length > 50, "Customer ID too long", "CUSTOMER_ID_TOO_LONG");
_customerId = customerId;
_customerName = customerName;
}
}
Build reusable domain logic with specifications and combine them for complex queries:
using FS.EntityFramework.Library.Domain;
// Basic specification
public class ExpensiveProductsSpecification : DomainSpecification<Product>
{
private readonly decimal _minimumPrice;
public ExpensiveProductsSpecification(decimal minimumPrice)
{
_minimumPrice = minimumPrice;
}
public override bool IsSatisfiedBy(Product candidate)
{
return candidate.Price >= _minimumPrice;
}
public override Expression<Func<Product, bool>> ToExpression()
{
return product => product.Price >= _minimumPrice;
}
}
// Category-based specification
public class ProductsInCategorySpecification : DomainSpecification<Product>
{
private readonly int _categoryId;
public ProductsInCategorySpecification(int categoryId)
{
_categoryId = categoryId;
}
public override bool IsSatisfiedBy(Product candidate)
{
return candidate.CategoryId == _categoryId;
}
public override Expression<Func<Product, bool>> ToExpression()
{
return product => product.CategoryId == _categoryId;
}
}
// Available products specification
public class AvailableProductsSpecification : DomainSpecification<Product>
{
public override bool IsSatisfiedBy(Product candidate)
{
return !candidate.IsDeleted && candidate.Stock > 0;
}
public override Expression<Func<Product, bool>> ToExpression()
{
return product => !product.IsDeleted && product.Stock > 0;
}
}
// Specification combinations
public class ProductSearchService
{
private readonly IDomainRepository<Product, int> _repository;
public async Task<IEnumerable<Product>> FindProductsAsync(ProductSearchCriteria criteria)
{
// Start with base specification
ISpecification<Product> specification = new AvailableProductsSpecification();
// Combine with price filter if specified
if (criteria.MinimumPrice.HasValue)
{
var priceSpec = new ExpensiveProductsSpecification(criteria.MinimumPrice.Value);
specification = specification.And(priceSpec);
}
// Combine with category filter if specified
if (criteria.CategoryId.HasValue)
{
var categorySpec = new ProductsInCategorySpecification(criteria.CategoryId.Value);
specification = specification.And(categorySpec);
}
// Execute combined specification
return await _repository.FindAsync(specification);
}
// Advanced specification combinations
public async Task<IEnumerable<Product>> FindPremiumOrDiscountedProductsAsync()
{
var expensiveSpec = new ExpensiveProductsSpecification(1000);
var discountedSpec = new DiscountedProductsSpecification();
// OR combination: expensive OR discounted products
var combinedSpec = expensiveSpec.Or(discountedSpec);
return await _repository.FindAsync(combinedSpec);
}
public async Task<IEnumerable<Product>> FindNonExpensiveProductsAsync()
{
var expensiveSpec = new ExpensiveProductsSpecification(500);
// NOT combination: products that are NOT expensive
var nonExpensiveSpec = expensiveSpec.Not();
return await _repository.FindAsync(nonExpensiveSpec);
}
}
// Complex specification with multiple conditions
public class PremiumProductsSpecification : DomainSpecification<Product>
{
public override bool IsSatisfiedBy(Product candidate)
{
return candidate.Price >= 1000 &&
candidate.Rating >= 4.5 &&
!candidate.IsDeleted;
}
public override Expression<Func<Product, bool>> ToExpression()
{
return product => product.Price >= 1000 &&
product.Rating >= 4.5 &&
!product.IsDeleted;
}
}
The DomainSpecification<T> class provides powerful features for building complex queries:
public class PagedProductsSpecification : DomainSpecification<Product>
{
public PagedProductsSpecification(int pageIndex, int pageSize)
{
// 0-based pagination
ApplyPagingByIndex(pageIndex, pageSize);
AddOrderBy(p => p.Name);
}
public override bool IsSatisfiedBy(Product candidate) => true;
public override Expression<Func<Product, bool>> ToExpression()
{
return product => !product.IsDeleted;
}
}
// Alternative: Skip/Take based pagination
public class OffsetProductsSpecification : DomainSpecification<Product>
{
public OffsetProductsSpecification(int skip, int take)
{
ApplyPagingBySkipAndTake(skip, take);
}
public override bool IsSatisfiedBy(Product candidate) => true;
public override Expression<Func<Product, bool>> ToExpression() => p => true;
}
public class SortedProductsSpecification : DomainSpecification<Product>
{
public SortedProductsSpecification()
{
// Multiple order expressions applied in sequence
AddOrderByDescending(p => p.CreatedAt); // Primary sort
AddOrderBy(p => p.Name); // Secondary sort (ThenBy)
AddOrderBy(p => p.Price); // Tertiary sort
}
public override bool IsSatisfiedBy(Product candidate) => true;
public override Expression<Func<Product, bool>> ToExpression() => p => !p.IsDeleted;
}
public class ProductSearchSpecification : DomainSpecification<Product>
{
public ProductSearchSpecification(string searchTerm)
{
// Search across multiple properties (case-insensitive Contains)
ApplySearch(searchTerm,
p => p.Name,
p => p.Description,
p => p.Brand,
p => p.Category.Name);
AsNoTracking(); // Read-only query optimization
}
public override bool IsSatisfiedBy(Product candidate)
{
return !candidate.IsDeleted;
}
public override Expression<Func<Product, bool>> ToExpression()
{
return product => !product.IsDeleted;
}
}
public class ProductWithRelationsSpecification : DomainSpecification<Product>
{
public ProductWithRelationsSpecification()
{
// Expression-based includes
AddInclude(p => p.Category);
AddInclude(p => p.Supplier);
// String-based includes for nested properties
AddInclude("Reviews.User");
AddInclude("OrderItems.Order");
// Multiple includes at once
AddIncludes(
p => p.Images,
p => p.Tags,
p => p.Variants
);
// Prevent Cartesian explosion with split queries
EnableSplitQuery();
}
public override bool IsSatisfiedBy(Product candidate) => true;
public override Expression<Func<Product, bool>> ToExpression() => p => !p.IsDeleted;
}
public class AllProductsIncludingDeletedSpecification : DomainSpecification<Product>
{
public AllProductsIncludingDeletedSpecification()
{
// Ignore global query filters (e.g., soft delete filter)
ApplyIgnoreQueryFilters();
// Enable tracking for updates
EnableTracking();
}
public override bool IsSatisfiedBy(Product candidate) => true;
public override Expression<Func<Product, bool>> ToExpression() => p => true;
}
public class ProductsByCategorySpecification : DomainSpecification<Product>
{
public ProductsByCategorySpecification()
{
ApplyGroupBy(p => p.CategoryId);
AddOrderBy(p => p.CategoryId);
}
public override bool IsSatisfiedBy(Product candidate) => !candidate.IsDeleted;
public override Expression<Func<Product, bool>> ToExpression() => p => !p.IsDeleted;
}
public class AdvancedProductSearchSpecification : DomainSpecification<Product>
{
public AdvancedProductSearchSpecification(
string? searchTerm = null,
decimal? minPrice = null,
decimal? maxPrice = null,
int? categoryId = null,
int pageIndex = 0,
int pageSize = 20,
bool includeDeleted = false)
{
// Dynamic criteria - only applied when parameter has value
AddCriteriaIf(minPrice.HasValue, p => p.Price >= minPrice!.Value);
AddCriteriaIf(maxPrice.HasValue, p => p.Price <= maxPrice!.Value);
AddCriteriaIf(categoryId.HasValue, p => p.CategoryId == categoryId!.Value);
// Text search if provided
if (!string.IsNullOrWhiteSpace(searchTerm))
{
ApplySearch(searchTerm, p => p.Name, p => p.Description);
}
// Eager load relations
AddIncludes(
p => p.Category,
p => p.Supplier,
p => p.Reviews
);
// Use split query for multiple collections
EnableSplitQuery();
// Sorting
AddOrderByDescending(p => p.CreatedAt);
AddOrderBy(p => p.Name);
// Pagination
ApplyPagingByIndex(pageIndex, pageSize);
// Include soft-deleted if requested
if (includeDeleted)
{
ApplyIgnoreQueryFilters();
}
// Read-only optimization
AsNoTracking();
}
public override bool IsSatisfiedBy(Product candidate)
{
return !candidate.IsDeleted;
}
public override Expression<Func<Product, bool>> ToExpression()
{
return product => !product.IsDeleted;
}
}
// Usage in repository
public class ProductService
{
private readonly IDomainRepository<Product, int> _repository;
public async Task<IEnumerable<Product>> SearchProductsAsync(
string searchTerm,
int page,
int pageSize)
{
var specification = new AdvancedProductSearchSpecification(
searchTerm: searchTerm,
minPrice: 10,
maxPrice: 1000,
pageIndex: page,
pageSize: pageSize
);
return await _repository.FindAsync(specification);
}
}
Build dynamic queries with optional filters directly in specifications:
public class ProductSearchSpecification : DomainSpecification<Product>
{
public ProductSearchSpecification(
string? categoryName = null,
decimal? minPrice = null,
decimal? maxPrice = null,
bool? isActive = null,
string? searchTerm = null)
{
// Conditional criteria - only applied when parameter has value
AddCriteriaIf(!string.IsNullOrEmpty(categoryName),
p => p.Category.Name == categoryName!);
AddCriteriaIf(minPrice.HasValue,
p => p.Price >= minPrice!.Value);
AddCriteriaIf(maxPrice.HasValue,
p => p.Price <= maxPrice!.Value);
AddCriteriaIf(isActive.HasValue,
p => p.IsActive == isActive!.Value);
// Always-applied criteria
AddCriteria(p => !p.IsDeleted);
// Search
if (!string.IsNullOrEmpty(searchTerm))
ApplySearch(searchTerm, p => p.Name, p => p.Description);
AddOrderByDescending(p => p.CreatedAt);
}
public override bool IsSatisfiedBy(Product candidate) => !candidate.IsDeleted;
public override Expression<Func<Product, bool>> ToExpression() => p => true;
}
EF Core's filtered include is supported through IncludeCollection:
public class BlogWithActivePostsSpecification : DomainSpecification<Blog>
{
public BlogWithActivePostsSpecification()
{
// Filtered include - only load active posts
IncludeCollection(b => b.Posts.Where(p => p.IsPublished && !p.IsDeleted))
.ThenInclude(p => p.Author);
// Regular include
Include(b => b.Owner);
}
public override bool IsSatisfiedBy(Blog candidate) => true;
public override Expression<Func<Blog, bool>> ToExpression() => b => true;
}
public class OrderWithDetailsSpecification : DomainSpecification<Order>
{
public OrderWithDetailsSpecification(Guid orderId)
{
// Type-safe ThenInclude chaining
Include(order => order.Customer)
.ThenInclude(customer => customer.Address);
// Collection ThenInclude
IncludeCollection(order => order.OrderItems)
.ThenInclude(item => item.Product)
.ThenInclude(product => product.Category);
// Multiple levels deep
IncludeCollection(order => order.Payments)
.ThenInclude(payment => payment.PaymentMethod);
AsTracking(); // Enable tracking for updates
}
public override bool IsSatisfiedBy(Order candidate) => true;
public override Expression<Func<Order, bool>> ToExpression() => o => o.Id == orderId;
}
var specification = new ActiveProductsSpecification(pageIndex: 0, pageSize: 20);
// Returns IPaginate<Product> with total count, pages, etc.
var pagedResult = await repository.FindPagedAsync(specification);
// With projection
var pagedDtos = await repository.FindPagedAsync(
specification,
selector: p => new ProductDto { Id = p.Id, Name = p.Name });
// FirstOrDefaultAsync with specification
var product = await repository.FirstOrDefaultAsync(specification);
// SingleOrDefaultAsync with specification (throws if multiple)
var uniqueProduct = await repository.SingleOrDefaultAsync(specification);
// FirstOrDefaultAsync with projection
var dto = await repository.FirstOrDefaultAsync(
specification,
selector: p => new ProductDto { Id = p.Id, Name = p.Name });
Available Methods:
Includes:
AddInclude(expression) - Simple eager load navigation propertyAddInclude(string) - String-based include for nested propertiesAddIncludes(expressions...) - Multiple includes at onceInclude<TProperty>(expression) - Type-safe include with ThenInclude supportIncludeCollection<TProperty>(expression) - Collection include with ThenInclude support (supports filtered include)ClearIncludes() - Remove all includesOrdering:
AddOrderBy(expression) - Ascending sortAddOrderByDescending(expression) - Descending sortClearOrdering() - Reset all orderingPagination & Limiting:
ApplyPagingByIndex(pageIndex, pageSize) - 0-based page paginationApplyPagingBySkipAndTake(skip, take) - Offset-based paginationApplyLimit(count) - Limit results without pagination metadataFiltering & Criteria:
AddCriteria(predicate) - Add an additional filter predicateAddCriteriaIf(condition, predicate) - Conditionally add a filter predicateClearCriteria() - Remove all additional criteriaApplySearch(term, properties...) - Text search across propertiesApplyIgnoreQueryFilters() - Bypass global filtersApplyDistinct() - Return only distinct resultsGrouping:
ApplyGroupBy(expression) - Group resultsProjection:
ApplySelector<TResult>(expression) - Define projection in specificationTracking:
AsNoTracking() - Disable change tracking (default)AsTracking() / EnableTracking() - Enable change trackingAsNoTrackingWithIdentityResolution() - No tracking with identity resolutionQuery Optimization:
EnableSplitQuery() - Prevent Cartesian explosionTagWith(tag) - Add query tag for debugging/loggingComposition:
And(spec), Or(spec), Not() - Logical combinationsThe library provides a robust interceptor system that automatically handles cross-cutting concerns:
Automatically tracks entity creation and modification:
// Automatic configuration via Fluent API
services.AddFSEntityFramework<ApplicationDbContext>()
.WithAudit()
.UsingHttpContext() // Uses current HTTP user
.Build();
// Manual interceptor registration
services.AddScoped<AuditInterceptor>(provider =>
{
var userProvider = () => provider.GetService<IHttpContextAccessor>()
?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return new AuditInterceptor(userProvider);
});
Automatically handles soft delete operations:
// Entities implementing ISoftDelete are automatically soft deleted
public class Product : BaseAuditableEntity<int>, ISoftDelete
{
public string Name { get; set; } = string.Empty;
// ISoftDelete properties (automatically managed)
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}
// Configuration
services.AddFSEntityFramework<ApplicationDbContext>()
.WithSoftDelete() // Enables soft delete interceptor
.Build();
// Usage - automatically becomes soft delete
var repository = _unitOfWork.GetRepository<Product, int>();
await repository.DeleteAsync(product); // Soft delete
await repository.RestoreAsync(productId); // Restore
Automatically generates IDs for new entities:
// Register ID generators
services.AddFSEntityFramework<ApplicationDbContext>()
.WithIdGeneration()
.WithGenerator<Guid, GuidV7Generator>() // GUID V7 for Guid properties
.WithGenerator<string, CustomStringIdGenerator>() // Custom string IDs
.Complete()
.Build();
// Custom ID generator example
public class CustomStringIdGenerator : IIdGenerator<string>
{
public Type KeyType => typeof(string);
public string Generate()
{
return $"PROD_{DateTime.UtcNow:yyyyMMdd}_{Guid.NewGuid():N}"[..20];
}
object IIdGenerator.Generate() => Generate();
}
The Fluent Configuration API provides a clean, type-safe way to configure all library features:
// Start configuration
services.AddFSEntityFramework<TDbContext>()
// Audit Configuration Chain
.WithAudit()
.UsingHttpContext() // Use HTTP context for user
.UsingUserProvider(provider => "user") // Custom user provider
.UsingUserContext<IUserContext>() // Interface-based user context
.UsingTimeProvider(provider => DateTime.UtcNow) // Custom time provider
.Complete() // End audit configuration
// Domain Events Configuration Chain
.WithDomainEvents()
.UsingDefaultDispatcher() // Use built-in dispatcher
.UsingCustomDispatcher<TDispatcher>() // Custom dispatcher
.WithAutoHandlerDiscovery() // Auto-discover handlers
.WithHandlerDiscovery(assembly) // Discover from specific assembly
.WithAttributedHandlers(assembly) // Use attributed handlers
.Complete() // End domain events configuration
// Soft Delete Configuration
.WithSoftDelete()
// ID Generation Configuration Chain
.WithIdGeneration()
.WithGenerator<TKey, TGenerator>() // Register generator for type
.WithFactory<TFactory>() // Custom factory
.Complete() // End ID generation configuration
// Validation and Build
.ValidateConfiguration() // Validate all configurations
.Build(); // Build and register services
// The fluent API includes built-in validation
services.AddFSEntityFramework<ApplicationDbContext>()
.WithAudit()
.UsingHttpContext()
.WithDomainEvents()
.UsingDefaultDispatcher()
.WithAutoHandlerDiscovery()
.Complete()
.ValidateConfiguration() // Throws detailed exceptions for invalid configs
.Build();
The library provides a complete infrastructure layer implementing DDD patterns:
// IDomainRepository interface for aggregate roots
public interface IDomainRepository<TAggregate, TKey>
where TAggregate : AggregateRoot<TKey>
where TKey : IEquatable<TKey>
{
// Core CRUD
Task<TAggregate?> GetByIdAsync(TKey id, List<Expression<Func<TAggregate, object>>>? includes = null, bool disableTracking = false, CancellationToken cancellationToken = default);
Task<TAggregate> GetByIdRequiredAsync(TKey id, List<Expression<Func<TAggregate, object>>>? includes = null, bool disableTracking = false, CancellationToken cancellationToken = default);
Task AddAsync(TAggregate aggregate, CancellationToken cancellationToken = default);
Task UpdateAsync(TAggregate aggregate, CancellationToken cancellationToken = default);
Task RemoveAsync(TAggregate aggregate, CancellationToken cancellationToken = default);
// Specification queries
Task<IEnumerable<TAggregate>> FindAsync(ISpecification<TAggregate> specification, CancellationToken cancellationToken = default);
Task<bool> AnyAsync(ISpecification<TAggregate> specification, CancellationToken cancellationToken = default);
Task<int> CountAsync(ISpecification<TAggregate> specification, CancellationToken cancellationToken = default);
// Single entity from specification
Task<TAggregate?> FirstOrDefaultAsync(ISpecification<TAggregate> specification, CancellationToken cancellationToken = default);
Task<TAggregate?> SingleOrDefaultAsync(ISpecification<TAggregate> specification, CancellationToken cancellationToken = default);
// Projection methods
Task<TResult?> GetByIdAsync<TResult>(TKey id, Expression<Func<TAggregate, TResult>> selector, CancellationToken cancellationToken = default);
Task<IEnumerable<TResult>> FindAsync<TResult>(ISpecification<TAggregate> specification, Expression<Func<TAggregate, TResult>> selector, CancellationToken cancellationToken = default);
Task<IEnumerable<TResult>> FindWithSelectorAsync<TResult>(ISpecification<TAggregate> specification, CancellationToken cancellationToken = default);
Task<TResult?> FirstOrDefaultAsync<TResult>(ISpecification<TAggregate> specification, Expression<Func<TAggregate, TResult>> selector, CancellationToken cancellationToken = default);
// Paginated results
Task<IPaginate<TAggregate>> FindPagedAsync(ISpecification<TAggregate> specification, CancellationToken cancellationToken = default);
Task<IPaginate<TResult>> FindPagedAsync<TResult>(ISpecification<TAggregate> specification, Expression<Func<TAggregate, TResult>> selector, CancellationToken cancellationToken = default);
}
// Usage with automatic registration
services.AddDomainServices()
.AddDomainRepository<OrderAggregate, Guid>()
.AddDomainRepository<CustomerAggregate, Guid>();
// Custom repository implementation
public class OrderRepository : DomainRepository<OrderAggregate, Guid>, IOrderRepository
{
public OrderRepository(DbContext context, IServiceProvider serviceProvider)
: base(context, serviceProvider) { }
public async Task<OrderAggregate?> FindByOrderNumberAsync(string orderNumber)
{
return await FirstOrDefaultAsync(new OrderByNumberSpecification(orderNumber));
}
}
// IDomainUnitOfWork for aggregate-focused operations
public interface IDomainUnitOfWork : IDisposable
{
IDomainRepository<TAggregate, TKey> GetRepository<TAggregate, TKey>()
where TAggregate : AggregateRoot<TKey>
where TKey : IEquatable<TKey>;
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
Task BeginTransactionAsync(CancellationToken cancellationToken = default);
Task CommitTransactionAsync(CancellationToken cancellationToken = default);
Task RollbackTransactionAsync(CancellationToken cancellationToken = default);
}
// Usage in application services
public class OrderApplicationService
{
private readonly IDomainUnitOfWork _domainUnitOfWork;
public async Task ProcessOrderAsync(ProcessOrderCommand command)
{
var orderRepository = _domainUnitOfWork.GetRepository<OrderAggregate, Guid>();
var order = await orderRepository.GetByIdAsync(command.OrderId);
order?.ProcessOrder();
await _domainUnitOfWork.SaveChangesAsync(); // Domain events dispatched automatically
}
}
The library provides comprehensive pagination capabilities:
// IPaginate interface provides rich pagination information
public interface IPaginate<T>
{
int Index { get; } // Current page index (0-based)
int Size { get; } // Page size
int Count { get; } // Total item count
int Pages { get; } // Total page count
IList<T> Items { get; } // Current page items
bool HasPrevious { get; } // Has previous page
bool HasNext { get; } // Has next page
}
// Repository pagination methods
var repository = _unitOfWork.GetRepository<Product, int>();
// Simple pagination
var pagedProducts = await repository.GetPagedAsync(
pageIndex: 0,
pageSize: 20,
orderBy: query => query.OrderBy(p => p.Name)
);
// Pagination with includes
var pagedProductsWithCategory = await repository.GetPagedAsync(
pageIndex: 0,
pageSize: 20,
includes: new List<Expression<Func<Product, object>>> { p => p.Category },
orderBy: query => query.OrderBy(p => p.Name)
);
// Pagination with dynamic filtering
var filter = new FilterModel
{
SearchTerm = "laptop", // Searches across all string properties
Filters = new List<FilterItem>
{
new() { Field = "Price", Operator = "greaterthan", Value = "500" },
new() { Field = "CategoryId", Operator = "equals", Value = "1" }
}
};
var filteredPage = await repository.GetPagedWithFilterAsync(
filter,
pageIndex: 0,
pageSize: 20,
orderBy: query => query.OrderByDescending(p => p.CreatedAt),
includes: new List<Expression<Func<Product, object>>> { p => p.Category }
);
// Available filter operators (full name / alias):
// "equals" (eq), "notequals" (neq), "contains", "startswith" (sw), "endswith" (ew)
// "greaterthan" (gt), "greaterthanorequal" (gte), "lessthan" (lt), "lessthanorequal" (lte)
// "isnull", "isnotnull", "isempty", "isnotempty" (no value required)
// "in", "notin" (comma-separated values, e.g. "1,2,3")
//
// Or use the type-safe FilterOperator enum / FilterBuilder<T> for compile-time validation
Cursor pagination is more efficient than offset pagination for large datasets:
// Basic cursor pagination
var repository = _unitOfWork.GetRepository<Product, int>();
// Get first page
var firstPage = await repository.GetCursorPagedAsync<int>(
pageSize: 20,
afterCursor: null, // null for first page
beforeCursor: null,
cursorSelector: p => p.Id, // Use ID as cursor
predicate: p => p.IsActive,
orderBy: q => q.OrderBy(p => p.Id)
);
// Get next page using LastCursor
var nextPage = await repository.GetCursorPagedAsync<int>(
pageSize: 20,
afterCursor: firstPage.LastCursor, // Start after last item
beforeCursor: null,
cursorSelector: p => p.Id
);
// ICursorPaginate interface
// - Items: Current page items
// - Size: Requested page size
// - Count: Actual items returned
// - FirstCursor/LastCursor: Cursor values for navigation
// - HasNext/HasPrevious: Navigation availability
// Cursor pagination with projection
var projectedPage = await repository.GetCursorPagedAsync<ProductDto, int>(
selector: p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price },
pageSize: 20,
afterCursor: null,
beforeCursor: null,
cursorSelector: p => p.Id
);
The library provides comprehensive projection methods for efficient data retrieval:
var repository = _unitOfWork.GetRepository<Product, int>();
// Project single entity by ID
var productDto = await repository.GetByIdAsync(
id: 1,
selector: p => new ProductDto
{
Id = p.Id,
Name = p.Name,
CategoryName = p.Category.Name,
Price = p.Price
});
// Project all entities
var allProductDtos = await repository.GetAllAsync(
selector: p => new ProductSummaryDto
{
Id = p.Id,
Name = p.Name
});
// Project first match
var cheapestProduct = await repository.FirstOrDefaultAsync(
predicate: p => p.CategoryId == categoryId,
selector: p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price });
// Project single match (throws if multiple)
var uniqueProduct = await repository.SingleOrDefaultAsync(
predicate: p => p.Sku == "ABC123",
selector: p => new ProductDto { Id = p.Id, Name = p.Name });
// Project with filter and ordering
var filteredProducts = await repository.FindAsync(
predicate: p => p.Price > 100,
selector: p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price },
orderBy: q => q.OrderByDescending(p => p.Price));
// Paginated projection
var pagedProducts = await repository.GetPagedAsync(
selector: p => new ProductListDto
{
Id = p.Id,
Name = p.Name,
CategoryName = p.Category.Name
},
pageIndex: 0,
pageSize: 20,
predicate: p => p.IsActive,
orderBy: q => q.OrderBy(p => p.Name));
// Filtered pagination with projection
var filteredPagedProducts = await repository.GetPagedWithFilterAsync(
selector: p => new ProductDto { Id = p.Id, Name = p.Name },
filter: filterModel,
pageIndex: 0,
pageSize: 20);
// Specification with projection
var spec = new ActiveProductsSpecification();
var specProducts = await repository.GetAsync(spec,
selector: p => new ProductDto { Id = p.Id, Name = p.Name });
var domainRepository = _domainUnitOfWork.GetRepository<ProductAggregate, Guid>();
// Project aggregate by ID
var productDto = await domainRepository.GetByIdAsync(
id: productId,
selector: p => new ProductDetailDto
{
Id = p.Id,
Name = p.Name,
TotalOrderCount = p.Orders.Count
});
// Project with specification
var specification = new ActiveProductsSpecification();
var products = await domainRepository.FindAsync(
specification,
selector: p => new ProductSummaryDto { Id = p.Id, Name = p.Name });
Define projections directly in specifications:
public class ProductListSpecification : DomainSpecification<Product>
{
public ProductListSpecification(int categoryId, int pageIndex, int pageSize)
{
// Define the projection
ApplySelector<ProductListDto>(p => new ProductListDto
{
Id = p.Id,
Name = p.Name,
CategoryName = p.Category.Name,
Price = p.Price,
ReviewCount = p.Reviews.Count
});
AddInclude(p => p.Category);
AddOrderBy(p => p.Name);
ApplyPagingByIndex(pageIndex, pageSize);
}
public override bool IsSatisfiedBy(Product candidate) =>
candidate.CategoryId == categoryId;
public override Expression<Func<Product, bool>> ToExpression() =>
p => p.CategoryId == categoryId;
}
// Usage
var specification = new ProductListSpecification(categoryId: 5, pageIndex: 0, pageSize: 20);
var results = await repository.FindWithSelectorAsync<ProductListDto>(specification);
Get exactly one entity or null, throws if multiple match:
// Entity version
var product = await repository.SingleOrDefaultAsync(
predicate: p => p.Sku == "UNIQUE-SKU",
includes: new List<Expression<Func<Product, object>>> { p => p.Category },
disableTracking: true);
// Projection version
var productDto = await repository.SingleOrDefaultAsync(
predicate: p => p.Sku == "UNIQUE-SKU",
selector: p => new ProductDto { Id = p.Id, Name = p.Name });
Check existence with optional predicate:
// Check if any products exist
var hasAnyProducts = await repository.AnyAsync();
// Check with predicate
var hasExpensiveProducts = await repository.AnyAsync(p => p.Price > 1000);
var hasActiveProducts = await repository.AnyAsync(p => p.IsActive && !p.IsDeleted);
The library supports modular ID generation strategies:
// Install: dotnet add package FS.EntityFramework.Library.GuidV7
services.AddFSEntityFramework<ApplicationDbContext>()
.WithGuidV7() // Automatic GUID V7 generation
.Build();
// Entity with GUID V7
public class User : BaseAuditableEntity<Guid>
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
// ID will be automatically generated as GUID V7
}
// Install: dotnet add package FS.EntityFramework.Library.UlidGenerator
services.AddFSEntityFramework<ApplicationDbContext>()
.WithUlid() // Automatic ULID generation
.Build();
// Entity with ULID
public class Order : BaseAuditableEntity<Ulid>
{
public string OrderNumber { get; set; } = string.Empty;
// ID will be automatically generated as ULID
}
Configure audit tracking with different user context providers:
// Web applications with HttpContext
services.AddFSEntityFramework<ApplicationDbContext>()
.WithAudit()
.UsingHttpContext() // Uses NameIdentifier claim
.Build();
// Custom user provider
services.AddFSEntityFramework<ApplicationDbContext>()
.WithAudit()
.UsingUserProvider(provider =>
{
var userService = provider.GetService<ICurrentUserService>();
return userService?.GetCurrentUserId();
})
.Build();
// Interface-based user context
public class ApplicationUserContext : IUserContext
{
private readonly ICurrentUserService _userService;
public ApplicationUserContext(ICurrentUserService userService)
{
_userService = userService;
}
public string? CurrentUser => _userService.GetCurrentUserId();
}
services.AddScoped<IUserContext, ApplicationUserContext>();
services.AddFSEntityFramework<ApplicationDbContext>()
.WithAudit()
.UsingUserContext<IUserContext>()
.Build();
Here's a full-featured configuration example:
services.AddFSEntityFramework<ApplicationDbContext>()
// Audit Configuration
.WithAudit()
.UsingHttpContext() // User tracking via HTTP context
// Domain Events Configuration
.WithDomainEvents()
.UsingDefaultDispatcher() // Default event dispatcher
.WithAutoHandlerDiscovery() // Auto-discover event handlers
.Complete()
// Soft Delete Configuration
.WithSoftDelete()
// ID Generation Configuration
.WithIdGeneration()
.WithGenerator<string, CustomStringIdGenerator>()
.Complete()
// Validation & Build
.ValidateConfiguration()
.Build();
The library provides comprehensive error handling patterns:
using FS.EntityFramework.Library.Domain;
// Domain-specific exceptions
public class OrderDomainException : DomainException
{
public OrderDomainException(string message) : base(message) { }
public OrderDomainException(string message, Exception innerException) : base(message, innerException) { }
}
// Business rule validation exception handling
public class OrderApplicationService
{
private readonly IDomainUnitOfWork _unitOfWork;
private readonly ILogger<OrderApplicationService> _logger;
public async Task<OrderResult> ProcessOrderAsync(ProcessOrderCommand command)
{
try
{
var repository = _unitOfWork.GetRepository<OrderAggregate, Guid>();
var order = await repository.GetByIdAsync(command.OrderId);
if (order == null)
{
return OrderResult.NotFound(command.OrderId);
}
// Business logic with domain validation
order.ProcessOrder();
await _unitOfWork.SaveChangesAsync();
return OrderResult.Success(order);
}
catch (BusinessRuleValidationException ex)
{
_logger.LogWarning("Business rule violation: {Rule} - {Message}",
ex.BrokenRule.ErrorCode, ex.BrokenRule.Message);
return OrderResult.BusinessRuleViolation(ex.BrokenRule);
}
catch (DomainException ex)
{
_logger.LogError(ex, "Domain error processing order {OrderId}", command.OrderId);
return OrderResult.DomainError(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error processing order {OrderId}", command.OrderId);
return OrderResult.UnexpectedError();
}
}
}
// Result pattern for better error handling
public class OrderResult
{
public bool IsSuccess { get; private set; }
public string? ErrorMessage { get; private set; }
public string? ErrorCode { get; private set; }
public OrderAggregate? Order { get; private set; }
public static OrderResult Success(OrderAggregate order) =>
new() { IsSuccess = true, Order = order };
public static OrderResult NotFound(Guid orderId) =>
new() { IsSuccess = false, ErrorMessage = $"Order {orderId} not found", ErrorCode = "ORDER_NOT_FOUND" };
public static OrderResult BusinessRuleViolation(IBusinessRule rule) =>
new() { IsSuccess = false, ErrorMessage = rule.Message, ErrorCode = rule.ErrorCode };
public static OrderResult DomainError(string message) =>
new() { IsSuccess = false, ErrorMessage = message, ErrorCode = "DOMAIN_ERROR" };
public static OrderResult UnexpectedError() =>
new() { IsSuccess = false, ErrorMessage = "An unexpected error occurred", ErrorCode = "UNEXPECTED_ERROR" };
}
Optimize your application with these performance best practices:
// ✅ Good: Use built-in projection methods (v10.0.2+)
public async Task<IReadOnlyList<ProductSummaryDto>> GetProductSummariesAsync()
{
var repository = _unitOfWork.GetRepository<Product, int>();
// Built-in projection method - cleaner and more efficient
return await repository.GetAllAsync(p => new ProductSummaryDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
CategoryName = p.Category.Name
});
}
// ✅ Good: Alternative using GetQueryable for complex scenarios
public async Task<IEnumerable<ProductSummaryDto>> GetProductSummariesManualAsync()
{
var repository = _unitOfWork.GetRepository<Product, int>();
return await repository.GetQueryable(disableTracking: true)
.Select(p => new ProductSummaryDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
CategoryName = p.Category.Name
})
.ToListAsync();
}
// ✅ Good: Use includes strategically
public async Task<Product?> GetProductWithDetailsAsync(int id)
{
var repository = _unitOfWork.GetRepository<Product, int>();
return await repository.GetQueryable()
.Include(p => p.Category)
.Include(p => p.Reviews.Take(5)) // Limit related data
.FirstOrDefaultAsync(p => p.Id == id);
}
// ✅ Good: Use compiled queries for frequently used queries
private static readonly Func<ApplicationDbContext, int, Task<Product?>> GetProductByIdCompiled =
EF.CompileAsyncQuery((ApplicationDbContext context, int id) =>
context.Products.FirstOrDefault(p => p.Id == id));
public async Task<Product?> GetProductByIdOptimizedAsync(int id)
{
return await GetProductByIdCompiled(_context, id);
}
// ✅ Good: Use bulk operations for large datasets
public async Task ImportProductsAsync(IEnumerable<Product> products)
{
var repository = _unitOfWork.GetRepository<Product, int>();
// Bulk insert for better performance
await repository.BulkInsertAsync(products, saveChanges: true);
}
// ✅ Good: Batch operations
public async Task UpdateMultipleProductPricesAsync(Dictionary<int, decimal> priceUpdates)
{
var repository = _unitOfWork.GetRepository<Product, int>();
var productIds = priceUpdates.Keys.ToList();
var products = await repository.GetQueryable()
.Where(p => productIds.Contains(p.Id))
.ToListAsync();
foreach (var product in products)
{
if (priceUpdates.TryGetValue(product.Id, out var newPrice))
{
product.SetPrice(newPrice);
}
}
await _unitOfWork.SaveChangesAsync(); // Single save operation
}
// ✅ Good: Implement caching for frequently accessed data
public class CachedProductService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMemoryCache _cache;
private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(15);
public async Task<Product?> GetProductAsync(int id)
{
var cacheKey = $"product_{id}";
if (_cache.TryGetValue(cacheKey, out Product? cachedProduct))
{
return cachedProduct;
}
var repository = _unitOfWork.GetRepository<Product, int>();
var product = await repository.GetByIdAsync(id);
if (product != null)
{
_cache.Set(cacheKey, product, _cacheExpiry);
}
return product;
}
}
The library provides opt-in OpenTelemetry-compatible metrics via System.Diagnostics.Metrics. Metrics are disabled by default and can be enabled via the fluent configuration API.
services.AddFSEntityFramework<ApplicationDbContext>()
.WithMetrics() // Enable production metrics
.WithAudit()
.UsingHttpContext()
.Build();
| Metric | Type | Description |
|---|---|---|
repository.operations | Counter | Total repository operations (tag: operation) |
repository.operations.errors | Counter | Repository operation errors (tags: operation, error_type) |
repository.operation.duration | Histogram | Operation duration in ms (tag: operation) |
unitofwork.savechanges | Counter | SaveChanges calls (tag: status) |
unitofwork.transactions | Counter | Transaction operations (tag: type) |
unitofwork.cache.hits | Counter | Repository cache hits |
unitofwork.cache.misses | Counter | Repository cache misses |
interceptor.audit.entities | Counter | Audited entities (tag: state) |
interceptor.idgeneration.generated | Counter | Generated IDs (tag: key_type) |
events.dispatched | Counter | Dispatched domain events (tag: event_type) |
events.handler.errors | Counter | Handler errors (tags: event_type, handler_type) |
events.dispatch.duration | Histogram | Event dispatch duration in ms (tag: event_type) |
All metrics use the meter name FS.EntityFramework.Library and are compatible with any OpenTelemetry collector (Prometheus, Grafana, Azure Monitor, etc.).
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter("FS.EntityFramework.Library");
});
Follow these guidelines when designing your entities:
// ✅ Good: Well-designed entity
public class Product : BaseAuditableEntity<int>, ISoftDelete
{
// Private setters for business logic enforcement
public string Name { get; private set; } = string.Empty;
public decimal Price { get; private set; }
// Public properties for simple data
public string Description { get; set; } = string.Empty;
// Soft delete properties (automatic)
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
// Factory method for creation
public static Product Create(string name, decimal price)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name cannot be empty", nameof(name));
if (price <= 0)
throw new ArgumentException("Price must be positive", nameof(price));
var product = new Product();
product.SetName(name);
product.SetPrice(price);
// Raise domain event
product.AddDomainEvent(new ProductCreatedEvent(product.Id, name, price));
return product;
}
// Business methods with validation
public void SetName(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name cannot be empty", nameof(name));
Name = name;
}
public void SetPrice(decimal price)
{
if (price <= 0)
throw new ArgumentException("Price must be positive", nameof(price));
var oldPrice = Price;
Price = price;
if (oldPrice != price)
{
AddDomainEvent(new ProductPriceChangedEvent(Id, oldPrice, price));
}
}
}
Implement clean service layer patterns:
// ✅ Good: Service with proper separation of concerns
public class ProductApplicationService
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ProductApplicationService> _logger;
public ProductApplicationService(
IUnitOfWork unitOfWork,
ILogger<ProductApplicationService> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
public async Task<ProductDto> CreateProductAsync(CreateProductCommand command)
{
// Input validation
if (string.IsNullOrWhiteSpace(command.Name))
throw new ArgumentException("Product name is required");
var repository = _unitOfWork.GetRepository<Product, int>();
// Business logic
var product = Product.Create(command.Name, command.Price);
// Persistence
await repository.AddAsync(product);
await _unitOfWork.SaveChangesAsync(); // Domain events dispatched here
_logger.LogInformation("Created product {ProductId}: {ProductName}",
product.Id, product.Name);
// Return DTO
return new ProductDto(product.Id, product.Name, product.Price);
}
}
Problem: Domain events are not being handled even though handlers are registered.
Solution: Ensure you're using the domain unit of work or have properly configured event dispatching:
// ❌ Wrong: Using regular SaveChanges
await _unitOfWork.SaveChangesAsync(); // Events might not be dispatched
// ✅ Correct: Ensure domain events are configured
services.AddFSEntityFramework<ApplicationDbContext>()
.WithDomainEvents()
.UsingDefaultDispatcher()
.WithAutoHandlerDiscovery()
.Complete()
.Build();
Problem: Entities are being hard deleted instead of soft deleted.
Solution: Ensure entity implements ISoftDelete and soft delete is configured:
// ✅ Entity must implement ISoftDelete
public class Product : BaseAuditableEntity<int>, ISoftDelete
{
// ISoftDelete properties
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
}
// ✅ Configure soft delete
services.AddFSEntityFramework<ApplicationDbContext>()
.WithSoftDelete()
.Build();
Problem: CreatedAt, CreatedBy, etc., are not being populated automatically.
Solution: Ensure audit configuration is properly set up:
// ✅ Configure audit with user provider
services.AddFSEntityFramework<ApplicationDbContext>()
.WithAudit()
.UsingHttpContext() // or another user provider
.Build();
Problem: InvalidOperationException when trying to get a repository.
Solution: Ensure your DbContext is properly registered before adding FS.EntityFramework:
// ✅ Register DbContext first
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
// ✅ Then add FS.EntityFramework
services.AddFSEntityFramework<ApplicationDbContext>()
.Build();
// ✅ Use built-in projection methods for better performance (v10.0.2+)
public async Task<IReadOnlyList<ProductSummaryDto>> GetProductSummariesAsync()
{
var repository = _unitOfWork.GetRepository<Product, int>();
// Direct projection method
return await repository.GetAllAsync(p => new ProductSummaryDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
});
}
// ✅ Paginated projection
public async Task<IPaginate<ProductSummaryDto>> GetPagedProductSummariesAsync(int page, int size)
{
var repository = _unitOfWork.GetRepository<Product, int>();
return await repository.GetPagedAsync(
selector: p => new ProductSummaryDto { Id = p.Id, Name = p.Name, Price = p.Price },
pageIndex: page,
pageSize: size,
orderBy: q => q.OrderBy(p => p.Name));
}
// ✅ Disable tracking for read-only queries
var products = await repository.GetQueryable(disableTracking: true)
.Where(p => p.Price > 100)
.ToListAsync();
// ✅ Use bulk operations for better performance
await repository.BulkInsertAsync(products, saveChanges: true);
We welcome contributions! This project is open source and benefits from community involvement.
This project is licensed under the MIT License. See the LICENSE file for details.
If you encounter any issues or have questions:
Happy Domain Modeling! 🏛️
Made with ❤️ by Furkan Sarıkaya