Back to "Accès aux données dans .NET: Comparaison des ORM et des stratégies de cartographie (Partie 2 - Approches Dapper, Raw SQL et Hybrid)"

This is a viewer only at the moment see the article on how this works.

To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk

This is a preview from the server running through my markdig pipeline

.NET Dapper Database Performance PostgreSQL

Accès aux données dans .NET: Comparaison des ORM et des stratégies de cartographie (Partie 2 - Approches Dapper, Raw SQL et Hybrid)

Wednesday, 03 December 2025

Bienvenue à la partie 2 de notre guide complet sur l'accès aux données dans .NET! Première partie, nous avons exploré le noyau de cadre de l'entité en profondeur, y compris la génération SQL, les pièges communs, et cet avertissement critique sur les proxies et la mise en cache.

Dans cet article, nous explorerons les alternatives plus légères et la façon de combiner plusieurs approches pour une performance optimale:

  • Détonateur: La tache douce micro-ORM
  • ADO.NET brut/Npgsql: Performance et contrôle maximaux
  • Bibliothèques de cartographie d'objets: Cartouche vs AutoMapper
  • Approches hybrides: Combiner EF Core et Dapper (CQRS pattern)
  • Points de référence en matière de performance: Comparaisons du monde réel
  • Matrice de décision: Choisir le bon outil pour votre scénario

Table des matières

Dapper: Le Micro-ORM

Détonateur est un micro-ORM léger et haute performance créé par Stack Overflow. Il fournit une couche mince sur ADO.NET, en gérant le travail fastidieux de mappage des résultats de requête aux objets tout en vous donnant un contrôle SQL complet.

Pourquoi Dapper existe

Dapper est né du besoin de Stack Overflow pour l'accès aux données haute performance. L'équipe a constaté que les ORM complets comme Entity Framework (pre-Core) ont ajouté trop de frais généraux pour leurs scénarios de trafic élevé. Dapper vous donne 95% de la commodité avec seulement 5-15% de frais généraux sur ADO.NET brut.

Principales caractéristiques

  • SQL brut: Vous écrivez tout SQL vous-même - contrôle complet
  • Haute performance: Minimale au-dessus d'ADO.NET (5-15%)
  • Multi-mapping: Les requêtes complexes de la carte vers plusieurs types liés
  • Manipulation des paramètres: La paramétrisation automatique empêche l'injection SQL
  • Soutien aux transactions: Contrôle total des transactions
  • Simplicité: Pas de configuration, pas de suivi de changement, pas de magie
  • Async/Attention: Support complet async pour le .NET moderne

Exemples de base de Dapper

using Npgsql;
using Dapper;

public class DapperBlogRepository
{
    private readonly string _connectionString;

    public DapperBlogRepository(string connectionString)
    {
        _connectionString = connectionString;

        // Configure Dapper to work with PostgreSQL naming conventions
        DefaultTypeMap.MatchNamesWithUnderscores = true;
    }

    // Simple query
    public async Task<IEnumerable<BlogPost>> GetRecentPostsAsync(int count)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT id, title, content, tags, published_date, category_id
            FROM blog_posts
            ORDER BY published_date DESC
            LIMIT @Count";

        return await connection.QueryAsync<BlogPost>(sql, new { Count = count });
    }

    // Query with WHERE clause
    public async Task<BlogPost> GetPostByIdAsync(int id)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT id, title, content, published_date
            FROM blog_posts
            WHERE id = @Id";

        return await connection.QueryFirstOrDefaultAsync<BlogPost>(sql, new { Id = id });
    }

    // Insert with returning ID
    public async Task<int> CreatePostAsync(BlogPost post)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            INSERT INTO blog_posts (title, content, published_date, category_id)
            VALUES (@Title, @Content, @PublishedDate, @CategoryId)
            RETURNING id";

        return await connection.ExecuteScalarAsync<int>(sql, post);
    }

    // Update
    public async Task UpdatePostAsync(BlogPost post)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            UPDATE blog_posts
            SET title = @Title,
                content = @Content,
                published_date = @PublishedDate
            WHERE id = @Id";

        await connection.ExecuteAsync(sql, post);
    }

    // Delete
    public async Task DeletePostAsync(int id)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = "DELETE FROM blog_posts WHERE id = @Id";

        await connection.ExecuteAsync(sql, new { Id = id });
    }
}

Multi-mapping: Manipulation des jointures

L'une des caractéristiques les plus puissantes de Dapper est le multi-mapping - gérer efficacement les jointures et le mapping à plusieurs objets liés:

