Back to "Accesso ai dati in .NET: confronto tra ORM e strategie di mappatura (parte 2 - Dapper, Raw SQL e approcci ibridi)"

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

Accesso ai dati in .NET: confronto tra ORM e strategie di mappatura (parte 2 - Dapper, Raw SQL e approcci ibridi)

Wednesday, 03 December 2025

Benvenuti nella parte 2 della nostra guida completa all'accesso ai dati in .NET! In Parte 1, abbiamo esplorato in profondità Entity Framework Core, tra cui la generazione di SQL, insidie comuni, e quell'avvertimento critico sui proxy e sulla cache.

In questo articolo, esploreremo le alternative più leggere e come combinare più approcci per prestazioni ottimali:

Indice

Dapper: Il Micro-ORM

DapperCity name (optional, probably does not need a translation) è un micro-ORM leggero e ad alte prestazioni creato da Stack Overflow. Fornisce un sottile strato su ADO.NET, maneggiando il lavoro noioso di mappatura dei risultati delle query agli oggetti mentre ti dà il pieno controllo SQL.

Perché Dapper esiste

Dapper è nato dalla necessità di Stack Overflow per l'accesso ai dati ad alte prestazioni. Il team ha scoperto che le ORM complete come Entity Framework (pre-Core) hanno aggiunto troppe spese generali per i loro scenari ad alto traffico. Dapper offre il 95% della convenienza con solo il 5-15% di spese generali su ADO.NET grezzo.

Caratteristiche chiave

  • SQL grezzo: Si scrive tutto SQL da soli - controllo completo
  • Alte prestazioni: Minimale sopra ADO.NET (5-15%)
  • Multi-Mappatura: Mappa le query complesse a più tipi correlati
  • Gestione parametri: La parametrizzazione automatica impedisce l'iniezione di SQL
  • Supporto per le transazioni: Pieno controllo sulle transazioni
  • Semplicità: Nessuna configurazione, nessun monitoraggio del cambiamento, nessuna magia
  • Async/Await: Supporto completo async per il moderno .NET

Esempi di base di 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-Mappatura: la manipolazione si unisce

Una delle caratteristiche più potenti di Dapper è la multi-mapping: gestire in modo efficiente unisce e mappare in più oggetti correlati:

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;
}

Tecniche avanzate di Dapper

Parametri dinamici per le interrogazioni complesse:

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);
}

Gestori di tipo personalizzato per i tipi PostgreSQL:

// 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>>());

Operazioni di massa con 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();
}

Supporto transazione:

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;
    }
}

Quando usare Dapper

Usare Dapper quando:

  • Le prestazioni sono critiche, ma non è necessaria la velocità massima assoluta
  • Sei a tuo agio a scrivere SQL
  • Hai domande complesse che non mappano bene a LINQ
  • Hai bisogno di un controllo accurato sulla generazione SQL
  • Stai lavorando con uno schema di database esistente
  • Vuoi un minimo di overhead e dipendenze
  • L'uso della memoria è una preoccupazione
  • È necessario sfruttare ampiamente le caratteristiche specifiche del database

Evitare di sorridere quando:

  • Il tuo team non è a suo agio con SQL
  • È necessario il monitoraggio automatico del cambiamento
  • Vuoi le migrazioni schema come codice
  • Hai uno schema in rapida evoluzione
  • Hai bisogno di portabilità cross-database
  • Preferisci LINQ rispetto alla sintassi SQL

Caratteristiche di prestazione

  • Prestazioni di query: 5-15% di spese generali su ADO.NET grezzo
  • Uso della memoria: Minimale - nessun cambiamento di monitoraggio o proxy
  • Operazioni all'ingrosso: Eccellente con PostgreSQL COPY
  • Prima interrogazione: Veloce - nessun passo di compilazione
  • Produttività degli sviluppatori: Richiede conoscenze SQL ma molto prevedibili

Raw ADO.NET con Npgsql

Per le massime prestazioni e il controllo assoluto, è possibile utilizzare NpgsqlCity name (optional, probably does not need a translation) direttamente senza alcun livello ORM.

Quando Raw ADO.NET fa sentire

ADO.NET grezzo è appropriato quando:

  • Hai bisogno di ogni millisecondo di prestazioni
  • Stai costruendo processori di dati ad alta velocità
  • Stai lavorando con funzionalità specifiche di PostgreSQL non supportate da ORM
  • Hai bisogno di un controllo preciso sulle allocazioni di memoria
  • Stai facendo operazioni all'ingrosso o streaming di grandi set di dati

Esempio: 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)
            };
        }
    }
}

Caratteristiche di prestazione

  • Prestazioni di query: Base di riferimento - il più veloce possibile
  • Uso della memoria: Più basso - controllo completo sulle assegnazioni
  • Operazioni all'ingrosso: Eccellente con il protocollo COPY
  • Produttività degli sviluppatori: Richiede la maggior parte del lavoro manuale

