Gegevenstoegang in .NET: Vergelijken van ORMs en Mapping Strategieën (Deel 2 - Dapper, Raw SQL en Hybrid Approaches) (Nederlands (Dutch))

Gegevenstoegang in .NET: Vergelijken van ORMs en Mapping Strategieën (Deel 2 - Dapper, Raw SQL en Hybrid Approaches)

Wednesday, 03 December 2025

//

21 minute read

Welkom bij Deel 2 van onze uitgebreide gids voor toegang tot gegevens in .NET! Deel 1, we onderzochten Entity Framework Core in detail, waaronder SQL generatie, gemeenschappelijke valkuilen, en die kritische waarschuwing over proxies en caching.

In dit artikel verkennen we de lichtergewicht alternatieven en hoe meerdere benaderingen te combineren voor optimale prestaties:

  • Dapper: De micro-ORM-sweetvlek
  • Raw ADO.NET/Npgsql: Maximale prestaties en controle
  • Object Mapping Bibliotheken: Mapster vs Automapper
  • Hybride naderingen: Het combineren van EF Core en Dapper (CQRS patroon)
  • Prestatiebenchmarks: Vergelijkingen in de reële wereld
  • Decision Matrix: Het kiezen van de juiste tool voor uw scenario

Inhoudstabel

Dapper: De Micro-ORM

Dapper is een lichtgewicht, high-performance micro-ORM gemaakt door Stack Overflow. Het biedt een dunne laag over ADO.NET, het omgaan met het vervelende werk van het in kaart brengen van query resultaten naar objecten terwijl u volledige SQL controle.

Waarom Dapper bestaat

Dapper werd geboren uit de behoefte van Stack Overflow aan high-performance data-toegang. Het team vond dat volledige ORM's zoals Entity Framework (pre-Core) te veel overhead toegevoegd voor hun high-traffic scenario's. Dapper geeft u 95% van het gemak met slechts 5-15% overhead over rauwe ADO.NET.

Belangrijkste kenmerken

  • Raw SQL: U schrijft alle SQL zelf - volledige controle
  • Hoge prestaties: Minimale overhead boven ADO.NET (5-15%)
  • Multi-Mapping: Kaart complexe vragen aan meerdere verwante types
  • Parameterbehandeling: Automatische parameterisatie voorkomt SQL injectie
  • Transactieondersteuning: Volledige controle over transacties
  • Eenvoud: Geen configuratie, geen wijziging van tracking, geen magie
  • Async/Await: Volledige async ondersteuning voor moderne .NET

Basis Dapper Voorbeelden

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: Handling sluit zich aan

Een van Dapper's meest krachtige functies is multi-mapping - efficiënt omgaan met joins en mapping naar meerdere gerelateerde objecten:

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

Geavanceerde Dapper Technieken

Dynamische parameters voor complexe zoekopdrachten:

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

Aangepaste type handlers voor PostgreSQL types:

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

Bulk Operations met 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();
}

Transactieondersteuning:

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

Wanneer Dapper moet worden gebruikt

Gebruik Dapper wanneer:

  • Prestaties zijn cruciaal, maar je hebt geen absolute maximale snelheid nodig
  • Je bent comfortabel met het schrijven van SQL
  • U hebt complexe queries die niet goed in kaart brengen naar LINQ
  • Je hebt fijne controle over SQL generatie nodig
  • Je werkt met een bestaand databaseschema
  • U wilt minimale overhead en afhankelijkheden
  • Geheugengebruik is een zorg
  • U moet uitgebreide gebruik maken van database-specifieke functies

Vermijd dapperheid wanneer:

  • Uw team is niet comfortabel met SQL
  • U heeft automatische change tracking nodig
  • U wilt schema migraties als code
  • Je hebt een snel evoluerend schema.
  • Je hebt cross-database draagbaarheid nodig
  • U verkiest LINQ boven SQL syntax

Prestatiekenmerken

  • Query Performance (Query Performance): 5-15% overhead over raw ADO.NET
  • Geheugengebruik: Minimale - geen wijziging tracking of proxies
  • Bulkbewerkingen: Uitstekend met PostgreSQL COPY
  • Eerste zoekopdracht: Snel - geen compilatie stap
  • Productiviteit van de ontwikkelaar: Vereist SQL kennis maar zeer voorspelbaar

Raw ADO.NET met Npgsql

Voor absolute maximale prestaties en controle, kunt u gebruiken Npgsql direct zonder ORM-laag.

Wanneer Raw ADO.NET sense maakt

Raw ADO.NET is geschikt wanneer:

  • Je hebt elke milliseconde van de prestaties nodig.
  • Je bouwt high-throughput data processors
  • U werkt met PostgreSQL-specifieke functies die niet worden ondersteund door ORMs
  • U heeft nauwkeurige controle over geheugentoewijzingen nodig
  • U doet bulk operaties of streamt grote datasets

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