public async Task<IEnumerable<BlogPost>> GetPostsWithCategoryAsync()
{
    using var connection = new NpgsqlConnection(_connectionString);

    const string sql = @"
        SELECT
            p.id, p.title, p.content, p.published_date,
            c.id, c.name, c.description
        FROM blog_posts p
        INNER JOIN categories c ON p.category_id = c.id
        ORDER BY p.published_date DESC";

    return await connection.QueryAsync<BlogPost, Category, BlogPost>(
        sql,
        (post, category) =>
        {
            post.Category = category;
            return post;
        },
        splitOn: "id"  // Split at the second "id" column
    );
}

// More complex: Posts with comments
public async Task<IEnumerable<BlogPost>> GetPostsWithCommentsAsync()
{
    using var connection = new NpgsqlConnection(_connectionString);

    const string sql = @"
        SELECT
            p.id, p.title, p.content,
            c.id, c.author, c.content, c.created_at
        FROM blog_posts p
        LEFT JOIN comments c ON p.id = c.blog_post_id
        ORDER BY p.published_date DESC, c.created_at";

    var postDict = new Dictionary<int, BlogPost>();

    await connection.QueryAsync<BlogPost, Comment, BlogPost>(
        sql,
        (post, comment) =>
        {
            if (!postDict.TryGetValue(post.Id, out var existingPost))
            {
                existingPost = post;
                existingPost.Comments = new List<Comment>();
                postDict.Add(post.Id, existingPost);
            }

            if (comment != null)
            {
                existingPost.Comments.Add(comment);
            }

            return existingPost;
        },
        splitOn: "id"
    );

    return postDict.Values;
}

Techniques avancées de Dapper

Paramètres dynamiques pour les requêtes complexes :

public async Task<IEnumerable<BlogPost>> SearchWithDynamicFiltersAsync(SearchCriteria criteria)
{
    using var connection = new NpgsqlConnection(_connectionString);

    var parameters = new DynamicParameters();
    var conditions = new List<string>();

    var sql = new StringBuilder("SELECT * FROM blog_posts");

    if (!string.IsNullOrEmpty(criteria.SearchTerm))
    {
        conditions.Add("search_vector @@ to_tsquery('english', @SearchTerm)");
        parameters.Add("SearchTerm", criteria.SearchTerm);
    }

    if (criteria.CategoryIds?.Any() == true)
    {
        conditions.Add("category_id = ANY(@CategoryIds)");
        parameters.Add("CategoryIds", criteria.CategoryIds);
    }

    if (criteria.FromDate.HasValue)
    {
        conditions.Add("published_date >= @FromDate");
        parameters.Add("FromDate", criteria.FromDate.Value);
    }

    if (criteria.Tags?.Any() == true)
    {
        conditions.Add("tags && @Tags");  // PostgreSQL array overlap
        parameters.Add("Tags", criteria.Tags);
    }

    if (conditions.Any())
    {
        sql.Append(" WHERE ");
        sql.Append(string.Join(" AND ", conditions));
    }

    sql.Append(" ORDER BY published_date DESC LIMIT @Limit");
    parameters.Add("Limit", criteria.Limit);

    return await connection.QueryAsync<BlogPost>(sql.ToString(), parameters);
}

Handlers de type personnalisés pour les types PostgreSQLTM :

// Handle PostgreSQL arrays
public class PostgresArrayTypeHandler : SqlMapper.TypeHandler<string[]>
{
    public override void SetValue(IDbDataParameter parameter, string[] value)
    {
        parameter.Value = value;
        ((NpgsqlParameter)parameter).NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text;
    }

    public override string[] Parse(object value)
    {
        return (string[])value;
    }
}

// Handle PostgreSQL JSONB
public class JsonTypeHandler<T> : SqlMapper.TypeHandler<T>
{
    public override void SetValue(IDbDataParameter parameter, T value)
    {
        parameter.Value = JsonSerializer.Serialize(value);
        ((NpgsqlParameter)parameter).NpgsqlDbType = NpgsqlDbType.Jsonb;
    }

    public override T Parse(object value)
    {
        return JsonSerializer.Deserialize<T>(value.ToString());
    }
}

// Register handlers (in startup)
SqlMapper.AddTypeHandler(new PostgresArrayTypeHandler());
SqlMapper.AddTypeHandler(new JsonTypeHandler<Dictionary<string, object>>());

Opérations en vrac avec PostgreSQL COPY :

public async Task BulkInsertPostsAsync(IEnumerable<BlogPost> posts)
{
    using var connection = new NpgsqlConnection(_connectionString);
    await connection.OpenAsync();

    using var writer = await connection.BeginBinaryImportAsync(
        "COPY blog_posts (title, content, tags, published_date) FROM STDIN (FORMAT BINARY)"
    );

    foreach (var post in posts)
    {
        await writer.StartRowAsync();
        await writer.WriteAsync(post.Title);
        await writer.WriteAsync(post.Content);
        await writer.WriteAsync(post.Tags, NpgsqlDbType.Array | NpgsqlDbType.Text);
        await writer.WriteAsync(post.PublishedDate);
    }

    await writer.CompleteAsync();
}

