¿Patrón repositorio en CDS? ¿por qué no?
Estamos acostumbrados a ver cientos de patrones, cada cual más sofisticado, en nuestro día a día cuando tenemos que diseñar alguna arquitectura. Algunos están sobredimensionados para lo que realmente necesitamos, o incluso resultan innecesarios. Hoy en día vemos patrones que buscan la optimización en cuanto a velocidad, pero luego se ven lastrados por la escritura de logs en SQL (incluso en Azure SQL como servicio serverless). También encontramos implementaciones de CQRS cuando solo necesitamos realizar un par de operaciones.
Y eso sin entrar en servicios sobre Kubernetes, cuando en realidad con un monolito somos perfectamente eficientes. Pero claro, esto último daría para muchos debates… que no descartamos tener en algún directo.

Pero a lo que nos trae esta entrada, el Patrón Repositorio, seguramente muchos estaremos acostumbrados a utilizarlo y tener nuestro «ORM» (Entity Framework) conectado a nuestra Azure SQL (o la bd que decidamos), no entraremos en el uso extendido del mismo, si en cuanto a la buenas practicas, pero la pregunta llegados a este punto es:
¿Necesitamos Patron Repositorio para acceder a Dataverse?
Claramente, en la mayoría de los casos, la respuesta sería NO cuando se plantea si el CDS de Dataverse (Common Data Service) debería utilizarse como un modelo cerrado, donde su interfaz se nutra exclusivamente de datos provenientes de sus distintas fuentes oficiales (Dynamics 365, Customer Insights, Services… y un largo etcétera).
Sin embargo, la realidad muchas veces va más allá de esta visión idealizada. ¿Y por qué lo digo? Pues porque en numerosos escenarios nos encontramos con la necesidad de que nuestros clientes migren desde un CRM semiobsoleto hacia una solución contenida dentro de Dataverse (cualquiera de las anteriormente mencionadas).
.

Caso de uso
Vamos a suponer que tenemos un cliente que necesita, ya sea migrar sus servicios o incluso mantenerlos pero derivar la carga de trabajo en Dataverse,
¿Podriamos hacer esa migración utilizado las API de Dataverse expuestas y el protocolo OData?

Si, obviamente (como vemos en el ejemplo anterior), pero ¿es esto todo lo rápido seguro y sobre todo estaría preparado para equipos de desarrolladores no con la sufuciente madurez tecnica para pelearse?.
Si partimos que la curva de aprendizaje para utilizar Patrón Repositorio es mas sencilla, que aprenderse llamadas, metadadas, como hacer lookups, extends etc…. con el que nos deberiamos pelear atacando a la API, o por el contrario sin hacemos uso del cliente de .NET de Dataverse y hacemos peticiones planas, donde tenemos que saber que tipo de valores y metadas tiene los distintos campos para su upsert y obtención, ¿Cuanto tiempo pasariamos revisando de que tipo y que posible valores en texto y como key tendrian los mismo? Ese tiempo seria demasiado alto.
Todo ese tiempo es asumible si queremos tener acceso a varias entidades y poco más, aun así en ese caso no recomendaría el uso de un patron como el repository, pero como habrán casos mas extensos y donde la estructura de datos tocara multibles tablas, y multiples procesos, ya sea insercion, actualizacion o borrado, vamos a ver como lo deberiamos plantear.

Metadatas y herramientas
Es nuestra columna vertebral sin esto no podriamos hacer nada, estaríamos totalmente a ciegas, pero gracias a herramientas contenidas en XrmToolBox, y haciendo uso de una cadena válida de conexión a nuestro dataverse, podemos ver en cada entidad cuales son sus metadatos.

Es decir podemos ver desde su nombre, sus tipos, sus enumerados, realmente toda la informacion que necesitamos, para esto tenemos que instalarnos dentro de XrmToolBox: Latebound Constants

