Creating a CQRS Architecture in .NET Core 8

GitRepository

In software development, efficient management of read and write operations is crucial to keeping applications scalable and maintainable. This article presents a practical implementation of the CQRS (Command Query Responsibility Segregation) architecture using C# and MediatR. What is the main difference between CQRS and other architectures? The main one is to focus on the process and not on the data; let me explain: CQRS is a pattern that separates reading operations (Queries) from writing operations (Commands). Instead of having a one-size-fits-all model, we separate our operations by purpose.

This gives us some advantages, such as maintaining a cleaner and more organized code, and different technologies or ORM can be used for readings and writing; with MediatR, we simplify the management of handlers in queries and commands in addition to reducing the coupling between components.

This article describes an architecture with the above in a very simple way. It contains the basics: authentication, security, data validations, and data mapping. I hope it is useful to you.

Project Application

We will start with the domain and make our repositories.

DOMAIN

We add a project of type library “Project. Domain” in .NET Core, and we add 1 Entities folder to that folder, which we will create.

BaseEntity.cs

  • Person. cs //table for people
  • User.cs //system users table
public class BaseEntity
{
	public virtual DateTime? CreatedAt { get; set; }
	public virtual string? CreatedBy { get; set; } = string.Empty;
	public virtual DateTime? ModifiedAt { get; set; }
	public virtual string? ModifiedBy { get; set; } =string.Empty;
    [NotMapped]
	public virtual long Id { get; set; }

	
}
public class Person :BaseEntity
{
    public string Name { get; set; }
	public string LastName { get; set; }	
	public DateTime BirthDate { get; set; }
	public string Email { get; set; }
	public string PhoneNumber { get; set; }
}
public partial class User
{
	public long UserId { get; set; }
	public string? Email { get; set; } = string.Empty;
	public string Password { get; set; } = string.Empty;
	public string FirstName { get; set; } = string.Empty;
	public string LastName { get; set; } = string.Empty;
	public string? PhoneNumber { get; set; } = string.Empty;
}

Domain

The base entity is an abstraction for the fields common to our tables, it will also help us in creating our generic repository.

The user table handles sensitive information; we do not use it with the base entity.

INFRASTRUCTURE

We create a library-type project “Project. Infrastructure”, and add those references to this project.

  • Microsoft.EntityFrameworkCore
  • Microsoft.EntityFrameworkCore.SqlServer
  • Pomelo.EntityFrameworkCore.MySql

create the folders

  • Migrations (if you are going to work code first, this is not the case)
  • Persistence
  • Context
  • Repositories

Folder

In the Context folder, create an AppDbContext.cs file in order to define a custom Entity Framework Core DbContext; in this app part, we manage the database data interactions (CRUD)

in this example, I only have two DbSets Person and User, by another hand in the method "OnModelCreating" I'm using an overridden in order to configure the entity mappings.

In the repositories folder, we will create BaseRepository.cs, PersonRepository.cs, and UserRepository.cs.

Let's start with BaseRepository, there we will create all the methods to interact with the data referring to its interface IAsyncRepository<T> where T: BaseEntity.

  • GetAllAsync
  • GetAllIquerable
  • AddAsync
  • UpdateAsync
  • DeleteAsync
  • GetByIdAsync
  • AddEntity
  • UpdateEntity
  • DeleteEntity
  • GetAsync (has several overrides for queries, simple, combined, and pagination; usage examples are in the attached file).

PersonRepository

In this part, we use the RepositoryBase<Person> class

public class PersonRepository : RepositoryBase<Person>, IPersonRepository
{
    public PersonRepository(AppDbContext context) : base(context)
    {
    }
}

If you need additional information or any process that is not in the base repository, you can add it here and publish it in your personal interface.

User Repository

There are basic methods to handle users in a small project. If you wish, you could add more stuff like the locked user, unlocked user, levels, etc.

public class UserRepository : IUserRepository
{
    private readonly AppDbContext _context;