Soutien aux transactions :

public async Task TransferPostToCategoryAsync(int postId, int newCategoryId)
{
    using var connection = new NpgsqlConnection(_connectionString);
    await connection.OpenAsync();

    using var transaction = await connection.BeginTransactionAsync();

    try
    {
        // Update the post
        await connection.ExecuteAsync(
            "UPDATE blog_posts SET category_id = @CategoryId WHERE id = @PostId",
            new { CategoryId = newCategoryId, PostId = postId },
            transaction
        );

        // Log the change
        await connection.ExecuteAsync(
            @"INSERT INTO category_history (post_id, category_id, changed_at)
              VALUES (@PostId, @CategoryId, @ChangedAt)",
            new { PostId = postId, CategoryId = newCategoryId, ChangedAt = DateTime.UtcNow },
            transaction
        );

        await transaction.CommitAsync();
    }
    catch
    {
        await transaction.RollbackAsync();
        throw;
    }
}

Quand utiliser Dapper

Utiliser Dapper quand:

  • La performance est critique mais vous n'avez pas besoin d'une vitesse maximale absolue
  • Vous êtes à l'aise d'écrire SQL
  • Vous avez des requêtes complexes qui ne mappent pas bien à LINQ
  • Vous avez besoin d'un contrôle fin sur la génération SQL
  • Vous travaillez avec un schéma de base de données existant
  • Vous voulez des frais généraux et des dépendances minimales
  • L'utilisation de la mémoire est une préoccupation
  • Vous devez exploiter largement les fonctionnalités spécifiques à la base de données

Éviter le piétinement lorsque:

  • Votre équipe n'est pas à l'aise avec SQL
  • Vous avez besoin d'un suivi automatique des changements
  • Vous voulez des migrations de schéma comme code
  • Vous avez un schéma en évolution rapide
  • Vous avez besoin de la portabilité de la base de données croisée
  • Vous préférez LINQ à la syntaxe SQL

Caractéristiques de performance

  • Exécution de la requête: 5-15 % de frais généraux sur ADO.NET brut
  • Utilisation de la mémoire: Minimal - pas de suivi de changement ou de proxies
  • Opérations en vrac: Excellent avec PostgreSQL COPY
  • Première requête: Rapide - pas d'étape de compilation
  • Productivité des développeurs: Nécessite une connaissance SQL mais très prévisible

ADO.NET brut avec Npgsql

Pour une performance et un contrôle absolus maximums, vous pouvez utiliser Npgsql directement sans couche ORM.

Quand ADO.NET brut rend sensé

ADO.NET brut est approprié lorsque:

  • Vous avez besoin de toutes les millisecondes de performance
  • Vous construisez des processeurs de données à haut débit
  • Vous travaillez avec des fonctionnalités spécifiques à PostgreSQLTM non prises en charge par ORMs
  • Vous avez besoin d'un contrôle précis sur les allocations de mémoire
  • Vous effectuez des opérations en vrac ou en streaming de gros ensembles de données

Exemple : Pure Npgsql

public class NpgsqlBlogRepository
{
    private readonly string _connectionString;

    public async Task<List<BlogPost>> GetRecentPostsAsync(int count)
    {
        var posts = new List<BlogPost>();

        using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        using var command = new NpgsqlCommand(
            "SELECT id, title, content, tags, published_date FROM blog_posts ORDER BY published_date DESC LIMIT @count",
            connection
        );

        command.Parameters.AddWithValue("count", count);

        using var reader = await command.ExecuteReaderAsync();

        while (await reader.ReadAsync())
        {
            posts.Add(new BlogPost
            {
                Id = reader.GetInt32(0),
                Title = reader.GetString(1),
                Content = reader.GetString(2),
                Tags = reader.GetFieldValue<string[]>(3),
                PublishedDate = reader.GetDateTime(4)
            });
        }

        return posts;
    }

    // Using prepared statements for repeated queries
    public async Task<BlogPost> GetPostByIdAsync(int id)
    {
        using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        using var command = new NpgsqlCommand(
            "SELECT id, title, content FROM blog_posts WHERE id = $1",
            connection
        );

        command.Parameters.AddWithValue(id);
        await command.PrepareAsync(); // Prepared statement for performance

        using var reader = await command.ExecuteReaderAsync();

        if (await reader.ReadAsync())
        {
            return new BlogPost
            {
                Id = reader.GetInt32(0),
                Title = reader.GetString(1),
                Content = reader.GetString(2)
            };
        }

        return null;
    }

    // Working with PostgreSQL JSONB
    public async Task<Dictionary<string, object>> GetPostMetadataAsync(int id)
    {
        using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        using var command = new NpgsqlCommand(
            "SELECT metadata FROM blog_posts WHERE id = $1",
            connection
        );

        command.Parameters.AddWithValue(id);

        var json = await command.ExecuteScalarAsync() as string;
        return JsonSerializer.Deserialize<Dictionary<string, object>>(json);
    }

