Поиск  
Always will be ready notify the world about expectations as easy as possible: job change page
Oct 26, 2023

CQRS in ASP.NET with separate read/write models using MediatR

Автор:
Источник:
Просмотров:
9550

How to implement CQRS in ASP.NET using MediatR. A guided example using CQRS with separate Read and Write models using Enity Framework Core for Commands and Dapper for Queries.

CQRS in ASP.NET

When people think about CQRS they often think about complex, event-driven, distributed architectures with separate Read and Write databases. Using different databases for Read and Write means we can use a Polyglot Architecture, where we pick a database that perfectly fits the problem on each side. If you don’t require mega scale, then it is generally absolutely fine to run your CQRS setup using just a single database. This makes things much simpler because we don’t have to deal with propagating changes from the Write side to the Read side or scenarios where each side becomes out of sync.

Single Database vs Different Read/Write Databases
Single Database vs Different Read/Write Databases

There are lots of different options for using CQRS: Choosing a CQRS Architecture That Works for You. In this article I will be focusing on ‘Different Read/Write Models, Single Database’. The full code can be found in my GitHub repo at the end of the article.

• • •

Different Read/Write Models, Single Database

Even when using a single database we can still take advantage of CQRS and design our Commands on the Write side and our Queries on the Read side slightly differently. In this example SQL Server is used as a database.

Write Side — Create/Update/Delete data

Entity Framework Core will be used for the Write side. Entity Framework is a powerful ORM which helps encapsulate business logic and validation for writing data. We will also use Domain-Driven Design for the Write side so that business and validation logic can be encapsulated in our Write models/Entities.

Read Side — Read data

Dapper will be used on the Read side to Query data. Dapper is a lightweight Object Mapper which allows data to be read extremely efficiently. Direct SQL Queries will be used on the Read side so we can ensure that the most optimal way of reading data is used. There will be no business logic or DTO mappings slowing us down.

• • •

Command Query Responsibility Segregation (CQRS)

CQRS splits data access into Commands and Queries. Every Write or Read operation has a dedicated Command or Query, compared to more traditional patterns where larger ‘Service’ or ‘Business’ classes with lots of different methods are used.

  • Commands: Write data — Create/Update/Delete
  • Queries: Read data

The following shows how a Weather Forecast can be Created and Read using CQRS compared to Service-Based implementations.

CQRS vs Service-Based Data Access
CQRS vs Service-Based Data Access

Each Command and Query class has a corresponding Handler class. Commands and Queries are dispatched to their Handler using a synchronous in-process Mediator implementation using the MediatR Nuget package.

Splitting our code into granular Commands/Queries/Handlers ensures that the Single Responsibility Principle (SRP) is adhered to, which makes our solutions flexible for change and easy to test. When using Service-Based patterns our classes can quickly become large and unwieldy. For this reason even if you are not using different Read/Write models, following CQRS can make your code much cleaner and easier to maintain.

Another benefit to using CQRS is that the only dependency your code now needs is the Mediator instance. The Mediator is responsible for dispatching your Commands and Queries to their corresponding Handler.

Before we get into the nuts and bolts, let’s look at the difference between the 2 approaches when accessing data via an API Controller.

CQRS vs Service-Based at High Level
CQRS vs Service-Based at High Level

For the Service-Based approach the same WeatherForecastsService class is reused for each endpoint, whereas for CQRS each endpoint uses a dedicated Command or Query. MediatR’s ISender interface is used to dispatch the Commands and Queries to their retrospective Handlers. In the next section we will take a look under the hood and see how everything is implemented.

• • •

CQRS Implementation

First we will look at how the Write side is implemented using Commands via Entity Framework Core and then we will look at how the Read side is implemented using Queries via Dapper.

Commands

Commands are used to Create/Update/Delete data using our Write models. In this example Domain-Driven Design is being followed so that we can encapsulate as much Business and Validation logic as possible within our Aggregates/Entities. When I refer to Write models here, I am talking about our Entities that are stored in the database via Entity Framework.

All Commands implement the Command base class and their Handlers implement the CommandHandler base class. These base classes inherit from MediatR IRequest and IRequestHandler interfaces, which allows them to be dispatched via an ISender.

namespace AspNetCore.Cqrs.Application.Abstractions.Commands
{
    public abstract record Command : IRequest<Unit>;
    
