c# - mvc - onion architecture asp net core 2
Separando la capa de servicio de la capa de validaciĆ³n (1)
Actualmente tengo una capa de servicio basada en el artículo Validación con una capa de servicio del sitio ASP.NET.
Según this respuesta, este es un mal enfoque porque la lógica de servicio se mezcla con la lógica de validación que viola el principio de responsabilidad única.
Realmente me gusta la alternativa que se ofrece, pero durante la re-factorización de mi código me he encontrado con un problema que no puedo resolver.
Considere la siguiente interfaz de servicio:
interface IPurchaseOrderService
{
void CreatePurchaseOrder(string partNumber, string supplierName);
}
Con la siguiente implementación concreta basada en la respuesta enlazada:
public class PurchaseOrderService : IPurchaseOrderService
{
public void CreatePurchaseOrder(string partNumber, string supplierName)
{
var po = new PurchaseOrder
{
Part = PartsRepository.FirstOrDefault(p => p.Number == partNumber),
Supplier = SupplierRepository.FirstOrDefault(p => p.Name == supplierName),
// Other properties omitted for brevity...
};
validationProvider.Validate(po);
purchaseOrderRepository.Add(po);
unitOfWork.Savechanges();
}
}
El objeto PurchaseOrder
que se pasa al validador también requiere otras dos entidades, Part
y Supplier
(supongamos para este ejemplo que una orden de compra solo tiene una sola parte).
Los objetos de la Part
y del Supplier
podrían ser nulos si los detalles proporcionados por el usuario no corresponden a las entidades en la base de datos, lo que requeriría que el validador arrojara una excepción.
El problema que tengo es que, en esta etapa, el validador ha perdido la información contextual (el número de pieza y el nombre del proveedor), por lo que no puede informar un error exacto al usuario. El mejor error que puedo proporcionar es el siguiente: "Una orden de compra debe tener una parte asociada" que no tendría sentido para el usuario porque proporcionó un número de parte (simplemente no existe en la base de datos).
Usando la clase de servicio del artículo de ASP.NET, estoy haciendo algo como esto:
public void CreatePurchaseOrder(string partNumber, string supplierName)
{
var part = PartsRepository.FirstOrDefault(p => p.Number == partNumber);
if (part == null)
{
validationDictionary.AddError("",
string.Format("Part number {0} does not exist.", partNumber);
}
var supplier = SupplierRepository.FirstOrDefault(p => p.Name == supplierName);
if (supplier == null)
{
validationDictionary.AddError("",
string.Format("Supplier named {0} does not exist.", supplierName);
}
var po = new PurchaseOrder
{
Part = part,
Supplier = supplier,
};
purchaseOrderRepository.Add(po);
unitOfWork.Savechanges();
}
Esto me permite proporcionar información de validación mucho mejor al usuario, pero significa que la lógica de validación está contenida directamente en la clase de servicio, violando el principio de responsabilidad única (el código también se duplica entre las clases de servicio).
¿Hay alguna manera de obtener lo mejor de ambos mundos? ¿Puedo separar la capa de servicio de la capa de validación mientras proporciono el mismo nivel de información de error?
Respuesta corta:
Estás validando lo incorrecto.
Respuesta muy larga:
Está intentando validar una orden de PurchaseOrder
pero eso es un detalle de implementación. En su lugar, lo que debe validar es la operación en sí misma, en este caso los parámetros partNumber
y supplierName
.
Validar esos dos parámetros por sí mismos sería incómodo, pero esto se debe a su diseño: se está perdiendo una abstracción.
En pocas palabras, el problema está en su interfaz IPurchaseOrderService
. No debería tomar dos argumentos de cadena, sino un solo argumento (un objeto de parámetro ). Llamemos a este objeto de parámetro: CreatePurchaseOrder
. En ese caso la interfaz se vería así:
public class CreatePurchaseOrder
{
public string PartNumber;
public string SupplierName;
}
interface IPurchaseOrderService
{
void CreatePurchaseOrder(CreatePurchaseOrder command);
}
El objeto Parameter CreatePurchaseOrder
envuelve los argumentos originales. Este objeto de parámetro es un mensaje que describe la intención de la creación de una orden de compra. En otras palabras: es un comando .
Con este comando, puede crear una IValidator<CreatePurchaseOrder>
que puede realizar todas las validaciones adecuadas, incluida la verificación de la existencia del proveedor de piezas adecuado y la notificación de mensajes de error fáciles de usar.
Pero, ¿por qué es IPurchaseOrderService
responsable de la validación? La validación es una preocupación transversal y debe intentar evitar mezclarla con la lógica empresarial. En su lugar, podría definir un decorador para esto:
public class ValidationPurchaseOrderServiceDecorator : IPurchaseOrderService
{
private readonly IPurchaseOrderService decoratee;
private readonly IValidator<CreatePurchaseOrder> validator;
ValidationPurchaseOrderServiceDecorator(IPurchaseOrderService decoratee,
IValidator<CreatePurchaseOrder> validator)
{
this.decoratee = decoratee;
this.validator = validator;
}
public void CreatePurchaseOrder(CreatePurchaseOrder command)
{
this.validator.Validate(command);
this.decoratee.CreatePurchaseOrder(command);
}
}
De esta manera podemos agregar validación simplemente envolviendo un PurchaseOrderService
real:
var service =
new ValidationPurchaseOrderServiceDecorator(
new PurchaseOrderService(),
new CreatePurchaseOrderValidator());
Por supuesto, el problema con este enfoque es que sería realmente incómodo definir dicha clase de decorador para cada servicio en el sistema. Eso sería una grave violación del principio DRY.
Pero el problema es causado por una falla. La definición de una interfaz por servicio específico (como IPurchaseOrderService
) suele ser problemática. Desde que definimos el CreatePurchaseOrder
ya tenemos dicha definición. Ahora podemos definir una única abstracción para todas las operaciones comerciales en el sistema:
public interface ICommandHandler<TCommand>
{
void Handle(TCommand command);
}
Con esta abstracción ahora podemos refactorizar PurchaseOrderService
a lo siguiente:
public class CreatePurchaseOrderHandler : ICommandHandler<CreatePurchaseOrder>
{
public void Handle(CreatePurchaseOrder command)
{
var po = new PurchaseOrder
{
Part = ...,
Supplier = ...,
};
unitOfWork.Savechanges();
}
}
Con este diseño, ahora podemos definir un único decorador genérico para manejar las validaciones para cada operación comercial en el sistema:
public class ValidationCommandHandlerDecorator<T> : ICommandHandler<T>
{
private readonly ICommandHandler<T> decoratee;
private readonly IValidator<T> validator;
ValidationCommandHandlerDecorator(
ICommandHandler<T> decoratee, IValidator<T> validator)
{
this.decoratee = decoratee;
this.validator = validator;
}
void Handle(T command)
{
var errors = this.validator.Validate(command).ToArray();
if (errors.Any())
{
throw new ValidationException(errors);
}
this.decoratee.Handle(command);
}
}
Observe cómo este decorador es casi el mismo que el ValidationPurchaseOrderServiceDecorator
previamente definido, pero ahora como una clase genérica. Este decorador se puede envolver alrededor de nuestra nueva clase de servicio:
var service =
new ValidationCommandHandlerDecorator<PurchaseOrderCommand>(
new CreatePurchaseOrderHandler(),
new CreatePurchaseOrderValidator());
Pero como este decorador es genérico, podemos envolverlo alrededor de cada controlador de comando en nuestro sistema. ¡Guauu! ¿Cómo es eso de estar seco?
Este diseño también hace que sea muy fácil agregar preocupaciones transversales más adelante. Por ejemplo, su servicio actualmente parece ser responsable de llamar a SaveChanges
en la unidad de trabajo. Esto también puede considerarse una preocupación transversal y se puede extraer fácilmente a un decorador. De esta manera, sus clases de servicio se vuelven mucho más simples con menos código para probar.
El validador CreatePurchaseOrder
podría tener el siguiente aspecto:
public sealed class CreatePurchaseOrderValidator : IValidator<CreatePurchaseOrder>
{
private readonly IRepository<Part> partsRepository;
private readonly IRepository<Supplier> supplierRepository;
public CreatePurchaseOrderValidator(IRepository<Part> partsRepository,
IRepository<Supplier> supplierRepository)
{
this.partsRepository = partsRepository;
this.supplierRepository = supplierRepository;
}
protected override IEnumerable<ValidationResult> Validate(
CreatePurchaseOrder command)
{
var part = this.partsRepository.Get(p => p.Number == command.PartNumber);
if (part == null)
{
yield return new ValidationResult("Part Number",
$"Part number {partNumber} does not exist.");
}
var supplier = this.supplierRepository.Get(p => p.Name == command.SupplierName);
if (supplier == null)
{
yield return new ValidationResult("Supplier Name",
$"Supplier named {supplierName} does not exist.");
}
}
}
Y tu controlador de comando como este:
public class CreatePurchaseOrderHandler : ICommandHandler<CreatePurchaseOrder>
{
private readonly IUnitOfWork uow;
public CreatePurchaseOrderHandler(IUnitOfWork uow)
{
this.uow = uow;
}
public void Handle(CreatePurchaseOrder command)
{
var order = new PurchaseOrder
{
Part = this.uow.Parts.Get(p => p.Number == partNumber),
Supplier = this.uow.Suppliers.Get(p => p.Name == supplierName),
// Other properties omitted for brevity...
};
this.uow.PurchaseOrders.Add(order);
}
}
Tenga en cuenta que los mensajes de comando pasarán a formar parte de su dominio . Existe una asignación uno a uno entre casos de uso y comandos, y en lugar de validar entidades, esas entidades serán un detalle de implementación. Los comandos se convierten en el contrato y obtendrán la validación.
Tenga en cuenta que probablemente hará su vida mucho más fácil si sus comandos contienen tantas ID como sea posible. Por lo tanto, su sistema podría beneficiarse de la definición de un comando de la siguiente manera:
public class CreatePurchaseOrder
{
public int PartId;
public int SupplierId;
}
Cuando haga esto, no tendrá que verificar si existe una parte con el nombre dado. La capa de presentación (o un sistema externo) le pasó una identificación, por lo que ya no tiene que validar la existencia de esa parte. El controlador de comandos, por supuesto, debe fallar cuando no hay ninguna parte por ese ID, pero en ese caso hay un error de programación o un conflicto de concurrencia. En cualquier caso, no es necesario comunicar al cliente los errores de validación expresivos y fáciles de usar.
Sin embargo, esto mueve el problema de obtener las ID correctas a la capa de presentación. En la capa de presentación, el usuario tendrá que seleccionar una parte de una lista para que obtengamos el ID de esa parte. Pero aún así experimenté esto para hacer el sistema mucho más fácil y escalable.
También resuelve la mayoría de los problemas que se indican en la sección de comentarios del artículo al que se refiere, como:
- Dado que los comandos se pueden serializar fácilmente y el modelo de enlace, el problema con la serialización de entidades desaparece.
- Los atributos de DataAnnotation se pueden aplicar fácilmente a los comandos y esto permite la validación del lado del cliente (Javascript).
- Se puede aplicar un decorador a todos los controladores de comandos que envuelven la operación completa en una transacción de base de datos.
- Elimina la referencia circular entre el controlador y la capa de servicio (a través del ModelState del controlador), eliminando la necesidad de que el controlador genere una nueva clase de servicio.
Si desea obtener más información sobre este tipo de diseño, debe consultar este artículo .