Database access patterns for performance. Separate read/write models, avoid N+1 queries, use AsNoTracking, apply row limits, and never do application-side…
Database Performance Patterns
When to Use This Skill
Use this skill when:
Designing data access layers
Optimizing slow database queries
Choosing between EF Core and Dapper
Avoiding common performance pitfalls
Core Principles
Separate read and write models - Don't use the same types for both
Think in batches - Avoid N+1 queries
Only retrieve what you need - No SELECT *
Apply row limits - Always have a configurable Take/Limit
Do joins in SQL - Never in application code
AsNoTracking for reads - EF Core change tracking is expensive
Read/Write Model Separation (CQRS Pattern)
Read and write models are fundamentally different - they have different shapes, columns, and purposes. Don't create a single "User" entity and reuse it everywhere.
Read models are denormalized, optimized for query efficiency, and return multiple projection types (UserProfile, UserSummary, UserDetailForAdmin)
Write models are normalized, validation-focused, and accept strongly-typed commands (CreateUserCommand, UpdateUserCommand)
Architecture
src/
MyApp.Data/
Users/
# Read side - multiple optimized projections
IUserReadStore.cs
PostgresUserReadStore.cs
# Write side - command handlers
IUserWriteStore.cs
PostgresUserWriteStore.cs
# Read DTOs - lightweight, denormalized
UserProfile.cs
UserSummary.cs
# Write commands - validation-focused
CreateUserCommand.cs
UpdateUserCommand.cs
Orders/
IOrderReadStore.cs
IOrderWriteStore.cs
(similar structure...)
Read Store Interface
// Read models: Multiple specialized projections optimized for different use cases
public interface IUserReadStore
{
// Returns detailed profile for single-user view
Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default);
// Returns lightweight info for lookups
Task<UserProfile?> GetByEmailAsync(EmailAddress email, CancellationToken ct = default);
// Returns paginated summaries - only what the list view needs
Task<IReadOnlyList<UserSummary>> GetAllAsync(int limit, UserId? cursor = null, CancellationToken ct = default);
// Boolean query - no entity needed
Task<bool> EmailExistsAsync(EmailAddress email, CancellationToken ct = default);
}
Write Store Interface
// Write model: Accepts strongly-typed commands, minimal return values
public interface IUserWriteStore
{
// Returns only the created ID - caller doesn't need the full entity
Task<UserId> CreateAsync(CreateUserCommand command, CancellationToken ct = default);
// Update validates command, returns void (success or throws)
Task UpdateAsync(UserId id, UpdateUserCommand command, CancellationToken ct = default);
// Delete is simple and explicit
Task DeleteAsync(UserId id, CancellationToken ct = default);
}
Key structural differences illustrated:
Read store returns multiple different DTOs (UserProfile, UserSummary, bool flag)
Write store returns minimal data (just UserId on create) or void
Read queries are stateless projections - no tracking needed
Write operations focus on command validation, not retrieving data afterwards
Different databases/tables can back read vs write (eventual consistency pattern)
Always Apply Row Limits
Never return unbounded result sets. Every read method should have a configurable limit.
Pattern: Limit Parameter
public interface IOrderReadStore
{
// Limit is required, not optional
Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
CustomerId customerId,
int limit,
OrderId? cursor = null,
CancellationToken ct = default);
}
// Implementation
public async Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(
CustomerId customerId,
int limit,
OrderId? cursor = null,
CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
const string sql = """
SELECT id, customer_id, total, status, created_at
FROM orders
WHERE customer_id = @CustomerId
AND (@Cursor IS NULL OR created_at < (SELECT created_at FROM orders WHERE id = @Cursor))
ORDER BY created_at DESC
LIMIT @Limit
""";
var rows = await connection.QueryAsync<OrderRow>(sql, new
{
CustomerId = customerId.Value,
Cursor = cursor?.Value,
Limit = limit
});
return rows.Select(r => r.ToOrderSummary()).ToList();
}
EF Core with Pagination
public async Task<PaginatedList<OrderSummary>> GetOrdersAsync(
CustomerId customerId,
Paginator paginator,
CancellationToken ct = default)
{
var query = _context.Orders
.AsNoTracking()
.Where(o => o.CustomerId == customerId.Value)
.OrderByDescending(o => o.CreatedAt);
var totalCount = await query.CountAsync(ct);
var orders = await query
.Skip((paginator.PageNumber - 1) * paginator.PageSize)
.Take(paginator.PageSize) // Always limit!
.Select(o => new OrderSummary(
new OrderId(o.Id),
o.Total,
o.Status,
o.CreatedAt))
.ToListAsync(ct);
return new PaginatedList<OrderSummary>(
orders,
totalCount,
paginator.PageSize,
paginator.PageNumber);
}
AsNoTracking for Read Queries
EF Core's change tracking is expensive. Disable it for read-only queries.
// DO: Disable tracking for reads
var users = await _context.Users
.AsNoTracking()
.Where(u => u.IsActive)
.ToListAsync();
// DON'T: Track entities you won't modify
var users = await _context.Users
.Where(u => u.IsActive)
.ToListAsync(); // Change tracking enabled - wasteful
Configure Default Behavior
// For read-heavy applications, consider this in DbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
Then explicitly enable tracking when needed:
var user = await _context.Users
.AsTracking() // Explicit - we intend to modify
.FirstOrDefaultAsync(u => u.Id == userId);
Avoid N+1 Queries
The N+1 problem: fetching a list, then querying for each item's related data.
The Problem
// BAD: N+1 queries
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
// Each iteration hits the database!
var items = await _context.OrderItems
.Where(i => i.OrderId == order.Id)
.ToListAsync();
}
Solution 1: Include (EF Core)
// GOOD: Single query with join
var orders = await _context.Orders
.AsNoTracking()
.Include(o => o.Items)
.ToListAsync();
Solution 2: Batch Query (Dapper)
// GOOD: Two queries, no N+1
const string sql = """
SELECT id, customer_id, total FROM orders WHERE customer_id = @CustomerId;
SELECT oi.* FROM order_items oi
INNER JOIN orders o ON oi.order_id = o.id
WHERE o.customer_id = @CustomerId;
""";
using var multi = await connection.QueryMultipleAsync(sql, new { CustomerId = customerId });
var orders = (await multi.ReadAsync<OrderRow>()).ToList();
var items = (await multi.ReadAsync<OrderItemRow>()).ToList();
// Join in memory (acceptable - data already fetched)
foreach (var order in orders)
{
order.Items = items.Where(i => i.OrderId == order.Id).ToList();
}
Never Do Application-Side Joins
Joins must happen in SQL, not in C#.
// BAD: Application join - two queries, memory waste
var customers = await _context.Customers.ToListAsync();
var orders = await _context.Orders.ToListAsync();
var result = customers.Select(c => new
{
Customer = c,
Orders = orders.Where(o => o.CustomerId == c.Id).ToList() // O(n*m) in memory!
});
// GOOD: SQL join - single query
var result = await _context.Customers
.AsNoTracking()
.Include(c => c.Orders)
.ToListAsync();
// GOOD: Explicit join (Dapper)
const string sql = """
SELECT c.id, c.name, COUNT(o.id) as order_count
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name
""";
Avoid Cartesian Explosions
Multiple Include calls can cause Cartesian products.
// DANGEROUS: Can explode into millions of rows
var product = await _context.Products
.Include(p => p.Reviews) // 100 reviews
.Include(p => p.Images) // 20 images
.Include(p => p.Categories) // 5 categories
.FirstOrDefaultAsync(p => p.Id == id);
// Result: 100 * 20 * 5 = 10,000 rows transferred!
Solution: Split Queries
// GOOD: Multiple queries, no Cartesian explosion
var product = await _context.Products
.AsSplitQuery()
.Include(p => p.Reviews)
.Include(p => p.Images)
.Include(p => p.Categories)
.FirstOrDefaultAsync(p => p.Id == id);
// Result: 4 separate queries, ~125 rows total
Solution: Explicit Projection
// BEST: Only fetch what you need
var product = await _context.Products
.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDetail(
p.Id,
p.Name,
p.Description,
p.Reviews.OrderByDescending(r => r.CreatedAt).Take(10).ToList(),
p.Images.Take(5).ToList(),
p.Categories.Select(c => c.Name).ToList()))
.FirstOrDefaultAsync();
Constrain Column Sizes
Define maximum lengths in your EF Core model to prevent oversized data.
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.Property(u => u.Email)
.HasMaxLength(254) // RFC 5321 limit
.IsRequired();
builder.Property(u => u.Name)
.HasMaxLength(100)
.IsRequired();
builder.Property(u => u.Bio)
.HasMaxLength(500);
// For truly large content, use text type explicitly
builder.Property(u => u.Notes)
.HasColumnType("text");
}
}
Don't Build Generic Repositories
Generic repositories hide query complexity and make optimization difficult.
// BAD: Generic repository
public interface IRepository<T>
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync(); // No limit!
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate); // Can't optimize
}
// GOOD: Purpose-built read stores
public interface IOrderReadStore
{
Task<OrderDetail?> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task<IReadOnlyList<OrderSummary>> GetByCustomerAsync(CustomerId id, int limit, CancellationToken ct = default);
Task<IReadOnlyList<OrderSummary>> GetPendingAsync(int limit, CancellationToken ct = default);
}
Problems with generic repositories:
Can't optimize specific queries
No way to enforce limits
Hide N+1 problems
Make it easy to fetch too much data
Encourage lazy thinking about data access
Dapper for Read-Heavy Workloads
For complex read queries, Dapper with explicit SQL is often cleaner and faster.
public sealed class PostgresUserReadStore : IUserReadStore
{
private readonly NpgsqlDataSource _dataSource;
public PostgresUserReadStore(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
public async Task<UserProfile?> GetByIdAsync(UserId id, CancellationToken ct = default)
{
await using var connection = await _dataSource.OpenConnectionAsync(ct);
const string sql = """
SELECT id, email, name, bio, created_at
FROM users
WHERE id = @Id
""";
var row = await connection.QuerySingleOrDefaultAsync<UserRow>(
sql, new { Id = id.Value });
return row?.ToUserProfile();
}
// Internal row type for Dapper mapping
private sealed class UserRow
{
public Guid id { get; set; }
public string email { get; set; } = null!;
public string name { get; set; } = null!;
public string? bio { get; set; }
public DateTime created_at { get; set; }
public UserProfile ToUserProfile() => new(
Id: new UserId(id),
Email: new EmailAddress(email),
Name: new PersonName(name),
Bio: bio,
CreatedAt: new DateTimeOffset(created_at, TimeSpan.Zero));
}
}
When to Use EF Core vs Dapper
Scenario
Recommendation
Simple CRUD
EF Core
Complex read queries
Dapper
Writes with validation
EF Core
Bulk operations
Dapper or raw SQL
Reporting/analytics
Dapper
Domain-heavy writes
EF Core
You can use both in the same project - EF Core for writes, Dapper for reads.
Quick Reference
Anti-Pattern
Solution
No row limit
Add limit parameter to every read method
SELECT *
Project only needed columns
N+1 queries
Use Include or batch queries
Application joins
Do joins in SQL
Cartesian explosion
Use AsSplitQuery or projection
Tracking read-only data
Use AsNoTracking
Generic repository
Purpose-built read/write stores
Unbounded strings
Configure MaxLength in model
Resources
EF Core Performance: https://learn.microsoft.com/en-us/ef/core/performance/
Dapper: https://github.com/DapperLib/Dapper
AsSplitQuery: https://learn.microsoft.com/en-us/ef/core/querying/single-split-queriesdon't have the plugin yet? install it then click "run inline in claude" again.
added explicit inputs/external connections, restructured 10-step procedure with input/output signatures, extracted decision logic into dedicated section covering EF Core vs Dapper vs Dapper QueryMultiple, pagination styles, Cartesian handling, and error scenarios, formalized output contract with file locations and type constraints, and defined outcome signals with code review and profiling guidance.
Use this skill when designing data access layers, optimizing slow database queries, or choosing between EF Core and Dapper. The skill covers read/write model separation (CQRS), pagination, change tracking, N+1 prevention, and SQL-first joins. Apply it to avoid common performance pitfalls: unbounded result sets, application-side joins, Cartesian explosions, and generic repositories that hide query complexity.
EF Core Setup (if using)
Dapper Setup (if using)
Database Requirements
Operational Context
1. Design Read and Write Models Separately
Inputs: Domain entity, query requirements, command requirements
Create two interfaces per domain entity (e.g., User):
Read models are denormalized, stateless, optimized for query efficiency. Write models are normalized, validation-focused, command-driven.
Output: Two interfaces and supporting DTO types (UserProfile.cs, UserSummary.cs, CreateUserCommand.cs, UpdateUserCommand.cs)
2. Implement Read Store with Row Limits
Inputs: Read store interface, database connection, target query pattern (GetById, GetByEmail, GetAll with pagination)
Every read method must have a configurable limit parameter. Use cursor-based pagination (OrderId, created_at) for scrolling or offset/Take for fixed pages.
For EF Core: call .AsNoTracking() before .Where(), then .Take(limit) before .ToListAsync()
For Dapper: add LIMIT @Limit to SQL query, pass limit as parameter
Output: Paginated result set (IReadOnlyList
3. Implement Write Store with Validation
Inputs: Write store interface, domain entity, command types
CreateAsync accepts a command, validates it (throw if invalid), persists, returns only the new ID. UpdateAsync accepts an ID and command, validates, persists, returns void (throw on failure). DeleteAsync accepts an ID, persists, returns void.
Change tracking is acceptable here (write operations need it). No need for AsNoTracking.
Output: Persisted state in database. Return type: UserId or void. Throw exception on validation failure or constraint violation.
4. Use AsNoTracking for All Read Queries
Inputs: EF Core read query (already built with .Where, .Include, or .Select)
Insert .AsNoTracking() before .Where() or immediately after dbContext.Entity. This disables EF Core's change tracking, saving memory and CPU.
If you need to modify entities afterward, fetch without AsNoTracking (or use .AsTracking()). Never track read-only data.
Output: Query execution plan with change tracking disabled. Result set mapped to DTOs without entity state overhead.
5. Prevent N+1 Queries
Inputs: Parent entity query (e.g., Orders), need to access child collection (e.g., OrderItems)
Choose one approach:
A. Use .Include(o => o.Items).AsNoTracking() (EF Core, single query with join)
B. Use .AsSplitQuery() with multiple .Include() calls if Cartesian explosion risk is high (EF Core, 4 queries instead of 1 giant row)
C. Use connection.QueryMultipleAsync(sql) with two separate SQL statements, join in memory (Dapper, explicit control, acceptable memory use)
Output: Result set with all related data fetched. Query count: 1 for Include, N for N+1 (bad), multiple for AsSplitQuery (good).
6. Avoid Application-Side Joins
Inputs: Two entity collections (Customers, Orders), need to correlate them
Do not load both collections and use .Where() in C# to join. Move the join to SQL using INNER JOIN or LEFT JOIN.
In EF Core: use .Include() or explicit .Select() projection with navigation properties.
In Dapper: write SQL with explicit JOIN clause.
Output: Single query result (EF Core) or parameterized SQL query (Dapper). Memory usage proportional to result set size, not O(n*m).
7. Handle Cartesian Products
Inputs: Query with multiple .Include() or .Join() calls that multiply rows
If you have Include(p => p.Reviews) [100 rows] + Include(p => p.Images) [20 rows] + Include(p => p.Categories) [5 rows], result bloats to 100205 = 10k rows.
Use .AsSplitQuery() to auto-split into multiple queries. Or use explicit .Select() projection to fetch only what you need and apply .Take() limits per child collection.
Output: 4 separate queries (AsSplitQuery) returning ~125 rows total, or 1 query with projection returning only necessary columns and rows.
8. Choose EF Core vs Dapper per Query
Inputs: Query complexity (simple CRUD vs complex reporting), workload type (writes vs reads)
Simple CRUD (GetById, Create, Update, Delete): EF Core Complex read queries (multi-table joins, aggregations, reporting): Dapper Writes with domain logic/validation: EF Core Bulk operations (INSERT 100k rows): Dapper or raw SQL Reads in same project: Dapper (EF Core for writes)
Output: Chosen ORM. You can use both in the same project.
9. Configure Model Constraints
Inputs: EF Core entity type builder
Set .HasMaxLength(N) on string properties to prevent oversized columns. Use .HasColumnType("text") for truly large content (notes, descriptions).
Output: Database schema with enforced column size limits. EF Core validation rejects oversized data at mapping time.
10. Test Query Performance
Inputs: Implemented read store, sample dataset, query profiler (SQL Server Profiler, pg_stat_statements, EF Core logging)
Execute queries and inspect query count, execution time, row count, memory allocation. Verify:
Output: Performance profile. Adjust query if row count > limit, query count > expected, or execution time > threshold.
If using EF Core for reads:
.Include() and .AsNoTracking().Include() calls: use .AsSplitQuery() instead.Select() projection (most efficient) and skip Include.AsTracking() explicitly or omit .AsNoTracking(). Otherwise always use .AsNoTracking()If using Dapper for reads:
QuerySingleOrDefaultAsync<T>(), parameterize, map to DTOQueryAsync<T>(), always add WHERE + LIMIT clause, parameterizeQueryMultipleAsync() to execute two SQL statements in one roundtrip, join in C# afterward (acceptable if children count is reasonable)If designing pagination:
If you hit a Cartesian explosion:
.AsSplitQuery() and retest.Select() with nested .Take() on child collectionsIf query timeout or memory spikes:
.Include() or convert to .AsSplitQuery()If you need to bulk insert/update > 1k rows:
ExecuteAsync() with multi-row INSERT or MERGE statement, or EF Core BulkInsert() via EFCore.BulkExtensions packageIf rate limiting or connection pool exhaustion occurs:
Read Store Implementation
File location: src/[Domain]/[Entity]ReadStore.cs (e.g., src/Users/PostgresUserReadStore.cs)
Methods return:
Task<T?> (T is DTO type, nullable)Task<IReadOnlyList<T>>Task<bool>Task<PaginatedList<T>> or tuple (IReadOnlyList<T> items, int totalCount)All read methods have signature: async Task<T> GetXxxAsync(..., int limit, CancellationToken ct = default)
Pagination parameters included:
Write Store Implementation
File location: src/[Domain]/[Entity]WriteStore.cs (e.g., src/Users/PostgresUserWriteStore.cs)
Methods return:
Task<[Id]> (e.g., Task<UserId>)Task or Task<void>Task or Task<void>All write methods accept CancellationToken and throw on validation/constraint failure.
DTO Types
File location: src/[Domain]/Dtos/[DtoName].cs
Structure: readonly record or sealed class, flattened (no nested navigation properties), only columns needed for UI/API response.
Example: public sealed record UserProfile(UserId Id, EmailAddress Email, PersonName Name, string? Bio, DateTimeOffset CreatedAt);
Database
Tables indexed on:
Column constraints applied:
Query executes without errors and returns correct data shape
Run a query via read store method. Result should:
Query count is 1 or N, never N+1
Enable query logging (EF Core: .LogTo(Console.WriteLine), Dapper: SQL Server Profiler or pg_stat_statements). Execute a read with related data. Verify:
No unbounded result sets returned
Query any read store method. Inspect result count:
Change tracking disabled for reads
Inspect EF Core query output or code review:
.AsNoTracking() before .Where() or after DbContext property access.AsNoTracking() (or use .AsTracking() explicitly)No application-side joins
Code review: search for loops that correlate two collections in C#. Should find none:
foreach (var c in customers) { c.Orders = orders.Where(o => o.CustomerId == c.Id).ToList(); }Row limit parameter enforced at database
Inspect SQL query or EF Core LINQ .Take(limit) call. Query should have:
.Take(limit) before .ToListAsync() (EF Core)Performance test passes
Run performance test suite (if present). Metrics:
No generic repository interface exists
Code review: search for IRepository<T>, IGenericRepository, etc. Should find none in read/write stores. Each domain has specific interfaces (IUserReadStore, IOrderReadStore, etc.) with purpose-built methods.