Back to "Le CQRS moderne et l'approvisionnement événementiel dans .NET: le faire correctement"

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

Architecture ASP.NET CQRS Event Sourcing Marten Performance

Le CQRS moderne et l'approvisionnement événementiel dans .NET: le faire correctement

Thursday, 13 November 2025

REMARQUE: C'est un vieil article que j'ai oublié de publier.

Voilà, amusez-vous !

Je l'ai mis à jour, mais il y a peut-être des problèmes que j'ai manqués.

CQRS et Event Sourcing - deux modèles souvent mentionnés ensemble mais souvent mal compris.

Dans cet article, je vais vous montrer comment les mettre en œuvre correctement à l'aide d'outils .NET modernes : Marten pour le sourcing d'événements, Dapper pour les requêtes optimisées, et MediatR pour garder tout organisé.

  • **Je vais aussi vous montrer l'alternative basée sur le cache à mi-cuisine, et expliquer pourquoi essayer de mélanger Event Sourcing avec l'invalidation manuelle du cache est une idée terrible.**Présentation

  • **CQRS (Command Query Responsibility Segregation) et Event Sourcing sont deux modèles distincts qui fonctionnent exceptionnellement bien ensemble :**CQRS

  • Séparer vos modèles de lecture de vos modèles d'écriture

  • Événement Sourcing

    • Entreposer chaque changement d'état comme une séquence immuable d'événements
  • Quand on le fait correctement avec des outils comme Marten, on obtient :

  • Compléter la piste de vérification de chaque changement

Capacité à reconstruire l'état à n'importe quel momentProjections du modèle de lecture automatiqueAjustement naturel au design de domaine

Cet article se concentre sur le faire

correctement

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

Si vous voulez l'approche basée sur le cache rapide et sale, je vais le couvrir brièvement à la fin - mais ce n'est pas vraiment CQRS, et il ne vous donne pas les avantages de l'Approvisionnement Événement.

Qu'est-ce que CQRS?

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

À son cœur, CQRS signifie utiliser différents modèles pour lire et écrire des données :

Le côté écriture se concentre sur la logique d'entreprise et la validation.

**Le côté lecture est dénormalisé et optimisé pour l'affichage.**Assez simple, mais vrai CQRS signifie qu'ils sont des chemins complètement séparés à travers votre application.

**Qu'est-ce que l'approvisionnement en événements?**Au lieu de stocker l'état actuel, vous stockez les événements qui ont mené à cet état:

**L'état actuel est dérivé en rejouant les événements.**Cela vous donne une histoire complète de tout ce qui s'est passé dans votre système.

**Pourquoi utiliser Event Sourcing avec CQRS?**Voie de vérification complète

**: Chaque changement est enregistré.**Parfait pour les systèmes financiers, les soins de santé, ou n'importe où, vous devez prouver ce qui s'est passé et quand.

Demandes de renseignements temporelles

**: "À quoi ressemblait ce billet de blog mardi dernier ?" devient trivial - juste rejouer les événements jusqu'à ce point.**Déboguement

**: Reproduire les bugs en rejouant la séquence exacte des événements qui les ont causés.**Renseignements commerciaux

**: Construisez de nouveaux rapports à partir de données historiques sans effectuer de migrations.**Les événements sont déjà là.

Fit CQRS naturel: Les événements se séparent naturellement des écritures (les événements en annexe) des lectures (les projections de la requête).

Quand tu ne devrais pas

Un CRUD simple: Si vous stockez et récupérez des données, Event Sourcing est ridicule.Petite équipe sans expérience

: La courbe d'apprentissage est raide.

  • Pas d'exigences en matière d'audit
  • : Si vous ne vous souciez que de l'état actuel, ne stockez pas l'historique.
  • Grandes données binaires
  • : Les événements fonctionnent mal avec des images, des vidéos, des fichiers.
  • Sourcing d'événement moderne dans .NET: Marten

Le meilleur outil pour l'Approvisionnement Événement dans .NET en 2025 est

Martre

dotnet add package Marten
dotnet add package Marten.AspNetCore

Program.cs:

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

C'est un magasin d'événements construit sur PostgreSQL, activement entretenu par Jeremy Miller, et il fonctionne juste.

Pourquoi Marten ?

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

Construit sur PostgreSQL (vous le connaissez déjà)

  • Projections automatiques des événements pour lire les modèlesDémon Async pour le traitement de la projection
  • Capacités de requête richesPrête à la production et testée au combat
  • Mise en place de MartenInstallez les paquets & #160;:
  • Configurer dans

L'architecture

Voici comment tout s'harmonise :

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

Points clés:

  • Commandesannexer les événements au magasin d'événements
  • Démon d'Asynctraite les événements et les mises à jour des modèles de lecture
  • Demandes de renseignementslire à partir des modèles de lecture dénormalisés

Aucune invalidation manuelle du cache n'est nécessaire !

Définition des événements

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

Les événements sont des documents immuables qui décrivent des choses qui se sont passées :

  1. Les événements devraient être les suivants :
  2. Immutable
    • une fois écrit, jamais changé

Temps passé

  • ils décrivent ce qui s'est passé
// 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);
    }
}

Riche en termes d'affaires

    • "BlogPostTitleChanged" pas "PropriétéMise à jour"
  1. Création d'agrégats
  2. Les agrégats sont le modèle d'écriture.
  3. Ils valident les règles commerciales et produisent des événements:

Le modèle:

Les méthodes d'affaires valident et retournent les événements

Appliquer les méthodes mettre à jour l'état interne