    // Streaming large result sets
    public async IAsyncEnumerable<BlogPost> StreamAllPostsAsync()
    {
        using var connection = new NpgsqlConnection(_connectionString);
        await connection.OpenAsync();

        using var command = new NpgsqlCommand(
            "SELECT id, title, content FROM blog_posts ORDER BY id",
            connection
        );

        using var reader = await command.ExecuteReaderAsync();

        while (await reader.ReadAsync())
        {
            yield return new BlogPost
            {
                Id = reader.GetInt32(0),
                Title = reader.GetString(1),
                Content = reader.GetString(2)
            };
        }
    }
}

Caractéristiques de performance

  • Exécution de la requête: Base de référence - le plus rapide possible
  • Utilisation de la mémoire: Le plus bas - contrôle complet des allocations
  • Opérations en vrac: Excellent avec le protocole COPY
  • Productivité des développeurs: Nécessite la plupart des travaux manuels

Bibliothèques de cartographie d'objets

Lorsque vous travaillez avec Dapper ou ADO.NET brut, vous devez souvent cartographier entre différentes représentations d'objets (ODD, entités, modèles de vue). Plusieurs bibliothèques peuvent automatiser cela.

Mapster: Cartographie haute performance

Cartouche est un cartographe d'objets rapide, basé sur la convention, qui utilise la génération de source pour une performance optimale.

// Install: Mapster and Mapster.Tool
using Mapster;

public class BlogPostDto
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Summary { get; set; }
    public List<string> CategoryNames { get; set; }
}

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public List<Category> Categories { get; set; }
}

// Configuration
public class MappingConfig : IRegister
{
    public void Register(TypeAdapterConfig config)
    {
        config.NewConfig<BlogPost, BlogPostDto>()
            .Map(dest => dest.Summary, src => src.Content.Substring(0, Math.Min(200, src.Content.Length)))
            .Map(dest => dest.CategoryNames, src => src.Categories.Select(c => c.Name).ToList());

        // Reverse map with ignore
        config.NewConfig<BlogPostDto, BlogPost>()
            .Ignore(dest => dest.Content);
    }
}

// Registration in Program.cs
TypeAdapterConfig.GlobalSettings.Scan(Assembly.GetExecutingAssembly());

// Usage with Dapper
public class BlogService
{
    private readonly string _connectionString;

    public async Task<List<BlogPostDto>> GetPostsAsync()
    {
        using var connection = new NpgsqlConnection(_connectionString);

        var posts = await connection.QueryAsync<BlogPost>(@"
            SELECT p.id, p.title, p.content
            FROM blog_posts p
        ");

        // Map to DTOs - very fast with Mapster
        return posts.Adapt<List<BlogPostDto>>();
    }

    // Projection mapping (compile-time)
    public async Task<List<BlogPostDto>> GetPostsDtosDirectlyAsync()
    {
        using var connection = new NpgsqlConnection(_connectionString);

        // Query directly to DTO shape
        return (await connection.QueryAsync<BlogPostDto>(@"
            SELECT
                id,
                title,
                SUBSTRING(content, 1, 200) as summary
            FROM blog_posts
        ")).ToList();
    }
}

AutoMapper: Mapper basé sur les conventions matures

AutoMapper est la bibliothèque de cartographie la plus populaire, bien que plus lente que Mapster.

// Install: AutoMapper and AutoMapper.Extensions.Microsoft.DependencyInjection
using AutoMapper;

public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<BlogPost, BlogPostDto>()
            .ForMember(d => d.Summary, opt => opt.MapFrom(s =>
                s.Content.Length > 200 ? s.Content.Substring(0, 200) : s.Content))
            .ForMember(d => d.CategoryNames, opt => opt.MapFrom(s =>
                s.Categories.Select(c => c.Name)));

        // Reverse map
        CreateMap<BlogPostDto, BlogPost>()
            .ForMember(d => d.Content, opt => opt.Ignore());
    }
}

// Registration in Program.cs
services.AddAutoMapper(typeof(MappingProfile));

// Usage
public class BlogService
{
    private readonly IMapper _mapper;
    private readonly string _connectionString;

    public BlogService(IMapper mapper, IConfiguration configuration)
    {
        _mapper = mapper;
        _connectionString = configuration.GetConnectionString("DefaultConnection");
    }

    public async Task<List<BlogPostDto>> GetPostsAsync()
    {
        using var connection = new NpgsqlConnection(_connectionString);

        var posts = await connection.QueryAsync<BlogPost>(@"
            SELECT id, title, content FROM blog_posts
        ");

        return _mapper.Map<List<BlogPostDto>>(posts.ToList());
    }
}

Cartographie manuelle: Contrôle complet

Parfois, la meilleure approche est la cartographie manuelle explicite:

public static class BlogPostMapper
{
    public static BlogPostDto ToDto(this BlogPost post)
    {
        return new BlogPostDto
        {
            Id = post.Id,
            Title = post.Title,
            Summary = post.Content.Length > 200
                ? post.Content.Substring(0, 200) + "..."
                : post.Content,
            CategoryNames = post.Categories?.Select(c => c.Name).ToList() ?? new List<string>()
        };
    }

