MediatR changed its licensing in 2025. Instead of looking for another package that did exactly the same thing, I used the moment to implement only the CQRS pieces I actually needed.
For years, installing MediatR felt automatic in .NET projects using Clean Architecture. But CQRS and MediatR are not the same thing.
CQRS only separates operations that change state from operations that read state. A mediator is one way to route those operations to their handlers, but that routing layer can be implemented with very little code.
This tutorial implements only:
- Commands for writes.
- Queries for reads.
- One handler per operation.
- A dispatcher that locates the correct handler.
- Automatic handler registration with Dependency Injection.
- A complete
Projectsfeature withPOSTandGETendpoints.
Note: this is not legal advice. Every organization should review the license that applies to its situation.
The flow we are building
POST /api/projects
→ CreateProjectCommand
→ Dispatcher
→ CreateProjectCommandHandler
→ ProjectResponse
GET /api/projects/{id}
→ GetProjectByIdQuery
→ Dispatcher
→ GetProjectByIdQueryHandler
→ ProjectResponseThe controller does not know the handler implementation. It only creates a command or query and sends it to the dispatcher.
Final project structure
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.csThese folders can become separate projects in a Clean Architecture solution. To keep the example easy to reproduce, they are shown inside one Web API project.
1. Define commands and 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>
{
}These are marker interfaces. They do not implement behavior, they only express intent:
ICommand<TResponse>may change state.IQuery<TResponse>should only read state.
Keeping them separate lets us apply different rules later without changing controllers or handlers.
2. Create the handler contracts
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);
}Every request has one concrete handler. That relationship is the core of the sample:
CreateProjectCommand → CreateProjectCommandHandler
GetProjectByIdQuery → GetProjectByIdQueryHandler3. Define the dispatcher API
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);
}SendAsync has two overloads, one for commands and one for queries. Both return the response type declared by the request.
4. Implement the 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;
}
}The dispatcher performs four steps:
- Reads the concrete command or query type.
- Builds the matching generic handler type.
- Resolves that handler from
IServiceProvider. - Invokes
Handle()and returns its response.
This version uses reflection to keep the implementation small and readable. In a traditional API, the meaningful work is usually in the database or external services, not this dispatch call. If Native AOT or extremely high dispatch volume is a real requirement, measure it and consider code generation.
5. Register handlers automatically
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);
}
}
}
}
}The method scans the Application assembly and registers every class that implements:
ICommandHandler<TCommand, TResponse>
IQueryHandler<TQuery, TResponse>That means every new handler is discovered without adding another line to Program.cs.
6. Add a minimal domain for the example
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();The domain validates the name and controls how a Project is created. CQRS does not replace business rules, it only organizes how operations reach them.
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);
}
}The sample uses memory so it runs without PostgreSQL or EF Core. In a real project, this implementation would use a DbContext, while the commands and queries would remain unchanged.
7. Create the HTTP contracts
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);
}
}These DTOs belong at the Application boundary. The controller does not expose the domain entity directly.
8. Create the command and 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?>;The command carries what the write operation needs. The query carries the identifier needed to read. Neither depends on HTTP or infrastructure.
9. Implement the 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);
}
}The command handler creates and stores. The query handler finds and projects a response. That split makes writes and reads visible in the codebase.
10. Connect CQRS to the 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);
}
}The controller only translates HTTP into Application requests:
POSTcreates aCreateProjectCommand.GETcreates aGetProjectByIdQuery.- The dispatcher locates the handler.
- The controller turns the response into an HTTP result.
11. Register everything in 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() registers the dispatcher and scans handlers. The in-memory repository is a singleton so projects survive across requests during the demo.
12. Run the sample
Create a project:
curl -X POST http://localhost:5000/api/projects \
-H 'Content-Type: application/json' \
-d '{"name":"CQRS tutorial"}'Response:
{
"id": "<guid>",
"name": "CQRS tutorial",
"createdAt": "2026-07-17T00:00:00+00:00"
}Retrieve it:
curl http://localhost:5000/api/projects/<guid>The complete sample builds with .NET 10, and the POST → command and GET → query flows return the same project.
What this version gives us
- Explicit commands and queries.
- One handler per use case.
- Small controllers.
- Automatic handler registration.
- No MediatR dependency.
- A foundation that can be understood by reading a few files.
Conscious limitations
This implementation does not include notifications, streaming, or compile-time dispatch generation. It also uses reflection to invoke Handle().
I would not try to rebuild the whole MediatR ecosystem. The goal is narrower: commands, queries, and handlers inside an API with an architecture the team can control.
Conclusion
Since MediatR changed its licensing, my approach has not been to find an exact clone. I separate CQRS from the package first:
- Command means write.
- Query means read.
- Handler means one use case.
- Dispatcher means routing.
Once those concepts are clear, the implementation fits in a small set of files and can grow only when the project actually needs it.
Sources and further reading
- Official MediatR website
- MediatR README and license key configuration
- Lucky Penny Software Licensing FAQ
- Microsoft: CQRS and domain patterns
- Microsoft: Dependency Injection in .NET
Tutorial updated in July 2026. The code uses .NET 10 and an in-memory repository to keep the focus entirely on CQRS.