Добрый день!
Существует бизнес-приложение (.NET), состоящее из нескольких слоев, например:
- Utils - Cross cutting concerns
- Domain - Entities, DomainServices
- Infrastructure - DB, External API, SmsService, и т.д.
- Application - Логика приложения, UseCases, ApplicationServices
- WebUI - Слой представления, ASP.NET Core MVC
В слоях Domain (DomainServices) и Application (UseCases и ApplicationServices) могу возникнуть исключения связанные либо с бизнес логикой, либо с логикой приложения.
Раньше такие исключения выглядели примерно так (например, в UseCases):
public class CreateOrderCommand : IRequest
{
...
public PaymentType PaymentType { get; set; }
public string CountryCode { get; set; }
...
}
internal class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
private readonly IDbContext _dbContext;
public CreateOrderCommandHandler(IDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<int> Handle(CreateOrderCommand command, CancellationToken cancellationToken)
{
...
if (command.PaymentType == PaymentType.PayPal && command.CountryCode == "RU")
{
throw new AppException(Messages.InvalidPaymentType);
}
...
}
}
Где
public class AppException : Exception
{
public class AppException(string message) : base(message)
{}
}
А
Messages.InvalidPaymentType это локализованная строка из файла ресурсов (т.е. значение зависит от
CultureInfo.CurrentCulture установленным в момент HttpRequest на WebUI...).
Преимущества такого подхода заключаются в том, что на слое WebUI достаточно написать
HandleErrorAttribute, который будет содержать примерно такие строчки:
public class HandleErrorAttribute : TypeFilterAttribute
{
....
public void OnException(ExceptionContext context)
{
if (context.ExceptionHandled)
return;
...
if (context.HttpContext.Request.IsAjaxRequest())
HandleAjaxRequestException(context);
else
HandleRequestException(context);
context.ExceptionHandled = true;
}
private void HandleAjaxRequestException(ExceptionContext context)
{
string message = Messages.Error500;
if (context.Exception is AppException)
{
message = context.Exception.Message; // Используем текст исключения заданный на Domain/Application слоях
}
context.Result = new JsonResult(new { Ok = false, Message = message});
}
}
Таким образом мы дотянули текст бизнес-исключения до вывода в результате выполнения Ajax-запроса, например.
Очевидно, что недостатками такого подхода являются:
Нет типизации исключений, т.е. перехватить и обработать их нельзя, они все
AppException;
При записи текста исключений, например, в лог, текст будет локализованным и зависимым от языка в интерфейсе пользователя.
И эти недостатки очень существенные, поэтому требуется рефакторинг.
Соответственно я вижу следующий вариант решения проблемы:
Для каждого бизнес-исключения создаётся отдельный класс. Для описанного выше примера будет создан класс
InvalidPaymentTypeException и код будет переписан как:
public class InvalidPaymentTypeException : Exception
{
public InvalidPaymentTypeException(PaymentType paymentType, string countryCode)
: base($"Invalid payment type \"{paymentType}\" for country \"{countryCode}\".")
{}
}
internal class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, int>
{
...
public async Task<int> Handle(CreateOrderCommand command, CancellationToken cancellationToken)
{
...
if (command.PaymentType == PaymentType.PayPal && command.CountryCode == "RU")
{
throw new InvalidPaymentTypeException(command.PaymentType, command.CountryCode);
}
...
}
}
Но тогда на слое WebUI нужно будет перебирать все типы исключений и заменять на локализованные сообщения. Что-то вроде:
public class HandleErrorAttribute : TypeFilterAttribute
{
....
private void HandleAjaxRequestException(ExceptionContext context)
{
string message = Messages.Error500;
if (context.Exception is InvalidPaymentTypeException)
{
message = Messages.InvalidPaymentType; // Локализованный текст из файла ресурсов
}
else if (context.Exception is InvalidCredentialsException)
{
message = Messages.InvalidCredentials; // Локализованный текст из файла ресурсов
}
// И так все исключения
else {
message = Messages.Error500; // Внутренняя ошибка
}
context.Result = new JsonResult(new { Ok = false, Message = message});
}
}
Но тогда в случае появления нового исключения, например,
OrderNotFoundException, нужно обязательно не забыть его добавить в
else if/switch для локализации ошибки. И это довольно неудобно.
Еще одним вариантом является создание абстрактного
LocalizedException, который в свою очередь в свойстве
Message хранит сообщение об ошибки для разработчиков, а в свойстве
LocalizedMessage - локализованное сообщение об ошибки из файла ресурсов. Примерно так:
public abstract class LocalizedException : Exception
{
public string LocalizedMessage { get; }
protected LocalizedException(string message, string localizedMessage)
: base(message)
{
LocalizedMessage = localizedMessage;
}
}
И все-все бизнес-исключения наследуются от этого исключения, следующим образом:
public class InvalidPaymentTypeException : LocalizedException
{
public InvalidPaymentTypeException(PaymentType paymentType, string countryCode)
: base(
message: $"Invalid payment type \"{paymentType}\" for country \"{countryCode}\".",
localizedMessage: Messages.InvaidPaymentType // Вторым параметром передаем локализованную строку из файла ресурсов
)
{}
}
И на слое WebUI всегда используем свойство
LocalizedMessage.
...
private void HandleAjaxRequestException(ExceptionContext context)
{
string message = Messages.Error500;
if (context.Exception is LocalizedException)
{
message = context.Exception.LocalizedMessage;
}
context.Result = new JsonResult(new { Ok = false, Message = message});
}
...
Вопрос:
Подходят ли описанные мной варианты для организации Exception Handling (возможно я что-то упускаю)?
Или прошу подсказать best-practices по выбрасыванию (на внутренних слоях) и перехвату (на слое представления) бизнес-исключений?
Прошу обратить внимание, что слоем представления является именно UI, не API, так как у UI есть особенность - язык интерфейса.
Спасибо за помощь и советы!