    public static List<BlogPostDto> ToDtoList(this IEnumerable<BlogPost> posts)
    {
        return posts.Select(p => p.ToDto()).ToList();
    }

    // Inline mapping for simple cases
    public static BlogPostDto MapToDto(BlogPost post) => new()
    {
        Id = post.Id,
        Title = post.Title,
        Summary = post.Content[..Math.Min(200, post.Content.Length)]
    };
}

// Usage
var posts = await _repository.GetAllPostsAsync();
var dtos = posts.ToDtoList();

Comparaison des performances cartographiques

BenchmarkDotNet Results (mapping 1000 objects):

Method              | Mean      | Allocated
--------------------|-----------|----------
Manual Mapping      | 45.2 μs   | 78 KB
Mapster             | 52.1 μs   | 79 KB
AutoMapper          | 184.3 μs  | 156 KB

Tâches clés :

  • La cartographie manuelle est la plus rapide mais nécessite plus de code
  • Mapster est presque aussi rapide avec moins de code
  • AutoMapper est pratique mais a des frais généraux plus élevés

Les approches hybrides : le meilleur des deux mondes

Dans les applications réelles, vous voulez souvent utiliser différentes approches pour différents scénarios dans la même application. l ' approche recommandée pour la plupart des systèmes de production.

CQRS Pattern: Séparer les lectures et les écrits

Le modèle CQRS (Command Query Responsibility Segregation) est un ajustement naturel pour les approches d'accès aux données hybrides. Martre, voir mon article sur Le CQRS moderne et l'approvisionnement en événements.

Comment Marten se rapporte à cette discussion :

Marten est une base de données de documents et un magasin d'événements construit sur PostgreSQLTM qui permet d'accéder à des données hybrides à un autre niveau.

  • Sourcing d'événements pour les écrits (flux d'événements immuables)
  • Projections pour lire (vues matérialisées optimisées pour les requêtes)
  • JSONB de PostgreSQL pour le stockage des documents
  • Modèles CQRS intégrés

Bien que cet article se concentre sur l'accès traditionnel aux données relationnelles (EF Core, Dapper), Marten montre comment utiliser les fonctionnalités avancées de PostgreSQLTM (JSONB, flux d'événements) pour mettre en œuvre des architectures sophistiquées. Les principes sont les mêmes :

  • Modèles d'écriture distincts (optimisés pour les transactions et la cohérence)
  • Modèles de lecture séparés (optimisés pour les requêtes et les performances)
  • Utilisez le bon outil pour chaque emploi
graph TB
    Client[Client Application]

    subgraph "Write Side - Commands"
        WriteAPI[Write API / Commands]
        EFCore[EF Core Context]
        WriteDB[(PostgreSQL<br/>Write Operations)]
    end

    subgraph "Read Side - Queries"
        ReadAPI[Read API / Queries]
        Dapper[Dapper Repository]
        ReadDB[(PostgreSQL<br/>Read Operations)]
    end

    Client -->|Create/Update/Delete| WriteAPI
    WriteAPI --> EFCore
    EFCore -->|Change Tracking<br/>Validation<br/>Business Logic| WriteDB

    Client -->|Query/Search| ReadAPI
    ReadAPI --> Dapper
    Dapper -->|Optimized SQL<br/>DTOs<br/>No Tracking| ReadDB

    WriteDB -.->|Same Database| ReadDB

    style Client stroke:#6366f1,stroke-width:2px
    style WriteAPI stroke:#2563eb,stroke-width:2px
    style EFCore stroke:#2563eb,stroke-width:2px
    style WriteDB stroke:#2563eb,stroke-width:2px
    style ReadAPI stroke:#059669,stroke-width:2px
    style Dapper stroke:#059669,stroke-width:2px
    style ReadDB stroke:#059669,stroke-width:2px

Ce modèle permet de tirer parti des facteurs suivants :

  • EF Noyau pour écrit: Suivi de changement, validation, règles d'affaires
  • Détonateur pour lire: Performance maximale de la requête et flexibilité
  • Même base de données, différents modèles d'accès optimisés pour chaque cas d'utilisation

Mise en œuvre: CQRS avec EF Core et Dapper

// Commands: Use EF Core for change tracking and validation
public class BlogCommandService
{
    private readonly BlogDbContext _context;
    private readonly ILogger<BlogCommandService> _logger;

    public BlogCommandService(BlogDbContext context, ILogger<BlogCommandService> logger)
    {
        _context = context;
        _logger = logger;
    }