    public UserRepository(AppDbContext context)
    {
        _context = context;
    }
    public async Task<long> Insert(User data)
    {
        try
        {
            _context.Set<User>().Add(data);
            await _context.SaveChangesAsync();
            return data.UserId;
        }
        catch (Exception ex)
        {
            throw new Exception(ex.ToString());
        }
    }
    public async Task<User> SelectByIdASync(long userId)
    {
        User obj = await _context.Set<User>().FindAsync(userId);
        return obj;
    }
    public async Task DeleteASync(long userId)
    {
        _context.Set<User>().Remove(await SelectByIdASync(userId));
        await _context.SaveChangesAsync();
    }
    public async Task<User> UpdateASync(long userId, User obj)
    {
        var objPrev = (from iClass in _context.Users
                       where iClass.UserId == userId
                       select iClass).FirstOrDefault();
        _context.Entry(objPrev).State = EntityState.Detached;
        _context.Entry(obj).State = EntityState.Modified;
        await _context.SaveChangesAsync();
        return obj;
    }
    public Task<User> GetByEmail(string email)
    {
        var objPrev = (from iClass in _context.Users
                       where iClass.Email.ToLower() == email.ToLower()
                       select iClass).FirstOrDefaultAsync();
        return objPrev;
    }
}

APPLICATION

We add a project of type library “Project. Application” in netCore, then add the next references to Project. Application.

  • AutoMapper.Extensions.Microsoft.DependencyInjection Version 12.0.1
  • JWT Version 10.1.1
  • MediatR Version 12.4.1
  • Pediatr.Extensions.FluentValidation.AspNetCore Version 5.1.0
  • Pediatr.Extensions.Microsoft.DependencyInjection Version 11.1.0
  • Microsoft.AspNetCore.Cryptography.KeyDerivation Version 8.0.8
  • Microsoft.Extensions.Configuration Version 8.0.0
  • Microsoft.IdentityModel.Tokens Version 8.1.0
  • System.IdentityModel.Tokens.Jwt Version 8.1.0

Please create the next folder path in this project.

Folder path

I'm going to explain the most important classes in this project,

Mapping in order to make the programming work, we use the Mapper package order to map from DTO's to Class.

public MappingProfile()
{
	CreateMap<PersonRequestDto, Person>();
	CreateMap<Person,PersonRequestDto>();
	CreateMap<Person, PersonListDto>();
}

COMMANDS AND QUERIES

Since this is a CQRS architecture project, this is where the magic happens. The CQRS pattern focuses on processes rather than data. Let me explain: if you have an "employee" entity, you focus on the processes of that entity - creating, deleting, modifying, and querying. We must also remember that this pattern separates reads and writes into different models, using commands to update data and queries to read data. Commands should be task-based rather than data-focused. To achieve this, we use the MediatR library, where we will declare handlers that inherit the properties and methods of IRequestHandler from the MediatR library.

For example, in the person class, we have Hire a Person (Create), Update Person info (Update), Fired Person (Delete), and Get info for Person (Query).

Example code for GetAllPersonHandler

public class GetAllPersonQueryHandler : IRequestHandler<GetAllPersonQuery, List<PersonListDto>>
{
    private readonly IPersonRepository _repository;
    private readonly IMapper _mapper;
    public GetAllPersonQueryHandler(IPersonRepository repository, IMapper mapper)
    {
        _repository = repository;
        _mapper = mapper;
    }
    public async Task<List<PersonListDto>> Handle(GetAllPersonQuery request, CancellationToken cancellationToken)
    {
        var persons = await _repository.GetAllAsync();
        return _mapper.Map<List<PersonListDto>>(persons);
    }
}

This code defines a handler class called GetAllPersonQueryHandler. It handles queries of type GetAllPersonQuery and returns a list of PersonListDto.

The class uses two dependencies: IPersonRepository for accessing data and IMapper for mapping data objects. When the Handle method is called, it fetches all persons from the repository asynchronously.

It then maps the fetched persons to a list of PersonListDto and returns the result.

In the case of a command, such as creating a person, the process is a bit more complex. Besides using mapping to convert from DTO to the person class, we also need to perform validations. Fortunately, we use FluentValidation, which centralizes our validations into a single process and makes programming easier.

