Skip to content

Performance Best Practices

Optimizing the performance of applications using Raiqub Expressions requires understanding how specifications and query strategies translate to database queries and how to leverage database capabilities effectively.

Overview

Key areas for optimization:

  • Query Optimization - Write efficient specifications and query strategies
  • Database Indexing - Ensure proper indexes on queried columns
  • Projection - Select only needed data
  • Caching - Cache frequently accessed data
  • Pagination - Handle large result sets efficiently
  • Monitoring - Profile and measure query performance

Query Optimization

Use Projections

Always project to DTOs rather than returning full entities:

csharp
public class GetProductsQueryStrategy : EntityQueryStrategy<Product, Product>
{
    protected override IQueryable<Product> ExecuteCore(IQueryable<Product> source)
    {
        return source
            .Where(ProductSpecification.IsInStock)
            .OrderBy(p => p.Name);
        // Returns entire Product entity with all columns
    }
}
csharp
public class GetProductsQueryStrategy : EntityQueryStrategy<Product, ProductDto>
{
    protected override IQueryable<ProductDto> ExecuteCore(IQueryable<Product> source)
    {
        return source
            .Where(ProductSpecification.IsInStock)
            .OrderBy(p => p.Name)
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price
                // Only select needed columns
            });
    }
}

Why? Projections reduce:

  • Network bandwidth usage
  • Memory consumption
  • Serialization overhead
  • Data transferred from database

Avoid N+1 Queries

Use projections with navigation properties to avoid N+1 query problems:

csharp
public class GetOrdersQueryStrategy : EntityQueryStrategy<Order, Order>
{
    protected override IQueryable<Order> ExecuteCore(IQueryable<Order> source)
    {
        // This may trigger N+1 queries when accessing Customer property
        return source.Where(o => o.Status == OrderStatus.Completed);
    }
}

// Usage that causes N+1:
var orders = await query.ToListAsync();
foreach (var order in orders)
{
    Console.WriteLine(order.Customer.Name); // Separate query per order!
}
csharp
public class GetOrdersQueryStrategy : EntityQueryStrategy<Order, OrderDto>
{
    protected override IQueryable<OrderDto> ExecuteCore(IQueryable<Order> source)
    {
        return source
            .Where(o => o.Status == OrderStatus.Completed)
            .Select(o => new OrderDto
            {
                OrderId = o.Id,
                CustomerName = o.Customer.Name, // Translated to JOIN
                TotalAmount = o.TotalAmount
            });
    }
}

Keep Specifications Simple

Complex operations may not translate to SQL efficiently:

csharp
public static class ProductSpecification
{
    public static Specification<Product> HasComplexCalculation { get; } =
        Specification.Create<Product>(p =>
            // Complex calculations that may not translate
            p.Name.Split(' ').Length > 2 &&
            DateTime.Parse(p.CreatedAt.ToString()).Year == 2024);
}
csharp
public static class ProductSpecification
{
    public static Specification<Product> HasLongName { get; } =
        Specification.Create<Product>(p => p.Name.Length > 10);

    public static Specification<Product> CreatedInYear(int year) =>
        Specification.Create<Product>(p => p.CreatedAt.Year == year);
}

Use Parameterized Queries

EF Core automatically parameterizes queries to enable query plan caching:

csharp
// Good - Value is automatically parameterized
public static Specification<Product> InCategory(string category) =>
    Specification.Create<Product>(p => p.Category == category);

// Usage
var spec = ProductSpecification.InCategory("Electronics"); // Parameter: @p0 = 'Electronics'

Limit Result Sets

Always consider limiting results for list queries:

csharp
public static class ProductQueryStrategy
{
    // For lists, use pagination or Take()
    public static IEntityQueryStrategy<Product, ProductDto> GetTopProducts(int count = 20) =>
        QueryStrategy.CreateForEntity(
            (IQueryable<Product> source) => source
                .OrderByDescending(p => p.Sales)
                .Take(count) // Translates to TOP/LIMIT in SQL
                .Select(p => new ProductDto
                {
                    Id = p.Id,
                    Name = p.Name,
                    Sales = p.Sales
                }));
}

Database Indexing

Index Specification Columns