    public async Task<int> CreatePostAsync(CreatePostCommand command)
    {
        // Business logic and validation
        var post = new BlogPost
        {
            Title = command.Title,
            Content = command.Content,
            CategoryId = command.CategoryId,
            PublishedDate = DateTime.UtcNow
        };

        _context.BlogPosts.Add(post);
        await _context.SaveChangesAsync();

        _logger.LogInformation("Created blog post {PostId}", post.Id);

        return post.Id;
    }

    public async Task UpdatePostAsync(UpdatePostCommand command)
    {
        var post = await _context.BlogPosts.FindAsync(command.Id);

        if (post == null)
            throw new InvalidOperationException($"Post {command.Id} not found");

        post.Title = command.Title;
        post.Content = command.Content;
        post.UpdatedAt = DateTime.UtcNow;

        await _context.SaveChangesAsync();

        _logger.LogInformation("Updated blog post {PostId}", post.Id);
    }

    public async Task DeletePostAsync(int id)
    {
        var post = await _context.BlogPosts.FindAsync(id);

        if (post != null)
        {
            _context.BlogPosts.Remove(post);
            await _context.SaveChangesAsync();

            _logger.LogInformation("Deleted blog post {PostId}", id);
        }
    }
}

// Queries: Use Dapper for read performance
public class BlogQueryService
{
    private readonly string _connectionString;
    private readonly ILogger<BlogQueryService> _logger;

    public BlogQueryService(IConfiguration configuration, ILogger<BlogQueryService> logger)
    {
        _connectionString = configuration.GetConnectionString("DefaultConnection");
        _logger = logger;
    }

    public async Task<BlogPostDto> GetPostBySlugAsync(string slug)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT
                p.id,
                p.title,
                p.slug,
                p.content,
                p.published_date,
                c.id as category_id,
                c.name as category_name,
                (SELECT COUNT(*) FROM comments WHERE blog_post_id = p.id) as comment_count
            FROM blog_posts p
            INNER JOIN categories c ON p.category_id = c.id
            WHERE p.slug = @Slug";

        var post = await connection.QueryFirstOrDefaultAsync<BlogPostDto>(sql, new { Slug = slug });

        if (post != null)
        {
            _logger.LogInformation("Retrieved blog post by slug {Slug}", slug);
        }

        return post;
    }

    public async Task<PagedResult<BlogPostSummaryDto>> GetRecentPostsAsync(int page, int pageSize)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT
                p.id,
                p.title,
                p.slug,
                LEFT(p.content, 200) as summary,
                p.published_date,
                c.name as category_name
            FROM blog_posts p
            INNER JOIN categories c ON p.category_id = c.id
            ORDER BY p.published_date DESC
            LIMIT @PageSize OFFSET @Offset";

        const string countSql = "SELECT COUNT(*) FROM blog_posts";

        var posts = await connection.QueryAsync<BlogPostSummaryDto>(
            sql,
            new { PageSize = pageSize, Offset = (page - 1) * pageSize }
        );

        var totalCount = await connection.ExecuteScalarAsync<int>(countSql);

        return new PagedResult<BlogPostSummaryDto>
        {
            Items = posts.ToList(),
            TotalCount = totalCount,
            Page = page,
            PageSize = pageSize
        };
    }

    public async Task<List<BlogPostDto>> SearchPostsAsync(string searchTerm)
    {
        using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT
                p.id,
                p.title,
                p.slug,
                p.content,
                p.published_date,
                c.name as category_name,
                ts_rank(p.search_vector, query) as relevance_score
            FROM blog_posts p
            INNER JOIN categories c ON p.category_id = c.id,
                 to_tsquery('english', @SearchTerm) query
            WHERE p.search_vector @@ query
            ORDER BY relevance_score DESC
            LIMIT 50";

        var posts = await connection.QueryAsync<BlogPostDto>(sql, new { SearchTerm = searchTerm });

        _logger.LogInformation(
            "Searched posts with term {SearchTerm}, found {Count} results",
            searchTerm,
            posts.Count()
        );

        return posts.ToList();
    }
}

// Service layer orchestrating commands and queries
public class BlogService
{
    private readonly BlogCommandService _commands;
    private readonly BlogQueryService _queries;

    public BlogService(BlogCommandService commands, BlogQueryService queries)
    {
        _commands = commands;
        _queries = queries;
    }

    // Write operations delegate to command service
    public Task<int> CreatePostAsync(CreatePostCommand command) => _commands.CreatePostAsync(command);
    public Task UpdatePostAsync(UpdatePostCommand command) => _commands.UpdatePostAsync(command);
    public Task DeletePostAsync(int id) => _commands.DeletePostAsync(id);

