К основному содержанию
скилл CX Codex

database-performance — скилл для Claude Code: обзор и установка

Скилл задаёт правила производительности баз данных для .NET: разделяет чтение и запись, устраняет N+1 запросы и требует лимиты на выборки.

Установить в Claude Code · Через Skills CLI (Vercel) Все способы и платформы ↓
npx skills add Aaronontheweb/dotnet-skills --skill database-performance -a claude-code
Открыть на GitHub

Что делает database-performance

Скилл внедряет в ассистента инженерные правила для работы с базами данных через EF Core и Dapper. Он запрещает использовать одну сущность для чтения и записи. Вместо этого агент формирует специализированные DTO под выборки (например, UserProfile или UserSummary) и строгие команды для модификации данных вроде CreateUserCommand.

Архитектура строится на понятных ограничениях. Все операции чтения обязаны иметь лимиты строк через Take или LIMIT, а объединения данных переносятся в SQL, исключая join-операции в коде приложения. Для EF Core скилл отключает отслеживание изменений с помощью AsNoTracking(), потому что change tracking заметно нагружает память на тяжёлых выборках.

У скилла выставлен флаг user-invocable: false. Его не вызывают отдельной слэш-командой. Правила активируются автоматически, когда вы просите агента спроектировать слой доступа к данным или ускорить медленные запросы.

Какие задачи решает

  • разделить модели чтения и записи по паттерну CQRS
  • отключить ресурсоёмкий change tracking в EF Core через AsNoTracking
  • устранить проблему N+1 запросов при загрузке связанных данных
  • добавить обязательные лимиты строк и курсорную пагинацию в выборки
  • перенести связывание таблиц из кода приложения в SQL-запросы

Примеры запросов агенту

  • Спроектируй слой доступа к данным для заказов на EF Core с разделением на чтение и запись.
  • Оптимизируй метод выборки пользователей в Dapper и добавь курсорную пагинацию.
  • Проверь репозиторий на N+1 запросы и отключи отслеживание изменений там, где оно не нужно.

Как установить database-performance

1Через Skills CLI (Vercel)

npx skills add Aaronontheweb/dotnet-skills --skill database-performance -a claude-code

2Вручную: положить файлы

~/.claude/skills/database-performance/SKILL.md

Папка скилла целиком; для одного проекта — .claude/skills/database-performance/

По шагам, с командами для каждой платформы: Как установить скилл в Claude Code: пять способов

Вопросы о database-performance

Как установить database-performance в Claude Code?

Выполните в терминале команду npx skills add Aaronontheweb/dotnet-skills --skill database-performance -a claude-code. Также можно вручную сохранить файл по пути ~/.claude/skills/database-performance/SKILL.md.

Что делает скилл database-performance?

Он направляет ИИ-агента применять надёжные паттерны работы с базами данных: внедрять раздельные интерфейсы чтения и записи и ограничивать объём выборок в .NET.

С какими ORM и библиотеками работает этот скилл?

Инструкции рассчитаны на работу с Entity Framework Core и Dapper, включая настройку QueryTrackingBehavior.NoTracking и прямые SQL-запросы с параметризацией.

Исходный текст

SKILL.md · MIT
---
name: database-performance
description: Database access patterns for performance. Separate read/write models, avoid N+1 queries, use AsNoTracking, apply row limits, and never do application-side joins. Works with EF Core and Dapper.
user-invocable: false
tags: [cqrs, performance, patterns]
---

# 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

1. **Separate read and write models** - Don't use the same types for both
2. **Think in batches** - Avoid N+1 queries
3. **Only retrieve what you need** - No SELECT *
4. **Apply row limits** - Always have a configurable Take/Limit
5. **Do joins in SQL** - Never in application code
6. **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

```csharp
// 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

```csharp
// 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

```csharp
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

```csharp
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.

```csharp
// 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

```csharp
// For read-heavy applications, consider this in DbContext
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
```

Then explicitly enable tracking when needed:

```csharp
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

```csharp
// 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)

```csharp
// GOOD: Single query with join
var orders = await _context.Orders
    .AsNoTracking()
    .Include(o => o.Items)
    .ToListAsync();
```

### Solution 2: Batch Query (Dapper)

```csharp
// 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#.**

```csharp
// 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.

```csharp
// 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

```csharp
// 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

```csharp
// 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.

```csharp
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.

```csharp
// 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.

```csharp
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-queries

© Aaronontheweb · MIT · Aaronontheweb/dotnet-skills

Похожие скиллы

Все скиллы по теме «Данные и базы» →
dotnet-backend-patterns Seth Hobson Скилл для разработки на C# и .NET: паттерны асинхронного кода, внедрение зависимостей, Entity Framework Core, кэширование и тестирование. Нужен, если вы пишете backend API, MCP-серверы или рецензируете код на C#. CX Codex 19 076установок optimizing-ef-core-queries dotnet Разбирает доступ к данным на EF Core в сервисах, репозиториях и фоновых заданиях и показывает, где запросы тормозят, дублируют строки или бьют по базе лишний раз. CX Codex +1 5 700установок database-performance-debugging aj-geddes Скилл помогает найти медленные SQL-запросы и разобрать их планы выполнения. Пригодится, когда приложение тормозит или база упирается в CPU. CX Codex +1 493установок analyzing-dotnet-performance dotnet Ищет около 50 антипаттернов производительности в коде на C# и .NET — от лишних аллокаций в строках до блокировок в async. Пригодится при аудите горячих путей перед релизом. CX Codex +1 4 900установок dotnet-inspect-performance richlander Скилл находит узкие места в сборках .NET через анализ графа вызовов IL-кода. Он помогает приоритизировать рефакторинг библиотек без ручного профилирования. CX Codex +1 411установок optimize-database-performance davila7 Слэш-команда для Claude, которая разбирает запросы, индексы и схему базы и предлагает, что переписать. Пригодится, когда страница тормозит на выборке. 794установок

Из того же набора32

modern-csharp-coding-standards Задаёт правила современного C# 12+: неизменяемые рекорды, pattern matching, структуры Span<T> и асинхронный код без лишних аллокаций памяти. CX Codex +1 2 200установок efcore-patterns Скилл для работы с Entity Framework Core: настройка NoTracking, разбиение запросов, миграции и типичные ошибки. CX Codex +1 1 600установок csharp-concurrency-patterns Помогает агенту выбирать подходящие абстракции многопоточности в .NET: от async/await до Channels и Akka.NET. Пригодится при проектировании очередей и избавлении от блокировок. CX Codex +1 1 600установок dependency-injection-patterns Скилл учит раскладывать регистрации сервисов в ASP.NET Core по расширениям IServiceCollection, чтобы Program.cs не распухал. CX Codex +1 668установок api-design Скилл для проектирования публичных API по принципу «только расширять»: помогает не ломать совместимость при выпуске новых версий. CX Codex +1 660установок dotnet-project-structure Скилл подсказывает агенту, как разложить .NET-решение: .slnx, Directory.Build.props, центральные версии пакетов и SourceLink. CX Codex +1 658установок type-design-performance Скилл для проектирования типов .NET с оглядкой на производительность: запечатанные классы, readonly-структуры, отложенный перебор коллекций. CX Codex +1 655установок ilspy-decompile Декомпилирует сборки .NET через утилиту ilspycmd. Помогает изучать исходники пакетов NuGet и внутреннее устройство runtime-библиотек прямо в сессии агента. CX Codex +1 602установок

Спонсоры проекта

Компании, которые помогают держать аналитику открытой