Create indexes on columns used in specifications:

csharp
// Specification
public static Specification<Product> IsInStock { get; } =
    Specification.Create<Product>(p => p.AvailableQuantity > 0);

public static Specification<Product> InCategory(string category) =>
    Specification.Create<Product>(p => p.Category == category);
sql
-- Create indexes for these specifications
CREATE INDEX IX_Products_AvailableQuantity ON Products(AvailableQuantity);
CREATE INDEX IX_Products_Category ON Products(Category);

Composite Indexes

For combined specifications, consider composite indexes:

csharp
// Combined specification
var spec = ProductSpecification.IsInStock
    .And(ProductSpecification.InCategory("Electronics"))
    .And(ProductSpecification.InPriceRange(100, 500));
sql
-- Composite index for this query pattern
CREATE INDEX IX_Products_Category_Price_Quantity
ON Products(Category, Price, AvailableQuantity)
WHERE AvailableQuantity > 0;

Configure Indexes in EF Core

csharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Product>(entity =>
    {
        // Single column index
        entity.HasIndex(p => p.AvailableQuantity)
            .HasDatabaseName("IX_Products_AvailableQuantity");

        // Composite index
        entity.HasIndex(p => new { p.Category, p.Price })
            .HasDatabaseName("IX_Products_Category_Price");

        // Filtered index (SQL Server, PostgreSQL)
        entity.HasIndex(p => p.Category)
            .HasDatabaseName("IX_Products_ActiveCategory")
            .HasFilter("IsActive = 1");
    });
}

Pagination Strategies

Use Built-in Pagination

Raiqub Expressions provides efficient pagination through ToPagedListAsync:

csharp
public async Task<PagedResult<ProductDto>> GetProductsAsync(
    int pageNumber,
    int pageSize,
    CancellationToken cancellationToken = default)
{
    var query = _session.Query(new GetProductsQueryStrategy());

    // Efficient pagination with single query for data + count
    return await query.ToPagedListAsync(pageNumber, pageSize, cancellationToken);
}

How it works:

  • Single roundtrip to database
  • Efficient OFFSET/FETCH or SKIP/TAKE in SQL
  • Total count retrieved with COUNT(*) OVER()

Keyset Pagination for Large Datasets

For very large datasets, consider keyset (seek) pagination:

csharp
public class GetProductsAfterQueryStrategy : EntityQueryStrategy<Product, ProductDto>
{
    private readonly Guid _afterId;
    private readonly int _pageSize;

    public GetProductsAfterQueryStrategy(Guid afterId, int pageSize = 20)
    {
        _afterId = afterId;
        _pageSize = pageSize;
    }

    protected override IQueryable<ProductDto> ExecuteCore(IQueryable<Product> source)
    {
        return source
            .Where(p => p.Id > _afterId) // Keyset condition
            .OrderBy(p => p.Id)
            .Take(_pageSize)
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price
            });
    }
}

Benefits:

  • Consistent performance regardless of page number
  • No OFFSET overhead
  • Works well with infinite scrolling

Caching Strategies

Query Result Caching

Cache frequently accessed, rarely changing data:

csharp
public class CachedProductService
{
    private readonly IDbQuerySession _session;
    private readonly IMemoryCache _cache;

    public CachedProductService(IDbQuerySession session, IMemoryCache cache)
    {
        _session = session;
        _cache = cache;
    }

    public async Task<IReadOnlyList<CategoryDto>> GetCategoriesAsync(
        CancellationToken cancellationToken = default)
    {
        const string cacheKey = "product_categories";

        // Try to get from cache
        if (_cache.TryGetValue(cacheKey, out IReadOnlyList<CategoryDto> categories))
        {
            return categories;
        }

        // Query database
        var query = _session.Query(new GetCategoriesQueryStrategy());
        categories = await query.ToListAsync(cancellationToken);

        // Cache for 1 hour
        _cache.Set(cacheKey, categories, TimeSpan.FromHours(1));

        return categories;
    }
}

Specification Result Caching

Cache specification evaluation results for in-memory collections:

csharp
public class CachedSpecificationEvaluator<T>
{
    private readonly ConcurrentDictionary<string, Func<T, bool>> _cache = new();