Esta herramienta es capaz de generarnos los tipos necesarios para que luego con herramientas de mapeo en este caso AutoMapper podemos realizar operaciones con esos registros y tablas.
Para obtener los metadatos pulsaremos en la parte superior en Generate Constants y nos descargara la clase lista para usar.
En el ejemplo que compartiremos vamos a suponer que queremos hacer un Upsert sobre varias entidades, dos genericas (customer, contact) y tres custom (Country, Currency, LegalEntity) estas últimas las usaremos en modo master, que veremos mas adelante.
¿Como realizar peticiones?
Ves todo muy bonito pero llegado a este caso necesitamos saber que haremos con esos metadatos extraidos y como hacer que el patrón funcione.
flowchart LR User["User"] --> Request["Request"] --> Service["Service"] --> Handle["Handle"] --> Repository["Repository"] --> Dto["Dto"] --> Response["Response"] Response --> User
Vamos a tener varios endpoints de entrada de peticiones sobre Azure Functions, en este caso tendremos un serverless con lo que nos garantizamos que el gasto en Azure será mínimo en el momento que no se es importando ningún tipo de dato, debemos tener en cuenta de esta manera que los tiempo de respuesta de parado son mas altos la primera vez, aunque insignificantes en un proceso tan largo.
Alta demanda y orden
El proceso que estamos presentando tambien dispondrá de un Azure Service Bus,
¿Para que plantemos este servicio?

Lo plantemos por varios motivos, queremos que cuando se produzca un petición de integración, tener resiliencia y politica de reintentos de la que ya se dispone nativamente en las colas, ademas de poder ordenar los datos de entrada, es decir el primer envío entrará antes que el siguiente.
Tened en cuenta que algunos sitios tambien se recomemienda el uso de Azure Queue Storage pero estas anque son mas baratas no son ordenadas.
En el ejemplo que veremos y que compartiremos tenemos una Azure funtion con:
- Endpoint de entrada tipo Service Bus
- Endpoint de entrada tipo HttpTrigger
La entrada de function del tipo Service Bus escuchando los topics y la subscription a la espera de mensajes, pero para hacerlo de manera mas sencilla tambien hemos expuesto un metodo como HttpTrigger donde hacer peticiones de ejemplo (upsert a un Cutomer).
Lets play
Que hará nuestro código una vez se ejecute, intentara atraves del program de nuestra function mediante Automapper pues mapear todas las entidades posibles, tened en cuenta que para definir un mapeo válido es necesario heredar de Profile
public class ExampleFunctionProfile : Profile
{
public ExampleFunctionProfile()
{
CreateMap<UpsertCustomerModel, UpsertCustomerRequest>()
.ForMember(dest => dest.IdErp, opt => opt.MapFrom(src =>
src.CustomerId));
}
}
En este caso los profiles los hemos dividido entre request y entidades masters, por supuesto le añadimos soporte para hacer peticiones httpClient, podriamos incluso optimizarlo para tener htttpClient factotry pero no es necesario (Lo apuntamos como mejora) y añadimos todas nuestras dependencias:
- CDS: Dataverse
- Repositorios (Patrón Repositorio)
- Servicios (Patrón Repositorio)
- Helpers
- SQL (Azure SQL)
- Service Bus
var host = new HostBuilder()
.ConfigureServices((hostContext, services) =>
{
services.AddAutoMapper(AppDomain.CurrentDomain.GetAssemblies());
services.AddAutoMapper(typeof(ExampleFunctionProfile).Assembly);
services.AddAutoMapper(typeof(MastersProfiles).Assembly);
services.AddOptions();
services.AddHttpClient();
services.AddLogging(configure => configure.AddConsole((c) =>
{
c.IncludeScopes = true;
c.LogToStandardErrorThreshold = LogLevel.Trace;
}))
.AddCDSDataProviderDependencies()
.AddRepositoriesDependencies()
.AddServicesDependencies()
.AddHelpersDependencies()
.AddSqlDependencies()
.AddPresentersInputsDependencies()
.AddSingleton<CommonFunctions>()
.AddAzureClients(builder =>
{
builder.AddServiceBusClient(hostContext.Configuration.GetValue<string>("ServiceBusConnection"))
.WithName("sbDefault")
.ConfigureOptions(options =>
{
/*options.RetryOptions.Delay = TimeSpan.FromMilliseconds(50);
options.RetryOptions.MaxDelay = TimeSpan.FromSeconds(5);*/
options.RetryOptions.MaxRetries = 20;
});
builder.AddServiceBusAdministrationClient(hostContext.Configuration.GetValue<string>("ServiceBusConnection"))
.WithName("sbAdminDefault");
});
services.AddSingleton<IRetryManager, RetryManager>();
services.AddDbContext<SqlDbDemoContext>(options => options.UseSqlServer(hostContext.Configuration.GetValue<string>("SQLConnectionString")));
})
.ConfigureFunctionsWebApplication(workerApplication =>
{
workerApplication.UseMiddleware<ExceptionLoggingMiddleware>();
workerApplication.UseNewtonsoftJson();
})
.ConfigureOpenApi()
.Build();
host.Run();
Añadimos politica de reintentos y el contexto para nuestra conexion de SQL
¿Lo probamos?
Si ejecutamos nuestro código con nuestras cadenas de conexion correcta, recordad que es necesario montar cadena de conexion a:
- SQL (SqlConnectionString)
- Dataverse
- Azure Service Bus
Nuesta cadena de conexion a SQL, la tenemos implementada para realizar un log en Azure SQL (podríamos abrir otro melón, en cuento a porque no usar directemente Application Insight), te leo en comentarios.
La conexión en Dataverse se recomienda usar un applicacion de Entra conectada a Dynamics con permisos de aplicación y system administrator, si en este caso hay dudas, lo vemos. Y por último configuramos un Azure Service Bus con un topic y una subscription, esperando por interaciones via cola de mensajes.
En nuestro ejemplo vamos a realizar un upsert de un customer, ejecutamos nuestra aplicacion (Azure Function local) y nos debe quedar algo como esto.

