Gargar.Common.Repository 2.3.0

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 base EfCoreBulkRepository. 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.BulkExtensions or Z.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
    }
);
// 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

  1. Different Entity Types: Tenant repositories require ITenantEntity<TTenantKey>, non-tenant repositories work with regular entities
  2. Independent Registration: Each repository type is registered separately
  3. No Conflicts: They don't interfere with each other because they handle different entity types
  4. Shared DbContext: All repositories share the same DbContext instance, so Unit of Work works across both types
  5. 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 entities
  • AddTenantRepository<TDbContext, TEntity, TTenantKey>() for tenant entities
  • AddTenantEntityRepository<TDbContext, TEntity, TTenantKey>() for tenant entities with audit fields
  • AddRepositoriesFromAssembly<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

  1. Always use tenant filtering for multi-tenant applications to prevent data leaks
  2. Validate tenant ownership on all write operations (update, delete)
  3. Set tenant ID automatically on insert to prevent user manipulation
  4. Use scoped lifetime for tenant filters to ensure correct tenant context
  5. Test tenant isolation thoroughly with integration tests
  6. Audit tenant access by logging tenant ID with all operations
  7. 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:

  1. Shared DbContext: All repositories share the same DbContext instance, allowing them to participate in the same transaction
  2. Save Strategy: Controls when SaveChanges() is called:
    • PerUnitOfWork (default): Defers saving until the transaction commits
    • PerOperation: 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:

  1. EF Core opens a database connection (if not already open)
  2. Begins a database transaction (BEGIN TRANSACTION in SQL)
  3. Sets Context.Database.CurrentTransaction to the new transaction
  4. 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 (COMMIT in SQL)
    • Connection is released back to the pool

2. Rollback (Failure)

await transaction.RollbackAsync();
  • What happens:
    • All changes in the change tracker are discarded
    • Transaction is rolled back (ROLLBACK in 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 using statements 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

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:

  1. Transaction starts: BeginTransactionAsync() opens a database transaction
  2. Operations execute: All InsertAsync and UpdateAsync calls add entities to the change tracker
  3. SaveChanges deferred: SaveChanges() is not called after each operation (because CurrentTransaction != null)
  4. Transaction commits: CommitAsync() automatically calls SaveChanges() for all tracked changes
  5. Changes persisted: All changes are saved atomically to the database
  6. On error: RollbackAsync() discards all changes without calling SaveChanges()

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

  1. Use IUnitOfWork instead of DbContext: Prefer IUnitOfWork<TDbContext> for transaction management
  2. Use PerUnitOfWork for complex operations: When multiple repositories need to coordinate
  3. Always use transactions with PerUnitOfWork: Wrap related operations in BeginTransactionAsync()
  4. Handle exceptions properly: Always call RollbackAsync() in catch blocks
  5. Use using statements: Ensures transaction disposal even on exceptions
  6. Keep transactions short: Don't hold transactions open for long periods
  7. Check for active transactions: Use _unitOfWork.HasActiveTransaction if 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 DbContext directly
  • Cleaner API: Transaction management through repository layer
  • Consistent Pattern: All operations go through the same abstraction
  • Testability: Easier to mock IUnitOfWork than DbContext
  • Backward Compatible: Existing code using DbContext directly 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)
  • ReadUncommitted
  • RepeatableRead
  • Serializable (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 (single SaveChanges() 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
);
// 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

  1. Use bulk operations for > 100 records
  2. Use batching for > 10,000 records
  3. Use projections to reduce data transfer
  4. Disable change tracking for read-only queries (done automatically)
  5. Use appropriate indexes on frequently queried columns
  6. Use split queries for collections (via LoadableRelatedProperty attribute)

Migration Guide: v1.0.x to v2.0.0

Breaking Changes

Property Rename: QuaryQuery

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

  1. Multi-Tenant Support: Built-in tenant isolation and filtering
  2. New Interfaces: ITenantFilter<TTenantKey>, ITenantEntity<TTenantKey>
  3. New Repository Classes: EfCoreTenantRepositoryBase, EfCoreTenantEntityRepositoryBase
  4. 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

  1. Search for Quary in your codebase and replace with Query
  2. Run your test suite to ensure everything works
  3. Consider adding tenant support to new features
  4. 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:

  1. Update project references: Add Gargar.Common.Repository package
  2. Update namespaces:
    • Gargar.Common.Domain.RepositoryGargar.Common.Repository.Interfaces
    • Gargar.Common.Domain.HelpersGargar.Common.Repository.Helpers or Gargar.Common.Repository.Models
    • Gargar.Common.Domain.OptionsGargar.Common.Repository.Options
    • Gargar.Common.Persistance.RepositoryGargar.Common.Repository.Implementations
  3. Update service registration: Use new extension methods from the library
  4. 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> and ITransaction interfaces
  • Transaction management: Enhanced transaction support without requiring direct DbContext injection
  • Automatic registration: AddRepositories<TDbContext>() now registers IUnitOfWork<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: QuaryQuery (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.

Version 2.2.0 - Dependency Cleanup and Improved Versioning - Removed unnecessary dependencies: Microsoft.Data.SqlClient and LinqKit - Updated to flexible versioning for EF Core packages (10.* instead of 10.0.1) - Improved compatibility with automatic minor version updates - Cleaner dependency tree for better package management - No breaking changes or code modifications required Version 2.1.0 - Unit of Work Pattern - Added IUnitOfWork<TDbContext> interface for transaction management - Added ITransaction interface for transaction abstraction - Added EfCoreUnitOfWork<TDbContext> and EfCoreTransaction implementations - AddRepositories<TDbContext>() now automatically registers IUnitOfWork<TDbContext> - Enhanced transaction management without requiring direct DbContext injection - Updated documentation with Unit of Work examples and best practices Version 2.0.0 - Multi-Tenant Support and Breaking Changes - BREAKING: Renamed 'Quary' property to 'Query' in EfCoreQueryRepositoryBase - Added ITenantFilter<TTenantKey> interface for tenant identification - Added ITenantEntity<TTenantKey> interface for tenant-scoped entities - Added EfCoreTenantRepositoryBase for automatic tenant filtering - Added EfCoreTenantEntityRepositoryBase for tenant filtering with audit fields - Added AddTenantRepository and AddTenantEntityRepository extension methods - Added AddCustomRepository extension method for easier custom repository registration - Enhanced documentation with multi-tenant examples and security considerations - Improved support for custom filtered repositories (e.g., MerchantEntity pattern)

Version Downloads Last updated
3.1.0 28 04/20/2026
3.0.0 4 04/19/2026
2.3.0 3 04/19/2026
2.2.1 21 04/14/2026
2.2.0 2 04/14/2026
2.1.0 5 01/24/2026
2.0.0 3 01/24/2026
1.0.1 44 01/10/2026
1.0.0 2 01/10/2026