Gargar.Common.Repository 2.2.1
Gargar.Common.Repository
A comprehensive Entity Framework Core repository pattern library with query operations, CRUD functionality, bulk operations, paging, sorting, multi-tenant support, and flexible configuration options.
Features
- Generic Repository Pattern: Type-safe repository with full CRUD operations
- Query Repository: Read-only operations with advanced querying
- Bulk Operations: High-performance bulk insert, update, delete, and upsert
- Paging & Sorting: Built-in support for paginated and sorted queries
- Related Properties: Automatic eager loading with circular reference detection
- Multi-Tenant Support: Built-in tenant isolation and filtering (v2.0.0+)
- Flexible Configuration: Customizable options for save strategies and bulk operations
- Progress Tracking: Monitor bulk operation progress
- Batch Processing: Process large datasets in configurable batches
- Unit of Work: Transaction management across multiple repositories
Installation
From Private NuGet Feed
dotnet add package Gargar.Common.Repository
From Local Project Reference
<ItemGroup>
<ProjectReference Include="path\to\Gargar.Common.Repository\Gargar.Common.Repository.csproj" />
</ItemGroup>
AI assistants (Cursor, Copilot, Claude)
Consumer applications can copy the skills/gargar-common-repository/ folder from this repository into their own repo (see skills/gargar-common-repository/INSTALL.md) so assistants use correct registration, query parameter order, and tenant/UoW usage.
Quick Start
1. Define Your Entity
using Gargar.Common.Repository.Helpers;
using Gargar.Common.Repository.Attributes;
public class Product : Entity
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
[LoadableRelatedProperty]
public Category? Category { get; set; }
public long CategoryId { get; set; }
}
2. Configure Services
using Gargar.Common.Repository.Extensions;
// Register repositories for your DbContext
builder.Services.AddRepositories<MyDbContext>(options =>
{
options.RelatedPropertiesMaxDepth = 3;
options.SaveChangesStrategy = SaveChangesStrategy.PerUnitOfWork;
});
// Or with bulk operations support
builder.Services.AddRepositoriesWithBulkOperations<MyDbContext>();
// Auto-discover repositories from assembly
builder.Services.AddRepositoriesFromAssembly<MyDbContext>(typeof(Product).Assembly);
3. Use Repositories
public class ProductService
{
private readonly IRepository<Product> _repository;
public ProductService(IRepository<Product> repository)
{
_repository = repository;
}
public async Task<Product> CreateProduct(string name, decimal price)
{
var product = new Product { Name = name, Price = price };
return await _repository.InsertAsync(product);
}
public async Task<Product?> GetProduct(long id)
{
return await _repository.GetAsync(id, relatedProperties: ["Category"]);
}
public async Task<PagedList<Product>> GetProducts(int page, int pageSize)
{
return await _repository.GetPagedListAsync(
pageIndex: page,
pageSize: pageSize,
predicate: p => p.Price > 0,
sortingDetails: new SortingDetails(new SortItem("Name", SortDirection.Ascending))
);
}
}
Usage Examples
Basic CRUD Operations
// Insert
var product = new Product { Name = "Laptop", Price = 999.99m };
await repository.InsertAsync(product);
// Get by ID
var product = await repository.GetAsync(1);
// Get with related properties
var product = await repository.GetAsync(1, relatedProperties: ["Category", "Supplier"]);
// Update
product.Price = 899.99m;
await repository.UpdateAsync(product);
// Update with action
await repository.UpdateAsync(1, async (product) =>
{
product.Price *= 0.9m; // 10% discount
});
// Delete
await repository.DeleteAsync(product);
// Delete by ID
await repository.DeleteAsync(1);
// Delete by predicate
await repository.DeleteAsync(p => p.Price < 10);
Query Operations
// Get list with filtering
var products = await repository.GetListAsync(
predicate: p => p.Price > 100 && p.Name.Contains("Phone"),
skip: 0,
take: 10
);
// Get list with projection
var productNames = await repository.GetListAsync(
projection: p => p.Name,
predicate: p => p.Price > 100
);
// Count
var count = await repository.CountAsync(p => p.Price > 100);
// Exists
var exists = await repository.ExistsAsync(p => p.Name == "Laptop");
Paging and Sorting
// Simple paging
var pagedProducts = await repository.GetPagedListAsync(
pageIndex: 0,
pageSize: 20
);
Console.WriteLine($"Total: {pagedProducts.PagingDetails.TotalCount}");
Console.WriteLine($"Pages: {pagedProducts.PagingDetails.TotalPages}");
Console.WriteLine($"Has Next: {pagedProducts.PagingDetails.HasNextPage}");
// Paging with sorting
var sortingDetails = new SortingDetails(new List<SortItem>
{
new("Price", SortDirection.Descending),
new("Name", SortDirection.Ascending)
});
var pagedSorted = await repository.GetPagedListAsync(
pageIndex: 0,
pageSize: 20,
sortingDetails: sortingDetails
);
// Paging with projection
var pagedProjected = await repository.GetPagedListAsync(
projection: p => new { p.Id, p.Name, p.Price },
pageIndex: 0,
pageSize: 20
);
Bulk Operations
// Bulk insert
var products = Enumerable.Range(1, 10000)
.Select(i => new Product { Name = $"Product {i}", Price = i * 10 })
.ToList();
await bulkRepository.BulkInsertAsync(products);
// Bulk insert with count
var insertedCount = await bulkRepository.BulkInsertWithCountAsync(products);
Console.WriteLine($"Inserted {insertedCount} records");
// Bulk update
foreach (var product in products)
{
product.Price *= 1.1m; // 10% price increase
}
await bulkRepository.BulkUpdateAsync(products);
// Bulk delete
await bulkRepository.BulkDeleteAsync(products);
// Bulk upsert (insert or update)
await bulkRepository.BulkUpsertAsync(products);
Bulk Operations with Batching and Progress
var progress = new Progress<BulkProgress>(p =>
{
Console.WriteLine($"Progress: {p.PercentComplete:F1}% - " +
$"Batch {p.CurrentBatch}/{p.TotalBatches} - " +
$"{p.Operation}");
});
await bulkRepository.BulkInsertBatchedAsync(
entities: products,
batchSize: 1000,
progress: progress
);
// Output:
// Progress: 10.0% - Batch 1/10 - Inserting batch 1 of 10
// Progress: 20.0% - Batch 2/10 - Inserting batch 2 of 10
// ...
// Progress: 100.0% - Batch 10/10 - Completed
Bulk Operations with Options
⚠️ IMPORTANT NOTE: Bulk operation configuration options (
PropertiesToInclude,PropertiesToExclude,MergeByProperties, etc.) are currently NOT IMPLEMENTED in the baseEfCoreBulkRepository. The option parameters are accepted for API compatibility and future extensibility, but they have no effect on the actual bulk operations. All entity properties are currently processed in bulk operations.For production scenarios requiring advanced bulk operations with property filtering, custom mappings, or optimized SQL Server bulk copy, consider:
- Implementing a custom bulk repository extending
EfCoreBulkRepository- Using specialized libraries like
EFCore.BulkExtensionsorZ.EntityFramework.Extensions- Contributing to this library to implement these features
This feature is planned for a future release.
Future API (Not Currently Implemented):
// Insert only specific properties (NOT IMPLEMENTED)
await bulkRepository.BulkInsertAsync(
products,
bulkOptions =>
{
bulkOptions.PropertiesToInclude = p => new { p.Name, p.Price };
bulkOptions.IncludeShadowProperties = false;
}
);
// Update excluding certain properties (NOT IMPLEMENTED)
await bulkRepository.BulkUpdateAsync(
products,
bulkOptions =>
{
bulkOptions.PropertiesToExclude = p => new { p.CreatedAt, p.CreatedBy };
bulkOptions.MergeByProperties = p => new { p.Id };
}
);
// Custom timeout (NOT IMPLEMENTED)
await bulkRepository.BulkInsertAsync(
products,
bulkOptions: null,
bulkCopyOptions: options =>
{
options.Timeout = 60; // 60 seconds
}
);
Related Properties Loading
// Load all related properties
var product = await repository.GetAsync(1, relatedProperties: LoadRelatedProperties.All);
// Load specific related properties
var product = await repository.GetAsync(1, relatedProperties: ["Category", "Supplier"]);
// Load nested related properties
var product = await repository.GetAsync(1, relatedProperties: ["Category.ParentCategory"]);
// Define loadable properties with attribute
public class Product
{
[LoadableRelatedProperty]
public Category? Category { get; set; }
[LoadableRelatedProperty(splitQuery: true)]
public List<Review> Reviews { get; set; } = [];
[LoadableRelatedProperty(onlyForQuerying: true)]
public Supplier? Supplier { get; set; }
}
Multi-Tenant Repositories
The repository package provides built-in support for multi-tenant applications with automatic tenant isolation and filtering (v2.0.0+).
Built-in Tenant Pattern
1. Define Tenant Filter
using Gargar.Common.Repository.Interfaces;
public class TenantFilter : ITenantFilter<long>
{
private readonly IHttpContextAccessor _httpContextAccessor;
public TenantFilter(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public long GetCurrentTenantId()
{
var tenantIdClaim = _httpContextAccessor.HttpContext?.User.FindFirst("tenant_id")?.Value;
return long.TryParse(tenantIdClaim, out var tenantId) ? tenantId : 0;
}
}
2. Define Tenant Entity
using Gargar.Common.Repository.Helpers;
using Gargar.Common.Repository.Interfaces;
public class TenantEntity : Entity, ITenantEntity<long>
{
public long TenantId { get; set; }
}
public class Address : TenantEntity
{
public long Id { get; set; }
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
public string Country { get; set; } = string.Empty;
}
3. Register Services
// Register tenant filter
builder.Services.AddScoped<ITenantFilter<long>, TenantFilter>();
// Register repositories
builder.Services.AddRepositories<MyDbContext>();
// Register tenant repository
builder.Services.AddTenantEntityRepository<MyDbContext, Address, long>();
// Or with user ID provider for audit fields
builder.Services.AddTenantEntityRepository<MyDbContext, Address, long>(
sp => () =>
{
var httpContext = sp.GetRequiredService<IHttpContextAccessor>().HttpContext;
var userIdClaim = httpContext?.User.FindFirst("sub")?.Value;
return long.TryParse(userIdClaim, out var userId) ? userId : 0;
}
);
4. Use Tenant Repository
public class AddressService
{
private readonly IRepository<Address> _repository;
public AddressService(IRepository<Address> repository)
{
_repository = repository;
}
public async Task<Address> CreateAddress(string street, string city)
{
var address = new Address { Street = street, City = city };
// TenantId is automatically set to current tenant
return await _repository.InsertAsync(address);
}
public async Task<List<Address>> GetAllAddresses()
{
// Only returns addresses for current tenant
return await _repository.GetListAsync();
}
public async Task<Address?> GetAddress(long id)
{
// Only returns address if it belongs to current tenant
return await _repository.GetAsync(id);
}
public async Task UpdateAddress(Address address)
{
// Validates tenant ownership before updating
await _repository.UpdateAsync(address);
}
}
Custom Tenant Repository (Advanced)
For custom tenant property names or complex filtering logic:
using Gargar.Common.Repository.Implementations;
using Gargar.Common.Repository.Helpers;
// Define your entity interface
public interface IMerchantEntity
{
long MerchantId { get; set; }
}
public class MerchantEntity : Entity, IMerchantEntity
{
public long MerchantId { get; set; }
}
public class Address : MerchantEntity
{
public long Id { get; set; }
public string Street { get; set; } = string.Empty;
public string City { get; set; } = string.Empty;
}
// Create custom repository
public class MerchantEntityRepository<TDbContext, TEntity>
: EfCoreEntityRepositoryBase<TDbContext, TEntity>
where TDbContext : DbContext
where TEntity : Entity, IMerchantEntity
{
private readonly IIdentityService _identity;
public MerchantEntityRepository(
TDbContext context,
RepositoryOptions<TDbContext> options,
IIdentityService identity,
Func<long>? userIdProvider = null)
: base(context, options, userIdProvider)
{
_identity = identity;
}
// Override Query to filter by MerchantId
protected override IQueryable<TEntity> Query
=> base.Query.Where(e => e.MerchantId == _identity.GetMerchantId());
// Override InsertAsync to set MerchantId
public override async Task<TEntity> InsertAsync(TEntity entity, CancellationToken cancellationToken = default)
{
entity.MerchantId = _identity.GetMerchantId();
return await base.InsertAsync(entity, cancellationToken);
}
// Override UpdateAsync to validate merchant ownership
public override async Task<TEntity> UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
{
if (entity.MerchantId != _identity.GetMerchantId())
{
throw new UnauthorizedAccessException("Cannot update entity from different merchant");
}
return await base.UpdateAsync(entity, cancellationToken);
}
}
// Register custom repository
builder.Services.AddScoped<IRepository<Address>>(sp =>
{
var context = sp.GetRequiredService<MyDbContext>();
var options = sp.GetRequiredService<RepositoryOptions<MyDbContext>>();
var identity = sp.GetRequiredService<IIdentityService>();
Func<long> userIdProvider = () =>
{
var httpContext = sp.GetRequiredService<IHttpContextAccessor>().HttpContext;
var userIdClaim = httpContext?.User.FindFirst("sub")?.Value;
return long.TryParse(userIdClaim, out var userId) ? userId : 0;
};
return new MerchantEntityRepository<MyDbContext, Address>(
context, options, identity, userIdProvider);
});
Using Tenant and Non-Tenant Repositories Together
Yes, you can use both tenant and non-tenant repositories in the same application! They work independently because they're registered for different entity types.
Example: Mixed Repository Types
// Define entities
public class Product : Entity // Non-tenant entity
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
public class Order : Entity, ITenantEntity<long> // Tenant entity
{
public long Id { get; set; }
public long TenantId { get; set; } // Required for tenant filtering
public long CustomerId { get; set; }
public decimal TotalAmount { get; set; }
}
public class OrderItem : Entity, ITenantEntity<long> // Tenant entity
{
public long Id { get; set; }
public long TenantId { get; set; }
public long OrderId { get; set; }
public long ProductId { get; set; }
public int Quantity { get; set; }
}
// Register base repository services (required - sets up options and helpers)
builder.Services.AddRepositories<MyDbContext>();
// Register tenant filter (required for tenant repositories)
builder.Services.AddScoped<ITenantFilter<long>, TenantFilter>();
// Register non-tenant repository (regular entity) - REQUIRED
// AddRepositories() alone does NOT register repositories for entities
builder.Services.AddGenericRepository<MyDbContext, Product>();
// Register tenant repositories (tenant entities) - REQUIRED
builder.Services.AddTenantEntityRepository<MyDbContext, Order, long>();
builder.Services.AddTenantEntityRepository<MyDbContext, OrderItem, long>();
// Use in services
public class OrderService
{
private readonly IRepository<Product> _productRepository; // Non-tenant
private readonly IRepository<Order> _orderRepository; // Tenant-filtered
private readonly IRepository<OrderItem> _orderItemRepository; // Tenant-filtered
public OrderService(
IRepository<Product> productRepository,
IRepository<Order> orderRepository,
IRepository<OrderItem> orderItemRepository)
{
_productRepository = productRepository;
_orderRepository = orderRepository;
_orderItemRepository = orderItemRepository;
}
public async Task<Order> CreateOrder(CreateOrderDto dto)
{
// Non-tenant repository - returns all products (no filtering)
var product = await _productRepository.GetAsync(dto.ProductId);
if (product == null)
throw new NotFoundException("Product not found");
// Tenant repository - automatically filters by current tenant
var order = new Order
{
CustomerId = dto.CustomerId,
TotalAmount = product.Price * dto.Quantity
// TenantId is automatically set
};
await _orderRepository.InsertAsync(order);
// Tenant repository - automatically filters by current tenant
var orderItem = new OrderItem
{
OrderId = order.Id,
ProductId = product.Id,
Quantity = dto.Quantity
// TenantId is automatically set
};
await _orderItemRepository.InsertAsync(orderItem);
return order;
}
public async Task<List<Product>> GetAllProducts()
{
// Non-tenant - returns all products across all tenants
return await _productRepository.GetListAsync();
}
public async Task<List<Order>> GetMyOrders()
{
// Tenant repository - only returns orders for current tenant
return await _orderRepository.GetListAsync();
}
}
Key Points
- Different Entity Types: Tenant repositories require
ITenantEntity<TTenantKey>, non-tenant repositories work with regular entities - Independent Registration: Each repository type is registered separately
- No Conflicts: They don't interfere with each other because they handle different entity types
- Shared DbContext: All repositories share the same
DbContextinstance, so Unit of Work works across both types - Transaction Support: You can use transactions that include both tenant and non-tenant operations
When to Use Each Type
Use Tenant Repositories For:
- User-specific data (orders, addresses, preferences)
- Tenant-isolated business data
- Data that must be separated by tenant
Use Non-Tenant Repositories For:
- Shared reference data (products catalog, categories)
- System configuration
- Global data that all tenants can access
- Master data that doesn't belong to any tenant
Complete Registration Example
// Register base repository services (REQUIRED - sets up options and helpers)
// This does NOT automatically register repositories for entities
builder.Services.AddRepositories<MyDbContext>(options =>
{
options.SaveChangesStrategy = SaveChangesStrategy.PerUnitOfWork;
});
// Register tenant filter (required for tenant repositories)
builder.Services.AddScoped<ITenantFilter<long>, TenantFilter>();
// Non-tenant repositories (shared data) - MUST register each entity explicitly
builder.Services.AddGenericRepository<MyDbContext, Product>();
builder.Services.AddGenericRepository<MyDbContext, Category>();
builder.Services.AddGenericRepository<MyDbContext, SystemConfig>();
// Tenant repositories (tenant-isolated data) - MUST register each entity explicitly
builder.Services.AddTenantEntityRepository<MyDbContext, Order, long>();
builder.Services.AddTenantEntityRepository<MyDbContext, OrderItem, long>();
builder.Services.AddTenantEntityRepository<MyDbContext, Address, long>();
builder.Services.AddTenantEntityRepository<MyDbContext, Customer, long>();
Important: Explicit Registration Required
AddRepositories<TDbContext>() only registers:
RepositoryOptions<TDbContext>(configuration)- Helper services (
IPropertyGetterCache,IEntityPropertiesProvider)
It does NOT automatically register repositories for entities. You must explicitly register each repository using:
AddGenericRepository<TDbContext, TEntity>()for non-tenant entitiesAddTenantRepository<TDbContext, TEntity, TTenantKey>()for tenant entitiesAddTenantEntityRepository<TDbContext, TEntity, TTenantKey>()for tenant entities with audit fieldsAddRepositoriesFromAssembly<TDbContext>()for auto-discovery (requires[Repository]attributes)
Alternative: Auto-Discovery from Assembly
Instead of manually registering each repository, you can use assembly-based auto-discovery:
// Register base services
builder.Services.AddRepositories<MyDbContext>();
// Register tenant filter
builder.Services.AddScoped<ITenantFilter<long>, TenantFilter>();
// Auto-discover and register repositories from assembly
builder.Services.AddRepositoriesFromAssembly<MyDbContext>(typeof(Product).Assembly);
This method scans the assembly for entity types and registers appropriate repositories. Note that tenant repositories still need explicit registration with AddTenantRepository or AddTenantEntityRepository.
Security Considerations
- Always use tenant filtering for multi-tenant applications to prevent data leaks
- Validate tenant ownership on all write operations (update, delete)
- Set tenant ID automatically on insert to prevent user manipulation
- Use scoped lifetime for tenant filters to ensure correct tenant context
- Test tenant isolation thoroughly with integration tests
- Audit tenant access by logging tenant ID with all operations
- Be careful with non-tenant repositories - ensure they're only used for truly shared data
Tenant Filtering Benefits
- Automatic isolation: All queries automatically filter by tenant
- Security by default: Prevents cross-tenant data access
- Simplified code: No need to add
.Where(e => e.TenantId == ...)to every query - Consistent behavior: Tenant filtering applied across all repository methods
- Validation: Automatic tenant ownership validation on updates/deletes
Entity with Audit Fields
public class Product : Entity
{
public long Id { get; set; }
public string Name { get; set; } = string.Empty;
// CreatedBy, UpdatedBy, CreatedAt, UpdatedAt inherited from Entity
}
// Use with user ID provider
builder.Services.AddScoped<IEntityRepository<Product>>(sp =>
{
var context = sp.GetRequiredService<MyDbContext>();
var options = sp.GetRequiredService<RepositoryOptions<MyDbContext>>();
var httpContext = sp.GetRequiredService<IHttpContextAccessor>().HttpContext;
Func<long> userIdProvider = () =>
{
var userIdClaim = httpContext?.User.FindFirst("sub")?.Value;
return long.TryParse(userIdClaim, out var userId) ? userId : 0;
};
return new EfCoreEntityRepositoryBase<MyDbContext, Product>(context, options, userIdProvider);
});
Unit of Work Pattern
The repository library implements the Unit of Work pattern to manage transactions across multiple repository operations. This ensures that all changes are committed atomically or rolled back together if any operation fails.
How It Works
The Unit of Work pattern is implemented through Entity Framework Core's transaction management combined with a configurable save strategy:
- Shared DbContext: All repositories share the same
DbContextinstance, allowing them to participate in the same transaction - Save Strategy: Controls when
SaveChanges()is called:PerUnitOfWork(default): Defers saving until the transaction commitsPerOperation: Saves immediately after each operation
Transaction Lifecycle
How Transactions Start
Transactions are started using the IUnitOfWork<TDbContext> interface (recommended) or DbContext directly:
// Using UnitOfWork (Recommended)
await using var transaction = await _unitOfWork.BeginTransactionAsync();
// Using DbContext directly (Legacy - still supported)
await using var transaction = await _context.Database.BeginTransactionAsync();
What happens when a transaction starts:
- EF Core opens a database connection (if not already open)
- Begins a database transaction (
BEGIN TRANSACTIONin SQL) - Sets
Context.Database.CurrentTransactionto the new transaction - All subsequent repository operations participate in this transaction
When Transactions End
Transactions end in one of three ways:
1. Commit (Success)
await transaction.CommitAsync();
- What happens:
- EF Core automatically calls
SaveChanges()for all tracked changes - All changes are persisted to the database
- Transaction is committed (
COMMITin SQL) - Connection is released back to the pool
- EF Core automatically calls
2. Rollback (Failure)
await transaction.RollbackAsync();
- What happens:
- All changes in the change tracker are discarded
- Transaction is rolled back (
ROLLBACKin SQL) - No changes are persisted to the database
- Connection is released back to the pool
3. Disposal (Automatic)
await using var transaction = await _context.Database.BeginTransactionAsync();
// If transaction is not explicitly committed/rolled back, it's rolled back on disposal
- What happens:
- If not committed, transaction is automatically rolled back
- Connection is released back to the pool
- This is why
usingstatements are recommended
Complete Transaction Flow
Here's what happens step-by-step with PerUnitOfWork strategy:
public async Task<Order> CreateOrder(OrderDto dto)
{
// STEP 1: Start transaction using UnitOfWork
await using var transaction = await _unitOfWork.BeginTransactionAsync();
// ✅ Transaction started
// ✅ Context.Database.CurrentTransaction is now set
// ✅ Database connection opened
try
{
// STEP 2: Perform operations (SaveChanges is deferred)
var order = new Order { CustomerId = dto.CustomerId };
await _orderRepository.InsertAsync(order);
// ✅ Order added to change tracker
// ✅ SaveChanges() is NOT called (deferred because transaction exists)
var orderItem = new OrderItem { OrderId = order.Id };
await _orderItemRepository.InsertAsync(orderItem);
// ✅ OrderItem added to change tracker
// ✅ SaveChanges() is NOT called (deferred because transaction exists)
// STEP 3: Commit transaction
await transaction.CommitAsync();
// ✅ EF Core automatically calls SaveChanges() for all tracked changes
// ✅ All changes are persisted atomically
// ✅ Transaction committed in database
// ✅ Connection released
return order;
}
catch
{
// STEP 4: Rollback on error
await transaction.RollbackAsync();
// ✅ All changes discarded
// ✅ Transaction rolled back in database
// ✅ Connection released
throw;
}
// STEP 5: Disposal (automatic via 'using')
// ✅ If we reach here without commit/rollback, transaction is rolled back
}
Without Transaction (PerUnitOfWork Strategy)
When no transaction is active, SaveChanges() is called immediately:
// No transaction started - using UnitOfWork
// Check if transaction is needed
if (!_unitOfWork.HasActiveTransaction)
{
var order = new Order { CustomerId = 1 };
await _orderRepository.InsertAsync(order);
// ✅ SaveChanges() IS called immediately (no transaction exists)
// ✅ Order is persisted to database
var orderItem = new OrderItem { OrderId = order.Id };
await _orderItemRepository.InsertAsync(orderItem);
// ✅ SaveChanges() IS called immediately (no transaction exists)
// ✅ OrderItem is persisted to database
}
Transaction State Detection
The repository checks for active transactions using Context.Database.CurrentTransaction:
// Inside repository's SaveChanges method:
if (RepositoryOptions.SaveChangesStrategy == SaveChangesStrategy.PerUnitOfWork
&& Context.Database.CurrentTransaction == null) // ← Checks if transaction exists
{
// No transaction - save immediately
return Context.SaveChangesAsync(cancellationToken);
}
// Transaction exists - defer saving
return Task.CompletedTask;
Implementation Details
The SaveChanges method in EfCoreRepositoryBase implements the strategy:
internal Task SaveChanges(CancellationToken cancellationToken)
{
// PerOperation: Always save immediately
if (RepositoryOptions.SaveChangesStrategy == SaveChangesStrategy.PerOperation)
{
return Context.SaveChangesAsync(cancellationToken);
}
// PerUnitOfWork: Only save if no active transaction (first operation)
// Otherwise, defer to transaction commit
if (RepositoryOptions.SaveChangesStrategy == SaveChangesStrategy.PerUnitOfWork
&& Context.Database.CurrentTransaction == null)
{
return Context.SaveChangesAsync(cancellationToken);
}
// Transaction exists - defer saving
return Task.CompletedTask;
}
Using Unit of Work
Example 1: Using Unit of Work Interface (Recommended)
The repository library provides an IUnitOfWork<TDbContext> interface for transaction management, eliminating the need to inject DbContext directly:
using Gargar.Common.Repository.Interfaces;
public class OrderService
{
private readonly IRepository<Order> _orderRepository;
private readonly IRepository<OrderItem> _orderItemRepository;
private readonly IRepository<Inventory> _inventoryRepository;
private readonly IUnitOfWork<MyDbContext> _unitOfWork;
public OrderService(
IRepository<Order> orderRepository,
IRepository<OrderItem> orderItemRepository,
IRepository<Inventory> inventoryRepository,
IUnitOfWork<MyDbContext> unitOfWork)
{
_orderRepository = orderRepository;
_orderItemRepository = orderItemRepository;
_inventoryRepository = inventoryRepository;
_unitOfWork = unitOfWork;
}
public async Task<Order> CreateOrderWithItems(OrderDto orderDto)
{
// Start a transaction using Unit of Work
await using var transaction = await _unitOfWork.BeginTransactionAsync();
try
{
// Create order
var order = new Order
{
CustomerId = orderDto.CustomerId,
TotalAmount = orderDto.Items.Sum(i => i.Price * i.Quantity)
};
await _orderRepository.InsertAsync(order);
// Create order items
foreach (var itemDto in orderDto.Items)
{
var orderItem = new OrderItem
{
OrderId = order.Id,
ProductId = itemDto.ProductId,
Quantity = itemDto.Quantity,
Price = itemDto.Price
};
await _orderItemRepository.InsertAsync(orderItem);
// Update inventory
var inventory = await _inventoryRepository.GetAsync(
i => i.ProductId == itemDto.ProductId);
if (inventory != null)
{
inventory.Quantity -= itemDto.Quantity;
await _inventoryRepository.UpdateAsync(inventory);
}
}
// Commit transaction - all changes saved atomically
await transaction.CommitAsync();
return order;
}
catch
{
// Rollback on any error - all changes are discarded
await transaction.RollbackAsync();
throw;
}
}
}
Example 1a: Using DbContext Directly (Legacy - Still Supported)
You can still use DbContext directly if needed:
public class OrderService
{
private readonly IRepository<Order> _orderRepository;
private readonly IRepository<OrderItem> _orderItemRepository;
private readonly IRepository<Inventory> _inventoryRepository;
private readonly MyDbContext _context;
public OrderService(
IRepository<Order> orderRepository,
IRepository<OrderItem> orderItemRepository,
IRepository<Inventory> inventoryRepository,
MyDbContext context)
{
_orderRepository = orderRepository;
_orderItemRepository = orderItemRepository;
_inventoryRepository = inventoryRepository;
_context = context;
}
public async Task<Order> CreateOrderWithItems(OrderDto orderDto)
{
// Start a transaction using DbContext directly
await using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// ... same operations as above ...
await transaction.CommitAsync();
return order;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
Transaction Flow:
- Transaction starts:
BeginTransactionAsync()opens a database transaction - Operations execute: All
InsertAsyncandUpdateAsynccalls add entities to the change tracker - SaveChanges deferred:
SaveChanges()is not called after each operation (becauseCurrentTransaction != null) - Transaction commits:
CommitAsync()automatically callsSaveChanges()for all tracked changes - Changes persisted: All changes are saved atomically to the database
- On error:
RollbackAsync()discards all changes without callingSaveChanges()
Example 2: Using Unit of Work with Multiple Repositories
public class InventoryService
{
private readonly IRepository<Inventory> _inventoryRepository;
private readonly IRepository<TransferLog> _transferLogRepository;
private readonly IUnitOfWork<MyDbContext> _unitOfWork;
public InventoryService(
IRepository<Inventory> inventoryRepository,
IRepository<TransferLog> transferLogRepository,
IUnitOfWork<MyDbContext> unitOfWork)
{
_inventoryRepository = inventoryRepository;
_transferLogRepository = transferLogRepository;
_unitOfWork = unitOfWork;
}
public async Task TransferInventory(long fromWarehouseId, long toWarehouseId, long productId, int quantity)
{
await using var transaction = await _unitOfWork.BeginTransactionAsync();
try
{
// Get source inventory
var sourceInventory = await _inventoryRepository.GetAsync(
i => i.WarehouseId == fromWarehouseId && i.ProductId == productId);
if (sourceInventory == null || sourceInventory.Quantity < quantity)
{
throw new InvalidOperationException("Insufficient inventory");
}
// Get destination inventory
var destInventory = await _inventoryRepository.GetAsync(
i => i.WarehouseId == toWarehouseId && i.ProductId == productId);
// Update source
sourceInventory.Quantity -= quantity;
await _inventoryRepository.UpdateAsync(sourceInventory);
// Update or create destination
if (destInventory != null)
{
destInventory.Quantity += quantity;
await _inventoryRepository.UpdateAsync(destInventory);
}
else
{
destInventory = new Inventory
{
WarehouseId = toWarehouseId,
ProductId = productId,
Quantity = quantity
};
await _inventoryRepository.InsertAsync(destInventory);
}
// Create transfer log
var transferLog = new TransferLog
{
FromWarehouseId = fromWarehouseId,
ToWarehouseId = toWarehouseId,
ProductId = productId,
Quantity = quantity,
TransferredAt = DateTimeOffset.UtcNow
};
await _transferLogRepository.InsertAsync(transferLog);
// All changes committed together
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
Example 3: PerOperation Strategy
When using PerOperation strategy, each operation saves immediately:
builder.Services.AddRepositories<MyDbContext>(options =>
{
options.SaveChangesStrategy = SaveChangesStrategy.PerOperation;
});
// Each operation saves immediately
await _orderRepository.InsertAsync(order); // Saved to DB
await _orderItemRepository.InsertAsync(item1); // Saved to DB
await _orderItemRepository.InsertAsync(item2); // Saved to DB
// If item2 fails, item1 and order are already saved
// Use transactions to ensure atomicity
await using var transaction = await _unitOfWork.BeginTransactionAsync();
try
{
await _orderRepository.InsertAsync(order);
await _orderItemRepository.InsertAsync(item1);
await _orderItemRepository.InsertAsync(item2);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
Save Strategy Comparison
| Strategy | When to Use | Pros | Cons |
|---|---|---|---|
| PerUnitOfWork (default) | Multiple related operations that must succeed together | Better performance, atomic operations, rollback support | Requires explicit transaction management |
| PerOperation | Independent operations, simple scenarios | Simpler code, immediate persistence | Less efficient, no automatic rollback |
Best Practices
- Use
IUnitOfWorkinstead ofDbContext: PreferIUnitOfWork<TDbContext>for transaction management - Use
PerUnitOfWorkfor complex operations: When multiple repositories need to coordinate - Always use transactions with
PerUnitOfWork: Wrap related operations inBeginTransactionAsync() - Handle exceptions properly: Always call
RollbackAsync()in catch blocks - Use
usingstatements: Ensures transaction disposal even on exceptions - Keep transactions short: Don't hold transactions open for long periods
- Check for active transactions: Use
_unitOfWork.HasActiveTransactionif needed
Unit of Work Interface
The repository library provides an IUnitOfWork<TDbContext> interface for managing transactions, eliminating the need to inject DbContext directly. This provides better encapsulation and follows the repository pattern principles.
IUnitOfWork Interface
public interface IUnitOfWork<TDbContext> where TDbContext : DbContext
{
/// <summary>
/// Begins a new database transaction
/// </summary>
Task<ITransaction> BeginTransactionAsync(
System.Data.IsolationLevel? isolationLevel = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the underlying DbContext (for advanced scenarios)
/// </summary>
TDbContext Context { get; }
/// <summary>
/// Checks if there is an active transaction
/// </summary>
bool HasActiveTransaction { get; }
}
ITransaction Interface
public interface ITransaction : IAsyncDisposable
{
/// <summary>
/// Commits all changes made in the transaction
/// </summary>
Task CommitAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Rolls back all changes made in the transaction
/// </summary>
Task RollbackAsync(CancellationToken cancellationToken = default);
}
Usage Example
using Gargar.Common.Repository.Interfaces;
public class OrderService
{
private readonly IRepository<Order> _orderRepo;
private readonly IRepository<OrderItem> _itemRepo;
private readonly IUnitOfWork<MyDbContext> _unitOfWork;
public OrderService(
IRepository<Order> orderRepo,
IRepository<OrderItem> itemRepo,
IUnitOfWork<MyDbContext> unitOfWork)
{
_orderRepo = orderRepo;
_itemRepo = itemRepo;
_unitOfWork = unitOfWork; // Use UnitOfWork instead of DbContext
}
public async Task<Order> CreateOrder(OrderDto dto)
{
// Start transaction using UnitOfWork
await using var transaction = await _unitOfWork.BeginTransactionAsync();
try
{
var order = new Order { CustomerId = dto.CustomerId };
await _orderRepo.InsertAsync(order);
foreach (var item in dto.Items)
{
await _itemRepo.InsertAsync(new OrderItem
{
OrderId = order.Id,
ProductId = item.ProductId
});
}
await transaction.CommitAsync();
return order;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
Benefits of using IUnitOfWork:
- Better Encapsulation: No need to inject
DbContextdirectly - Cleaner API: Transaction management through repository layer
- Consistent Pattern: All operations go through the same abstraction
- Testability: Easier to mock
IUnitOfWorkthanDbContext - Backward Compatible: Existing code using
DbContextdirectly still works
Transaction Isolation Levels
You can specify isolation levels when beginning transactions:
// Using UnitOfWork (Recommended)
await using var transaction = await _unitOfWork.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable);
// Using DbContext directly (Legacy - still supported)
await using var transaction = await _context.Database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable);
Available isolation levels:
ReadCommitted(default)ReadUncommittedRepeatableReadSerializable(highest isolation, prevents phantom reads)Snapshot(SQL Server specific)
Unit of Work with Dependency Injection
IUnitOfWork<TDbContext> is automatically registered when you call AddRepositories<TDbContext>():
// Register repositories and UnitOfWork
builder.Services.AddRepositories<MyDbContext>();
// In your service, inject IUnitOfWork instead of DbContext
public class OrderService
{
private readonly IRepository<Order> _orderRepo;
private readonly IRepository<OrderItem> _itemRepo;
private readonly IUnitOfWork<MyDbContext> _unitOfWork; // Automatically registered
public OrderService(
IRepository<Order> orderRepo,
IRepository<OrderItem> itemRepo,
IUnitOfWork<MyDbContext> unitOfWork)
{
_orderRepo = orderRepo;
_itemRepo = itemRepo;
_unitOfWork = unitOfWork; // All repositories share the same DbContext via UnitOfWork
}
}
Note: All repositories and IUnitOfWork share the same DbContext instance, so they all participate in the same transaction.
Performance Considerations
PerUnitOfWork: More efficient for batch operations (singleSaveChanges()call)PerOperation: More database round-trips but simpler error handling- Transaction overhead: Keep transactions as short as possible
- Connection pooling: EF Core manages connection pooling automatically
Advanced Configuration
Repository Options
builder.Services.AddRepositories<MyDbContext>(options =>
{
// Maximum depth for loading related properties
options.RelatedPropertiesMaxDepth = 3;
// Save changes strategy
options.SaveChangesStrategy = SaveChangesStrategy.PerUnitOfWork; // or PerOperation
// Bulk copy options
options.BulkCopyOptions = new BulkCopyOptions
{
Timeout = 30 // seconds
};
});
Custom Repository Implementation
public class ProductRepository : EfCoreRepositoryBase<MyDbContext, Product>
{
public ProductRepository(MyDbContext context, RepositoryOptions<MyDbContext> options)
: base(context, options)
{
}
// Add custom methods
public async Task<List<Product>> GetExpensiveProducts()
{
return await DbSet.Where(p => p.Price > 1000).ToListAsync();
}
// Override Query property to add default filtering
protected override IQueryable<Product> Query
=> base.Query.Where(p => p.IsActive);
}
// Register custom repository
builder.Services.AddRepository<Product, ProductRepository>();
API Reference
IRepository
Inherits from IQueryRepository<TEntity> and adds:
// Get for update (with tracking)
Task<List<TEntity>> GetListForUpdateAsync(
string[]? relatedProperties = null,
Expression<Func<TEntity, bool>>? predicate = null,
int? skip = null,
int? take = null,
SortingDetails? sortingDetails = null,
CancellationToken cancellationToken = default)
Task<TEntity?> GetForUpdateAsync(
object key,
string[]? relatedProperties = null,
CancellationToken cancellationToken = default)
Task<TEntity?> GetForUpdateAsync(
Expression<Func<TEntity, bool>> predicate,
string[]? relatedProperties = null,
CancellationToken cancellationToken = default)
// CRUD operations
Task<TEntity> InsertAsync(
TEntity entity,
CancellationToken cancellationToken = default)
Task<TEntity> UpdateAsync(
TEntity entity,
CancellationToken cancellationToken = default)
Task<TEntity?> UpdateAsync(
object key,
Func<TEntity, Task> updateAction,
CancellationToken cancellationToken = default)
Task DeleteAsync(
TEntity entity,
CancellationToken cancellationToken = default)
Task DeleteAsync(
object key,
CancellationToken cancellationToken = default)
Task DeleteAsync(
Expression<Func<TEntity, bool>> predicate,
CancellationToken cancellationToken = default)
IQueryRepository
// Get list
Task<List<TEntity>> GetListAsync(
string[]? relatedProperties = null,
Expression<Func<TEntity, bool>>? predicate = null,
int? skip = null,
int? take = null,
SortingDetails? sortingDetails = null,
CancellationToken cancellationToken = default)
Task<List<TResult>> GetListAsync<TResult>(
Expression<Func<TEntity, TResult>> projection,
string[]? relatedProperties = null,
Expression<Func<TEntity, bool>>? predicate = null,
int? skip = null,
int? take = null,
SortingDetails? sortingDetails = null,
CancellationToken cancellationToken = default)
// Get paged list
Task<PagedList<TEntity>> GetPagedListAsync(
int pageIndex,
int pageSize,
string[]? relatedProperties = null,
Expression<Func<TEntity, bool>>? predicate = null,
SortingDetails? sortingDetails = null,
CancellationToken cancellationToken = default)
Task<PagedList<TResult>> GetPagedListAsync<TResult>(
Expression<Func<TEntity, TResult>> projection,
int pageIndex,
int pageSize,
string[]? relatedProperties = null,
Expression<Func<TEntity, bool>>? predicate = null,
SortingDetails? sortingDetails = null,
CancellationToken cancellationToken = default)
// Get single entity
Task<TEntity?> GetAsync(
object key,
string[]? relatedProperties = null,
CancellationToken cancellationToken = default)
Task<TResult?> GetAsync<TResult>(
Expression<Func<TEntity, TResult>> projection,
object key,
string[]? relatedProperties = null,
CancellationToken cancellationToken = default)
Task<TEntity?> GetAsync(
Expression<Func<TEntity, bool>> predicate,
string[]? relatedProperties = null,
CancellationToken cancellationToken = default)
Task<TResult?> GetAsync<TResult>(
Expression<Func<TEntity, TResult>> projection,
Expression<Func<TEntity, bool>> predicate,
string[]? relatedProperties = null,
CancellationToken cancellationToken = default)
// Count and exists
Task<long> CountAsync(
Expression<Func<TEntity, bool>>? predicate = null,
CancellationToken cancellationToken = default)
Task<bool> ExistsAsync(
Expression<Func<TEntity, bool>>? predicate = null,
CancellationToken cancellationToken = default)
IBulkRepository
// Basic bulk operations
Task BulkInsertAsync(
IEnumerable<TEntity> entities,
Action<BulkInsertOptions<TEntity>>? bulkOptions = null,
Action<BulkCopyOptions>? bulkCopyOptions = null,
CancellationToken cancellationToken = default)
Task BulkUpdateAsync(
IEnumerable<TEntity> entities,
Action<BulkUpdateOptions<TEntity>>? bulkOptions = null,
Action<BulkCopyOptions>? bulkCopyOptions = null,
CancellationToken cancellationToken = default)
Task BulkDeleteAsync(
IEnumerable<TEntity> entities,
Action<BulkDeleteOptions<TEntity>>? bulkOptions = null,
Action<BulkCopyOptions>? bulkCopyOptions = null,
CancellationToken cancellationToken = default)
// Bulk operations with count
Task<int> BulkInsertWithCountAsync(
IEnumerable<TEntity> entities,
Action<BulkInsertOptions<TEntity>>? bulkOptions = null,
Action<BulkCopyOptions>? bulkCopyOptions = null,
CancellationToken cancellationToken = default)
Task<int> BulkUpdateWithCountAsync(
IEnumerable<TEntity> entities,
Action<BulkUpdateOptions<TEntity>>? bulkOptions = null,
Action<BulkCopyOptions>? bulkCopyOptions = null,
CancellationToken cancellationToken = default)
Task<int> BulkDeleteWithCountAsync(
IEnumerable<TEntity> entities,
Action<BulkDeleteOptions<TEntity>>? bulkOptions = null,
Action<BulkCopyOptions>? bulkCopyOptions = null,
CancellationToken cancellationToken = default)
// Advanced bulk operations
Task BulkUpsertAsync(
IEnumerable<TEntity> entities,
Action<BulkUpsertOptions<TEntity>>? bulkOptions = null,
Action<BulkCopyOptions>? bulkCopyOptions = null,
CancellationToken cancellationToken = default)
Task BulkInsertBatchedAsync(
IEnumerable<TEntity> entities,
int batchSize,
IProgress<BulkProgress>? progress = null,
Action<BulkInsertOptions<TEntity>>? bulkOptions = null,
Action<BulkCopyOptions>? bulkCopyOptions = null,
CancellationToken cancellationToken = default)
Task BulkUpdateBatchedAsync(
IEnumerable<TEntity> entities,
int batchSize,
IProgress<BulkProgress>? progress = null,
Action<BulkUpdateOptions<TEntity>>? bulkOptions = null,
Action<BulkCopyOptions>? bulkCopyOptions = null,
CancellationToken cancellationToken = default)
IUnitOfWork
/// <summary>
/// Unit of Work pattern interface for managing transactions across multiple repository operations
/// </summary>
public interface IUnitOfWork<TDbContext> where TDbContext : DbContext
{
/// <summary>
/// Begins a new database transaction
/// </summary>
Task<ITransaction> BeginTransactionAsync(
System.Data.IsolationLevel? isolationLevel = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the underlying DbContext (for advanced scenarios)
/// </summary>
TDbContext Context { get; }
/// <summary>
/// Checks if there is an active transaction
/// </summary>
bool HasActiveTransaction { get; }
}
ITransaction
/// <summary>
/// Represents a database transaction that can be committed or rolled back
/// </summary>
public interface ITransaction : IAsyncDisposable
{
/// <summary>
/// Commits all changes made in the transaction
/// </summary>
Task CommitAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Rolls back all changes made in the transaction
/// </summary>
Task RollbackAsync(CancellationToken cancellationToken = default);
}
Base Repository Classes
EfCoreQueryRepositoryBase<TDbContext, TEntity>
Base class for read-only operations. Provides the Query property (v2.0.0+) that can be overridden:
public class CustomQueryRepository<TDbContext, TEntity>
: EfCoreQueryRepositoryBase<TDbContext, TEntity>
where TDbContext : DbContext
where TEntity : class
{
protected override IQueryable<TEntity> Query
=> base.Query.Where(e => e.IsActive);
}
EfCoreRepositoryBase<TDbContext, TEntity>
Inherits from EfCoreQueryRepositoryBase and adds CRUD operations.
EfCoreEntityRepositoryBase<TDbContext, TEntity>
Inherits from EfCoreRepositoryBase and automatically handles audit fields (CreatedBy, UpdatedBy, CreatedAt, UpdatedAt).
EfCoreTenantRepositoryBase<TDbContext, TEntity, TTenantKey>
Inherits from EfCoreRepositoryBase and provides automatic tenant filtering. Overrides the Query property to filter by TenantId and automatically sets TenantId on insert.
EfCoreTenantEntityRepositoryBase<TDbContext, TEntity, TTenantKey>
Inherits from EfCoreTenantRepositoryBase and combines tenant filtering with audit fields.
Extension Methods
// Register repositories
AddRepositories<TDbContext>(Action<RepositoryOptions<TDbContext>>? configure = null)
AddRepositoriesWithBulkOperations<TDbContext>(Action<RepositoryOptions<TDbContext>>? configure = null)
AddRepositoriesFromAssembly<TDbContext>(Assembly assembly, ServiceLifetime lifetime = ServiceLifetime.Scoped)
// Register tenant repositories
AddTenantRepository<TDbContext, TEntity, TTenantKey>(
ServiceLifetime lifetime = ServiceLifetime.Scoped)
// Overload 1: Without user ID provider
AddTenantEntityRepository<TDbContext, TEntity, TTenantKey>(
ServiceLifetime lifetime = ServiceLifetime.Scoped)
// Overload 2: With user ID provider
AddTenantEntityRepository<TDbContext, TEntity, TTenantKey>(
Func<IServiceProvider, Func<long>> userIdProviderFactory,
ServiceLifetime lifetime = ServiceLifetime.Scoped)
// Register custom repository
AddCustomRepository<TEntity, TRepository>(ServiceLifetime lifetime = ServiceLifetime.Scoped)
AddRepository<TEntity, TRepository>(ServiceLifetime lifetime = ServiceLifetime.Scoped)
// Register specific repository types
AddGenericRepository<TDbContext, TEntity>(ServiceLifetime lifetime = ServiceLifetime.Scoped)
AddBulkRepository<TDbContext, TEntity>(ServiceLifetime lifetime = ServiceLifetime.Scoped)
Models
PagedList
public class PagedList<T>
{
public PagingDetails PagingDetails { get; set; }
public List<T> List { get; set; }
}
PagingDetails
public class PagingDetails
{
public int PageIndex { get; set; }
public int PageNumber { get; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages { get; }
public bool HasPreviousPage { get; }
public bool HasNextPage { get; }
public bool IsFirstPage { get; }
public bool IsLastPage { get; }
public bool IsEmpty { get; }
}
SortingDetails
public class SortingDetails
{
public List<SortItem> SortItems { get; set; }
public SortingDetails(SortItem sortItem)
public SortingDetails(List<SortItem> sortItems)
}
public class SortItem
{
public string SortBy { get; set; }
public SortDirection SortDirection { get; set; }
}
public enum SortDirection
{
Ascending,
Descending
}
BulkProgress
public class BulkProgress
{
public int TotalRecords { get; set; }
public int ProcessedRecords { get; set; }
public int CurrentBatch { get; set; }
public int TotalBatches { get; set; }
public double PercentComplete { get; }
public string Operation { get; set; }
}
Entity Base Class
public class Entity
{
public long CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public long UpdatedBy { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}
Interfaces
// Tenant support interfaces
public interface ITenantFilter<TTenantKey>
{
TTenantKey GetCurrentTenantId();
}
public interface ITenantEntity<TTenantKey>
{
TTenantKey TenantId { get; set; }
}
Best Practices
1. Use Projections for Large Datasets
// Instead of loading full entities
var products = await repository.GetListAsync();
// Use projection to load only needed fields
var productSummaries = await repository.GetListAsync(
projection: p => new { p.Id, p.Name, p.Price }
);
2. Use Bulk Operations for Large Inserts/Updates
// For < 100 records, use regular insert
if (products.Count < 100)
{
foreach (var product in products)
await repository.InsertAsync(product);
}
else
{
// For >= 100 records, use bulk insert
await bulkRepository.BulkInsertAsync(products);
}
3. Use Batching for Very Large Operations
// For operations with 10,000+ records
await bulkRepository.BulkInsertBatchedAsync(
entities: largeProductList,
batchSize: 1000, // Process 1000 at a time
progress: progressReporter
);
4. Specify Related Properties Explicitly
// Avoid loading all related properties
var product = await repository.GetAsync(1, LoadRelatedProperties.All);
// Load only what you need
var product = await repository.GetAsync(1, ["Category"]);
5. Use Appropriate Save Strategy
// For single operations
options.SaveChangesStrategy = SaveChangesStrategy.PerOperation;
// For unit of work pattern (better performance)
options.SaveChangesStrategy = SaveChangesStrategy.PerUnitOfWork;
6. Override Query Property for Default Filtering
public class ActiveProductRepository : EfCoreRepositoryBase<MyDbContext, Product>
{
protected override IQueryable<Product> Query
=> base.Query.Where(p => p.IsActive);
}
Performance Tips
Bulk Operations Benchmarks
| Operation | Records | Regular | Bulk | Improvement |
|---|---|---|---|---|
| Insert | 1,000 | ~2.5s | ~0.3s | 8x faster |
| Insert | 10,000 | ~25s | ~1.2s | 20x faster |
| Update | 1,000 | ~2.8s | ~0.4s | 7x faster |
| Delete | 1,000 | ~2.3s | ~0.2s | 11x faster |
Optimization Strategies
- Use bulk operations for > 100 records
- Use batching for > 10,000 records
- Use projections to reduce data transfer
- Disable change tracking for read-only queries (done automatically)
- Use appropriate indexes on frequently queried columns
- Use split queries for collections (via LoadableRelatedProperty attribute)
Migration Guide: v1.0.x to v2.0.0
Breaking Changes
Property Rename: Quary → Query
What Changed:
The protected virtual IQueryable<TEntity> Quary property in EfCoreQueryRepositoryBase has been renamed to Query.
Impact:
If you have custom repository implementations that override the Quary property, you must update them.
Migration:
Before (v1.0.x):
public class CustomRepository<TDbContext, TEntity> : EfCoreRepositoryBase<TDbContext, TEntity>
where TDbContext : DbContext
where TEntity : class
{
protected override IQueryable<TEntity> Quary
=> base.Quary.Where(e => e.IsActive);
}
After (v2.0.0):
public class CustomRepository<TDbContext, TEntity> : EfCoreRepositoryBase<TDbContext, TEntity>
where TDbContext : DbContext
where TEntity : class
{
protected override IQueryable<TEntity> Query
=> base.Query.Where(e => e.IsActive);
}
Quick Fix: Use find-and-replace in your codebase:
- Find:
Quary - Replace:
Query
New Features in v2.0.0
- Multi-Tenant Support: Built-in tenant isolation and filtering
- New Interfaces:
ITenantFilter<TTenantKey>,ITenantEntity<TTenantKey> - New Repository Classes:
EfCoreTenantRepositoryBase,EfCoreTenantEntityRepositoryBase - New Extension Methods:
AddTenantRepository,AddTenantEntityRepository,AddCustomRepository
Backward Compatibility
All other features remain backward compatible:
- ✅ CRUD operations work the same
- ✅ Bulk operations unchanged
- ✅ Paging and sorting unchanged
- ✅ Related properties loading unchanged
- ✅ Existing repository registrations work the same
Testing Your Migration
- Search for
Quaryin your codebase and replace withQuery - Run your test suite to ensure everything works
- Consider adding tenant support to new features
- Review security if implementing multi-tenant features
Dependencies
- Microsoft.EntityFrameworkCore (10.*)
- Microsoft.EntityFrameworkCore.Relational (10.*)
- Microsoft.Extensions.DependencyInjection.Abstractions (10.*)
Target Framework
- .NET 10.0
Publishing to NuGet
Pack the Library
dotnet pack Gargar.Common.Repository/Gargar.Common.Repository.csproj -c Release
Push to Private Feed
dotnet nuget push Gargar.Common.Repository/bin/Release/Gargar.Common.Repository.2.2.0.nupkg --source "https://nuget.gargar.cc" --api-key "YOUR_API_KEY"
Migration from Existing Code
If you're migrating from the old structure:
- Update project references: Add
Gargar.Common.Repositorypackage - Update namespaces:
Gargar.Common.Domain.Repository→Gargar.Common.Repository.InterfacesGargar.Common.Domain.Helpers→Gargar.Common.Repository.HelpersorGargar.Common.Repository.ModelsGargar.Common.Domain.Options→Gargar.Common.Repository.OptionsGargar.Common.Persistance.Repository→Gargar.Common.Repository.Implementations
- Update service registration: Use new extension methods from the library
- Test thoroughly: Ensure all repository operations work as expected
License
Private - Gargar
Support
For issues or questions, contact the Gargar development team.
Changelog
Version 2.2.0 (Current)
- Dependency cleanup: Removed unnecessary dependencies (
Microsoft.Data.SqlClient,LinqKit) - Flexible versioning: Updated to wildcard versioning for EF Core packages (10.*)
- Improved compatibility: Better support for automatic minor version updates
- Cleaner dependency tree for better package management
Version 2.1.0
- Unit of Work Pattern: Added
IUnitOfWork<TDbContext>andITransactioninterfaces - Transaction management: Enhanced transaction support without requiring direct
DbContextinjection - Automatic registration:
AddRepositories<TDbContext>()now registersIUnitOfWork<TDbContext> - Updated documentation with Unit of Work examples and best practices
Version 2.0.0
- Multi-tenant support: Built-in tenant isolation and filtering
- Fixed typo:
Quary→Query(breaking change) - New interfaces:
ITenantFilter<TTenantKey>,ITenantEntity<TTenantKey> - New repository classes:
EfCoreTenantRepositoryBase,EfCoreTenantEntityRepositoryBase - New extension methods:
AddTenantRepository,AddTenantEntityRepository,AddCustomRepository - Enhanced documentation
Version 1.0.1
- Initial stable release
- Generic repository pattern
- Query repository with projections
- CRUD operations
- Bulk operations (insert, update, delete, upsert)
- Batch processing with progress tracking
- Paging and sorting
- Related properties loading
- Comprehensive API
No packages depend on Gargar.Common.Repository.
.NET 10.0
- Microsoft.EntityFrameworkCore (>= 10.0.6)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.6)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.6)