VALIDATORS

To implement validations in a CQRMS (Command Query Responsibility Segregation with Microservices) architecture, FluentValidation makes it easy to define rules in a fluid and concise way. In the CreatePersonCommandValidator example, each property of the CreatePersonCommand command is validated individually, ensuring the consistency and accuracy of the data received before being processed. For example, it is established that the first and last names must not be empty and cannot exceed 45 characters. The email property is checked for both its mandatory nature and its correct format, while the birth must be a valid date in the past.

This modular structure not only ensures data integrity but also provides custom error messages, improving the user experience and facilitating code maintenance in complex systems. Another good news is the validator focuses on processes rather than data. Let me explain again; we can create different validators for different business rules and easily find them because they are in the same folder of commands or queries.

Example for Validator in process Creates Person

public class CreatePersonCommandValidator : AbstractValidator<CreatePersonCommand>
{
    public CreatePersonCommandValidator()
    {
        RuleFor(x => x.Person.Name)
            .NotEmpty().WithMessage("The name is required.")
            .MaximumLength(45).WithMessage("The name cannot exceed 45 characters.");
        RuleFor(x => x.Person.LastName)
            .NotEmpty().WithMessage("The last name is required.")
            .MaximumLength(45).WithMessage("The last name cannot exceed 45 characters.");
        RuleFor(x => x.Person.Email)
            .NotEmpty().WithMessage("The email is required.")
            .EmailAddress().WithMessage("Invalid email format.")
            .MaximumLength(45).WithMessage("The email cannot exceed 45 characters.");
        RuleFor(x => x.Person.BirthDate)
            .NotEmpty().WithMessage("The birth date is required.")
            .LessThan(DateTime.Now).WithMessage("Birth date must be in the past.");
    }
}

Example for Result.

Result

SECURITY

For the security of the application, we will use two JWT tools and our password generator, with passwordhasher, we mix the user password to create maximum security encryption and store it in the database, On the other hand, services after the user is correctly identified, this service provides them with a token. With this token, the user can authenticate herself across different system endpoints. Together, these two systems ensure that only the right people can access and use the system securely.

Security

Program. cs

In ASP.NET Core applications, the app's startup code is located in the Program.cs file. This is where all the necessary services for the application are configured, including database connections, middleware, security, and other essential components. In our program, we will set up the following configuration

  • Database Configuration: A connection to a MySQL database is established using Pomelo lib.
  • Dependency Injection: The different repositories are registered, it is important to indicate that the IPasswordHandler is also registered.
  • MediatR Logging: MediatR is used to handle commands and queries.
  • FluentValidation: A validation behavior is added to the MediatR pipeline to automatically verify data before executing each command or query.
  • Swagger: Swagger is configured at a basic level to have documentation of the APIs that allow us to test easily.
  • JWT: JWT authentication parameters are configured.
  • AutoMapper: to facilitate conversions between models and DTOs in the application.
// Import necessary namespaces for the application
using FluentValidation;
using Serilog;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
// ... (other usings)

// Create the application builder instance
var builder = WebApplication.CreateBuilder(args);

// Configure Database Context
// Add MySQL database context with specific version configuration
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseMySql(
        builder.Configuration.GetConnectionString("DefaultConnection"),
        new MySqlServerVersion(new Version(9, 0, 1))
    )
);

// Register Dependencies
// Configure Dependency Injection for repositories and services
// Base repository pattern implementation
builder.Services.AddScoped(typeof(IAsyncRepository<>), typeof(RepositoryBase<>));
// Specific repositories
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IPersonRepository, PersonRepository>();
// Services
builder.Services.AddScoped<IPasswordHasher, PasswordHasher>();

// Configure Logging
// Set up Serilog for application-wide logging with console output
builder.Host.UseSerilog((context, config) =>
{
    config.WriteTo.Console();
});

