Back to "Modern CQRS och sekvensering av händelser i .NET: gör det på rätt sätt"

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

Modern CQRS och sekvensering av händelser i .NET: gör det på rätt sätt

Thursday, 13 November 2025

NOTA: Det här är en gammal artikel som jag glömde att publicera . Här är den , glädjas av~ ! Jag uppdaterade den men det kan vara några problem som jag missade.

CQRS och Event Sourcing - två mönster som ofta nämns tillsammans men som ofta missförstås.

I' ska jag också visa er den "halfM SK2assed " kass, -baserade alternativen och förklara varför att mixa Event Sourcing med manuell cache-invalidering är en hemsk idé.

Inleiding

CQRS (Kommandofrågans ansvarsskiktning Segregering ) och Event Sourcing är två olika mönster som fungerar exceptionellt bra tillsammans

  • CQRS - Separerar dina läsmodeller från dina skrivmodeller
  • Event Sourcing - Att spara varje statförändring som en förändringslös sekvens av händelser

När man gör det ordentligt med verktyg som Marten, får man

  • Complete audit trail of every change
  • Förmågor att bygga om tillstånd vid vilken tidpunkt som helst
  • Automatiska projektioner av modeller
  • Naturlig tillpassning med domain-driven design

Detta artikel fokuserar på att göra det på rätt sätt. Om du vill ha den snabbaM SK1 och - smutsiga cachen-baserade ansatsen, m.s.k.,, så tar jag den kort i slutet, men det är inte riktigt CQRS, ,, och det ger dig inga fördelar med Event Sourcing.

Vad är CQRS?

I grunden betyder CQRS att använda olika modeller för att läsa och skriva data

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

Den skrivna sidan fokuserar på affärslogik och validering. Den läsa sidan är denormaliserad och optimerad för att se ut

Vad är händelsernas efterfrågan?

Istället för att lagra den nuvarande tillstånden, lagar du in händelserna som ledde till det tillståndet.

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

Den nuvarande tillstånden kommer från att spela upp händelser igen. Det ger dig en komplett historia av allt som någonsin hänt i ditt system

Varför använda Event Sourcing med CQRS?

komplett Audit Trail: Varje förändring är registrerad . Perfekt för finansiella system

Tydliga frågor: " Hur såg den här bloggposten ut förra tiistaina? ♫ ?" ♫ blir trivial ♫

Debugging: Reproduera bugs genom att åter spela in den exakta sekvensen av händelser som orsakade dem

Ekonomisk intelligens: Att bygga nya rapporter från historiska data utan att göra migrationer

Pass för naturlig CQRS: Erfarenheter som är naturligt separerade skriver ♫ ( ♫ ansluter händelser ♫ ) ♫ från svar ♫ МSK3 ♫ sökprojektioner ♫

När man ska göra det

Enkel CRUD: Om du

En liten grupp utan erfarenhet: Inlärningskurvan är hög

Inga revīzijasbehov: Om du bara bryr dig om det nuvarande tillståndet

Stora binära data: Erfarenheter fungerar dåligt med bilder

Moderna händelser i .NET: Marten

Det bästa verktyget för att hitta händelser i .NET i ♫ 2025 ♫ är Marten. Det är en eventbutik byggd på PostgreSQL , som aktivt underhålls av Jeremy Miller

Varför Marten?

  • Inbyggt på PostgreSQL ( du känner till det redan)
  • Automatiska prognoser från händelser för att läsa modeller
  • Async-daemon för projektionsprocessering
  • Rika sökkrafter
  • Tillverkning - redo och striden-testat

Att sätta upp Marten

Installera packagerna:

dotnet add package Marten
dotnet add package Marten.AspNetCore

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

Architekturen

Här är hur allt passar ihop.

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

Nyckelpunkter:

  • Kommandor lägga till händelser i event-butiken
  • Async-daemon bearbetar händelser och uppdaterar modeller
  • Frågor läsa från denormaliserade läsande modellerna
  • Ingen manuell cache-invalidering krävs!

Definierade händelser

Erfarelser är oföränderliga rekord som beskriver saker som har hänt.

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

Erfarenheter borde vara:

  • Oänderliga - en gång skrivetM SK1 aldrig ändrats
  • Förflutna - beskriver de vad som hände
  • Den rika affärsmässiga betydelsen - "BlogPostTitleChanged

Att skapa agregater

Aggregater är skrivmodellen, de validerar affärsregler och producerar händelser.

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

Pattern:

  1. affärsmetoder validerar och återskapar händelser
  2. Applicera metoder för att uppdatera inre tillstånd
  3. Marten hanterar händelsernas persistens och återuppspelning

Kommandohandler (skriv sida)

Kommandon hanteras genom att lägga till händelser i strömmar:

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

