Saltar a contenido

Andreani.ARQ.CQRS

Overview

Se desarrolló la librería Andreani.ARQ.CQRS para implementar EntityFramework y Dapper. Su objetivo es abstraer las configuraciones a lxs desarrolladorxs y proveer interfaces comunes para facilitar la adopción y uso de estas tecnologías.

Instalación

  1. Instalar en el proyecto Application la dependencia con Core.Relational:
    dotnet add package Andreani.Arq.Core.Relational
    
  2. Instalar en el proyecto Infrastructure la dependencia con CQRS:
    dotnet add package Andreani.Arq.Cqrs
    

Configuración

Para configurar, primero instala como paquete nuget la librería Andreani.ARQ.CQRS.

En appsettings, añade una sección para la configuración de CQRS:

DataAccessRegistry:
    ReadOnlyConnection: "Data Source=DBFileName.db"
    TransactionalConnection: "Data Source=DBFileName.db"
    ProviderName: Microsoft.Data.Sqlite
{
  "DataAccessRegistry":{
      "ReadOnlyConnection": "Data Source=DBFileName.db",
      "TransactionalConnection": "Data Source=DBFileName.db",
      "ProviderName": "Microsoft.Data.Sqlite",
  }
}
Nombre Descripción Requerido Default
Name Nombre de la conexión de base de datos No default
ReadOnlyConnection Connection string para consultas
TransactionalConnection Connection string para operaciones de escritura
ProviderName Proveedor de base de datos
Version Versión (solo para MySQL) No null
AutoMigrate Ejecución de migración automática No false

CQRS permite usar dos bases de datos: una para transacciones y otra para consultas. Puedes elegir usar la misma base de datos o dos diferentes.

Agrega la siguiente línea en el bootstrap de la capa de Infrastructure para incluir la configuración:

DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
    //...
    services.AddCQRS(configuration)
        .Configure<ApplicationDbContext>();
    //...
}
Breacking Change: Uso de ITransactionalRepository y IReadOnlyQuery

Las interfaces IReadOnlyQuery y ITransactionalRepository ya no están disponibles para uso directo. Crea una interfaz propia que extienda de estas y genera el objeto concreto para cada una.

Providers

Se soportan las siguientes bases de datos y sus respectivos ProviderName:

Base de Datos ProviderName
Sql Server System.Data.SqlClient
Oracle Oracle.ManagedDataAccess.Client
MySql MySql.Data.MySqlClient
Sqlite Microsoft.Data.Sqlite
PostgreSql Npgsql
MySql

MySql: desde Net 6, agrega el parámetro Version en la configuración.

DataAccessRegistry:
    ReadOnlyConnection: "Data Source=..."
    TransactionalConnection: "Data Source=..."
    ProviderName: MySql.Data.MySqlClient
    Version: 5.5.X

Healthcheck

La librería incluye healthcheck, que se complementa con Andreani.ARQ.WebHost. Accede a los healthchecks mediante http://*:*/health o http://*:*/healthcheck.

Interfaces Disponibles

La librería CQRS implementa dos interfaces con funcionalidades comunes para interactuar con la base de datos.

ITransactionalRepository

Implementa Ef core y ofrece operaciones de escritura.

public interface ITransactionalRepository
{
    // Métodos de la interfaz...
}

Esta interfaz permite persistir cualquier entidad mapeada en el contexto.

IReadOnlyQuery

Implementa Dapper para consultas de lectura, con métodos comunes para consultas simples y generales.

public interface IReadOnlyQuery
{
    // Métodos de la interfaz...
}

Recomendamos crear objetos Query propios para consultas complejas y no abusar de los métodos genéricéricos para múltiples consultas. Utiliza ExecuteQueryAsync o FirstOrDefaultQueryAsync, o crea tus propias consultas con Dapper.


Uso de Operaciones de Escritura

Crea una interfaz que extienda ITransactionalRepository.

interfaces
interface ICommandSqlServer : ITransactionalRepository { }

En Infrastructure/Repository, crea las implementaciones de la interfaz.

Command
public class CommandSqlServer : TransactionalRepository, ICommandSqlServer
{
    public CommandSqlServer([FromKeyedServices("default")] ITransactionalConfiguration config) : base(config)
    {

    }
}

KeyedServices

Incluye esta clase en la inyección de dependencias:

DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
    //...
    services.AddCQRS(configuration)
        .Configure<ApplicationDbContext>();

    services.AddScoped<ICommandSqlServer, CommandSqlServer>();
    //...
}
Ejemplo de uso
public class MyHandler
{
    private readonly ICommandSqlServer _repository;
    public MyHandler(ICommandSqlServer repository)
    {
        _repository = repository;
    }

    public async Task MyMethod(){
        var entity = new ObjectEntity
        {
            AnyValue = "ejemplo"
        };
        _repository.Insert(entity);
        await _repository.SaveChangeAsync();
    }
}