    public abstract class CommandHandler<TCommand> : IRequestHandler<TCommand, Unit> where TCommand : Command
    {
        public async Task<Unit> Handle(TCommand request, CancellationToken cancellationToken)
        {
            await HandleAsync(request);
            return Unit.Value;
        }

        protected abstract Task HandleAsync(TCommand request);
    }
}

Command and Command Handler Base Classes

Using records to create the Commands works well because they should be simple immutable data structures. The Command Handlers don’t return anything at all — this should generally be the case when following CQRS.

Example Command

Using a Mediator to dispatch Commands and Queries breaks dependencies when navigating through your code. That can make it difficult to quickly find the Handler implementations when looking at your consuming code. For this reason I usually put the Handler classes in the same file as the Command or Query.

Here is an example Command for creating a new Weather Forecast. In this case the WeatherForecast class is our Write model.

namespace AspNetCore.Cqrs.Application.Weather.Commands
{
    public sealed record CreateWeatherForecastCommand(Guid Id, int Temperature, DateTime Date, string? Summary, Guid LocationId) : Command;

    public sealed class CreateWeatherForecastCommandHandler : CommandHandler<CreateWeatherForecastCommand>
    {
        private readonly IRepository<WeatherForecast> _repository;
        private readonly IUnitOfWork _unitOfWork;

        public CreateWeatherForecastCommandHandler(IRepository<WeatherForecast> repository,
            IUnitOfWork unitOfWork) : base(unitOfWork)
        {
            _repository = repository;
            _unitOfWork = unitOfWork;
        }

        protected override async Task HandleAsync(CreateWeatherForecastCommand request)
        {
            var created = WeatherForecast.Create(request.Id,
                                                 request.Date,
                                                 Temperature.FromCelcius(request.Temperature),
                                                 request.Summary,
                                                 location.Id);
            _repository.Insert(created);
            await UnitOfWork.CommitAsync();
        }
    }
}

Example Create Weather Forecast Command

The WeatherForecast Write model uses Domain-Driven Design principles to encapsulate validation/update logic using private setters and Guards.

namespace AspNetCore.Cqrs.Core.Weather.Entities
{
    public sealed class WeatherForecast : AggregateRoot
    {
        private WeatherForecast(Guid id, DateTime date, Temperature temperature, string summary, Guid locationId) : base(id)
        {
            Date = date;
            Temperature = temperature;
            Summary = summary;
            LocationId = locationId;
        }

        public static WeatherForecast Create(Guid id, DateTime date, Temperature temperature, string? summary, Guid locationId)
        {
            return new WeatherForecast(id, date, temperature, ValidateSummary(summary), locationId);
        }

        public DateTime Date { get; private set; }
        public Temperature Temperature { get; private set; }
        public string Summary { get; private set; }
        public Guid LocationId { get; private set; }

        public void Update(Temperature temperature, string summary)
        {
            Temperature = temperature;
            Summary = ValidateSummary(summary);
        }

        private static string ValidateSummary(string? summary)
        {
            summary = (summary ?? string.Empty).Trim();
            Guard.Against.NullOrEmpty(summary, nameof(Summary));
            return summary;
        }
    }
}

Weather Forecast Write Model

The Write side uses a generic Entity Framework Core Repository implementation. EF will handle all of the complexity with storing our data. Since our Writes are less frequent, we are not as concerned with performance.

namespace AspNetCore.Cqrs.Infrastructure.Repositories
{
    internal class Repository<T> : IRepository<T> where T : AggregateRoot
    {
        private readonly WeatherContext _context;
        private readonly DbSet<T> _entitySet;

        public Repository(WeatherContext context)
        {
            _context = context;
            _entitySet = _context.Set<T>();
        }

        public IQueryable<T> GetAll(bool noTracking = true)
        {
            var set = _entitySet;
            if (noTracking)
            {
                return set.AsNoTracking();
            }
            return set;
        }

        public async Task<T?> GetByIdAsync(Guid id)
        {
            return await GetAll(false)
                .FirstOrDefaultAsync(e => e.Id == id);
        }

        public void Insert(T entity)
        {
            _entitySet.Add(entity);
        }

        public void Insert(List<T> entities)
        {
            _entitySet.AddRange(entities);
        }