// 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 gère la persistance de l'événement et rejoue

Gestionnaires de commandes (côté écriture)

Les commandes sont gérées par l'ajout d'événements aux flux :

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

Le débit:

Charger l'agrégat en rejouant des événements (ou en créant de nouveaux)

Méthode d'entreprise d'appel (validation et retour de l'événement)

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

Ajouter l'événement au flux

Enregistrer les modifications

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

Marten gère le reste - stocker des événements, déclencher des projections, etc.

Lire les modèles et les projections

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

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

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

Le démon async de Marten traite les événements en arrière-plan et garde à jour les modèles de lecture.

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

Vous n'écrivez aucun code d'invalidation de cache - c'est automatique.

Query Side avec Dapper

Maintenant, nous interrogeons les modèles de lecture en utilisant Dapper pour des performances maximales:

  • Pas de code de mise en cache.
  • Pas de logique d'invalidation.
  • Marten maintient les modèles de lecture automatiquement synchronisés.
  • Contrôleurs

Avec MediatR, les contrôleurs sont très simples :

Le flux complet

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

Voici comment tout fonctionne ensemble :

  • Cohérence événementielle
  • Une chose à comprendre : avec les projections d'async, il y a un petit délai entre l'écriture d'un événement et la mise à jour du modèle de lecture.
  • D'habitude des millisecondes, mais c'est là.
  • Si vous avez besoin d'une cohérence immédiate pour une opération spécifique, utilisez des projections en ligne:

Ou interrogez le flux d'événements directement pour les scénarios de lecture après écriture:

**Partie 2: L'approche à demi-assistance (invalidation de la cache)**Bien, donc vous avez lu à propos de l'Event Sourcing et vous pensez "c'est beaucoup de travail".

**C'est juste.**Voici l'approche « CQRS informelle » que la plupart des équipes utilisent réellement.

La mise en placeCommandes écrire dans la base de données (en utilisant EF ou Dapper)

Questions lues depuis IMemoryCache ou IDistributedCacheLes commandes invalident les entrées de cache pertinentes après l'écriture

Pas d'événement sourcing, pas de projections, pas de démon asyncCe n'est pas vrai CQRS.

Vous n'obtenez pas la piste d'audit, les requêtes temporelles ou les projections automatiques.

Mais vous obtenez des performances avec moins de complexité.

Exemple rapide

Quand ça marche

Applications simples sans exigences complexes en matière d'audit

Vous avez besoin de performance mais ne pouvez pas justifier Event Sourcing complexité

Petite équipe qui ne veut pas apprendre Event Sourcing

La stabilité de quelques secondes est acceptableLes problèmes

L'invalidation du cache est difficile: Il manque une clé de cache et vous servez des données statiques.

**Chaque commande doit connaître les caches à invalider.**Pas de piste d'audit

**: Vous n'avez que l'état actuel.**Je ne peux pas prouver ce qui s'est passé ou quand.

Pas de requêtes temporelles: Ne peut pas demander "à quoi ressemblait le système hier ?"

Étalonnage

: Avec IMemoryCache, chaque serveur a son propre cache.

  • Mise à jour sur le serveur A, le serveur B a toujours des données statiques.
  • IDistributedCache résout cela mais ajoute Redis.
  • Raccord serré
  • : Les commandes sont couplées aux clés cache.

Changer une requête, peut casser une commande.

  • Cela fonctionne, mais vous tradez la simplicité pour les capacités perdues.
  • Parfois, c'est le bon compromis.
  • Souvent, ce n'est pas le cas.

Partie 3: La pire idée - Événement Sourcing + Invalidation de la cache manuelle

Maintenant pour l'idée vraiment terrible : utiliser Event Sourcing mais aussi essayer d'invalider manuellement les caches.

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

Pourquoi les gens essaient ça

Ils veulent la piste d'audit d'Event Sourcing et les questions temporelles, mais ils s'inquiètent de la cohérence éventuelle.

Alors ils pensent "Je vais juste ajouter l'invalidation de cache pour rendre les lectures plus rapides et plus cohérentes!"

Ne fais pas ça.

Pourquoi c'est terrible

  • Vous avez vaincu le but.
  • : Event Sourcing construit déjà des modèles de lecture à travers des projections.
  • Ajouter l'invalidation manuelle du cache signifie que vous contournez ce système.
  • Double complexité

: Vous avez maintenant deux systèmes gardant les modèles de lecture en synchronisation - les projections de Marten ET l'invalidation de votre cache manuel.

  • Ils vont entrer en conflit.
  • État incohérent
  • : Le démon async de Marten met à jour la base de données.

Votre invalidation du cache s'exécute immédiatement.

Ils ne sont plus synchronisés.

  • Laquelle est correcte ?
  • Bénéfices perdus
  • : Événement Sourcing est tout le point est que les projections sont dérivées des événements.
  • L'invalidation manuelle du cache brise ce modèle.

Le cauchemar de débogage

  • : Les données sont-elles inexistantes parce que les projections n'ont pas été réalisées ?
  • Ou parce que tu as oublié d'invalider une clé de cache ?
  • Ou parce que le cache a été invalidé mais que la projection n'avait pas encore couru ?

Bonne chance.

La bonne approche

  • Si vous avez besoin d'Event Sourcing:

Utiliser les projections de Marten (async ou inline)

Interroger directement les modèles lusAccepter une éventuelle cohérence (c'est généralement bien)Utiliser des projections en ligne si vous avez vraiment besoin d'une cohérence immédiate

Si vous ne pouvez pas accepter la cohérence éventuelle:

logo

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