Prestatiekenmerken

  • Query Performance (Query Performance): Baseline - snelst mogelijk
  • Geheugengebruik: Laagste - volledige controle over toewijzingen
  • Bulkbewerkingen: Uitstekend met COPY protocol
  • Productiviteit van de ontwikkelaar: Vereist de meeste handarbeid

Object Mapping Bibliotheken

Bij het werken met Dapper of rauw ADO.NET moet je vaak in kaart brengen tussen verschillende objectrepresentaties (DTO's, entiteiten, weergavemodellen). Verschillende bibliotheken kunnen dit automatiseren.

Kaarten: High Performance Mapping

Mapster is een snelle, op conventies gebaseerde objectmapper die brongeneratie gebruikt voor optimale prestaties.

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

Automapper is de meest populaire mapping library, hoewel langzamer dan 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());
    }
}

Handmatig in kaart brengen: volledige controle

Soms is de beste aanpak een expliciete handmatige mapping:

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

Vergelijking van de prestaties bij het in kaart brengen

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:

  • Handmatig in kaart brengen is het snelst, maar vereist meer code
  • Mapster is bijna net zo snel met minder code
  • AutoMapper is handig, maar heeft hogere overhead

Hybride benaderingen: Beste van beide werelden

In echte toepassingen wil je vaak verschillende benaderingen gebruiken voor verschillende scenario's binnen dezelfde toepassing. de aanbevolen aanpak voor de meeste productiesystemen.

CQRS patroon: Scheiden Leest en schrijft

Het CQRS (Command Query Responsibility Segregation) patroon is een natuurlijke pasvorm voor hybride data access benaderingen. Voor een diepere duik in CQRS en event sourcing met Marten, zie mijn artikel over Moderne CQRS en Event Sourcing.

Hoe Marten zich verhoudt tot deze discussie:

Marten is een document database en event store gebouwd op PostgreSQL die hybride data toegang neemt tot een ander niveau. Het combineert:

  • Event sourcing voor writes (onveranderbare eventstreams)
  • Prognoses voor lezen (gematerialiseerde weergaven geoptimaliseerd voor vragen)
  • PostgreSQL's JSONB voor documentopslag
  • Ingebouwde CQRS-patronen

Terwijl dit artikel zich richt op traditionele relationele datatoegang (EF Core, Dapper), laat Marten zien hoe je de geavanceerde functies van PostgreSQL (JSONB, eventstreams) kunt benutten om geavanceerde architecturen te implementeren.

  • Aparte schrijfmodellen (geoptimaliseerd voor transacties en consistentie)
  • Aparte leesmodellen (geoptimaliseerd voor vragen en prestaties)
  • Gebruik het juiste gereedschap voor elke taak
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

Dit patroon maakt gebruik van:

  • EF-kern voor writes: tracking, validatie, bedrijfsregels wijzigen
  • Dapper voor lees: Maximale query prestaties en flexibiliteit
  • Dezelfde database, verschillende toegangspatronen geoptimaliseerd voor elke use case

Implementatie: CQRS met EF Core en 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 met Occasional Raw SQL

Voor toepassingen die voornamelijk EF Core zijn maar incidentele prestatieoptimalisatie nodig hebben:

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

Prestatievergelijking

Laten we kijken naar real-world prestatie benchmarks voor gemeenschappelijke operaties met PostgreSQL:

Benchmark: Het lezen van 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: 1000 records invoegen

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

Sleutelafhaalpunten

  1. Raw Npgsql is het snelst maar vereist de meeste code
  2. Dapper biedt uitstekende prestaties met minimale abstractiekosten (60-70% sneller dan EF Core)
  3. EF Core no-tracking queries zijn redelijk voor de meeste scenario's
  4. Bulkbewerkingen tonen de grootste prestatie gaps (10-13x verschil!)
  5. Geheugentoewijzingen een vergelijkbaar patroon volgen als de uitvoeringstijd

Decision Matrix: Welke aanpak te gebruiken

EF-kern gebruiken wanneer:

  • Bouwen aan een nieuwe toepassing met veranderende eisen
  • Domein-gedreven ontwerp met rijke entiteit modellen
  • Je hebt migraties en schemabeheer nodig
  • Team is comfortabeler met C# dan SQL
  • De lees-/schrijfverhouding is in evenwicht
  • De zoekresultaten binnen 20-50% van het optimale is aanvaardbaar
  • U wilt wijzigen tracking en eenheid van het werk patroon

Zie Deel 1 voor uitgebreide EF Core guidelance.