Herencia

Al heredar de TransactionalRepository, tu clase implementada contiene los métodos de ITransactionalRepository y puedes extender su funcionalidad agregando métodos a tu nueva interfaz y utilizando el Contexto.

Ejemplo de Extensión

ICommandSqlServer.cs
interface ICommandSqlServer : ITransactionalRepository 
{ 
    Task<List<Person>> AddMultiPerson(List<PersonDto> people);
}
CommandSqlServer.cs
public class CommandSqlServer : TransactionalRepository, ICommandSqlServer
{
    public CommandSqlServer([FromKeyedServices("default")] ITransactionalConfiguration config) : base(config)
    {

    }
    public async Task<List<Person>> AddMultiPerson(List<PersonDto> people)
    {
        List<Person> peopleEntity = people.Select(x => new Person()).ToList();
        context.AddRange(peopleEntity);
        await context.SaveChangesAsync();
        return peopleEntity;
    }
}

Uso de Operaciones de Lectura

Crea una interfaz que extienda IReadOnlyQuery.

IQuerySqlServer.cs
interface IQuerySqlServer : IReadOnlyQuery { }

En Infrastructure/Query, crea las implementaciones de la interfaz.

QuerySqlServer.cs
public class QuerySqlServer : ReadOnlyQuery, IQuerySqlServer
{
    public QuerySqlServer([FromKeyedServices("default")] IReadOnlyQueryConfiguration config) : base(config)
    {
    }
}

KeyedServices

Incluye esta clase en la inyección de dependencias:

DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
    //...
    services.AddCQRS(configuration)
        .Configure<ApplicationDbContext>();

    services.AddScoped<IQuerySqlServer, QuerySqlServer>();
    //...
}

Ejemplo de uso

public class MyHandler
{
    private readonly IQuerySqlServer _query;
    public MyHandler(IQuerySqlServer query)
    {
        _query = query;
    }

    public async Task<List<Person>> Handle(ListPerson request, CancellationToken cancellationToken)
    {
        var result = await _query.GetAllAsync<Person>();
        return result;
    }
}

Herencia

Al heredar de ReadOnlyQuery, tu clase implementada contiene los métodos de IReadOnlyQuery y puedes extender su funcionalidad agregando métodos a tu nueva interfaz y utilizando el Contexto.

Ejemplo de creación de un método personalizado:

Declara el método en tu Interface:

public interface IQuerySqlServer : IReadOnlyQuery
{
    Task<List<Person>> GetAllByNameAsync(string name);
}

En la implementación, usa UseConnection para ejecutar tu consulta:

public class QuerySqlServer : ReadOnlyQuery, IQuerySqlServer
{
    private const string FINDBYNAME = "SELECT * FROM Person WHERE Name = @name";
    public QuerySqlServer([FromKeyedServices("default")] IReadOnlyQueryConfiguration config) : base(config)
    {
    }
    public async Task<List<Person>> GetAllByNameAsync(string name)
    {
        return await UseConnectionAsync(async x => await x.QueryAsync<Person>(FINDBYNAME, new { name }));
    }
}

UseConnection

El método UseConnection inyecta la conexión de base de datos en la consulta. Construye e inyecta la conexión siguiendo las buenas prácticas, permitiendo el uso completo de Dapper y sus funcionalidades.

Ejemplo de uso sincrónico:

var result = _query.UseConnection(connection => {
    var sql = "SELECT * FROM Board";
    var resultSet = connection.Query<Board>(sql);
    return resultSet;
});

Para consultas asíncronas, utiliza UseConnectionAsync:

var results = await _query.UseConnectionAsync(async connection => {
    var sql = "SELECT * FROM Board";
    var resultSet = await connection.QueryAsync<Board>(sql);
    return resultSet;
});

Recomendación: Uso de Constantes o Variables Estáticas para Query Strings

Para optimizar el rendimiento, es altamente recomendable almacenar las query strings en constantes o variables estáticas. Esta práctica reduce el tiempo de ejecución ya que evita la recreación repetitiva de la misma cadena de consulta. Al utilizar constantes o variables estáticas, se garantiza que la cadena de consulta se compile una sola vez y se reutilice en todas las instancias donde se necesite. Esto no solo mejora la eficiencia en términos de procesamiento y uso de memoria, sino que también contribuye a un código más limpio y mantenible, facilitando las actualizaciones y la gestión de las queries en un solo lugar.


Implementación Multi Base de Datos

CQRS permite tener múltiples data stores, con configuraciones y objetos independientes.

Configuración

Ejemplo con dos configuraciones, default para SqlServer y cache para SqLite:

appsettings.yml
DataAccessRegistry:
    Name: "default"
    ReadOnlyConnection: "Data Source={sqlserver}..."
    TransactionalConnection: "Data Source={sqlserver}..."
    ProviderName: System.Data.SqlClient
