@WhiteNinja

Как правильно организовать Exception Handling в бизнес-приложении?

Добрый день!

Существует бизнес-приложение (.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 есть особенность - язык интерфейса.

Спасибо за помощь и советы!
  • Вопрос задан
  • 94 просмотра
Пригласить эксперта
Ваш ответ на вопрос

Войдите, чтобы написать ответ

Похожие вопросы