    // Read operations delegate to query service
    public Task<BlogPostDto> GetPostBySlugAsync(string slug) => _queries.GetPostBySlugAsync(slug);
    public Task<PagedResult<BlogPostSummaryDto>> GetRecentPostsAsync(int page, int pageSize)
        => _queries.GetRecentPostsAsync(page, pageSize);
    public Task<List<BlogPostDto>> SearchPostsAsync(string searchTerm)
        => _queries.SearchPostsAsync(searchTerm);
}

EF Core avec SQL brut occasionnel

Pour les applications qui sont principalement EF Core mais ont besoin d'optimisation de performance occasionnelle:

public class BlogService
{
    private readonly BlogDbContext _context;

    // 95% of queries: Use EF Core LINQ
    public async Task<List<BlogPost>> GetPostsByCategoryAsync(int categoryId)
    {
        return await _context.BlogPosts
            .Where(p => p.CategoryId == categoryId)
            .Include(p => p.Comments)
            .ToListAsync();
    }

    // 5% of queries: Use raw SQL for complex analytics
    public async Task<List<PostAnalytics>> GetPostAnalyticsAsync()
    {
        using var connection = _context.Database.GetDbConnection();
        await _context.Database.OpenConnectionAsync();

        using var command = connection.CreateCommand();
        command.CommandText = @"
            WITH post_metrics AS (
                SELECT
                    p.id,
                    p.title,
                    COUNT(DISTINCT c.id) as comment_count,
                    COUNT(DISTINCT v.id) as view_count,
                    AVG(c.sentiment_score) as avg_sentiment
                FROM blog_posts p
                LEFT JOIN comments c ON p.id = c.post_id
                LEFT JOIN post_views v ON p.id = v.post_id
                WHERE p.published_date >= NOW() - INTERVAL '30 days'
                GROUP BY p.id, p.title
            )
            SELECT * FROM post_metrics
            ORDER BY view_count DESC";

        var analytics = new List<PostAnalytics>();
        using var reader = await command.ExecuteReaderAsync();

        while (await reader.ReadAsync())
        {
            analytics.Add(new PostAnalytics
            {
                PostId = reader.GetInt32(0),
                Title = reader.GetString(1),
                CommentCount = reader.GetInt64(2),
                ViewCount = reader.GetInt64(3),
                AverageSentiment = reader.IsDBNull(4) ? 0 : reader.GetDouble(4)
            });
        }

        return analytics;
    }
}

Comparaison des performances

Examinons les critères de performance du monde réel pour les opérations communes avec PostgreSQLTM :

Points de référence : Lecture de 1000 dossiers

BenchmarkDotNet Results (Lower is Better):

Method                    | Mean       | Allocated
------------------------- |----------- |-----------
EF Core (No Tracking)    | 12.34 ms   | 2.4 MB
EF Core (With Tracking)  | 15.67 ms   | 4.8 MB
Dapper                   | 8.21 ms    | 1.8 MB
Raw Npgsql               | 7.45 ms    | 1.2 MB

Référence: insertion de 1000 dossiers

Method                    | Mean       | Allocated
------------------------- |----------- |-----------
EF Core (SaveChanges)    | 245.3 ms   | 15.2 MB
EF Core (BulkInsert)     | 42.1 ms    | 8.4 MB
Dapper (Loop)            | 189.7 ms   | 2.1 MB
Npgsql COPY              | 18.3 ms    | 0.8 MB

Benchmark: Complexe Joignez-vous à la demande

Method                    | Mean       | Allocated
------------------------- |----------- |-----------
EF Core (Include)        | 28.5 ms    | 5.2 MB
EF Core (Split Query)    | 24.1 ms    | 4.8 MB
Dapper (Multi-Map)       | 16.8 ms    | 3.1 MB
Raw Npgsql               | 15.2 ms    | 2.4 MB

Prises en charge des clés

  1. Npgsql brut est le plus rapide mais nécessite le plus de code
  2. Dapper offre d'excellentes performances avec un coût d'abstraction minimal (60-70% plus rapide que EF Core)
  3. Enquêtes EF Core sans suivi sont raisonnables pour la plupart des scénarios
  4. Opérations en vrac montrer les plus grandes lacunes de performance (10-13x différence!)
  5. Allocations de mémoire suivre un schéma similaire au temps d'exécution

Matrice de décision: Quelle approche utiliser

Utiliser EF Core Quand:

  • Construction d'une nouvelle application avec des exigences en évolution
  • Design de domaine avec des modèles d'entités riches
  • Vous avez besoin de migrations et de gestion de schéma
  • L'équipe est plus à l'aise avec C# que SQL
  • Le rapport lecture/écriture est équilibré
  • Les performances de requête dans les 20-50% de l'optimal sont acceptables
  • Vous voulez le suivi du changement et l'unité du modèle de travail

Voir Première partie pour une orientation de base complète de l'EF.