Dapper gebruiken wanneer:

  • De prestaties zijn belangrijk, maar niet kritisch.
  • Je hebt complexe queries die niet goed in kaart brengen naar LINQ
  • Je bent comfortabel aan het schrijven van SQL
  • Je hebt fijne controle over SQL generatie nodig
  • Werken met bestaande databaseschema's
  • Leeszware workloads met eenvoudige schrijfsels
  • U wilt minimale abstractie overhead

Raw Npgsql gebruiken wanneer:

  • De maximale prestaties zijn van cruciaal belang.
  • Bouwen van high-throughput data processors
  • Werkt uitgebreid met PostgreSQL-specifieke functies
  • Batchbewerkingen en invoer van bulkgoederen
  • Elke milliseconde en megabyte is belangrijk.
  • Je hebt absolute controle nodig.

Hybride nadering wanneer:

  • Verschillende onderdelen van de toepassing hebben verschillende behoeften
  • CQRS patroon (EF Core for writes, Dapper for reads)
  • De meeste vragen gebruiken EF Core, maar een paar hebben ruwe SQL nodig
  • U wilt geleidelijke migratie tussen benaderingen
  • Grote, complexe toepassingen met uiteenlopende eisen

Samenvatting van beste praktijken

Algemene richtsnoeren

  1. Beginnen met EF Core voor nieuwe projecten tenzij u specifieke prestatie-eisen heeft
  2. Profiel voor het optimaliseren - neem niet aan dat je Dapper/raw SQL nodig hebt
  3. Hybride benaderingen gebruiken - de sterktes van verschillende gereedschappen te combineren
  4. Gegevenstoegangslogica geïsoleerd houden - repository patroon helpt schakelen implementaties
  5. Verbindingspooling gebruiken - goed configureren voor uw werklast
  6. Leverage PostgreSQL functies - abstracteer geen krachtige database mogelijkheden

Dapperspecifiek

  1. Geparametriseerde queries gebruiken ter voorkoming van SQL-injectie
  2. Overweeg query resultaat caching voor dure, herhaalde vragen
  3. Multi-mapping gebruiken voor joins in plaats van meerdere ronde-trips
  4. Verbindingen hergebruiken uit verbindingspool
  5. Overweeg Dapper.Contrib voor eenvoudige CRUD-operaties

Prestatietips

  1. Uw queries indexeren - analyseer langzame vragen met EXPLAIN ANALYZE
  2. Gebruik voorbereide verklaringen voor herhaalde vragen
  3. Batchbewerkingen indien mogelijk
  4. Gebruik COPY voor bulk inserts in PostgreSQL
  5. Monitor verbinding pool - Min/max zwembadgrootte instellen
  6. Overweeg replica's te lezen voor read-heavy workloads

Conclusie

Het kiezen van de juiste data access benadering voor uw .NET applicatie met PostgreSQL gaat niet over het vinden van de "beste" tool - het gaat over het aanpassen van de juiste tool aan uw specifieke behoeften:

  • EF-kern (zie Deel 1) blinkt uit in snelle ontwikkeling, domeinmodellering en toepassingen waar de productiviteit van de ontwikkelaar de ruwe prestaties overstijgt
  • Dapper biedt een uitstekende middengrond met bijna optimale prestaties en redelijke abstractie
  • Raw Npgsql levert maximale prestaties en controle voor data-intensieve operaties
  • Bibliotheken in kaart brengen zoals Mapster en AutoMapper verminderen boilerplate bij het werken met lagere toegang tot gegevens

In de praktijk maken de meest succesvolle toepassingen vaak gebruik van een hybride benadering, gebruik makend van de sterke punten van elk instrument, in voorkomend geval:

  • Gebruik EF-kern voor uw domeinlogica en schrijft
  • Gebruik Dapper voor complexe leesvragen en rapportage
  • Gebruik ruw Npgsql voor bulktransacties en analyses

De sleutel is om:

  1. Begrijp uw eisen - prestaties, ontwikkelingssnelheid, teamvaardigheden
  2. Profiel uw toepassing - de feitelijke knelpunten te identificeren, niet veronderstelde knelpunten
  3. pragmatisch kiezen - gebruik de eenvoudigste tool die aan uw behoeften voldoet
  4. Blijf flexibel - u kunt benaderingen mengen binnen dezelfde toepassing

Vergeet niet: vroegtijdige optimalisatie is de wortel van alle kwaad, maar ook het bouwen van een systeem dat niet kan schalen wanneer dat nodig is. Start eenvoudig, meet prestaties, en optimaliseer waar het belangrijk is.

Referenties en verdere lezing

Deel 1 van deze serie:

Officiële documentatie:

Gerelateerde artikelen op dit blog:


Dat sluit onze tweedelige serie over datatoegang in .NET af! We hebben alles behandeld van EF Core's krachtige abstracties tot rauwe SQL's maximale prestaties, met praktische begeleiding over het combineren van benaderingen voor optimale resultaten.

Finding related posts...
logo

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