Back to "CQRS moderno y abastecimiento de eventos en .NET: Hacerlo correctamente"

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

CQRS moderno y abastecimiento de eventos en .NET: Hacerlo correctamente

Thursday, 13 November 2025

NOTA: Este es un viejo artículo que olvidé publicar.

Aquí está, ¡disfrútenlo!

Lo actualicé, pero pueden ser algunos problemas que me perdí.

CQRS y Event Sourcing - dos patrones que a menudo se mencionan juntos, pero con frecuencia mal entendido.

En este artículo, voy a mostrarles cómo implementarlas correctamente utilizando herramientas .NET modernas: Marten para la obtención de eventos, Dapper para consultas optimizadas y MediatR para mantener todo organizado.

  • **También te mostraré la alternativa basada en caché "mediana" y te explicaré por qué intentar mezclar Event Sourcing con la invalidación manual de caché es una idea terrible.**Introducción

  • **CQRS (Command Query Responsibility Segregation) y Event Sourcing son dos patrones distintos que funcionan excepcionalmente bien juntos:**CQRS

  • Separar sus modelos de lectura de sus modelos de escritura

  • Abastecimiento de eventos

    • Almacenar cada cambio de estado como una secuencia inmutable de eventos
  • Cuando se hace correctamente con herramientas como Marten, se obtiene:

  • Seguimiento completo de cada cambio en la auditoría

Capacidad de reconstruir el estado en cualquier momentoProyecciones automáticas de modelos de lecturaAjuste natural con diseño basado en el dominio

Este artículo se centra en hacerlo

correctamente

// 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 quieres el enfoque basado en caché rápido y sucio, lo cubriré brevemente al final - pero no es realmente CQRS, y no te da los beneficios de Event Sourcing.

¿Qué es el 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);

En esencia, CQRS significa utilizar diferentes modelos para leer y escribir datos:

El lado de la escritura se centra en la lógica de negocios y la validación.

**El lado de lectura está desnormalizado y optimizado para la visualización.**Lo suficientemente simple, pero el CQRS verdadero significa que son rutas completamente separadas a través de su aplicación.

**¿Qué es la Abastecimiento de Eventos?**En lugar de almacenar el estado actual, almacena los eventos que llevaron a ese estado:

**El estado actual se deriva de la repetición de eventos.**Esto te da una historia completa de todo lo que ha pasado en tu sistema.

**¿Por qué usar Abastecimiento de Eventos con CQRS?**Seguimiento completo de la auditoría

**: Cada cambio se registra.**Perfecto para sistemas financieros, atención médica o en cualquier lugar que necesite para demostrar lo que pasó y cuándo.

Consultas temporales

**: "¿Cómo se veía este blog el martes pasado?" se convierte en trivial - sólo repetir eventos hasta ese punto.**Depuración

**: Reproducir errores reproduciendo la secuencia exacta de los eventos que los causaron.**Inteligencia empresarial

**: Construir nuevos informes a partir de datos históricos sin ejecutar migraciones.**Los acontecimientos ya están allí.

Ajustar CQRS natural: Los eventos naturalmente separan las escrituras (append events) de las lecturas (proyecciones de petición).

Cuando no deberías

CRUD simple: Si sólo estás almacenando y recuperando datos, Event Sourcing es ridículo.Pequeño equipo sin experiencia

: La curva de aprendizaje es empinada.

  • Sin requisitos de auditoría
  • : Si sólo te preocupas por el estado actual, no almacenes la historia.
  • Grandes datos binarios
  • : Los eventos funcionan mal con imágenes, videos, archivos.
  • Abastecimiento de Eventos Modernos en .NET: Marten

La mejor herramienta para la Abastecimiento de Eventos en .NET en 2025 es

Marten

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

Es una tienda de eventos construida en PostgreSQL, mantenida activamente por Jeremy Miller, y simplemente funciona.

¿Por qué 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

Construido en PostgreSQL (ya lo sabes)

  • Proyecciones automáticas de eventos a modelos de lecturaDemonio de Async para procesamiento de proyecciones
  • Capacidades de consulta ricasPreparados para la producción y probados
  • Configuración de MartenInstalar los paquetes:
  • Configurar en

La arquitectura

Así es como todo encaja:

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

Puntos clave:

  • Comandosañadir eventos a la tienda de eventos
  • Demonio de asyncprocesa eventos y actualizaciones lee modelos
  • Consultasleer de los modelos de lectura desnormalizados

¡No se necesita invalidación manual de caché!

Definir los acontecimientos

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

Los eventos son registros inmutables que describen las cosas que han sucedido:

  1. Los eventos deben ser:
  2. Inmutable
    • una vez escrito, nunca cambiado

Tiempo pasado

  • describen lo que pasó.
// 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);
    }
}

Rico en significado de negocios

    • "BlogPostTitleChanged" no "PropertyUpdated"
  1. Crear agregados
  2. Los agregados son el modelo de escritura.
  3. Validan las reglas de negocio y producen eventos:

El patrón:

Los métodos de negocio validan y devuelven eventos

Aplicar métodos actualizar estado interno

// 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 se encarga de la persistencia y repetición de eventos

Manipuladores de órdenes (escritura lateral)

