DEV Community

Cover image for MediatR Response: Should the Request Handler Return Exceptions?
Kostiantyn Bilous for SharpAssembly

Posted on

MediatR Response: Should the Request Handler Return Exceptions?

Original article on Medium

In the evolving landscape of software architecture, the choice between traditional exception handling and adhering to functional programming principles to manage errors directly impacts system design and reliability.

Clean architecture advocates for systems independent of frameworks, UI, databases, and external agencies. This independence is crucial for maintaining the system's integrity over time. In line with this, exceptions - traditionally used to handle unforeseen errors - should be reserved for circumstances outside the application's normal functioning. However, the debate continues on how to handle predictable errors and validations.
On one side, proponents of thin controllers in architectures argue that the business logic should be encapsulated away from the API layer, making the API layer as lean as possible. This approach typically leads to handling exceptions at the middleware or framework level, thus maintaining a separation of concerns where controllers focus solely on routing and minimal preprocessing.

Conversely, functional programming offers an alternative by emphasizing immutability and pure functions, which leads to systems that are easier to reason about and test. Here, errors are treated as first-class citizens, taking the form of predictable results rather than as exceptions. This method suggests that all possible outcomes - including errors - are part of a function's expected result, encapsulated in types like Result, which can explicitly represent success or failure states without throwing exceptions.

Both methodologies aim to reduce side effects and enhance system robustness, yet they employ fundamentally different approaches to achieve these objectives. This article will compare these two paradigms by implementing the creation of a Bank Account, where domain validations ensure the account's name and associated bank are valid. We will explore exception handling at the API and application layers using NuGet packages like Ardalis.GuardClauses for assertions that throw ArgumentExceptions if the domain validation rules are violated and employing MediatR and FluentResults to handle operation outcomes more functionally.

Two Approaches to Handling Exceptions

For example, we will implement the creation and persistence of a new Bank Account domain entity from a personal investment tracking application, InWestMan, using two approaches to exception handling: Middleware-Based Error Handling at the API layer and Result Pattern at the application layer.

Upon creation and reconstitution, the Bank Account domain checks that its name is not blank and is associated with a bank. If the check fails, the domain object will throw an ArgumentException. For this purpose, I use Ardalis.GuardClauses NuGet package, which simplifies the domain validations.

using Ardalis.GuardClauses;
using InWestMan.DomainEvents;
using InWestMan.Globals.Currencies;
using InWestMan.Parties;

namespace InWestMan.Assets.BankAccounts;

public class BankAccount : Asset
{
    private BankAccount() { }

    public BankAccount(string name, Guid bankId, CurrencyCode currencyCode) : base(name, currencyCode)
    {
        Guard.Against.NullOrWhiteSpace(name, nameof(Name)); //throws ArgumentException
        Guard.Against.Null(bankId, nameof(BankId)); //throws ArgumentException
        Name = name;
        BankId = bankId;
    }

    public Party Bank { get; set; } = null!;
    public Guid BankId { get; set; }