// Configure MediatR for CQRS pattern
// Register command and query handlers from different assemblies
builder.Services.AddMediatR(options =>
{
    options.RegisterServicesFromAssembly(typeof(CreateUserCommandHandler).Assembly);
    options.RegisterServicesFromAssembly(typeof(CreatePersonCommandHandler).Assembly);
    options.RegisterServicesFromAssembly(typeof(GetAllPersonQueryHandler).Assembly);
    options.RegisterServicesFromAssembly(typeof(GetListPersonFilteredQueryHandler).Assembly);
});

// Configure Validation
// Register validators from the current assembly
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
// Register validators from the CreatePersonCommandValidator assembly
builder.Services.AddValidatorsFromAssembly(typeof(CreatePersonCommandValidator).Assembly);
// Add validation pipeline behavior
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

// Configure Swagger Documentation
// Set up Swagger with JWT authentication support
builder.Services.AddSwaggerGen(options =>
{
    // Basic Swagger information
    options.SwaggerDoc("v1", new OpenApiInfo
    {
        Version = "v1",
        Title = "CQRS-example API",
        Description = "API CQRS using MediatR",
    });

    // Configure JWT authentication in Swagger
    options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        In = ParameterLocation.Header,
        Description = "Please insert JWT with Bearer into field",
        Name = "Authorization",
        Type = SecuritySchemeType.ApiKey
    });

    // Add security requirement for JWT
    options.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        // ... (security requirement configuration)
    });
});

// Configure Controllers and AutoMapper
builder.Services.AddControllers();
builder.Services.AddAutoMapper(typeof(MappingProfile));

// Configure JWT Authentication
// Set up JWT Bearer authentication with custom parameters
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = builder.Configuration["Jwt:Issuer"],
        ValidAudience = builder.Configuration["Jwt:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]))
    };
});

// Register JWT service
builder.Services.AddSingleton<JwtService>();

// Build the application
var app = builder.Build();

// Configure the HTTP request pipeline
if (app.Environment.IsDevelopment())
{
    // Enable Swagger UI in development environment
    app.UseSwagger();
    app.UseSwaggerUI(options =>
    {
        options.SwaggerEndpoint("/swagger/v1/swagger.json", "CQRS API v1");
        options.RoutePrefix = string.Empty;
    });
}

// Configure middleware pipeline
app.UseAuthentication();    // Enable authentication
app.UseAuthorization();     // Enable authorization
app.MapControllers();       // Map controller endpoints
app.UseErrorHandlingMiddleware();  // Add custom error handling

// Start the application
app.Run();

As an additional item, I leave some examples of how to use the generic Repository for simple and complex queries.

BASICS QUERIES

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public DateTime CreatedDate { get; set; }
}

Example 1. Filter products with a price greater than a specific value.

var expensiveProducts = await _repository.GetAsync(
    p => p.Price > 100
);

Example 2. Filter products with a specific name.

var namedProducts = await _repository.GetAsync(
    p => p.Name == "Laptop"
);

Example 3. Filter products with stock greater than 50.

var inStockProducts = await _repository.GetAsync(
    p => p.Stock > 50
);

Example 4. Filter products created in the last month.

var recentProducts = await _repository.GetAsync(
    p => p.CreatedDate >= DateTime.Now.AddMonths(-1)
);

Example 5. Filter products whose name contains a specific word.

var filteredProducts = await _repository.GetAsync(
    p => p.Name.Contains("Pro")
);

Example 6. Filter products whose price is within a specific range.

var midRangeProducts = await _repository.GetAsync(
    p => p.Price >= 50 && p.Price <= 150
);

Example 7. Filter products by multiple conditions (for example, a specific name and price that is less than a value).

var specificProducts = await _repository.GetAsync(
    p => p.Name == "Phone" && p.Price < 200
);

Example 8. Filter products with stock between 10 and 100 units.

var rangeStockProducts = await _repository.GetAsync(
    p => p.Stock >= 10 && p.Stock <= 100
);

WITH ORDER BY PARAMETER

Example 1. Sort products by name alphabetically.

var productsOrderedByName = await _repository.GetAsync(
    orderBy: q => q.OrderBy(p => p.Name)
);

Example 2. Sort products by price from highest to lowest.

var productsOrderedByPriceDesc = await _repository.GetAsync(
    orderBy: q => q.OrderByDescending(p => p.Price)
);