Una vez conocemos el puerto y la url, hacemos uso de Postman para realizar la petición

Una vez realizamos la petición, comprobamos antes que el usuario existe o los datos son correctos..
[OpenApiIgnore]
[OpenApiOperation(operationId: "UpserCustomer", tags: new[] { "customer" }, Summary = "Customer", Description = "Upsert customer.", Visibility = OpenApiVisibilityType.Important)]
[OpenApiRequestBody(contentType: "application/json", bodyType: typeof(UpsertCustomerModel), Description = "UpsertCustomer model", Required = true)]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.OK, contentType: "application/json", bodyType: typeof(ResponseContent), Summary = "The response", Description = "200. Sucessfull")]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.BadRequest, contentType: "application/json", bodyType: typeof(ResponseContent), Summary = "The response", Description = "400. Bad Request")]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.Conflict, contentType: "application/json", bodyType: typeof(ResponseContent), Summary = "The response", Description = "409. Conflict")]
[OpenApiResponseWithBody(statusCode: HttpStatusCode.InternalServerError, contentType: "application/json", bodyType: typeof(ResponseContent), Summary = "The response", Description = "500. Internal Server Error")]
[Function("UpsertCustomer")]
public async Task<HttpResponseData> Run([HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req, FunctionContext context)
{
var upsertCustomerValidatable = await req.GetJsonBody<UpsertCustomerModel, UpsertCustomerValidator>();
var success = await UpsertCustomerInternal(context, upsertCustomerValidatable);
var response = await _helperFunc.GetResponse(context, _presenter.ContentResult, true);
if (!success)
await _retryManager.SendMessage(upsertCustomerValidatable.RequestBody, "Upsertcustomer", DateTime.UtcNow, ScheduledMode.Lineal);
return response;
}
Lo primero que validaremos y si el modelo es correcto mediante las reglas de validacion de AutoMapper.
public class UpsertCustomerValidator : AbstractValidator<UpsertCustomerModel>
{
public UpsertCustomerValidator()
{
//CustomerId
RuleFor(x => x.CustomerId).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "CustomerId"));
RuleFor(x => x.CustomerId).MaximumLength(100).WithMessage(string.Format(GetMessageKey(MessagesConstants.MAXIMUN_STRING_LENGTH), "CustomerId", "100"));
//LegalEntity
RuleFor(x => x.LegalEntityId).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "LegalEntity"));
//FiscalId
RuleFor(x => x.FiscalId).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "FiscalId"));
RuleFor(x => x.FiscalId).MaximumLength(100).WithMessage(string.Format(GetMessageKey(MessagesConstants.MAXIMUN_STRING_LENGTH), "FiscalId", "100"));
//Name
RuleFor(x => x.Name).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "Name"));
RuleFor(x => x.Name).MaximumLength(160).WithMessage(string.Format(GetMessageKey(MessagesConstants.MAXIMUN_STRING_LENGTH), "Name", "160"));
//Address
RuleFor(x => x.Address).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "Address"));
RuleFor(x => x.Address).MaximumLength(250).WithMessage(string.Format(GetMessageKey(MessagesConstants.MAXIMUN_STRING_LENGTH), "Address", "250"));
//City
RuleFor(x => x.City).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "City"));
RuleFor(x => x.City).MaximumLength(80).WithMessage(string.Format(GetMessageKey(MessagesConstants.MAXIMUN_STRING_LENGTH), "City", "80"));
//State or Province
RuleFor(x => x.State_Province).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "State_Province"));
RuleFor(x => x.State_Province).MaximumLength(50).WithMessage(string.Format(GetMessageKey(MessagesConstants.MAXIMUN_STRING_LENGTH), "State_Province", "50"));
//Country
RuleFor(x => x.Country).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "Country"));
RuleFor(x => x.Country).MaximumLength(50).WithMessage(string.Format(GetMessageKey(MessagesConstants.MAXIMUN_STRING_LENGTH), "Country", "50"));
//Código Postal
RuleFor(x => x.PostalCode).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "PostalCode"));
RuleFor(x => x.PostalCode).MaximumLength(20).WithMessage(string.Format(GetMessageKey(MessagesConstants.MAXIMUN_STRING_LENGTH), "PostalCode", "20"));
//Integration Time Stamp
RuleFor(x => x.IntegrationTimeStamp).NotEmpty().WithMessage(string.Format(GetMessageKey(MessagesConstants.FIELD_MANDATORY), "IntegrationTimeStamp"));
//Customer Type Code
RuleFor(x => x.RelationshipType).Must(RelationshipType => RelationshipType.Equals((int)RelationshipType_OptionSet.Competitor) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Consultant) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Customer) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Influencer) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Investor) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Partner) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Press) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Prospect) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Reseller) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Supplier) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Vendor) ||
RelationshipType.Equals((int)RelationshipType_OptionSet.Other) ||
RelationshipType.Equals(null)
).WithMessage(string.Format(GetMessageKey(MessagesConstants.INCORRECT_VALUES), "RelationshipType"));
}
}
Y, en caso de poder validarlo, nos permitiría continuar, dejando el código preparado para que, si falta algún campo o alguno no es correcto, sea transparente para el usuario y el error que devuelva la petición sea lo más entendible posible. Simulamos que falta el campo Name.
.
{
"Success": false,
"Message": "BAD REQUEST. Fail Validation 3: Errores detectados: Field: Name is mandatory",
"Errors": [
{
"Code": 3,
"Description": "Errores detectados: Field: Name is mandatory"
}
]
}
En el caso de que todo sea correcto, realizamos el mapeo, y entramos 100% en modo Patrón Repositorio, es decir, llamamos al servicio que a su vez llamará al repositorio.
private async Task<bool> UpsertCustomerInternal(FunctionContext context, ValidatableRequest<UpsertCustomerModel> upsertCustomerValidatable)
{
if (upsertCustomerValidatable.IsValid)
{
var dtoUpsertCustomerRequest = _mapper.Map<UpsertCustomerRequest>(upsertCustomerValidatable.Value);
//dtoUpsertCustomerRequest.IntegrationTimeStamp = originalTimeStamp;
return await _customerService.Handle(dtoUpsertCustomerRequest, _presenter);
}
else
{
throw new BadRequestException(upsertCustomerValidatable.Errors.Select(p => p.ErrorMessage).ToList());
}
}
En nuestro caso llamamos a un método Handle como sobreescritura para poder utilizar de manera generica las peticiones, como veréis en el código compartido. Este handle realiza la operación:
public async Task<bool> Handle(UpsertCustomerRequest message, IPresenter<UpsertCustomerResponse> presenter)
{
var currentCustomer = await _customerRepository.GetCustomerByErpId(message.IdErp);
LegalEntity? legalEntity = null;
if (!string.IsNullOrEmpty(message.LegalEntityId))
legalEntity = await _cache.GetLegalEntityByCode(message.LegalEntityId);
if (currentCustomer != null)
{
if (!currentCustomer.CanIntegrate(message.IntegrationTimeStamp))
{
var responseIgnoreMessage = new UpsertCustomerResponse(OperationType.Update, currentCustomer.CrmId.Value, true);
await presenter.Handle(responseIgnoreMessage);
return true;
}
var mapCustomer = _mapper.Map<Customer>(message);
mapCustomer.LegalEntity = legalEntity == null ? null : legalEntity.CrmId;
currentCustomer.Merge(mapCustomer);
await _customerRepository.SaveAsync();
var response = new UpsertCustomerResponse(OperationType.Update, currentCustomer.CrmId.Value, true);
await presenter.Handle(response);
return true;
}
else
{
var mapCustomer = _mapper.Map<Customer>(message);
mapCustomer.LegalEntity = legalEntity == null ? null : legalEntity.CrmId;
_customerRepository.Create(mapCustomer);
await _customerRepository.SaveAsync();
var response = new UpsertCustomerResponse(OperationType.Create, mapCustomer.CrmId.Value, true);
await presenter.Handle(response);
return true;
}
}
En este caso obtenemos el registro del Customer por el valor de Id.
Entra uno de los conceptos de mejora aplicable a los master, y es que en este caso los «Legal Entities» tal y como masters que son si ya existen en Cache no se hace la petición y en caso contrario se utilizan, teniendo en cuenta que ganamos en agilidad al hacer menos peticiones sobre una entidad que no deberia cambaiar con frecuencia.
Queda bajo nuestro tejado tener esto en cuenta para posibles periodos de vigencias de dicho caché. Tenemos ademas otro verificador en este caso de fecha para evitar hacer updates sobre entidades mas actualizadas, usando el IntegrationTimeStamp y por último igual que tenemos un modelo de entrada mapeado, necesitamos tener un modelo de salida mapeado, fijaros que los nombres de las entidades no coinciden con los de las propiedades solo son similares, la magia esta en el Latebound y en su mapedo de Metadatas, pongo un ejemplo corto de los metadatas de Customer.

Es decir nosotros en nuestro código de .NET únicamente nos preocupamos del nombre de la entidad por ejemplo Address, el valor real de Dataverse, en este caso al ser una entidad custom es jfs_address pero ahí esta la magia, en tener los metadatos listo y solo ocuparnos de mover nuetro patron para realizar los CRUDS necesarios.
Con todo esto tenemos nuestro Patrón Repositorio funcionando en Dataverse, con Resilencia y Retry (politicas de reintentos) en Service Bus, con Azure Functions en modo Serverless (si fuera necesario) y con capa de Caché para entidades con poco margen de actualizacion como son los master.
GRACIAS
Deja una respuesta
Lo siento, debes estar conectado para publicar un comentario.