        public void Delete(T entity)
        {
            _entitySet.Remove(entity);
        }

        public void Remove(IEnumerable<T> entitiesToRemove)
        {
            _entitySet.RemoveRange(entitiesToRemove);
        }
    }
}

Write Model Entity Framework Repository

Returning Data From Commands

If you are following ‘Pure’ CQRS, then no data should be returned from your Commands. There are cases where validations or metadata can be returned, however responses should not include any information about the Write model. One of the reasons for this is so that your Commands can be handled asynchronously in a fire-and-forget manner in the future if you need — in our example we are implementing an in-process Mediator so this will not be the case.

In the previous example a new Guid Id is passed into the Command so that it doesn’t need to be returned again. If you are using a database generated Identifier then this principle can be difficult to adhere to. I’d recommend just being pragmatic here — if you really think you need to return data from your Commands and it works for your team, then do it!

Queries

Queries are used to Read data. The Query and QueryHandler base classes also inherit from the MediatR interfaces so they can be dispatched via an ISender. The key difference is that Queries must specify a response type — this is the Read model that it knows how to query.

namespace AspNetCore.Cqrs.Application.Abstractions.Queries
{
    public abstract record Query<T> : IRequest<T>;
    
    public abstract class QueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, TResponse> where TQuery : Query<TResponse>
    {
        public async Task<TResponse> Handle(TQuery request, CancellationToken cancellationToken)
        {
            return await HandleAsync(request);
        }

        protected abstract Task<TResponse> HandleAsync(TQuery request);
    }
}

Query and Query Handler Base Classes

Example Query

Here is an example Query for reading a Weather Forecast. In this case the WeatherForecastReadModel class is our Read model.

namespace AspNetCore.Cqrs.Application.Weather.Queries
{
    public sealed record GetWeatherForecastQuery(Guid Id) : Query<WeatherForecastReadModel>;

    public sealed class GetWeatherForecastQueryHandler : QueryHandler<GetWeatherForecastQuery, WeatherForecastReadModel>
    {
        private readonly IWeatherForecastsReadModelRepository _repository;

        public GetWeatherForecastQueryHandler(IWeatherForecastsReadModelRepository repository)
        {
            _repository = repository;
        }

        protected override async Task<WeatherForecastReadModel> HandleAsync(GetWeatherForecastQuery request)
        {
            var forecast = await _repository.GetByIdAsync(request.Id);
            return Guard.Against.NotFound(forecast);
        }
    }
}

Example Get Weather Forecast Query

Our Query Handler and Read model are super simple and lightweight. There should be no complex logic or mapping so that the Read models can be read and serialized as efficiently as possible.

namespace AspNetCore.Cqrs.Core.Weather.ReadModels
{
    public sealed class WeatherForecastReadModel : ReadModel
    {
        public DateTime Date { get; set; }
        public int TemperatureC { get; set; }
        public int TemperatureF => 32 + (int)Math.Round((TemperatureC / 0.5556), 0);
        public string? Summary { get; set; }
        public Guid LocationId { get; set; }
    }
}

Weather Forecast Read Model

The main difference on the Read side is the repository implementation. A custom WeatherForecastsReadModelRepository is used to read data using Dapper. This means that we can craft our SQL queries exactly how we want to optimise performance, without Entity Framework getting in the way. Here we are reading from the same Table that Entity Framework used for the Write models, however we could use other options if we wanted e.g. Views, Materialised Views ect.

namespace AspNetCore.Cqrs.Infrastructure.Repositories
{
    public sealed class WeatherForecastsReadModelRepository : IWeatherForecastsReadModelRepository
    {
        private readonly DapperContext _context;

        public WeatherForecastsReadModelRepository(DapperContext context)
        {
            _context = context;
        }

        public async Task<List<WeatherForecastReadModel>> GetAllAsync()
        {
            var query = "SELECT * FROM WeatherForecasts ORDER BY Date";
            using var connection = _context.CreateConnection();
            var locations = await connection.QueryAsync<WeatherForecastReadModel>(query);
            return locations.ToList();
        }

        public async Task<List<WeatherForecastReadModel>> GetByLocationAsync(Guid locationId)
        {
            var query = "SELECT * FROM WeatherForecasts WHERE LocationId = @LocationId ORDER BY Date";
            using var connection = _context.CreateConnection();
            var locations = await connection.QueryAsync<WeatherForecastReadModel>(query, new { locationId });
            return locations.ToList();
        }

