# Modernisation du CQRS et de la recherche d’événements dans le .NET: Faire en sorte que cela soit correct

<!--category-- ASP.NET, Architecture, CQRS, Event Sourcing, Marten, Performance -->
<datetime class="hidden">2025-11-13T12:00</datetime>

> **Notes**: C'est un article antique que j'ai oublié de publier . Voici ce qu'il est, Profitez-enM SK3 Je l'ai mis à jour, mais il peut y avoir des problèmes que je n'ai pas encore relevésMSC4

CQRS et recherche d'événements - deux modèles qui sont souvent mentionnés ensemble mais souvent mal interprétés . Dans cet articleM SK2 I' je vais vous montrer comment les mettre en œuvre correctement à l'aide d'outils modernes |.NET |

I' Je vous montrerai également la "halfM SK2assedMSC3 cacheMST4alternative fondée sur , et je vous expliquerai pourquoi essayer de mélanger Event Sourcing avec l’invalidation manuelle du cache est une idée terrible.

## Introduction

CQRS (Segrégation de la responsabilité des demandes de commandes) et recherche d’événements sont deux modèles distincts qui fonctionnent extrêmement bien ensemble.

- **CQRS** - Séparation des modèles de lecture et des modèles d’écriture
- **Répartition des événements** - Tracer chaque changement d'état comme une séquence immuable des événements

Lorsque c’est fait correctement avec des outils comme Marten, vous obtiendrez

- Remplir la piste de vérification de chaque changement
- Capacité de reconstruire l'état à tout moment
- Lecture automatique des projections du modèle
- Adaptation naturelle avec le domaine-design dirigé

Cet article met l'accent sur la façon de le faire. **de manière appropriée**. Si vous voulez l’approche fondée sur la cache rapide-etM SK2dirtyMSC3, , J’aborderai brièvement cette question à la fin de mon article. - mais ce n’est pas vraiment le CQRSMST8 et cela ne vous donnera pas les avantages de la recherche d’événements.

[TOC]

## Qu'est-ce que CQRS?

À sa base, le CQRS signifie l’utilisation de différents modèles pour lire et écrire les données.

```csharp
// Write Model - Commands that change state
public record CreateBlogPostCommand(string Title, string Content, string AuthorId);

// Read Model - DTOs optimised for display
public class BlogPostListItemDto
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public string AuthorName { get; set; }
    public DateTime PublishedDate { get; set; }
    public int CommentCount { get; set; }
}
```

Le côté écriture se concentre sur la logique d’affaires et la validation. Le côté lecture est dénormalisé et optimisé pour l’affichage

## Qu’est-ce que la recherche d’événements?

Au lieu de stocker l’état actuel, vous sauvegardez les événements qui ont conduit à cet état.

```csharp
// Traditional: Store current state
public class BlogPost
{
    public Guid Id { get; set; }
    public string Title { get; set; }  // Current title
    public bool IsPublished { get; set; }  // Current status
}

// Event Sourcing: Store the events
public record BlogPostCreated(Guid Id, string Title, string Content, DateTime CreatedAt);
public record BlogPostTitleChanged(Guid Id, string OldTitle, string NewTitle, DateTime ChangedAt);
public record BlogPostPublished(Guid Id, DateTime PublishedAt);
```

L’état actuel est dérivé par le replay des événements.

## Pourquoi utiliser la recherche d’événements avec CQRS?

**Voie de vérification complète**: Tous les changements sont enregistrésM SK1 parfait pour les systèmes financiers , soins de santé, ou partout où vous avez besoin de prouver ce qui s’est passé et quand

**Demandes temporaires**: "Qu'est-ce que cet article de blog a-t-il été le mardi dernier?

**Débugging**: Reproduisez les bogues en répétant la séquence exacte des événements qui les ont provoquées

**Renseignements d'affaires**: Établir de nouveaux rapports à partir des données historiques sans effectuer de migrationsM SK1 Les événements sont déjà là.

