MediatR cambió su licencia en 2025. En vez de buscar otro paquete que hiciera exactamente lo mismo, aproveché para implementar la parte de CQRS que realmente necesitaba.
Durante años, instalar MediatR fue casi automático en proyectos .NET con Clean Architecture. Pero CQRS y MediatR no son la misma cosa.
CQRS solo separa las operaciones que cambian estado de las que leen estado. El mediator es una forma de enrutar esas operaciones hacia sus handlers, pero podemos construir esa pieza con poco código.
Este tutorial implementa únicamente:
- Commands para escritura.
- Queries para lectura.
- Un handler por operación.
- Un dispatcher que encuentra el handler correcto.
- Registro automático de handlers con Dependency Injection.
- Una feature
Projectscompleta conPOSTyGET.
Nota: esto no es asesoría legal. Cada organización debe revisar la licencia que corresponda a su caso.
El flujo que vamos a construir
POST /api/projects
→ CreateProjectCommand
→ Dispatcher
→ CreateProjectCommandHandler
→ ProjectResponse
GET /api/projects/{id}
→ GetProjectByIdQuery
→ Dispatcher
→ GetProjectByIdQueryHandler
→ ProjectResponseEl controller no conoce la implementación del handler. Solo crea un command o una query y los envía al dispatcher.
Estructura final del proyecto
src/
├── Application/
│ ├── Abstractions/
│ │ ├── Cqrs/
│ │ │ ├── ICommand.cs
│ │ │ ├── IQuery.cs
│ │ │ ├── ICommandHandler.cs
│ │ │ ├── IQueryHandler.cs
│ │ │ └── IDispatcher.cs
│ │ └── Projects/
│ │ └── IProjectRepository.cs
│ ├── Services/Cqrs/Dispatcher.cs
│ ├── Extensions/CqrsExtensions.cs
│ └── Features/Projects/
│ ├── Commands/CreateProjectCommand.cs
│ ├── Queries/GetProjectByIdQuery.cs
│ ├── Contracts/
│ └── Handlers/
├── Domain/Projects/Project.cs
├── Infrastructure/Projects/InMemoryProjectRepository.cs
└── Controllers/ProjectsController.csPuedes separar estas carpetas en proyectos físicos de una solución Clean Architecture. Para que el ejemplo sea fácil de copiar, aquí se muestran dentro de una sola Web API.
1. Definir commands y queries
Application/Abstractions/Cqrs/ICommand.cs
namespace CqrsTutorial.Application.Abstractions.Cqrs;
/// <summary>
/// Marks a request that represents an intent to change application state.
/// </summary>
/// <typeparam name="TResponse">The response returned by the command handler.</typeparam>
public interface ICommand<out TResponse>
{
}Application/Abstractions/Cqrs/IQuery.cs
namespace CqrsTutorial.Application.Abstractions.Cqrs;
/// <summary>
/// Marks a request that reads application state without modifying it.
/// </summary>
/// <typeparam name="TResponse">The response returned by the query handler.</typeparam>
public interface IQuery<out TResponse>
{
}Son marker interfaces. No implementan comportamiento, solo expresan intención:
ICommand<TResponse>puede modificar estado.IQuery<TResponse>solo debe leer.
Separarlos permite aplicar reglas distintas más adelante sin cambiar controllers ni handlers.
2. Crear los contratos de los handlers
Application/Abstractions/Cqrs/ICommandHandler.cs
namespace CqrsTutorial.Application.Abstractions.Cqrs;
/// <summary>
/// Executes one command use case.
/// </summary>
/// <typeparam name="TCommand">The command type handled by this implementation.</typeparam>
/// <typeparam name="TResponse">The command result type.</typeparam>
public interface ICommandHandler<in TCommand, TResponse>
where TCommand : ICommand<TResponse>
{
/// <summary>
/// Handles the command and returns its application response.
/// </summary>
Task<TResponse> Handle(
TCommand command,
CancellationToken cancellationToken);
}Application/Abstractions/Cqrs/IQueryHandler.cs
namespace CqrsTutorial.Application.Abstractions.Cqrs;
/// <summary>
/// Executes one query use case.
/// </summary>
/// <typeparam name="TQuery">The query type handled by this implementation.</typeparam>
/// <typeparam name="TResponse">The query result type.</typeparam>
public interface IQueryHandler<in TQuery, TResponse>
where TQuery : IQuery<TResponse>
{
/// <summary>
/// Handles the query and returns its application response.
/// </summary>
Task<TResponse> Handle(
TQuery query,
CancellationToken cancellationToken);
}Cada request tiene un handler concreto. Esa relación es la pieza central del ejemplo:
CreateProjectCommand → CreateProjectCommandHandler
GetProjectByIdQuery → GetProjectByIdQueryHandler3. Definir la API del dispatcher
Application/Abstractions/Cqrs/IDispatcher.cs
namespace CqrsTutorial.Application.Abstractions.Cqrs;
/// <summary>
/// Routes commands and queries to their registered handlers.
/// </summary>
public interface IDispatcher
{
/// <summary>
/// Sends a command to its registered command handler.
/// </summary>
Task<TResponse> SendAsync<TResponse>(
ICommand<TResponse> command,
CancellationToken cancellationToken = default);
/// <summary>
/// Sends a query to its registered query handler.
/// </summary>
Task<TResponse> SendAsync<TResponse>(
IQuery<TResponse> query,
CancellationToken cancellationToken = default);
}Hay dos overloads de SendAsync: uno acepta commands y otro queries. Ambos devuelven el tipo declarado por el request.
4. Implementar el dispatcher
Application/Services/Cqrs/Dispatcher.cs
using CqrsTutorial.Application.Abstractions.Cqrs;
using Microsoft.Extensions.DependencyInjection;
namespace CqrsTutorial.Application.Services.Cqrs;
/// <summary>
/// Resolves the closed generic handler for each request and invokes it.
/// </summary>
public sealed class Dispatcher(IServiceProvider serviceProvider) : IDispatcher
{
private const string HandleMethodName = "Handle";
/// <inheritdoc />
public Task<TResponse> SendAsync<TResponse>(
ICommand<TResponse> command,
CancellationToken cancellationToken = default)
{
return DispatchAsync<TResponse>(
command,
typeof(ICommandHandler<,>),
cancellationToken);
}
/// <inheritdoc />
public Task<TResponse> SendAsync<TResponse>(
IQuery<TResponse> query,
CancellationToken cancellationToken = default)
{
return DispatchAsync<TResponse>(
query,
typeof(IQueryHandler<,>),
cancellationToken);
}
private async Task<TResponse> DispatchAsync<TResponse>(
object request,
Type openHandlerType,
CancellationToken cancellationToken)
{
Type requestType = request.GetType();
// ICommandHandler<,> and IQueryHandler<,> are open generic types.
// Closing the selected type with the concrete request and response
// produces the exact service registered in Dependency Injection.
Type handlerType = openHandlerType.MakeGenericType(
requestType,
typeof(TResponse));
object handler = serviceProvider.GetRequiredService(handlerType);
// Both handler contracts expose the same Handle(request, token)
// signature, so the dispatcher can invoke either one uniformly.
System.Reflection.MethodInfo handleMethod =
handler.GetType().GetMethod(HandleMethodName)
?? throw new InvalidOperationException(
$"{handler.GetType().Name} does not define Handle().");
object result = handleMethod.Invoke(
handler,
[request, cancellationToken])
?? throw new InvalidOperationException(
$"{handler.GetType().Name}.Handle() returned null.");
return await (Task<TResponse>)result;
}
}El dispatcher realiza cuatro pasos:
- Obtiene el tipo real del command o query.
- Construye el tipo genérico del handler correspondiente.
- Resuelve ese handler desde
IServiceProvider. - Invoca
Handle()y devuelve su respuesta.
Esta versión usa reflexión para mantener el código pequeño y entendible. Para una API tradicional, el trabajo real normalmente está en la base de datos o servicios externos, no en este dispatch. Si tu escenario exige Native AOT o millones de dispatches, conviene medir y evaluar generación de código.
5. Registrar handlers automáticamente
Application/Extensions/CqrsExtensions.cs
using System.Reflection;
using CqrsTutorial.Application.Abstractions.Cqrs;
using CqrsTutorial.Application.Services.Cqrs;
namespace CqrsTutorial.Application.Extensions;
/// <summary>
/// Registers the CQRS dispatcher and all handlers in the Application assembly.
/// </summary>
public static class CqrsExtensions
{
/// <summary>
/// Adds the custom CQRS infrastructure to the service collection.
/// </summary>
public static IServiceCollection AddCqrsConfiguration(
this IServiceCollection services)
{
services.AddScoped<IDispatcher, Dispatcher>();
RegisterHandlers(services, typeof(CqrsExtensions).Assembly);
return services;
}
private static void RegisterHandlers(
IServiceCollection services,
Assembly assembly)
{
foreach (Type type in assembly.GetTypes())
{
// Only concrete classes can be activated as handlers.
if (type.IsAbstract || type.IsInterface)
{
continue;
}
foreach (Type serviceType in type.GetInterfaces())
{
if (!serviceType.IsGenericType)
{
continue;
}
Type definition = serviceType.GetGenericTypeDefinition();
// Register the implemented closed interface as the service key,
// for example ICommandHandler<CreateProjectCommand, ProjectResponse>.
if (definition == typeof(ICommandHandler<,>) ||
definition == typeof(IQueryHandler<,>))
{
services.AddScoped(serviceType, type);
}
}
}
}
}El método escanea el assembly de Application y registra cualquier clase que implemente:
ICommandHandler<TCommand, TResponse>
IQueryHandler<TQuery, TResponse>Así no necesitamos agregar manualmente cada handler nuevo en Program.cs.
6. Crear un dominio mínimo para el ejemplo
Domain/Projects/Project.cs
using CqrsTutorial.Application.Abstractions.Projects;
using CqrsTutorial.Application.Extensions;
using CqrsTutorial.Infrastructure.Projects;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Registers the dispatcher and discovers every command/query handler
// in the Application assembly.
builder.Services.AddCqrsConfiguration();
// The in-memory implementation keeps the tutorial self-contained.
// Replace it with an EF Core repository without changing commands or queries.
builder.Services.AddSingleton<IProjectRepository, InMemoryProjectRepository>();
WebApplication app = builder.Build();
app.MapControllers();
app.Run();El dominio valida el nombre y controla cómo nace un Project. CQRS no reemplaza las reglas de negocio, solo organiza cómo llegan las operaciones a ellas.
Application/Abstractions/Projects/IProjectRepository.cs
using CqrsTutorial.Domain.Projects;
namespace CqrsTutorial.Application.Abstractions.Projects;
/// <summary>
/// Persistence boundary consumed by project handlers.
/// </summary>
public interface IProjectRepository
{
/// <summary>Adds a project to the current persistence store.</summary>
Task AddAsync(
Project project,
CancellationToken cancellationToken);
/// <summary>Returns a project by id, or <c>null</c> when it does not exist.</summary>
Task<Project?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken);
}Infrastructure/Projects/InMemoryProjectRepository.cs
using System.Collections.Concurrent;
using CqrsTutorial.Application.Abstractions.Projects;
using CqrsTutorial.Domain.Projects;
namespace CqrsTutorial.Infrastructure.Projects;
/// <summary>
/// Thread-safe in-memory repository used to run the tutorial without a database.
/// </summary>
public sealed class InMemoryProjectRepository : IProjectRepository
{
private readonly ConcurrentDictionary<Guid, Project> _projects = new();
/// <inheritdoc />
public Task AddAsync(
Project project,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// The indexer performs an upsert. Project ids are generated once by
// the domain factory, so this behaves as an insert in the tutorial.
_projects[project.Id] = project;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<Project?> GetByIdAsync(
Guid id,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_projects.TryGetValue(id, out Project? project);
return Task.FromResult(project);
}
}Uso memoria para que el ejemplo pueda ejecutarse sin Postgres ni EF Core. En un proyecto real, esta implementación sería un repositorio sobre DbContext, pero los commands y queries no cambiarían.
7. Crear los contratos HTTP
Application/Features/Projects/Contracts/CreateProjectRequest.cs
namespace CqrsTutorial.Application.Features.Projects.Contracts;
/// <summary>
/// HTTP input required to create a project.
/// </summary>
public sealed record CreateProjectRequest(string Name);Application/Features/Projects/Contracts/ProjectResponse.cs
using CqrsTutorial.Domain.Projects;
namespace CqrsTutorial.Application.Features.Projects.Contracts;
/// <summary>
/// Application response returned by project commands and queries.
/// </summary>
public sealed record ProjectResponse(
Guid Id,
string Name,
DateTimeOffset CreatedAt)
{
/// <summary>
/// Maps the domain entity to the DTO exposed by the API.
/// </summary>
public static ProjectResponse From(Project project)
{
return new ProjectResponse(
project.Id,
project.Name,
project.CreatedAt);
}
}Estos DTOs pertenecen al borde de Application. No exponemos directamente la entidad de dominio desde el controller.
8. Crear el command y la query
Application/Features/Projects/Commands/CreateProjectCommand.cs
using CqrsTutorial.Application.Abstractions.Cqrs;
using CqrsTutorial.Application.Features.Projects.Contracts;
namespace CqrsTutorial.Application.Features.Projects.Commands;
/// <summary>
/// Represents the intent to create and persist a new project.
/// </summary>
public sealed record CreateProjectCommand(
CreateProjectRequest Request)
: ICommand<ProjectResponse>;Application/Features/Projects/Queries/GetProjectByIdQuery.cs
using CqrsTutorial.Application.Abstractions.Cqrs;
using CqrsTutorial.Application.Features.Projects.Contracts;
namespace CqrsTutorial.Application.Features.Projects.Queries;
/// <summary>
/// Reads one project without changing application state.
/// </summary>
public sealed record GetProjectByIdQuery(Guid Id)
: IQuery<ProjectResponse?>;El command contiene lo necesario para crear. La query contiene el identificador necesario para leer. Ninguno conoce infraestructura ni HTTP.
9. Implementar los handlers
Application/Features/Projects/Handlers/CreateProjectCommandHandler.cs
using CqrsTutorial.Application.Abstractions.Cqrs;
using CqrsTutorial.Application.Abstractions.Projects;
using CqrsTutorial.Application.Features.Projects.Commands;
using CqrsTutorial.Application.Features.Projects.Contracts;
using CqrsTutorial.Domain.Projects;
namespace CqrsTutorial.Application.Features.Projects.Handlers;
/// <summary>
/// Handles the project creation use case.
/// </summary>
public sealed class CreateProjectCommandHandler(
IProjectRepository repository)
: ICommandHandler<CreateProjectCommand, ProjectResponse>
{
/// <inheritdoc />
public async Task<ProjectResponse> Handle(
CreateProjectCommand command,
CancellationToken cancellationToken)
{
// The domain factory owns validation and initial values.
Project project = Project.Create(command.Request.Name);
// The handler depends on an abstraction, not on the in-memory
// implementation used by this tutorial.
await repository.AddAsync(project, cancellationToken);
return ProjectResponse.From(project);
}
}Application/Features/Projects/Handlers/GetProjectByIdQueryHandler.cs
using CqrsTutorial.Application.Abstractions.Cqrs;
using CqrsTutorial.Application.Abstractions.Projects;
using CqrsTutorial.Application.Features.Projects.Contracts;
using CqrsTutorial.Application.Features.Projects.Queries;
using CqrsTutorial.Domain.Projects;
namespace CqrsTutorial.Application.Features.Projects.Handlers;
/// <summary>
/// Handles the project lookup use case.
/// </summary>
public sealed class GetProjectByIdQueryHandler(
IProjectRepository repository)
: IQueryHandler<GetProjectByIdQuery, ProjectResponse?>
{
/// <inheritdoc />
public async Task<ProjectResponse?> Handle(
GetProjectByIdQuery query,
CancellationToken cancellationToken)
{
Project? project = await repository.GetByIdAsync(
query.Id,
cancellationToken);
// A missing project is represented as null in Application.
// The controller translates that result to HTTP 404.
return project is null
? null
: ProjectResponse.From(project);
}
}El command handler crea y guarda. El query handler busca y proyecta una respuesta. Esa separación hace visible qué operaciones escriben y cuáles leen.
10. Conectar CQRS con el controller
Controllers/ProjectsController.cs
using CqrsTutorial.Application.Abstractions.Cqrs;
using CqrsTutorial.Application.Features.Projects.Commands;
using CqrsTutorial.Application.Features.Projects.Contracts;
using CqrsTutorial.Application.Features.Projects.Queries;
using Microsoft.AspNetCore.Mvc;
namespace CqrsTutorial.Controllers;
/// <summary>
/// Translates HTTP requests into project commands and queries.
/// </summary>
[ApiController]
[Route("api/projects")]
public sealed class ProjectsController(IDispatcher dispatcher) : ControllerBase
{
/// <summary>Creates a project through the command side of CQRS.</summary>
[HttpPost]
public async Task<ActionResult<ProjectResponse>> Create(
CreateProjectRequest request,
CancellationToken cancellationToken)
{
CreateProjectCommand command = new(request);
ProjectResponse response = await dispatcher.SendAsync(
command,
cancellationToken);
return CreatedAtAction(
nameof(GetById),
new { id = response.Id },
response);
}
/// <summary>Reads a project through the query side of CQRS.</summary>
[HttpGet("{id:guid}")]
public async Task<ActionResult<ProjectResponse>> GetById(
Guid id,
CancellationToken cancellationToken)
{
GetProjectByIdQuery query = new(id);
ProjectResponse? response = await dispatcher.SendAsync(
query,
cancellationToken);
return response is null
? NotFound()
: Ok(response);
}
}El controller solo traduce HTTP a Application:
POSTcrea unCreateProjectCommand.GETcrea unGetProjectByIdQuery.- El dispatcher localiza el handler.
- El controller convierte la respuesta en un resultado HTTP.
11. Registrar todo en Program.cs
using CqrsTutorial.Application.Abstractions.Projects;
using CqrsTutorial.Application.Extensions;
using CqrsTutorial.Infrastructure.Projects;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddCqrsConfiguration();
builder.Services.AddSingleton<IProjectRepository, InMemoryProjectRepository>();
WebApplication app = builder.Build();
app.MapControllers();
app.Run();AddCqrsConfiguration() registra el dispatcher y escanea handlers. El repositorio en memoria es singleton para conservar proyectos entre requests durante la demo.
12. Probar el ejemplo
Crear un proyecto:
curl -X POST http://localhost:5000/api/projects \
-H 'Content-Type: application/json' \
-d '{"name":"CQRS tutorial"}'Respuesta:
{
"id": "<guid>",
"name": "CQRS tutorial",
"createdAt": "2026-07-17T00:00:00+00:00"
}Consultar el proyecto:
curl http://localhost:5000/api/projects/<guid>La muestra completa compila con .NET 10 y el flujo POST → command y GET → query devuelve el mismo proyecto.
Qué ganamos con esta versión
- Commands y queries explícitos.
- Un handler por caso de uso.
- Controllers pequeños.
- Registro automático de handlers.
- Cero dependencia de MediatR.
- Una base que se puede entender leyendo pocos archivos.
Limitaciones conscientes
Esta implementación no incluye notifications, streaming ni dispatch generado en compile-time. También usa reflexión para invocar Handle().
No intentaría reconstruir todo el ecosistema de MediatR. La intención es cubrir un caso concreto: commands, queries y handlers dentro de una API con una arquitectura que el equipo pueda controlar.
Conclusión
Desde que MediatR cambió su licencia, mi enfoque no ha sido buscar un clon exacto. Primero separo CQRS del paquete:
- Command significa escritura.
- Query significa lectura.
- Handler significa un caso de uso.
- Dispatcher significa routing.
Con esos conceptos claros, la implementación cabe en pocos archivos y puede crecer solo cuando el proyecto realmente lo necesite.
Fuentes y referencias
- Sitio oficial de MediatR
- MediatR README y configuración de license key
- Lucky Penny Software Licensing FAQ
- Microsoft: CQRS y patrones de dominio
- Microsoft: Dependency Injection en .NET
Tutorial actualizado en julio de 2026. El código usa .NET 10 y un repositorio en memoria para mantener el foco únicamente en CQRS.