    public void UpdateBank(Guid bankId)
    {
        Guard.Against.NullOrEmpty(bankId, nameof(BankId)); //throws ArgumentException
        if (!BankId.Equals(bankId))
        {
            BankId = bankId;
            Events.Add(new EntityFieldUpdated<BankAccount>(nameof(BankId), bankId.ToString()));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

1. Middleware-Based Error Handling

At the application layer, the Create command is designed to generate a new bank account entity, save it in the database, and return a unique identifier (GUID) of this bank account. This is reflected in the structured AddBankAccountCommand, which requires specific details such as the account name, currency code, and associated bank ID, each marked as required to ensure data integrity immediately.

public class AddBankAccountCommand : IRequest<Guid>
{
    [Required] public string Name { get; set; } = string.Empty;

    [Required] public CurrencyCode CurrencyCode { get; set; }

    [Required] public Guid BankId { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

The handler for this command, AddBankAccountCommandHandler, is tasked with validating the request against existing records to prevent duplications using a domain-specific specification. If duplication is detected, it throws a ValidationException, creating unique bank accounts. This validation process is essential for maintaining data consistency and reliability within the system.

using System.ComponentModel.DataAnnotations;
using InWestMan.Assets.BankAccounts.Specifications;
using InWestMan.Repositories;
using MediatR;

namespace InWestMan.Assets.BankAccounts.Commands.AddBankAccount;

public class AddBankAccountCommandHandler : IRequestHandler<AddBankAccountCommand, Guid>
{
    private readonly IRepository<BankAccount> _repository;

    public AddBankAccountCommandHandler(IRepository<BankAccount> repository)
    {
        _repository = repository;
    }

    public async Task<Guid> Handle(AddBankAccountCommand request, CancellationToken cancellationToken)
    {
        var bankAccountByNameSpecification = new BankAccountByNameSpecification(request.Name);
        if (await _repository.AnyAsync(bankAccountByNameSpecification, cancellationToken))
            throw new ValidationException("The bank account with the same name already exists.");

        var newBankAccount = new BankAccount(request.Name, request.BankId, request.CurrencyCode);

        await _repository.AddAsync(newBankAccount, cancellationToken);

        return newBankAccount.Id;
    }
}
Enter fullscreen mode Exit fullscreen mode

In the corresponding controller method, the API layer handles the client's POST request by mapping the incoming DTO AddBankAccountCommand and dispatching it using MediatR. The result, which includes the newly created bank account's GUID, is then returned to the client, facilitating a responsive and interactive user experience.

    [HttpPost]
    [ProducesResponseType(StatusCodes.Status201Created)]
    [ProducesDefaultResponseType]
    public async Task<ActionResult<Guid>> Create([FromBody] CreateBankAccountDto createDto)
    {
        var command = _mapper.Map<AddBankAccountCommand>(createDto);
        var commandResult = await _mediator.Send(command);

        return CreatedAtAction(nameof(GetById), new { id = commandResult }, commandResult);
    }
Enter fullscreen mode Exit fullscreen mode

Transitioning to error handling, we employ middleware to intercept and transform exceptions into structured HTTP responses. This middleware ensures that all unhandled exceptions are caught and appropriately formatted before being sent back to the client, thus safeguarding the API from leaking sensitive error details and providing a consistent error-handling mechanism across the application.

using System.ComponentModel.DataAnnotations;
using System.Net;
using System.Text.Json;

namespace InWestMan.Middlewares;

public class ExceptionHandlerMiddleware
{
    private readonly RequestDelegate _next;

    public ExceptionHandlerMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await ConvertException(context, ex);
        }
    }

    private Task ConvertException(HttpContext context, Exception exception)
    {
        var httpStatusCode = HttpStatusCode.InternalServerError;

        context.Response.ContentType = "application/json";

        var result = string.Empty;

        switch (exception)
        {
            case ValidationException validationException:
                httpStatusCode = HttpStatusCode.BadRequest;
                result = JsonSerializer.Serialize(validationException.ValidationResult.ErrorMessage);
                break;
            case ArgumentException argumentException:
                httpStatusCode = HttpStatusCode.BadRequest;
                result = JsonSerializer.Serialize(argumentException.Message);
                break;
            case BadRequestException badRequestException:
                httpStatusCode = HttpStatusCode.BadRequest;
                result = badRequestException.Message;
                break;
            case NotFoundException:
                httpStatusCode = HttpStatusCode.NotFound;
                break;
            case not null:
                httpStatusCode = HttpStatusCode.BadRequest;
                break;
        }

        context.Response.StatusCode = (int)httpStatusCode;

        if (result == string.Empty) result = JsonSerializer.Serialize(new { error = exception?.Message });

        return context.Response.WriteAsync(result);
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, the custom-created error-handling middleware should be registered in the application pipeline configuration.
builder.UseMiddleware();

2. Adopting the Result Pattern

At the application layer, our approach significantly shifts as we adopt the Result pattern, utilizing the FluentResults NuGet package to enhance our error-handling strategy. This method alters the expected output of our commands from a simple GUID to a Result<Guid>. Such a transformation encapsulates the outcome of command execution within a structured result, offering more granular control over error handling, which neatly complies with our business logic requirements.

public class AddBankAccountCommand : IRequest<Result<Guid>>
{
    [Required] public string Name { get; set; } = string.Empty;

    [Required] public CurrencyCode CurrencyCode { get; set; }

    [Required] public Guid BankId { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

In implementing this pattern, the AddBankAccountCommandHandler in the Application layer plays a crucial role. It processes the creation requests and directly integrates error handling into the workflow using the Result pattern. This allows for immediate feedback on the operation's success or failure and the communication of specific error messages directly related to the business rules, enhancing user experience and system reliability.

using FluentResults;
using InWestMan.Assets.BankAccounts.Specifications;
using InWestMan.Repositories;
using MediatR;

namespace InWestMan.Assets.BankAccounts.Commands.AddBankAccount;

public class AddBankAccountCommandHandler : IRequestHandler<AddBankAccountCommand, Result<Guid>>
{
    private readonly IRepository<BankAccount> _repository;

    public AddBankAccountCommandHandler(IRepository<BankAccount> repository)
    {
        _repository = repository;
    }

    public async Task<Result<Guid>> Handle(AddBankAccountCommand request, CancellationToken cancellationToken)
    {
        try
        {
            var bankAccountByNameSpecification = new BankAccountByNameSpecification(request.Name);
            if (await _repository.AnyAsync(bankAccountByNameSpecification, cancellationToken))
                return Result.Fail("The bank account with the same name already exists.");

            var newBankAccount = new BankAccount(request.Name, request.BankId, request.CurrencyCode);

            await _repository.AddAsync(newBankAccount, cancellationToken);

            return Result.Ok(newBankAccount.Id);
        }
        catch (ArgumentException ex)
        {
            return Result.Fail(ex.Message);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Transitioning to the controller level, the Create Bank Account Controller method adapts to accommodate the Result pattern. Instead of merely returning a GUID, it now handles the Result object, facilitating a response mechanism that directly reflects the outcome of the command - either confirming successful account creation or detailing the reasons for failure.

    [HttpPost]
    [ProducesResponseType(StatusCodes.Status201Created)]
    [ProducesDefaultResponseType]
    public async Task<ActionResult<Guid>> Create([FromBody] CreateBankAccountDto createDto)
    {
        var command = _mapper.Map<AddBankAccountCommand>(createDto);
        var commandResult = await _mediator.Send(command);
        if (commandResult.IsFailed)
        {
            return BadRequest(commandResult.Errors);
        }

        return CreatedAtAction(nameof(GetById), new { id = commandResult.Value }, commandResult.Value );
    }
Enter fullscreen mode Exit fullscreen mode

With the adoption of this comprehensive Result pattern, the necessity for custom exception-handling middleware diminishes. All potential exceptions and errors are addressed directly within the Application layer, obviating the need for middleware to catch and reformat exceptions. This results in a cleaner architecture that reduces overhead and enhances clarity and maintainability by localizing error handling within the context where it is most relevant.

Conclusion: Choosing the Error Handling Strategy

In software architecture, error-handling strategies are pivotal in defining the system's robustness and clarity. Comparing the two approaches discussed exception handling versus the application layer result reveals distinct advantages and considerations for each, informing their appropriate use cases.

API Layer: Middleware-Based

This approach centralizes error management in the middleware, abstracting it away from the business logic.

Pros:

  • Centralization of Error Handling: This method simplifies the control flow by managing exceptions centrally, making it easier to handle unanticipated errors uniformly across various application parts.
  • Simplicity in Implementation: Developers can implement and modify error-handling strategies without touching the business logic, promoting a cleaner separation of concerns.

Cons:

  • Potential for Overuse: There's a risk of using exceptions for control flow, which can lead to systems where exceptions are thrown for expected, non-exceptional conditions, potentially impacting performance.
  • Decreased Transparency: As exceptions bubble up to the middleware, it might obscure the source and context of errors, making debugging more challenging.

Use Cases:

It is ideal for applications requiring a uniform way to handle unanticipated or external system errors, such as failures in external services or database issues.

Application Layer: Result Pattern

Employing a result pattern at the application layer treats errors as expected outcomes, handled via types that explicitly represent success or failure.

Pros:

  • Enhanced Error Clarity: This approach treats errors as regular values, avoiding the pitfalls of exception handling for control flow and making error states explicit and predictable.
  • Better Alignment with Functional Principles: It promotes immutability and function purity, making the system easier to reason about and test.

Cons:

  • Increased Complexity: Requires more initial setup and can complicate the API with additional result-checking code.
  • Potential for Duplication: Handling errors explicitly in each function might lead to repetitive error handling code unless carefully managed.

Use Cases:

It is particularly suitable for domains where business rules are complex and errors are a regular, expected part of operations, such as financial applications or systems with complex input validation needs.

In conclusion, a project's requirements and constraints should guide the choice between these error-handling strategies. Middleware-based exception handling might be more appropriate for applications where unexpected errors from external systems dominate. In contrast, an application layer result pattern fits scenarios requiring fine-grained control over business rules and where mistakes are as informative as successful outcomes. Both approaches can coexist within a clean architecture, chosen based on different aspects or layers of the application to optimize clarity, maintainability, and functionality.

Original article on Medium

Stay tuned for more insights and detailed analyses, and feel free to share your thoughts or questions in the comments below!

SharpAssembly on Dev.to
SharpAssembly on Medium
SharpAssembly on Telegram

Cover credits: DALL·E generated

Top comments (0)