DataAccessRegistryCache:
    Name: "cache"
    ReadOnlyConnection: "Data Source=DBFileName.db"
    TransactionalConnection: "Data Source=DBFileName.db"
    ProviderName: Microsoft.Data.Sqlite

Agrega esto en el bootstrap de la capa de Infrastructure:

DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
    //...
    services.AddCQRS(configuration)
        .Configure<ApplicationDbContext>()
        .Configure<ApplicationCacheDbContext>("DataAccessRegistryCache");
    //...
}

Atención

Necesitarás un nuevo DbContext para la nueva conexión.

Genera las implementaciones para esta nueva conexión siguiendo la documentación en Uso de Operaciones de Escritura.

Ejemplo:

QuerySqLite.cs
public class QuerySqLite : ReadOnlyQuery, IQuerySqLite
{
    public QuerySqLite([FromKeyedServices("cache")] IReadOnlyQueryConfiguration config) : base(config)
    {
    }
}

FromKeyedServices

Usa el nombre dado en la configuración de la base de datos para que los recursos inyectados tengan la configuración adecuada.

Healthchecks

Get ~/healthchek
{
  "status": "Healthy",
  "totalDuration": "00:00:01.5170060",
  "entries": {
    "defaultTransactional": {
      "data": {

      },
      "duration": "00:00:01.5007649",
      "status": "Healthy",
      "tags": [
        "db"
      ]
    },
    "defaultReadOnly": {
      "data": {

      },
      "duration": "00:00:01.5007314",
      "status": "Healthy",
      "tags": [
        "db"
      ]
    },
    "cacheTransactional": {
      "data": {

      },
      "duration": "00:00:00.4042012",
      "status": "Healthy",
      "tags": [
        "db"
      ]
    },
    "cacheReadOnly": {
      "data": {

      },
      "duration": "00:00:00.4041246",
      "status": "Healthy",
      "tags": [
        "db"
      ]
    }
  }
}

Lectura con Ef Core

En caso de tener separado las base de lectura y escritura y sea necesario realizar consultas con Ef Core, puede configurar un contexto para las operaciones de solo lectura con la conexión a la base de solo lectura.

Configuración

DependencyInjection
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
    //...
    services.AddCQRS(configuration)
        .Configure<ApplicationDbContext, ApplicationReadOnlyDbContext>();
    //...
}

En este caso, necesitamos pasar como argumento el contexto que debera ser configurado como ReadOnly

FAQ

Problemas con los tiempos de vida(LifeTime)

Error con Query o Command:

Este error sucede cuando intentamos inyectar un servicio Scoped (IQuerySqlServer) en un servicio que está inyectado como Singleton (WorkerService).

Solución:

Para abordar este problema, contamos con dos soluciones posibles que dependen de cómo esté implementado nuestro servicio y de lo que necesitemos lograr:

  1. Si estamos utilizando únicamente el servicio IQuerySqlServer en toda la aplicación y no realiza consultas con el DbContext de EF Core, podemos ajustar el tiempo de vida (Lifetime) del servicio Cqrs mediante configuraciones. Esto se puede lograr de la siguiente manera:

  1. Si no cumplimos con las condiciones del caso 1, debemos inyectar el servicio (ya sea IQuerySqlServer o ICommandSqlServer) de manera similar a la solución proporcionada para el Mediator.

Warning

Es crucial que el DbContext esté configurado como Scoped en la configuración de Cqrs, ya que de lo contrario podría ocasionar problemas al intentar establecer múltiples conexiones a la base de datos.

Problema de consumo alto de memoria

Problema de consumo alto de memoria debido al contexto de EFCORE:

El problema se puede registrar en procesos que tienen un uso de Entity Framework Core, habitualmente aquellos sistemas que son consumidores o workers ya que la instancia del contexto de EF CORE se instancia como scoped lo que proboca para estos sistemas que actue como singleton.

Vemos sintomas relacionados con el consumo de memoria y la no liberación de esta en un periodo de tiempo lo que puede provovar reinicios de nuestros sistemas constantemente.

Solución

  • Actualización a la versión de Platform v8.5.0 o superior
  • Desactivar el trackeo de entidades del DbContext
infraestructure/persistence/ApplicationDbContext.cs
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options)
    {
        ChangeTracker.AutoDetectChangesEnabled = false;
    }
  • Validar que las querys que usamos con el contexto tengan el metodo AsNotTracking().
Querys
    public async Task<Users> GetUsers()
    {
        return context.Users
                        .AsNotTranking()
                        .Include<Orders>()
                        .ToListAsync();
    }

Tip

Platform al leer la configuración de AutoDetectChangesEnabled realiza la limpieza del Tracker. Puedes forzar la limipieza de la siguiente manera luego de un SaveChange:

    await context.SaveChangesAsync();
    context.ChangeTracker.Clear(); // Clear the change tracker