Example 3. Sort products by creation date and then by price.

var productsOrderedByDateAndPrice = await _repository.GetAsync(
    orderBy: q => q
        .OrderBy(p => p.CreatedDate)
        .ThenBy(p => p.Price)
);

Example 8. Filter products with stock between 10 and 100 units.

var rangeStockProducts = await _repository.GetAsync(
    p => p.Stock >= 10 && p.Stock <= 100
);

INCLUDE STRING: INCLUDE RELATIONSHIPS BETWEEN ENTITIES

The includeString parameter is used to include related entities in the query, that is, to load data from related entities (known as "eager loading"). This is useful when you need to access related data in the same query to avoid multiple trips to the database.

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public DateTime CreatedDate { get; set; }
    public int CategoryId { get; set; }
    public Category Category { get; set; }
}
public class Category
{
    public int Id { get; set; }
    public string Name { get; set; }
}

Example 1. Include the Category entity in the query.

var productsWithCategory = await _repository.GetAsync(
    includeString: "Category"
);

Example 2. Include a nested relationship (for example, if Category had a relationship with another entity).

var productsWithCategoryAndDetails = await _repository.GetAsync(
    includeString: "Category.Details"
);

Example 3. Bring the product list (id, name, price) of their respective categories (category. id).

var productsWithCategory = await _repository.GetAsync(
    includeString: "Category"
).Select(p => new 
{
    ProductId = p.Id,
    ProductName = p.Name,
    ProductPrice = p.Price,
    CategoryId = p.Category.Id
}).ToListAsync();

MORE COMPLETE METHODS INCLUDE

Example 1. Include the Category entity.

var productsWithCategory = await _repository.GetAsync(
    includes: new List<Expression<Func<Product, object>>>
    {
        p => p.Category
    }
);

Example 2. Include multiple related entities.

If the Product has another related entity, for example, a Supplier, you can include both relationships.

var productsWithCategoryAndSupplier = await _repository.GetAsync(
    includes: new List<Expression<Func<Product, object>>>
    {
        p => p.Category,
        p => p.Supplier
    }
);

Example 3. Using orderBy and predicate together with includes.

var filteredAndOrderedProducts = await _repository.GetAsync(
    predicate: p => p.Price > 100,
    orderBy: q => q.OrderBy(p => p.Name),
    includes: new List<Expression<Func<Product, object>>> 
    { 
        p => p.Category 
    }
);

Example 4. showing the id, name, and price from the product table and the name from the Category table.

var filteredAndOrderedProducts = await _repository.GetAsync(
    predicate: p => p.Price > 100,
    orderBy: q => q.OrderBy(p => p.Name),
    includes: new List<Expression<Func<Product, object>>> { p => p.Category }
)
.Select(p => new
{
    ProductId = p.Id,
    ProductName = p.Name,
    ProductPrice = p.Price,
    CategoryName = p.Category.Name
})
.ToListAsync();

USING PAGINATION

int pageNumber = 1;
int pageSize = 10;

var paginatedProducts = await _repository.GetAsync(
    predicate: p => p.Price > 100,
    orderBy: q => q.OrderBy(p => p.Name),
    includes: new List<Expression<Func<Product, object>>> { p => p.Category },
    skip: (pageNumber - 1) * pageSize,
    take: pageSize
).Select(p => new 
{
    ProductId = p.Id,
    ProductName = p.Name,
    ProductPrice = p.Price,
    CategoryName = p.Category.Name
}).ToListAsync();

example

var query = from en in context.Entities
            join ti in context.TableIndices
            on new { DocumentTypeId = en.DocumentTypeId.ToString(), Module = "Entity", Field = "DocumentType" }
            equals new { DocumentTypeId = ti.StringValue, ti.Module, ti.Field }
            into joinedData
            from you in joinedData.DefaultIfEmpty()
            select new
            {
                en.Id,
                en.SocialReason,
                en.DocumentTypeId,
                Description = you.Description,
                en.DocumentNumber
            };
var result = query.ToList();


Similar Articles