Los comandos se manejan añadiendo eventos a las secuencias:

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

El flujo:

Cargar agregado reproduciendo eventos (o crear nuevos)

Call business method (valida y devuelve el evento)

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

Añadir evento a transmitir

Guardar cambios

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 se encarga del resto - almacenar eventos, activar proyecciones, etc.

Leer modelos y proyecciones

Las proyecciones convierten los eventos en modelos de lectura desnormalizados:

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

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

El demonio async de Marten procesa eventos en segundo plano y mantiene actualizados los modelos de lectura.

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

Usted no escribe ningún código de invalidación de caché - es automático.

El lado de la pregunta con Dapper

Ahora consultamos los modelos de lectura usando Dapper para obtener el máximo rendimiento:

  • Sin código de caché.
  • No hay lógica de invalidación.
  • Marten mantiene los modelos de lectura sincronizados automáticamente.
  • Contralores

Con MediatR, los controladores son muy simples:

El flujo completo

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

Así es como funciona todo juntos:

  • Coherencia eventual
  • Una cosa a entender: con proyecciones asíncronas, hay un pequeño retraso entre la escritura de un evento y la actualización del modelo de lectura.
  • Normalmente milisegundos, pero está ahí.
  • Si necesita coherencia inmediata para una operación específica, utilice proyecciones en línea:

O consulta directamente el flujo de eventos para los escenarios de lectura después de escribir:

**Parte 2: El enfoque a mitad de camino (invalidación de la caché)**Bien, así que has leído sobre Event Sourcing apropiado y estás pensando "eso es mucho trabajo".

**Me parece justo.**Aquí está el enfoque "CQRS informal" que la mayoría de los equipos realmente utilizan.

La configuraciónLos comandos escriben en la base de datos (usando EF o Dapper)

Consultas leídas de IMemoryCache o IDistributedCacheLas órdenes invalidan las entradas de caché relevantes después de escribir

Sin fuentes de eventos, sin proyecciones, sin demonios asíncronosNo es verdad CQRS.

No se obtiene el rastro de auditoría, consultas temporales o proyecciones automáticas.

Pero se obtiene un rendimiento con menos complejidad.

Ejemplo rápido

Cuando esto funciona

Aplicaciones sencillas sin requisitos de auditoría complejos

Necesitas rendimiento pero no puedes justificar la complejidad de la Abastecimiento de Eventos

Pequeño equipo que no quiere aprender Event Sourcing

La rancio de unos segundos es aceptableLos problemas

La invalidación de caché es difícil: Falta una clave de caché y usted sirve datos rancios.

**Cada comando necesita saber qué cachés invalidar.**Sin pista de auditoría

**: Sólo tienes estado actual.**No puedo probar lo que pasó o cuándo.

Sin consultas temporales: No se puede preguntar "¿cómo era el sistema ayer?"

Escalado

: Con IMemoryCache, cada servidor tiene su propia caché.

  • Actualización en el servidor A, el servidor B todavía tiene datos rancios.
  • IDistributedCache resuelve esto pero añade Redis.
  • Acoplamiento apretado
  • : Los comandos se acoplan a las teclas de caché.

Cambiar una consulta, podría romper una orden.

  • Funciona, pero estás intercambiando simplicidad por capacidades perdidas.
  • A veces eso es lo correcto.
  • A menudo no lo es.

Parte 3: La peor idea - Abastecimiento de eventos + Invalidación manual de caché

Ahora para la idea verdaderamente terrible: usando Event Sourcing pero también tratando de invalidar cachés manualmente.

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

Por qué la gente intenta esto

Quieren el rastro de auditoría de Event Sourcing y las consultas temporales, pero están preocupados por la consistencia eventual.

Así que piensan: "¡Solo añadiré la invalidación de caché para que las lecturas sean más rápidas y coherentes!"

No hagas esto.

Por qué es terrible

  • Has derrotado el propósito.
  • : Event Sourcing ya construye modelos de lectura a través de proyecciones.
  • Agregar la invalidación manual de caché significa que estás evitando ese sistema.
  • Doble complejidad

: Ahora tienes dos sistemas que mantienen los modelos de lectura sincronizados - las proyecciones de Marten Y la invalidación manual de su caché.

  • Van a entrar en conflicto.
  • Estado inconsistente
  • : El demonio async de Marten actualiza la base de datos.

Su caché de invalidación se ejecuta inmediatamente.

Están fuera de sincronía.

  • ¿Cuál es la correcta?
  • Beneficios perdidos
  • : Event Sourcing todo el punto es que las proyecciones se derivan de eventos.
  • La invalidación manual de caché rompe ese modelo.

Depurar pesadilla

  • : ¿Los datos rancios porque las proyecciones no se han ejecutado?
  • ¿O porque olvidaste invalidar una clave de caché?
  • ¿O porque la caché invalidaba pero la proyección no se había ejecutado todavía?

Buena suerte.

El enfoque correcto

  • Si necesita Abastecimiento de Eventos:

Utilice las proyecciones de Marten (sincronización o inline)

Consultar directamente los modelos de lecturaAceptar la consistencia eventual (por lo general está bien)Utilice proyecciones en línea si realmente necesita consistencia inmediata

Si usted no puede aceptar la consistencia eventual:

logo

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