        public async Task<WeatherForecastReadModel?> GetByIdAsync(Guid id)
        {
            var query = "SELECT TOP 1 * FROM WeatherForecasts WHERE Id = @Id";
            using var connection = _context.CreateConnection();
            var location = await connection.QueryFirstOrDefaultAsync<WeatherForecastReadModel>(query, new { id });
            return location;
        }
    }
}

Weather Forecasts Read Model Dapper Repository

Where The Code Lives

If you are following Clean Architecture then your CQRS classes belong in the following layers:

Application

  • Commands
  • Command Handlers
  • Queries
  • Query Handlers

Core/Domain

  • Write Models (Entities/Aggregates)
  • Read Models

Infrastructure

  • Write Model Repositories (Entity Framework implementation)
  • Read Model Repositories (Dapper implementation)

Project Setup for CQRS
Project Setup for CQRS

Wiring It Up

MediatR can be added as a Nuget package using:

dotnet add package MediatR

Registering all of your Handlers is easy using an extension method provided by MediatR. Since all of the Commands, Queries and Handlers are in the Application layer, you just need to provide any type from that project:

services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(GetWeatherForecastQuery).Assembly));

Consuming Commands and Queries

Now that your code is split neatly into Commands and Queries, these can be consumed using MediatR’s ISender or IMediator interfaces. The mediator will match up the Command or Query with their Handler.

namespace AspNetCore.Cqrs.Api.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public sealed class WeatherForecastsController : ControllerBase
    {
        private readonly ISender _mediator;

        public WeatherForecastsController(ISender mediator)
        {
            _mediator = mediator;
        }

        [HttpGet("{id}")]
        [ProducesResponseType(typeof(WeatherForecastReadModel), StatusCodes.Status200OK)]
        public async Task<IActionResult> Get(Guid id)
        {
            var forecast = await _mediator.Send(new GetWeatherForecastQuery(id));
            return Ok(forecast);
        }

        [HttpPost]
        [ProducesResponseType(typeof(CreatedResultEnvelope), StatusCodes.Status201Created)]
        public async Task<IActionResult> Post([FromBody] WeatherForecastCreateDto forecast)
        {
            var id = Guid.NewGuid();
            await _mediator.Send(new CreateWeatherForecastCommand(id, forecast.TemperatureC, forecast.Date, forecast.Summary, forecast.LocationId));
            return CreatedAtAction(nameof(Get), new { id }, new CreatedResultEnvelope(id));
        }
        
        // ...
    }
}

Consuming Commands and Queries

• • •

Using CQRS is a brilliant technique for breaking down complex projects and cleaning your code. If you are just getting started with CQRS then using a single database for Read and Write models is an easy way to get going so you don’t have to manage syncing data or distributed transactions.

Using Dapper on the Read side may seem unnecessary to you, it is really just a demonstration of what is possible whilst still using the same database. Even if you still want to use Entity Framework or another ORM on the Read side then there is still value in using CQRS to break down your app into more granular Commands and Queries.

All of the code from the article can be found in my GitHub: https://github.com/matt-bentley/AspNetCore.Cqrs 

Похожее
May 13, 2023
Author: Juan Alberto España Garcia
Introduction to Async and Await in C# Asynchronous programming has come a long way in C#. Prior to the introduction of async and await, developers had to rely on callbacks, events and other techniques like the BeginXXX/EndXXX pattern or BackgroundWorker....
Aug 29
Author: Juldhais Hengkyawan
In some scenarios, we need to use different database providers for development and production environments. For instance, consider a scenario where we use SQLite in the development environment and SQL Server for the production environment. This article will guide us...
Nov 27, 2023
Author: Juldhais Hengkyawan
Use the Bogus library to generate and insert 1 million dummy product data into the SQL Server database We need to create 1 million dummy product data into the SQL Server database, which can be used for development or performance...
Apr 3, 2023
Author: Hr. N Nikitins
Master the art of caching in .NET applications to improve performance and user experience. Caching is a powerful technique to improve application performance and response times. By temporarily storing the results of expensive operations or frequently accessed data, you can...
Написать сообщение
Тип
Почта
Имя
*Сообщение