Optimizare Interogări în arhitectura CQRS

  • Doru Bulubasa
  • 29 September 2025

După ce am stabilit separarea responsabilităților între Command și Query, următorul pas natural este să optimizăm modul în care sunt realizate interogările. Într-o aplicație de tip blog, unde utilizatorii accesează frecvent date precum lista articolelor, comentariile sau profilurile autorilor, performanța interogărilor devine un factor critic pentru experiența finală. În această etapă, vom discuta câteva practici importante: proiecții directe, utilizarea AsNoTracking(), evitarea încărcărilor inutile prin .Include(), paginare și filtrare, precum și caching strategic pentru interogările cele mai costisitoare.


1. Proiecții directe (Select into DTO)

Unul dintre principiile de bază ale separării între Command și Query este faptul că interogările nu ar trebui să returneze entitățile domeniului direct, ci obiecte optimizate pentru afișare: DTO-uri (Data Transfer Objects).

De exemplu, în loc să încărcăm întreaga entitate Post din baza de date, cu toate relațiile sale, putem proiecta direct în obiectul PostDto:

public class GetPostsQueryHandler : IRequestHandler<GetPostsQuery, List<PostDto>>
{
    private readonly BloggingDbContext _context;

    public GetPostsQueryHandler(BloggingDbContext context)
    {
        _context = context;
    }

    public async Task<List<PostDto>> Handle(GetPostsQuery request, CancellationToken cancellationToken)
    {
        return await _context.Posts
            .AsNoTracking()
            .Select(p => new PostDto
            {
                Id = p.Id,
                Title = p.Title,
                AuthorName = p.Author.Name,
                PublishedAt = p.PublishedAt
            })
            .ToListAsync(cancellationToken);
    }
}

Avantajul acestei abordări este clar: extragem din baza de date doar câmpurile de care avem nevoie. Astfel, reducem dimensiunea rezultatului și evităm mapări suplimentare în memorie.


2. Evitarea .Include() dacă nu este necesar

Operatorul .Include() din Entity Framework este util atunci când avem nevoie de relații asociate (e.g., articole și comentarii). Totuși, folosirea lui excesivă conduce la interogări complexe și date redundante.

De exemplu, dacă vrem să afișăm doar titlul și autorul unui articol, nu este nevoie să încărcăm și comentariile sale. Prin utilizarea proiecțiilor selective (cum am arătat mai sus), eliminăm complet necesitatea unor .Include() inutile.

Regula de bază este: în Queries folosește doar datele strict necesare pentru scenariul respectiv.


3. AsNoTracking() pentru interogări de citire

Entity Framework, implicit, urmărește modificările obiectelor încărcate din baza de date, chiar dacă doar le citim. Acest lucru introduce un overhead suplimentar de performanță.

Pentru Queries, unde nu vom modifica obiectele returnate, este recomandat să folosim AsNoTracking():

var posts = await _context.Posts
    .AsNoTracking()
    .ToListAsync();

Astfel, eliminăm urmărirea entităților în ChangeTracker și obținem o creștere considerabilă de performanță pentru interogările mari.


4. Suport pentru paginare, filtrare și sortare

Pe măsură ce baza de date crește, este esențial să nu încărcăm toate înregistrările simultan. Într-un blog cu sute sau mii de articole, este nevoie de paginare, filtrare și sortare pentru a oferi o experiență eficientă utilizatorilor.

Un exemplu de Query cu paginare ar putea arăta astfel:

public class GetPostsQuery : IRequest<PaginatedResult<PostDto>>
{
    public int Page { get; set; }
    public string? Filter { get; set; }
    public string? Sort { get; set; }
}

public async Task<PaginatedResult<PostDto>> Handle(GetPostsQuery request, CancellationToken cancellationToken)
{
    var query = _context.Posts.AsNoTracking();

    if (!string.IsNullOrEmpty(request.Filter))
        query = query.Where(p => p.Title.Contains(request.Filter));

    if (request.Sort == "date_desc")
        query = query.OrderByDescending(p => p.PublishedAt);
    else
        query = query.OrderBy(p => p.PublishedAt);

    var total = await query.CountAsync(cancellationToken);
    var items = await query
        .Skip((request.Page - 1) * 10)
        .Take(10)
        .Select(p => new PostDto
        {
            Id = p.Id,
            Title = p.Title,
            AuthorName = p.Author.Name,
            PublishedAt = p.PublishedAt
        })
        .ToListAsync(cancellationToken);

    return new PaginatedResult<PostDto>(items, total, request.Page, 10);
}

Această implementare permite utilizatorului să navigheze prin pagini, să aplice filtre și să ordoneze rezultatele în funcție de criterii specifice.


5. Caching pentru Queries

Nu toate interogările merită cache, dar pentru cele frecvent utilizate sau costisitoare (ex: top 10 articole populare, statistici agregate) caching-ul poate aduce beneficii majore.

Două opțiuni comune sunt:

  • MemoryCache – potrivit pentru aplicații single-server.

  • Redis – recomandat pentru aplicații distribuite sau scalabile.

Exemplu simplu cu IMemoryCache:

public class GetPopularPostsQueryHandler : IRequestHandler<GetPopularPostsQuery, List<PostDto>>
{
    private readonly BloggingDbContext _context;
    private readonly IMemoryCache _cache;

    public GetPopularPostsQueryHandler(BloggingDbContext context, IMemoryCache cache)
    {
        _context = context;
        _cache = cache;
    }

    public async Task<List<PostDto>> Handle(GetPopularPostsQuery request, CancellationToken cancellationToken)
    {
        return await _cache.GetOrCreateAsync("popular_posts", async entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);

            return await _context.Posts
                .AsNoTracking()
                .OrderByDescending(p => p.Views)
                .Take(10)
                .Select(p => new PostDto
                {
                    Id = p.Id,
                    Title = p.Title,
                    AuthorName = p.Author.Name,
                    PublishedAt = p.PublishedAt
                })
                .ToListAsync(cancellationToken);
        });
    }
}

Concluzie

Optimizarea interogărilor este o etapă critică în arhitectura unei aplicații bazate pe CQRS. Prin folosirea proiecțiilor directe, evitarea .Include(), aplicarea AsNoTracking(), introducerea paginării și caching-ului, putem asigura un timp de răspuns mai mic și o utilizare eficientă a resurselor. Astfel, aplicația rămâne scalabilă și plăcută pentru utilizatori chiar și atunci când volumul de date crește.

Scrie un comentariu

Adresa de mail nu va fi publicata. Campurile obligatorii sunt marcate cu *