**Fit CQRS naturel**: Événements séparés naturellement écrivent (append les événementsM SK2 des lectures (query projections).

### Quand vous devrez't

**CRUD simple**: Si vous ' stockez et récupèrez seulement des données M SK2 La recherche d’événements est ridicule à l’échelon supérieur

**Petite équipe sans expérience**: La courbe d’apprentissage est abrupte .

**Aucune exigence de vérification**: Si vous ne vous souciez que de l’état actuelM SK1 n’utilisez pas ' ne stockez pas les antécédents

**Données binaires importantes**: Les événements ne fonctionnent pas bien avec les images

## Recherche d'événements modernes dans .NET: Marten

Le meilleur outil pour la recherche d'événements dans .NET en 2025 est [Marten](https://martendb.io/). Il est un magasin d’événements construit sur PostgreSQL , géré activement par Jeremy Miller

Pourquoi Marten?

- Élaboré sur PostgreSQL (vous le connaissez déjà)
- Projections automatiques des événements pour lire les modèles
- Daemon async pour le traitement des projections
- Capacités de recherche riche
- Production-réalisation et batailleM SK1essai

### Établissement de Marten

Installer les paquets:

```bash
dotnet add package Marten
dotnet add package Marten.AspNetCore
```

Configurer dans `Program.cs`:

```csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMarten(options =>
{
    options.Connection(builder.Configuration.GetConnectionString("Marten")!);

    // Register event types
    options.Events.AddEventType<BlogPostCreated>();
    options.Events.AddEventType<BlogPostPublished>();
    options.Events.AddEventType<BlogPostTitleChanged>();
    options.Events.AddEventType<CommentAdded>();

    // Configure async projections
    options.Projections.Add<BlogPostProjection>(ProjectionLifecycle.Async);
});

builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));

var app = builder.Build();
```

### L'architecture

Voici la façon dont tout se prête à l’unisson.

```mermaid
flowchart TB
    subgraph Client["Client Application"]
        UI[User Interface]
    end

    subgraph Commands["Command Side (Writes)"]
        CMD[Commands] --> CMDH[Command Handlers]
        CMDH --> MARTEN[Marten Session]
        MARTEN --> EVENTS[(Event Store)]
    end

    subgraph Background["Async Processing"]
        EVENTS -.->|Event Stream| DAEMON[Marten Async Daemon]
        DAEMON --> PROJ[Projections]
        PROJ --> READDB[(Read Models)]
    end

    subgraph Queries["Query Side (Reads)"]
        QRY[Queries] --> QRYH[Query Handlers with Dapper]
        QRYH --> READDB
    end

    UI -->|Commands| CMD
    UI -->|Queries| QRY

    classDef commandStyle fill:none,stroke:#e63946,stroke-width:3px
    classDef queryStyle fill:none,stroke:#457b9d,stroke-width:3px
    classDef dataStyle fill:none,stroke:#2a9d8f,stroke-width:3px

    class CMD,CMDH,MARTEN commandStyle
    class QRY,QRYH queryStyle
    class EVENTS,READDB dataStyle
```

Points clés:

- **Commandes** Ajouter des événements à la base de données
- **Daemon Async** Processus événements et mises à jour Modèles de lecture
- **Demandes de renseignements** lecture des modèles de lecture dénormalisés
- Aucune invalidation manuelle du cache n'est nécessaire!

## Définir les événements

Les événements sont des documents immuables qui décrivent les choses qui se sont produites:

```csharp
// Always past tense - these things have happened
public record BlogPostCreated(
    Guid BlogPostId,
    string Title,
    string Content,
    string AuthorId,
    DateTime CreatedAt
);

public record BlogPostPublished(
    Guid BlogPostId,
    DateTime PublishedAt
);

public record BlogPostTitleChanged(
    Guid BlogPostId,
    string OldTitle,
    string NewTitle,
    DateTime ChangedAt
);

public record CommentAdded(
    Guid BlogPostId,
    Guid CommentId,
    string Author,
    string Content,
    DateTime CreatedAt
);
```

Les événements devraient être:

- **Immutable** - une fois écriteM SK1 jamais changé
- **Tense passée** - ils décrivent ce qui s’est passé
- **Riche en signification commerciale** - "BlogPostTitleChanged" non "PropertyUpdatedM SK4

## Créer des aggregates

Les aggregates sont le modèle d’écriture. Ils valident les règles commerciales et produisent des événements:

```csharp
public class BlogPost
{
    // Marten requires an Id property
    public Guid Id { get; set; }

    // Current state (private setters)
    public string Title { get; private set; } = string.Empty;
    public string Content { get; private set; } = string.Empty;
    public string AuthorId { get; private set; } = string.Empty;
    public bool IsPublished { get; private set; }
    public DateTime? PublishedDate { get; private set; }
    private readonly List<Comment> _comments = new();
    public IReadOnlyList<Comment> Comments => _comments.AsReadOnly();

    // Apply methods - called by Marten when replaying events
    public void Apply(BlogPostCreated e)
    {
        Id = e.BlogPostId;
        Title = e.Title;
        Content = e.Content;
        AuthorId = e.AuthorId;
    }

    public void Apply(BlogPostPublished e)
    {
        IsPublished = true;
        PublishedDate = e.PublishedAt;
    }

    public void Apply(BlogPostTitleChanged e)
    {
        Title = e.NewTitle;
    }

    public void Apply(CommentAdded e)
    {
        _comments.Add(new Comment
        {
            Id = e.CommentId,
            Author = e.Author,
            Content = e.Content,
            CreatedAt = e.CreatedAt
        });
    }

    // Business logic methods that produce events
    public static BlogPostCreated Create(string title, string content, string authorId)
    {
        if (string.IsNullOrWhiteSpace(title))
            throw new ArgumentException("Title is required");

        return new BlogPostCreated(
            Guid.NewGuid(),
            title,
            content,
            authorId,
            DateTime.UtcNow
        );
    }

    public BlogPostPublished Publish()
    {
        if (IsPublished)
            throw new InvalidOperationException("Post is already published");

        return new BlogPostPublished(Id, DateTime.UtcNow);
    }

    public BlogPostTitleChanged ChangeTitle(string newTitle)
    {
        if (string.IsNullOrWhiteSpace(newTitle))
            throw new ArgumentException("Title cannot be empty");

        if (newTitle == Title)
            throw new InvalidOperationException("New title is the same as current title");

        return new BlogPostTitleChanged(Id, Title, newTitle, DateTime.UtcNow);
    }
}

public class Comment
{
    public Guid Id { get; set; }
    public string Author { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}
```

Le modèle:

1. Méthodes d'affaires validation et retour des événements
2. Appliquer les méthodes pour mettre à jour l'état interne
3. Marten gère la persistance et le replay des événements

## Dispositifs de commande (Write Side)

Les commandes sont gérées en ajoutant des événements aux flux:

```csharp
// Define commands
public record CreateBlogPostCommand(
    string Title,
    string Content,
    string AuthorId
) : IRequest<Guid>;

public record PublishBlogPostCommand(Guid BlogPostId) : IRequest;

public record ChangeBlogPostTitleCommand(
    Guid BlogPostId,
    string NewTitle
) : IRequest;

// Handler for creating a blog post
public class CreateBlogPostHandler : IRequestHandler<CreateBlogPostCommand, Guid>
{
    private readonly IDocumentSession _session;

    public CreateBlogPostHandler(IDocumentSession session)
    {
        _session = session;
    }

    public async Task<Guid> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken)
    {
        // Create the event
        var created = BlogPost.Create(
            request.Title,
            request.Content,
            request.AuthorId
        );

        // Start a new event stream
        _session.Events.StartStream<BlogPost>(created.BlogPostId, created);

        await _session.SaveChangesAsync(cancellationToken);

        return created.BlogPostId;
    }
}

// Handler for publishing
public class PublishBlogPostHandler : IRequestHandler<PublishBlogPostCommand>
{
    private readonly IDocumentSession _session;

    public PublishBlogPostHandler(IDocumentSession session)
    {
        _session = session;
    }

    public async Task Handle(PublishBlogPostCommand request, CancellationToken cancellationToken)
    {
        // Load the aggregate by replaying its events
        var blogPost = await _session.Events.AggregateStreamAsync<BlogPost>(
            request.BlogPostId,
            token: cancellationToken
        );

        if (blogPost == null)
            throw new InvalidOperationException($"Blog post {request.BlogPostId} not found");

        // Business logic produces new event
        var published = blogPost.Publish();

        // Append event to the stream
        _session.Events.Append(request.BlogPostId, published);

        await _session.SaveChangesAsync(cancellationToken);
    }
}

// Handler for changing title
public class ChangeBlogPostTitleHandler : IRequestHandler<ChangeBlogPostTitleCommand>
{
    private readonly IDocumentSession _session;

    public ChangeBlogPostTitleHandler(IDocumentSession session)
    {
        _session = session;
    }

    public async Task Handle(ChangeBlogPostTitleCommand request, CancellationToken cancellationToken)
    {
        var blogPost = await _session.Events.AggregateStreamAsync<BlogPost>(
            request.BlogPostId,
            token: cancellationToken
        );

        if (blogPost == null)
            throw new InvalidOperationException($"Blog post {request.BlogPostId} not found");

        var titleChanged = blogPost.ChangeTitle(request.NewTitle);

        _session.Events.Append(request.BlogPostId, titleChanged);

        await _session.SaveChangesAsync(cancellationToken);
    }
}
```

Le flux:

1. Chargez l’agrégat en replayant les événements (ou en créant de nouveauxM SK1
2. Méthode d'appel (validate et retourne l'événement)
3. Ajouter l'événement à la chaîne
4. Réserver les changements

Marten gère le reste - entreposage des événements , projections déclenchantesM SK2 etc.

## Lire les modèles et les projections

Les projections transforment les événements en modèles de lecture dénormalisés:

```csharp
// Read model - optimised for queries
public class BlogPostReadModel
{
    public Guid Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public string AuthorId { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public DateTime? PublishedAt { get; set; }
    public bool IsPublished { get; set; }
    public int CommentCount { get; set; }
}

// Projection - tells Marten how to build read models from events
public class BlogPostProjection : MultiStreamProjection<BlogPostReadModel, Guid>
{
    public BlogPostProjection()
    {
        // Identity tells Marten which stream each event belongs to
        Identity<BlogPostCreated>(x => x.BlogPostId);
        Identity<BlogPostPublished>(x => x.BlogPostId);
        Identity<BlogPostTitleChanged>(x => x.BlogPostId);
        Identity<CommentAdded>(x => x.BlogPostId);
    }

    // Apply methods - Marten calls these to update read models
    public void Apply(BlogPostReadModel view, BlogPostCreated e)
    {
        view.Id = e.BlogPostId;
        view.Title = e.Title;
        view.Content = e.Content;
        view.AuthorId = e.AuthorId;
        view.CreatedAt = e.CreatedAt;
        view.IsPublished = false;
    }

    public void Apply(BlogPostReadModel view, BlogPostPublished e)
    {
        view.IsPublished = true;
        view.PublishedAt = e.PublishedAt;
    }

    public void Apply(BlogPostReadModel view, BlogPostTitleChanged e)
    {
        view.Title = e.NewTitle;
    }

    public void Apply(BlogPostReadModel view, CommentAdded e)
    {
        view.CommentCount++;
    }
}
```

Marten's async daemon traite les événements dans l'arrière-plan et tient à jour les modèles de lecture.

## Le côté de la demande avec le Dapper

Nous faisons maintenant des recherches sur les modèles de lecture en utilisant Dapper pour obtenir une performance maximale:

```csharp
// Define queries
public record GetRecentBlogPostsQuery(
    int Count,
    bool PublishedOnly
) : IRequest<List<BlogPostListItemDto>>;

public record GetBlogPostByIdQuery(Guid Id) : IRequest<BlogPostDetailDto?>;

// DTOs for display
public class BlogPostListItemDto
{
    public Guid Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string AuthorName { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public DateTime? PublishedAt { get; set; }
    public int CommentCount { get; set; }
    public bool IsPublished { get; set; }
}

public class BlogPostDetailDto
{
    public Guid Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public string AuthorId { get; set; } = string.Empty;
    public string AuthorName { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
    public DateTime? PublishedAt { get; set; }
    public bool IsPublished { get; set; }
    public List<CommentDto> Comments { get; set; } = new();
}

public class CommentDto
{
    public Guid Id { get; set; }
    public string Author { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }
}

// Query handlers
public class GetRecentBlogPostsHandler : IRequestHandler<GetRecentBlogPostsQuery, List<BlogPostListItemDto>>
{
    private readonly string _connectionString;

    public GetRecentBlogPostsHandler(IConfiguration config)
    {
        _connectionString = config.GetConnectionString("Marten")!;
    }

    public async Task<List<BlogPostListItemDto>> Handle(
        GetRecentBlogPostsQuery request,
        CancellationToken cancellationToken)
    {
        await using var connection = new NpgsqlConnection(_connectionString);

        // Query the Marten-generated read model table
        const string sql = @"
            SELECT
                bp.id AS Id,
                bp.title AS Title,
                u.name AS AuthorName,
                bp.created_at AS CreatedAt,
                bp.published_at AS PublishedAt,
                bp.comment_count AS CommentCount,
                bp.is_published AS IsPublished
            FROM blog_post_read_models bp
            LEFT JOIN users u ON bp.author_id = u.id
            WHERE (@PublishedOnly = false OR bp.is_published = true)
            ORDER BY
                CASE WHEN bp.is_published THEN bp.published_at
                     ELSE bp.created_at
                END DESC
            LIMIT @Count";

        var results = await connection.QueryAsync<BlogPostListItemDto>(
            sql,
            new
            {
                PublishedOnly = request.PublishedOnly,
                Count = request.Count
            });

        return results.ToList();
    }
}

public class GetBlogPostByIdHandler : IRequestHandler<GetBlogPostByIdQuery, BlogPostDetailDto?>
{
    private readonly string _connectionString;

    public GetBlogPostByIdHandler(IConfiguration config)
    {
        _connectionString = config.GetConnectionString("Marten")!;
    }

    public async Task<BlogPostDetailDto?> Handle(
        GetBlogPostByIdQuery request,
        CancellationToken cancellationToken)
    {
        await using var connection = new NpgsqlConnection(_connectionString);

        const string sql = @"
            SELECT
                bp.id AS Id,
                bp.title AS Title,
                bp.content AS Content,
                bp.author_id AS AuthorId,
                u.name AS AuthorName,
                bp.created_at AS CreatedAt,
                bp.published_at AS PublishedAt,
                bp.is_published AS IsPublished
            FROM blog_post_read_models bp
            LEFT JOIN users u ON bp.author_id = u.id
            WHERE bp.id = @Id";

        var post = await connection.QuerySingleOrDefaultAsync<BlogPostDetailDto>(
            sql,
            new { request.Id });

        if (post == null)
            return null;

        // Get comments from event stream if needed
        // Or maintain a separate comment read model

        return post;
    }
}
```

Aucun code de cache. Aucune logique d'invalidation . Marten maintient les modèles liés en synchronisation automatiquement.

## Contrôles

Avec MediatR, les contrôleurs sont magnifiquement simples:

```csharp
[ApiController]
[Route("api/[controller]")]
public class BlogPostsController : ControllerBase
{
    private readonly IMediator _mediator;

    public BlogPostsController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet]
    public async Task<ActionResult<List<BlogPostListItemDto>>> GetRecent(
        [FromQuery] int count = 10,
        [FromQuery] bool publishedOnly = true)
    {
        var query = new GetRecentBlogPostsQuery(count, publishedOnly);
        var results = await _mediator.Send(query);
        return Ok(results);
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<BlogPostDetailDto>> GetById(Guid id)
    {
        var query = new GetBlogPostByIdQuery(id);
        var result = await _mediator.Send(query);

        if (result == null)
            return NotFound();

        return Ok(result);
    }

    [HttpPost]
    public async Task<ActionResult<Guid>> Create([FromBody] CreateBlogPostCommand command)
    {
        var postId = await _mediator.Send(command);
        return CreatedAtAction(nameof(GetById), new { id = postId }, postId);
    }

    [HttpPost("{id}/publish")]
    public async Task<ActionResult> Publish(Guid id)
    {
        await _mediator.Send(new PublishBlogPostCommand(id));
        return NoContent();
    }

    [HttpPut("{id}/title")]
    public async Task<ActionResult> ChangeTitle(
        Guid id,
        [FromBody] ChangeBlogPostTitleCommand command)
    {
        if (id != command.BlogPostId)
            return BadRequest();

        await _mediator.Send(command);
        return NoContent();
    }
}
```

## Le flux complet

Voici'la façon dont tout fonctionne ensemble:

```mermaid
sequenceDiagram
    participant Client
    participant Controller
    participant MediatR
    participant CommandHandler
    participant Marten
    participant EventStore
    participant AsyncDaemon
    participant ReadDB
    participant QueryHandler

    Note over Client,ReadDB: Write Operation
    Client->>Controller: POST /api/blogposts
    Controller->>MediatR: Send CreateBlogPostCommand
    MediatR->>CommandHandler: Handle command
    CommandHandler->>CommandHandler: Validate & create event
    CommandHandler->>Marten: StartStream(event)
    Marten->>EventStore: Append event
    EventStore-->>Marten: Success
    Marten-->>CommandHandler: Success
    CommandHandler-->>Controller: Return ID
    Controller-->>Client: 201 Created

    Note over AsyncDaemon,ReadDB: Background Processing
    EventStore->>AsyncDaemon: New event available
    AsyncDaemon->>AsyncDaemon: Apply projection
    AsyncDaemon->>ReadDB: Update read model
    ReadDB-->>AsyncDaemon: Updated

    Note over Client,ReadDB: Read Operation
    Client->>Controller: GET /api/blogposts
    Controller->>MediatR: Send Query
    MediatR->>QueryHandler: Handle query
    QueryHandler->>ReadDB: SELECT with Dapper
    ReadDB-->>QueryHandler: Return data
    QueryHandler-->>Controller: Return DTOs
    Controller-->>Client: 200 OK
```

## Conséquence finale

Une chose à comprendre: avec les projections asynciques , il y a une petite retardation entre l’écriture d’un événement et la mise à jour du modèle de lecture

Si vous avez besoin d'une cohérence immédiate pour une opération particulière, utilisez les projections en ligne.

```csharp
builder.Services.AddMarten(options =>
{
    // This projection runs synchronously
    options.Projections.Add<CriticalDataProjection>(ProjectionLifecycle.Inline);

    // This projection runs async
    options.Projections.Add<BlogPostProjection>(ProjectionLifecycle.Async);
});
```

Ou interroger directement le flux d’événement pour lire-after-write scenariosM SK2

```csharp
var blogPost = await _session.Events.AggregateStreamAsync<BlogPost>(id);
```

## Partie 2: La moitiéM SK1Approche ajustée (Invalidation du cache)

Correct, donc vous ' avez lu sur la correction du choix des événements et vousM SK2 pensez MSC3 que' c’est beaucoup de travailMNK5 Faire assezMRK6 iciMMK7 est l’approche informelle CQRSMEK9 que la plupart des équipes utilisent en fait MNK10

### La mise en place

- Les commandes écrites à la base de données (usant EF ou Dapper)
- Demandes liées à IMemoryCache ou IDistributedCache
- Les commandes invalident les entrées de cache pertinentes après avoir écrit
- Aucune source d'événements, aucune projection, aucun démon async

Ce n’est pas vrai CQRS. Vous ne obtiendrez pas les pistes de vérification ' vous n’obtiendrerez pas les demandes temporelles MSC4 ou les projections automatiques . Mais vous obtiendraz des résultats avec moins de complexité M SK6

### Exemple rapide

```csharp
// Command handler
public class CreateBlogPostHandler : IRequestHandler<CreateBlogPostCommand, int>
{
    private readonly ApplicationDbContext _context;
    private readonly IMemoryCache _cache;

    public async Task<int> Handle(CreateBlogPostCommand request, CancellationToken cancellationToken)
    {
        var blogPost = new BlogPost
        {
            Title = request.Title,
            Content = request.Content,
            AuthorId = request.AuthorId,
            PublishedDate = DateTime.UtcNow
        };

        _context.BlogPosts.Add(blogPost);
        await _context.SaveChangesAsync(cancellationToken);

        // Manual cache invalidation - this is the tedious bit
        _cache.Remove("recent-posts");
        _cache.Remove($"author-posts-{request.AuthorId}");
        _cache.Remove($"post-{blogPost.Id}");

        return blogPost.Id;
    }
}

// Query handler
public class GetRecentPostsHandler : IRequestHandler<GetRecentPostsQuery, List<BlogPostDto>>
{
    private readonly string _connectionString;
    private readonly IMemoryCache _cache;

    public async Task<List<BlogPostDto>> Handle(GetRecentPostsQuery request, CancellationToken cancellationToken)
    {
        var cacheKey = "recent-posts";

        if (_cache.TryGetValue<List<BlogPostDto>>(cacheKey, out var cached))
            return cached!;

        // Cache miss - query with Dapper
        using var connection = new NpgsqlConnection(_connectionString);
        var posts = (await connection.QueryAsync<BlogPostDto>(
            "SELECT id, title, author_name, published_date FROM blog_posts ORDER BY published_date DESC LIMIT 10"
        )).ToList();

        _cache.Set(cacheKey, posts, TimeSpan.FromMinutes(5));
        return posts;
    }
}
```

### Quand cela fonctionne

- Applications simples sans exigences de vérification complexes
- Vous avez besoin de performance, mais vous ne pouvez pas justifier la complexité des recherches d’événement.
- Petite équipe qui ne veut pas' apprendre la recherche d’événements
- La régularité de quelques secondes est acceptable

### Les problèmes

**L'invalidation du cache est difficile**: Vous manquez d’une clé de cache et vous servez des données bloquéesM SK1 Chaque commande doit savoir quelles caches invalider.

**Aucune piste de vérification**: Vous n’avez que l’état actuelM SK1 Ne pouvez't prouver ce qui s’est passé ou quand .

**Aucune demande temporelle**: Peut-on ' ne pas demander

**Étalonnage**: Avec IMemoryCache , chaque serveur a son propre cache. Mise à jour sur le serveur AM SK3 le serveur B a toujours des données bloquéesMSC4 IDistributedCache résout ce problème mais ajoute RedisMST5

**Couplage étroit**: Les commandes sont associées aux clés de cache. Modifier une demandeM SK2 pourrait rompre un commando .

Il fonctionne, mais vous trading simplicité pour les capacités perdues.

## Partie 3: La pire idée - Recherche des événements + Invalidation du cache manuel

Maintenant pour l’idée vraiment terrible: utilisant Event Sourcing mais aussi essayant de invalider manuellement les caches.

### Pourquoi les gens essaient de le faire

Ils veulent la recherche des événements, le suivi de l’audit et les demandes temporelles, mais ils sont préoccupés par une cohérence éventuelle.

Donn't do this.

### Pourquoi la situation est-elle terrible?

**Vous ' avez vaincu le but**: La recherche d’événements construit déjà des modèles de lecture par projections. Ajouter une invalidation manuelle du cache signifie que vous ' bypassez ce systèmeM SK3

**Complexité double**: Vous avez maintenant deux systèmes qui conservent les modèles de lecture en synchronisation - MartenM SK2s projections ET votre invalidation manuelle du cache . Ils seront en conflit

**État incohérent**: Marten's async daemon met à jour la base de données . Votre invalidation du cache se fait immédiatementMSC3 IlsM SK4 sont hors syncMNK5 Ce qui est correctMRK6

**Avantages perdus**: Évaluation des événements ' le point principal est que les projections sont dérivées d’événements

**Débugging nightmare**: Les données paralysées sont-elles parce que les projections n’ont pas été lancéesM SK1t run? Ou parce que vous avez oublié d’invalider une clé de cache ? ou parce que le cache a été invalidé mais que la projection n’a pas encore été lancée

### La bonne approche

Si vous avez besoin de recherche d'événements:

- Utiliser les projections de Marten' M SK1async ou inline)
- Demander directement les modèles de lecture
- Accepter la cohérence éventuelle (itM SK1s habituellement fine )
- Utilisez les projections en ligne si vous avez vraiment besoin d'une cohérence immédiate

Si vous pouvez't accepter une cohérence éventuelle:

- Don' ne pas utiliser la recherche d'événements
- Utiliser l’approche fondée sur le cache- de la partie 2
- Ou simplement utiliser le CRUD traditionnel avec EF Core

Don't try to do both . YouM SK2ll end up with the complexity of Event Sourcing plus the fragility of manual cache invalidation.

### Exemple de ce qui ne doit pas être fait

```csharp
// Don't do this!
public class PublishBlogPostHandler : IRequestHandler<PublishBlogPostCommand>
{
    private readonly IDocumentSession _session;
    private readonly IMemoryCache _cache;  // ← BAD

    public async Task Handle(PublishBlogPostCommand request, CancellationToken cancellationToken)
    {
        var blogPost = await _session.Events.AggregateStreamAsync<BlogPost>(request.BlogPostId);
        var published = blogPost.Publish();

        _session.Events.Append(request.BlogPostId, published);
        await _session.SaveChangesAsync(cancellationToken);

        // Manually invalidating cache while using Event Sourcing ← TERRIBLE IDEA
        _cache.Remove($"post-{request.BlogPostId}");
        _cache.Remove("recent-posts");

        // Now you have:
        // 1. Event in event store
        // 2. Cache invalidated
        // 3. But projection hasn't run yet!
        // Queries will hit database before projection completes = stale data
    }
}
```

Utilisez simplement les projections de Marten.

## Conclusion

Trois approches, trois cas d’utilisation:

### CQRS approprié avec la recherche des événements (MartenM SK1

**Utiliser quand:**

- Vous avez besoin d'une piste de vérification complète
- Les demandes temporaires sont valables
- Domaine complexe avec une logique commerciale riche
- Finances,santéM SK1 ou systèmes réglementés

**Ne 'ne utilisez pas lorsque:**

- Application simple CRUD
- L'équipe manque d'expérience dans la recherche de l'événement
- Aucune exigence de vérification

### CQRS informel avec cache

**Utiliser quand:**

- Il faut accroître la performance sur les lectures
- La piste de vérification n'est pas requise
- Plus simple que la recherche d'événements
- Conséquence définitive acceptable

**Ne 'ne utilisez pas lorsque:**

- Besoin de pistes d'audit
- Besoin de réponses temporelles
- La complexité de l'invalidation des caches dépasse les avantages

### Enquête sur les événements + Invalidation du cache manuel

**Utiliser quand:**

- Ne jamais. Just don'tM SK2

Choisissez l’outil qui convient à votre cas d’utilisation. La recherche des événements avec Marten est puissante lorsque vous en avez besoin . Cache-CQRS basé sur le CQRS est plus simple lorsqu’on ne le fait pasMSC3tM SK4 Mélanger les deux est une erreur

Pour la plupart des applications, commencer avec le CRUD traditionnel (voir ma série EF Core [ici](/blog/addingentityframeworkforblogpostspt1)). Lorsque vous rencontrez des problèmes de performance , ajouter le cachement. Ne vous atteignez que pour la recherche complète d'événements lorsque vous avez vraiment besoin de ses capacités spécifiquesM SK3

## Autres lectures

- [Documentation Marten](https://martendb.io/) - Dossiers excellents, bien dignes de la lecture
- [EventStoreDB](https://www.eventstore.com/) - Store d’événements de rechange si Postgres n’est pas votre chose
- [Documentation du Dapper](https://github.com/DapperLib/Dapper) - Demandes SQL rapides
- [MediatR](https://github.com/jbogard/MediatR) - Commande/expédition de demandes
- [Microsoft sur CQRS](https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs) - Directives officielles
- Ma série de cadres d'entités débutant [ici](/blog/addingentityframeworkforblogpostspt1) - Approche traditionnelle