Utiliser Dapper Quand:

  • La performance est importante, mais pas critique
  • Vous avez des requêtes complexes qui ne mappent pas bien à LINQ
  • Vous êtes à l'aise d'écrire SQL
  • Vous avez besoin d'un contrôle fin sur la génération SQL
  • Travail avec les schémas de base de données existants
  • Charges de travail lourdes de lecture avec des écritures simples
  • Vous voulez un minimum d'abstraction au-dessus

Utiliser Raw Npgsql Lorsque :

  • L'efficacité maximale est critique
  • Construction de processeurs de données à haut débit
  • • Travailler en profondeur avec les caractéristiques spécifiques de PostgreSQLTM
  • Opérations par lots et importations en vrac
  • Chaque milliseconde et mégaoctet compte
  • Vous avez besoin d'un contrôle absolu

Approche hybride lorsque:

  • Les différentes parties de l'application ont des besoins différents
  • Motif CQRS (EF Core pour écrire, Dapper pour lire)
  • La plupart des requêtes utilisent EF Core, mais quelques-uns ont besoin de SQL brut
  • Vous voulez une migration progressive entre les approches
  • Grandes applications complexes avec des exigences variées

Résumé des pratiques exemplaires

Directives générales

  1. Commencer par EF Core pour les nouveaux projets à moins que vous n'ayez des exigences de performance spécifiques
  2. Profil avant optimisation - ne supposez pas que vous avez besoin de Dapper/raw SQL
  3. Utiliser des approches hybrides - combiner les forces de différents outils
  4. Isolez la logique d'accès aux données - le modèle de dépôt aide à changer les implémentations
  5. Utiliser la mise en commun des connexions - configurer correctement pour votre charge de travail
  6. Utiliser les fonctionnalités de PostgreSQLTM - n'abstractionnez pas les puissantes capacités de base de données

Spécifique au pieu

  1. Utiliser des requêtes paramétrées pour empêcher l'injection SQL
  2. Considérer le résultat de la requête en cache pour des requêtes coûteuses et répétées
  3. Utiliser multi-mapping pour les jointures au lieu de plusieurs allers-retours
  4. Réutiliser les connexions à partir de la piscine de connexion
  5. Considérez Dapper.Contrib pour les opérations CRUD simples

Conseils de performance

  1. Indexer vos requêtes - analyser les requêtes lentes avec EXPLAIN ANALYZE
  2. Utiliser les déclarations préparées pour les requêtes répétées
  3. Opérations par lots dans la mesure du possible
  4. Utiliser COPY pour les inserts en vrac dans PostgreSQLTM
  5. Piscine de connexion de moniteur - configurer la taille de la piscine min/max
  6. Envisager de lire des répliques pour les charges de travail lourdes en lecture

Le présent règlement entre en vigueur le vingtième jour suivant celui de sa publication au Journal officiel de l'Union européenne.

Choisir la bonne approche d'accès aux données pour votre application .NET avec PostgreSQLTM ne consiste pas à trouver le meilleur outil - il s'agit d'adapter le bon outil à vos besoins spécifiques:

  • EF Noyau (voir Première partie) excelle dans le développement rapide, la modélisation de domaine, et les applications où la productivité du développeur prime sur les performances brutes
  • Détonateur fournit un excellent terrain intermédiaire avec des performances presque optimales et une abstraction raisonnable
  • Npgsql brut offre des performances et un contrôle maximaux pour les opérations à forte intensité de données
  • Bibliothèques cartographiques comme Mapster et AutoMapper réduisent la plaque de chaudière lors du travail avec l'accès aux données de niveau inférieur

Dans la pratique, les applications les plus réussies utilisent souvent approche hybride, en tirant parti des forces de chaque outil, le cas échéant:

  • Utilisation EF Noyau pour votre logique de domaine et écrit
  • Utilisation Détonateur pour les requêtes complexes en lecture et les rapports
  • Utilisation Npgsql brut pour les opérations en vrac et les analyses

La clé est de :

  1. Comprendre vos besoins - performance, vitesse de développement, compétences en équipe
  2. Profiler votre demande - identifier les goulets d'étranglement réels, non supposés
  3. Choisir de manière pragmatique - utiliser l'outil le plus simple qui répond à vos besoins
  4. Rester flexible - vous pouvez mélanger des approches dans la même application

Rappelez-vous : l'optimisation prématurée est la racine de tout mal, mais aussi la construction d'un système qui ne peut pas s'écheller au besoin. Commencer simple, mesurer les performances, et optimiser là où il compte.

Références et lectures complémentaires

Première partie de la présente série:

Documentation officielle:

Articles connexes sur ce blog:


Cela conclut notre série en deux parties sur l'accès aux données dans .NET! Nous avons tout couvert des abstractions puissantes d'EF Core à la performance maximale de SQL brut, avec des conseils pratiques sur la combinaison d'approches pour des résultats optimaux.

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.