    public bool Evaluate(T entity, Specification<T> specification)
    {
        var key = specification.GetType().FullName;

        var compiled = _cache.GetOrAdd(key, _ =>
            specification.ToExpression().Compile());

        return compiled(entity);
    }
}

Distributed Caching

Use distributed cache for multi-instance deployments:

csharp
public class DistributedCacheProductService
{
    private readonly IDbQuerySession _session;
    private readonly IDistributedCache _cache;

    public DistributedCacheProductService(
        IDbQuerySession session,
        IDistributedCache cache)
    {
        _session = session;
        _cache = cache;
    }

    public async Task<ProductDto?> GetProductAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        var cacheKey = $"product:{id}";

        // Try to get from distributed cache
        var cached = await _cache.GetStringAsync(cacheKey, cancellationToken);
        if (cached != null)
        {
            return JsonSerializer.Deserialize<ProductDto>(cached);
        }

        // Query database
        var query = _session.Query<Product>().Where(p => p.Id == id);
        var product = await query
            .Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price })
            .FirstOrDefaultAsync(cancellationToken);

        if (product != null)
        {
            // Cache for 5 minutes
            var serialized = JsonSerializer.Serialize(product);
            await _cache.SetStringAsync(
                cacheKey,
                serialized,
                new DistributedCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
                },
                cancellationToken);
        }

        return product;
    }
}

Async Streaming

For large result sets, use async streaming to reduce memory usage:

csharp
public async Task ProcessLargeDatasetAsync(CancellationToken cancellationToken = default)
{
    var query = _session.Query(new GetAllProductsQueryStrategy());

    // Stream results instead of loading all into memory
    await foreach (var product in query.ToAsyncEnumerable(cancellationToken))
    {
        await ProcessProductAsync(product, cancellationToken);

        // Memory is released as each product is processed
    }
}

Benefits:

  • Lower memory footprint
  • Start processing immediately
  • Handle datasets larger than available memory

Entity Framework Specific Optimizations

Query Splitting

For queries with multiple collections, consider split queries to avoid cartesian explosion:

csharp
services.AddEntityFrameworkExpressions()
    .Configure<Order>(options => options.UseSplitQuery = true)
    .AddSingleContext<AppDbContext>();

Or per query:

csharp
public class GetOrderWithDetailsQueryStrategy : EntityQueryStrategy<Order, OrderDto>
{
    protected override IQueryable<OrderDto> ExecuteCore(IQueryable<Order> source)
    {
        return source
            .AsSplitQuery() // Force split query for this query only
            .Select(o => new OrderDto
            {
                Id = o.Id,
                Items = o.Items.Select(i => new OrderItemDto
                {
                    ProductId = i.ProductId,
                    Quantity = i.Quantity
                }).ToList()
            });
    }
}

Compiled Queries

For frequently executed queries, use compiled queries:

csharp
private static readonly Func<AppDbContext, Guid, Task<Product?>> GetProductByIdCompiled =
    EF.CompileAsyncQuery((AppDbContext context, Guid id) =>
        context.Products.FirstOrDefault(p => p.Id == id));

public async Task<Product?> GetProductAsync(Guid id)
{
    await using var context = _contextFactory.CreateDbContext();
    return await GetProductByIdCompiled(context, id);
}

No Tracking for Read-Only Queries

Disable change tracking for read-only operations:

csharp
// Set globally for all sessions
services.AddEntityFrameworkExpressions()
    .AddSingleContext<AppDbContext>(ChangeTracking.Disabled);

// Or per query
public class GetProductsQueryStrategy : EntityQueryStrategy<Product, ProductDto>
{
    protected override IQueryable<ProductDto> ExecuteCore(IQueryable<Product> source)
    {
        return source
            .AsNoTracking() // Disable tracking for this query
            .Where(ProductSpecification.IsInStock)
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price
            });
    }
}

Monitoring and Profiling

Log Generated SQL

Enable logging to review generated SQL queries:

csharp
services.AddDbContextFactory<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
        .LogTo(Console.WriteLine, LogLevel.Information)
        .EnableSensitiveDataLogging() // Only in development!
        .EnableDetailedErrors();
});