Librerie di mappatura degli oggetti

Quando si lavora con Dapper o raw ADO.NET, è spesso necessario mappare tra diverse rappresentazioni di oggetti (DTO, entità, modelli di visualizzazione). Diverse librerie possono automatizzare questo.

Mapster: Mappatura ad alte prestazioni

MapsterCity name (optional, probably does not need a translation) è un mapper di oggetti veloce e basato su convenzioni che utilizza la generazione di sorgenti per prestazioni ottimali.

// 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: Mature Convention-Based Mapper

AutoMapper è la libreria di mappatura più popolare, anche se più lenta di 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());
    }
}

Mappatura manuale: Controllo completo

A volte l'approccio migliore è la mappatura manuale esplicita:

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();

Confronto delle prestazioni di mappatura

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

Key Takeaways:

  • La mappatura manuale è più veloce ma richiede più codice
  • Mapster è quasi altrettanto veloce con meno codice
  • AutoMapper è conveniente, ma ha un peso superiore

Approcci ibridi: il meglio di entrambi i mondi

Nelle applicazioni reali, spesso si desidera utilizzare approcci diversi per diversi scenari all'interno della stessa applicazione. l'approccio raccomandato per la maggior parte dei sistemi di produzione.

Schema CQRS: Separare letture e scritti

Il modello CQRS (Command Query Responsibility Segregation) è una misura naturale per l'accesso ai dati ibridi. Per un'immersione più profonda in CQRS e l'approvvigionamento di eventi con MartenCity name (optional, probably does not need a translation), vedi il mio articolo su Moderno CQRS ed Event Sourcing.

Come Marten si relaziona a questa discussione:

Marten è un database di documenti ed un archivio di eventi costruito su PostgreSQL che prende l'accesso di dati ibridi ad un altro livello.

  • Evento di approvvigionamento per writes (streaming di eventi immutabili)
  • Proiezioni per leggere (visite materializzate ottimizzate per le query)
  • JSONB di PostgreSQL per la conservazione dei documenti
  • Modelli CQRS incorporati

Mentre questo articolo si concentra sull'accesso tradizionale ai dati relazionali (EF Core, Dapper), Marten mostra come è possibile sfruttare le funzionalità avanzate di PostgreSQL (JSONB, flussi di eventi) per implementare architetture sofisticate. I principi sono gli stessi:

  • Modelli di scrittura separati (ottimizzata per transazioni e coerenza)
  • Modelli di lettura separati (ottimizzata per richieste e prestazioni)
  • Usa lo strumento giusto per ogni lavoro
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

Questo modello leva:

  • Centrale EF per writes: Change tracking, validation, business rules
  • DapperCity name (optional, probably does not need a translation) per leggere: Massime prestazioni di query e flessibilità
  • Stesso database, diversi modelli di accesso ottimizzati per ogni caso di utilizzo

Implementazione: CQRS con EF Core e 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 con SQL grezzo occasionale

Per applicazioni che sono principalmente EF Core, ma hanno bisogno di occasionali ottimizzazione delle prestazioni:

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;
    }
}

Confronto delle prestazioni

Diamo un'occhiata ai parametri di performance del mondo reale per operazioni comuni con PostgreSQL:

Benchmark: Reading 1000 Records

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

Benchmark: Inserimento di 1000 record

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: Complex Join Query

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

Key TakeawaysCity name (optional, probably does not need a translation)

  1. Raw Npgsql è il più veloce ma richiede la maggior parte del codice
  2. Dapper offre prestazioni eccellenti con un costo di astrazione minimo (60-70% più veloce del core EF)
  3. Core EF query senza tracciatura sono ragionevoli per la maggior parte degli scenari
  4. Operazioni all'ingrosso mostrare le maggiori differenze di prestazioni (10-13x differenza!)
  5. Allocazioni di memoria seguire un modello simile al tempo di esecuzione

Matrice della decisione: quale approccio usare

Usa EF Core quando:

  • Costruire una nuova applicazione con requisiti in evoluzione
  • Progettazione a dominio con ricchi modelli di entità
  • Hai bisogno di migrazioni e gestione degli schemi
  • Il team è più confortevole con C# di SQL
  • Il rapporto di lettura/scrittura è bilanciato
  • La prestazione di query entro il 20-50% di ottimale è accettabile
  • Vuoi cambiare il monitoraggio e l'unità del modello di lavoro

Vedi Parte 1 per una guida generale al nucleo dell'impronta ambientale.