Flöden:

  1. Lade agregat genom att spela om händelser ( eller skapa nyaM SK1
  2. Fråga affärsmetoden (Validerar och återskapar händelsen)
  3. Lägg till händelse i ström
  4. spara ändringar

Marten hanterar resten - lagring av händelser , aktivering av förutsägelser

Läs modeller och förutsägelser

Prognoser omvandlar händelser till denormaliserade läsade modeller:

// 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 bearbetar händelser i bakgrunden och håller uppdatet läsade modeller.

Fråga sida med dapper

Nu frågar vi de läsade modellerna med hjälp av Dapper för maxpresterande

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

Inget kaskande kode . Ingen invalideringslogik. Marten håller läsade modellerna i synkronisering automatiskt

Controller

Med MediatR är kontrollerna vackert enkla

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

Den kompletta strömmen

Här är hur allt fungerar tillsammans.

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

Till slutlig konsistens

En sak att förstå: med asyncka förutsägelser, där ' är en liten fördröjning mellan att skriva en händelse och att läsas modellen som håller på att göras uppdateradM SK3 Vanligtvis milsekunder , men det

Om du behöver en direkt konsistens för en specifik opération, använder du inlineprojektioner.

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

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

Eller sök upp händelserna direkt för att läsa

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

Del 2: Den halva | - | Avlägsna tillvägagångssätt |( | Maskinvalidering | МSK3

Okej, så ni har läst om korrekta Event Sourcing och ni tänker att ' är mycket arbete. ". är rättvist.. Här är ' den informella CQRS-metoden

Konfiguration

  • Kommandon skriver till database (usande EF eller Dapper)
  • Frågor som lästs från IMemoryCache eller IDistributedCache
  • Kommandon gör relevanta cache-Einträg ogültade efter att skriva
  • Inget uppfinning av händelser , inga förutsägelser, inga async-daemoner

Det är inte sant med CQRS.

Ett snabbt exempel

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

När det fungerar

  • Enkela applikationer utan komplexa revīzijasbehov
  • Du behöver prestation men kan inte rättfärdiga Event Sourcing komplexitet
  • En liten grupp som inte vill lära sig Event Sourcing
  • Enkelhet av några sekunder är acceptabel

Problemet

Cache-invalidering är svårt: Missar en kasknyck och du serverar baklagda data . Varje kommando behöver veta vilka kasker man ska avskaffa

Ingen revīzijasspår: Du har bara det nuvarande tillståndet . Kan inte bevisa vad som hände eller när

Inga tidiga frågor"" Hur såg systemet ut igår? ""

Skalering: Med IMemoryCache

Attight coupling: Kommandor är kopplade till kasknycklar

Det fungerar.,, men du, ', är enkelhet i att handla om förlorade kapaciteter.., Ibland är det det rätta.

Del 3: Den värsta idén - Erfarenhetssökningen | + | Hand Cache Invalidation

Nu till den verkligt hemska idén, att använda Event Sourcing, men också att försöka avfärda kaser manuell.

Varför försöker folk detta?

De vill ha Event Sourcing, auditspår och temporella frågor, men de är oroliga för slutgiltiga jämlikhet. Så de tror att "" ", I, ' "" ska bara lägga till cache-validation för att göra läsningar snabbare och mer jämlika.

gör inte det här

Varför är det hemskt?

Du ' har misslyckats med målet: Event Sourcing bygger redan upp läsande modeller genom förutsägelser

Dubbel komplexitet: Du har nu två system som håller läsade modellerna i synkronisering - Marten M SK2s prognoser Och din manuella cache-invalidering . De kommer att konfliktera

Inkonsistent tillstånd: Marten

Förlust av förmåner: Event Sourcing ' hela poängen är att förutsägelser kommer från händelser

Debuggen av mardröm: Är den föråldrade datan för att projektionerna inte fungerat ' inte fungerade ? Eller för att du glömde bort att avskaffa ett kassetts nyckel M SK3 Eller eftersom kaset avskadades men projektionen inte fungerad MSC4 inte ännu fungerade

Den rätta vägen

Om du behöver Event Sourcing:

  • Använd Martens prognoser
  • Fråga de läsade modellerna direkt
  • Acceptera den slutgiltiga konsistensen (it
  • Använd inline-projektioner om du verkligen behöver direkt konsistens

Om du kan acceptera konsistens

  • Don' inte använda Event Sourcing
  • Använd cache-baserat angreppet från del 2
  • Eller bara använda traditionell CRUD med EF Core

Att inte försöka göra båda två.

Ett exempel på vad man inte ska göra

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

Bara använda Martens prognoser. De är designade för den här.

Slutsatsen

Tre tillvägagångssätt

Rätt CQRS med sekvensering av händelser (MartenM SK1

Använd när:

  • Du behöver en komplett auditspårning
  • Tydliga sökningar är värdefulla
  • Kompleks domain med rik affärslogik
  • Finansiell1 , healthcare 1 MSK eller reglerade system

Använd inte när

  • En enkel CRUD-applikation
  • Team saknar erfarenhet av tävling
  • Inga revīzijasbehov

Informellt CQRS med Caching

Använd när:

  • Man behöver förbättra prestationen vid läsningar
  • Revideringsspår inte nödvändiga
  • Enklare än att leta efter händelser
  • Till slut accepterad konsekventhet

Använd inte när

  • Revideringsspår
  • Det behövs temporära sökningar
  • Cache-invaliderings komplexitet överväger fördelarna

Event Sourcing + Hand Cache Invalidation

Använd när:

  • Aldrig

Välj det rätta verktyget för ditt användandesfall. Event Sourcing med Marten är kraftfullt när du behöver det . Cache, -baserad CQRS är enklare när du inte gör det

För de flesta tillämpningar , börjar med traditionell CRUD ( se min EF Core-serie här). När du stöter på prestationens problem , lägga till kassering . nå bara till full Event Sourcing när du verkligen behöver dess specifika egenskaper M SK3

Fortsättning avläsning

logo

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