Use ToQueryString for Testing

Verify SQL generation in tests:

csharp
[Fact]
public void QueryStrategy_ShouldGenerateEfficientSql()
{
    // Arrange
    var strategy = new GetProductsQueryStrategy();
    var source = _context.Set<Product>();

    // Act
    var query = strategy.Execute(source);
    var sql = query.ToQueryString();

    // Assert
    Assert.DoesNotContain("SELECT *", sql); // Should use projection
    Assert.Contains("WHERE", sql);
    _testOutputHelper.WriteLine(sql);
}

Application Insights Integration

Track query performance with Application Insights:

csharp
public class MonitoredProductService
{
    private readonly IDbQuerySession _session;
    private readonly TelemetryClient _telemetry;

    public MonitoredProductService(
        IDbQuerySession _session,
        TelemetryClient telemetry)
    {
        _session = session;
        _telemetry = telemetry;
    }

    public async Task<IReadOnlyList<ProductDto>> GetProductsAsync(
        CancellationToken cancellationToken = default)
    {
        using var operation = _telemetry.StartOperation<DependencyTelemetry>("GetProducts");

        try
        {
            var query = _session.Query(new GetProductsQueryStrategy());
            var results = await query.ToListAsync(cancellationToken);

            operation.Telemetry.ResultCode = "Success";
            operation.Telemetry.Properties["ResultCount"] = results.Count.ToString();

            return results;
        }
        catch (Exception ex)
        {
            operation.Telemetry.Success = false;
            _telemetry.TrackException(ex);
            throw;
        }
    }
}

Connection Management

Connection Pooling

Ensure connection pooling is enabled (default in most providers):

csharp
// Entity Framework Core - Connection pooling is automatic
services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlServer(connectionString)); // Pooling enabled by default

// Marten - Configure connection pooling
services.AddMarten(options =>
{
    options.Connection(connectionString);
    // Connection pooling is handled by Npgsql
});

Session Lifetime

Use short-lived sessions and dispose properly:

csharp
// Good - Short-lived session
public async Task<ProductDto> GetProductAsync(Guid id)
{
    await using var session = _sessionFactory.Create();
    var query = session.Query<Product>().Where(p => p.Id == id);
    return await query
        .Select(p => new ProductDto { Id = p.Id, Name = p.Name })
        .FirstAsync();
}

// Bad - Long-lived session
private readonly IDbSession _session; // Don't inject as singleton!

Performance Checklist

Query Design

  • [ ] Use projections to select only needed columns
  • [ ] Avoid N+1 queries with proper projections
  • [ ] Keep specifications simple and translatable
  • [ ] Limit result sets with Take() or pagination
  • [ ] Use AsNoTracking() for read-only queries

Database

  • [ ] Create indexes on commonly queried columns
  • [ ] Use composite indexes for combined filters
  • [ ] Monitor index usage and fragmentation
  • [ ] Review query execution plans
  • [ ] Consider partitioning for very large tables

Caching

  • [ ] Cache frequently accessed, rarely changing data
  • [ ] Use appropriate cache durations
  • [ ] Implement cache invalidation strategies
  • [ ] Consider distributed caching for scalability

Monitoring

  • [ ] Log slow queries
  • [ ] Track query execution times
  • [ ] Monitor connection pool usage
  • [ ] Review SQL query plans
  • [ ] Profile memory usage

Common Performance Anti-Patterns

❌ Loading Full Entities

csharp
// Bad - Loads all columns
var products = await _session.Query<Product>().ToListAsync();

❌ Client-Side Evaluation

csharp
// Bad - Filtering happens in memory after loading all data
var filtered = (await _session.Query<Product>().ToListAsync())
    .Where(p => p.IsInStock())
    .ToList();

❌ Multiple Queries in Loop

csharp
// Bad - N+1 queries
foreach (var orderId in orderIds)
{
    var order = await _session.Query<Order>()
        .Where(o => o.Id == orderId)
        .FirstAsync();
}

❌ Unbounded Result Sets

csharp
// Bad - Could return millions of rows
var allProducts = await _session.Query<Product>().ToListAsync();

See Also

Released under the MIT License.