Usa Dapper quando:

  • Le prestazioni sono importanti ma non critiche
  • Avete query complesse che non mappano bene a LINQ
  • Sei comoda a scrivere SQL
  • Hai bisogno di controllo fine sulla generazione SQL
  • Lavorare con schemi di database esistenti
  • Carichi di carico di lavoro pesanti da leggere con semplici scritture
  • Vuoi un'astrazione minima in alto

Usare Raw Npgsql quando:

  • Le massime prestazioni sono critiche
  • Costruire processori di dati ad alto rendimento
  • Lavorare ampiamente con le caratteristiche specifiche di PostgreSQL
  • Operazioni in lotti e importazioni alla rinfusa
  • Ogni millisecondo e megabyte è importante
  • Hai bisogno di un controllo assoluto

Avvicinamento ibrido quando:

  • Diverse parti dell'applicazione hanno esigenze diverse
  • Modello CQRS (EF Core for writes, Dapper for reads)
  • La maggior parte delle query usa EF Core, ma alcuni hanno bisogno di SQL grezzo
  • Vuoi una migrazione graduale tra gli approcci
  • Grande, applicazioni complesse con requisiti diversi

Sintesi delle migliori pratiche

Orientamenti generali

  1. Inizia con il nucleo EF per i nuovi progetti a meno che non abbiate requisiti di prestazione specifici
  2. Profilo prima di ottimizzare - non assumere di aver bisogno di Dapper/raw SQL
  3. Utilizzare approcci ibridi - combinare i punti di forza dei diversi utensili
  4. Tenere isolata la logica di accesso ai dati - lo schema del repository aiuta a cambiare le implementazioni
  5. Utilizzare il pool di connessione - configurare correttamente il carico di lavoro
  6. Sfrutta le funzionalità di PostgreSQL - non astrarre potenti funzionalità del database

Specifico per il dapper

  1. Usa query parametrizzate per prevenire l'iniezione di SQL
  2. Considera la cache dei risultati della query per richieste costose e ripetute
  3. Usa multi-mapping per unioni invece di più tondeggianti
  4. Riutilizzare le connessioni dalla piscina di connessione
  5. Considera Dapper.Contrib per semplici operazioni CRUD

Suggerimenti per le prestazioni

  1. Index your query - analizzare le query lente con EXPLAIN ANALIZE
  2. Usa le dichiarazioni preparate per interrogazioni ripetute
  3. Operazioni in lotti quando possibile
  4. Usa COPY per inserti alla rinfusa in PostgreSQL
  5. Monitorare il pool di connessione - configurare la dimensione min/max della piscina
  6. Considera le repliche di lettura per carichi di lavoro pesanti in lettura

Conclusione

Scegliere il giusto approccio di accesso ai dati per l'applicazione .NET con PostgreSQL non significa trovare lo strumento "miglior" - si tratta di abbinare lo strumento giusto alle vostre esigenze specifiche:

  • Centrale EF (vedere Parte 1) eccelle nello sviluppo rapido, nella modellazione del dominio e nelle applicazioni in cui la produttività degli sviluppatori supera le prestazioni prime
  • DapperCity name (optional, probably does not need a translation) fornisce un ottimo terreno di mezzo con prestazioni quasi ottimali e ragionevole astrazione
  • Npgsql grezzo offre il massimo delle prestazioni e del controllo per operazioni ad alta intensità di dati
  • Librerie di mappatura come Mapster e AutoMapper riducono la piastra caldaia quando si lavora con l'accesso ai dati di livello inferiore

In pratica, le applicazioni di maggior successo spesso utilizzano un metodo ibrido, sfruttando, se del caso, i punti di forza di ogni strumento:

  • Uso Centrale EF per la tua logica di dominio e scrive
  • Uso DapperCity name (optional, probably does not need a translation) per interrogazioni di lettura complesse e reporting
  • Uso Npgsql grezzo per operazioni all'ingrosso e analisi

La chiave è:

  1. Comprendere le vostre esigenze - prestazioni, velocità di sviluppo, capacità di squadra
  2. Profilo della tua domanda - individuare le strozzature effettive, non quelle ipotizzate
  3. Scegliere pragmaticamente - utilizzare lo strumento più semplice che soddisfi le vostre esigenze
  4. Resta flessibile - è possibile mixare gli approcci all'interno della stessa applicazione

Ricordate: l'ottimizzazione prematura è la radice di tutto il male, ma così è la costruzione di un sistema che non può scalare quando necessario. Avviare semplice, misurare le prestazioni, e ottimizzare dove conta.

Riferimenti e ulteriori letture

Parte 1 della presente serie:

Documentazione ufficiale:

Articoli correlati su questo blog:


Con questo si conclude la nostra serie in due parti sull'accesso ai dati in .NET! Abbiamo trattato tutto, dalle potenti astrazioni di EF Core alle massime prestazioni di SQL, con una guida pratica sulla combinazione di approcci per risultati ottimali.

logo

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