Skip to content

Latest commit

 

History

History
411 lines (309 loc) · 22.3 KB

README.md

File metadata and controls

411 lines (309 loc) · 22.3 KB

DomainResult

NuGet for decoupling domain operation results from IActionResult and IResult types of ASP.NET Web API

CI Test Coverage DomainResult NuGet version DomainResult.Common NuGet version

Two tiny NuGet packages addressing challenges in the ASP.NET Web API realm posed by separation of the Domain Layer (aka Business Layer) from the Application Layer:

  • eliminating dependency on Microsoft.AspNetCore.* (IActionResult and IResult in particular) in the Domain Layer (usually a separate project);
  • mapping various of responses from the Domain Layer to appropriate ActionResult in classic Web API controllers or IResult in the minimal API.

Content:

Basic use-case

For a Domain Layer method like this:

public async Task<(InvoiceResponseDto, IDomainResult)> GetInvoice(int invoiceId)
{
    if (invoiceId < 0)
        // Returns a validation error
        return IDomainResult.Failed<InvoiceResponseDto>("Try harder");

    var invoice = await DataContext.Invoices.FindAsync(invoiceId);

    if (invoice == null)
        // Returns a Not Found response
        return IDomainResult.NotFound<InvoiceResponseDto>();

    // Returns the invoice
    return IDomainResult.Success(invoice);
}

or if you're against ValueTuple or static methods on interfaces (added in C# 8), then a more traditional method signature:

public async Task<IDomainResult<InvoiceResponseDto>> GetInvoice(int invoiceId)
{
    if (invoiceId < 0)
        // Returns a validation error
        return DomainResult.Failed<InvoiceResponseDto>("Try harder");

    var invoice = await DataContext.Invoices.FindAsync(invoiceId);

    if (invoice == null)
        // Returns a Not Found response
        return DomainResult.NotFound<InvoiceResponseDto>();

    // Returns the invoice
    return DomainResult.Success(invoice);
}

The Web API controller method would look like:

[ProducesResponseType(typeof(InvoiceResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<IActionResult> GetInvoice()
{
    return _service.GetInvoice().ToActionResult();
}

or leverage ActionResult<T> (see comparison with IActionResult)

[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public Task<ActionResult<InvoiceResponseDto>> GetInvoice()
{
    return _service.GetInvoice().ToActionResultOfT();
}

or for the Minimal APIs (added in .NET 6) convert to IResult:

app.MapGet("Invoice", () => _service.GetInvoice().ToResult())
   .Produces<InvoiceResponseDto>()
   .ProducesProblem(StatusCodes.Status400BadRequest)
   .ProducesProblem(StatusCodes.Status404NotFound);

The above returns:

  • HTTP code 200 OK along with an instance of InvoiceResponseDto on successful executions.
  • Non-2xx codes wrapped in ProblemDetails (as per RFC 7807):
    • HTTP code 400 Bad Request with a message "Try harder" when the invoice ID < 1 (the HTTP code can be configured to 422 Unprocessable Entity).
    • HTTP code 404 Not Found for incorrect invoice IDs.

Quick start

  • Install DomainResult NuGet package for the Web API project.
  • Install DomainResult.Common NuGet package for the Domain Layer (aka Business Layer) projects. If the Domain Layer is inside the Web API project, then skip this step.
  • Follow the documentation below, samples in the repo and common sense.

The library targets .NET 5, .NET 6, .NET 7 and .NET 8 (for .NET 3 & 3.1 support see older versions of the library).

'DomainResult.Common' package. Returning result from Domain Layer method

A tiny package with no dependency on Microsoft.AspNetCore.* namespaces that provides:

  • data types for returning from domain operations (wraps up the returned value and adds operation status with error messages if applicable);
  • extension methods to effortlessly form the desired response.

It's built around IDomainResult interface that has 3 properties:

IReadOnlyCollection<string> Errors { get; } // Collection of error messages if any
bool IsSuccess { get; }                     // Flag, whether the current status is successful or not
DomainOperationStatus Status { get; }       // Current status of the domain operation: Success, Failed, NotFound, Unauthorized, etc.

And IDomainResult<T> interface that also adds

// Value returned by the domain operation
T Value { get; }

It has 50+ static extension methods to return a successful or unsuccessful result from the domain method with one of the following types:

Returned type Returned type wrapped in Task
IDomainResult Task<IDomainResult>
IDomainResult<T> Task<IDomainResult<T>>
(T, IDomainResult) Task<(T, IDomainResult)>

Examples (Domain layer):

// Successful result with no value
IDomainResult res = IDomainResult.Success();        // res.Status is 'Success'
// Successful result with an int
(value, state) = IDomainResult.Success(10);         // value = 10; state.Status is 'Success'
// The same but wrapped in a task
var res = IDomainResult.SuccessTask(10);            // res is Task<(int, IDomainResult)>
// Implicit convertion
IDomainResult<int> res = 10;                        // res.Value = 10; res.Status is 'Success'

// Error message
IDomainResult res = IDomainResult.Failed("Ahh!");   // res.Status is 'Failed' and res.Errors = new []{ "Ahh!" }
// Error when expected an int
(value, state) = IDomainResult.Failed<int>("Ahh!"); // value = 0, state.Status is 'Failed' and state.Errors = new []{ "Ahh!" }

// 'Not Found' acts like the errors
(value, state) = IDomainResult.NotFound<int>();     // value = 0, state.Status is 'NotFound'
Task<(int val, IDomainResult state)> res = IDomainResult.NotFoundTask<int>();  // value = 0, state.Status is 'NotFound'

// 'Unauthorized' response
(value, state) = IDomainResult.Unauthorized<int>(); // value = 0, state.Status is 'Unauthorized'

Notes:

  • The Task suffix on the extension methods indicates that the returned type is wrapped in a Task (e.g. SuccessTask(), FailedTask(), NotFoundTask(), UnauthorizedTask()).
  • The Failed() and NotFound() methods take as input parameters: string, string[]. Failed() can also take ValidationResult.

Type conversion

Type conversion comes in handy for propagating errors from nested method calls, e.g. from IDomainResult to IDomainResult<T>, or the other way around, etc.

IDomainResult failedResult = IDomainResult.Failed("Ahh!");

IDomainResult<int>  resOfInt  = failedResult.To<int>();             // from IDomainResult to IDomainResult<T>
IDomainResult<long> resOfLong = resOfInt.To<long>();                // from IDomainResult<T> to IDomainResult<V>

DomainResult<int> resFromTuple = (default, failedResult);           // from IDomainResult to DomainResult<T>

Task<IDomainResult> failedResultTask = IDomainResult.FailedTask("Ahh!");
Task<IDomainResult<int>>    resOfInt = failedResultTask.To<int>();  // from Task<IDomainResult> to Task<IDomainResult<T>>

Note that returning Tuple types drastically simplifies type conversions.

'DomainResult' package

Converts a IDomainResult-based object to various IActionResult and IResult-based types providing 40+ static extension methods.

The mapping rules are built around IDomainResult.Status:

IDomainResult.Status Returned IActionResult/IResult type with default HTTP code
Success If no value is returned then 204 NoContent (docs), otherwise - 200 OK (docs)
Supports custom codes (e.g. 201 Created)
NotFound HTTP code 404 NotFound (docs)
Failed HTTP code 400 (docs) or can be configured to 422 (docs) or any other code
Unauthorized HTTP code 403 Forbidden (docs)
Conflict HTTP code 409 Conflict (docs)
ContentTooLarge HTTP code 413 Content Too Large (docs)
CriticalDependencyError HTTP code 503 Service Unavailable (docs)

Note: DomainResult package has dependency on Microsoft.AspNetCore.* namespace and DomainResult.Common package.

Conversion to IActionResult

For classic Web API controllers, call the following extension methods on a IDomainResult value:

Returned type Returned type wrapped in Task Extension methods
IActionResult Task<IActionResult> ToActionResult()
ToCustomActionResult()
ActionResult<T> Task<ActionResult<T>> ToActionResultOfT()
ToCustomActionResultOfT()

Examples (IActionResult conversion)

// Returns `IActionResult` with HTTP code `204 NoContent` on success
IDomainResult.ToActionResult();
// The same as above, but returns `Task<IActionResult>` with no need in 'await'
Task<IDomainResult>.ToActionResult();

// Returns `IActionResult` with HTTP code `200 Ok` along with the value
IDomainResult<T>.ToActionResult();
(T, IDomainResult).ToActionResult();
// As above, but returns `Task<IActionResult>` with no need in 'await'
Task<IDomainResult<T>>.ToActionResult();
Task<(T, IDomainResult)>.ToActionResult();

// Returns `ActionResult<T>` with HTTP code `200 Ok` along with the value
IDomainResult<T>.ToActionResultOfT();
(T, IDomainResult).ToActionResultOfT();
// As above, but returns `Task<ActionResult<T>>` with no need in 'await'
Task<IDomainResult<T>>.ToActionResultOfT();
Task<(T, IDomainResult)>.ToActionResultOfT();

Conversion to IResult (minimal API)

For the modern minimal API (for .NET 6+), call ToResult() extension method on a IDomainResult value to return the corresponding IResult instance.

Examples (IResult conversion)

// Returns `IResult` with HTTP code `204 NoContent` on success
IDomainResult.ToResult();
// The same as above, but returns `Task<IResult>` with no need in 'await'
Task<IDomainResult>.ToResult();

// Returns `IResult` with HTTP code `200 Ok` along with the value
IDomainResult<T>.ToResult();
(T, IDomainResult).ToResult();
// As above, but returns `Task<IResult>` with no need in 'await'
Task<IDomainResult<T>>.ToResult();
Task<(T, IDomainResult)>.ToResult();

Custom Problem Details output

There is a way to tune the Problem Details output case-by-case.

Custom response for 2xx HTTP codes

When returning a standard 200 or 204 HTTP code is not enough, there are extension methods to knock yourself out:

  • ToCustomActionResult() and ToCustomActionResultOfT() for returning IActionResult
  • ToCustomResult() for returning IResult

Examples of returning 201 Created along with a location header field pointing to the created resource (as per RFC7231):

Example (custom response for IActionResult)

[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
public ActionResult<int> CreateItem(CreateItemDto dto)
{
  // Service method for creating an item and returning its ID.
  // Can return any of the IDomainResult types (e.g. (int, IDomainResult, IDomainResult<int>, Task<...>, etc).
  var result = _service.CreateItem(dto);
  // Custom conversion of the successful response only. For others, it returns standard 4xx HTTP codes
  return result.ToCustomActionResultOfT(
            // On success returns '201 Created' with a link to '/{id}' route in HTTP headers
            val => CreatedAtAction(nameof(GetById), new { id = val }, val)
        );
}

// Returns an entity by ID
[HttpGet("{id}")]
public IActionResult GetById([FromRoute] int id)
{
	...
}

It works with any of extensions in Microsoft.AspNetCore.Mvc.ControllerBase. Here are some:

Example (custom response for IResult)

A similar example for a custom response with the minimal API would look like this

app.MapPost("/", 
            () => _service.CreateItem(dto)
                          .ToCustomResult(val => Results.CreatedAtRoute("GetById", new { id = val }, val))
           )

Custom error handling

The default HTTP codes for the supported statuses (Failed, NotFound, etc.) are defined in ActionResultConventions class. The default values are:

// The HTTP code to return when a request 'failed' (also can be 422)
int FailedHttpCode { get; set; }                  = 400;
// The 'title' property of the returned JSON on HTTP code 400
string FailedProblemDetailsTitle { get; set; }    = "Bad Request";

// The HTTP code to return when a record not found
int NotFoundHttpCode { get; set; }                = 404;
// The 'title' property of the returned JSON on HTTP code 404
string NotFoundProblemDetailsTitle { get; set; }  = "Not Found";

// ...and so on for `Unauthorized` (403), `Conflict` (409), `Content Too Large` (413), `CriticalDependencyError` (503), etc.

Feel free to change them (hmm... remember they're static, with all the pros and cons). The reasons you may want it:

  • Localisation of the titles
  • Favour 422 HTTP code in stead of 400 (see opinions here and here).

The extension methods also support a custom response for special cases when the IDomainResult.Status requires a different handler:

For the classic controllers:

[HttpGet("[action]")]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public Task<ActionResult<int>> GetFailedWithCustomStatusAndMessage()
{
  var res = _service.GetFailedWithNoMessage();
  return res.ToActionResultOfT(
            (problemDetails, state) =>
            {
              if (state.Errors?.Any() == true)
                return;
              problemDetails.Status = 422;      // Replace the default 400 code
              problemDetails.Title = "D'oh!";   // Replace the default 'Bad Request' title
              problemDetails.Detail = "I wish devs put more efforts into it...";   // Custom message
            });
}

The same for the minimal API:

app.MapGet("/", 
           () => _service.GetFailedWithNoMessage()
                         .ToResult((problemDetails, state) =>
                            {
                                if (state.Errors.Any())
                                    return;
                                problemDetails.Status = 422;
                                problemDetails.Title = "D'oh!";
                                problemDetails.Detail = "I wish devs put more efforts into it...";
                            }))

Alternative solutions

The problem solved here is not unique, so how does DomainResult stand out?

Why not FluentResults?

FluentResults is a great tool for indicating success or failure in the returned object. But there are different objectives:

  • FluentResults provides a generalised container for returning results and potential errors;
  • DomainResult is focused on a more specialised case when the Domain Logic is consumed by Web API.

Hence, DomainResult provides out-of-the-box:

  • Specialised extension methods (like IDomainResult.NotFound() that in FluentResult would be indistinctive from other errors)
  • Supports various ways of conversions to ActionResult (returning Problem Details in case of error), functionality that is not available in FluentResults and quite weak in the other NuGets extending FluentResults.

Why not Hellang's ProblemDetails?

Hellang.Middleware.ProblemDetails is another good one, where you can map exceptions to problem details.

In this case, the difference is ideological - "throwing exception" vs "returning a faulty status" for the sad path of execution in the business logic.

Main distinctive features of DomainResult are

  • Allows simpler nested calls of the domain logic (no exceptions handlers when severity of their "sad" path is not exception-worthy).
  • Provides a predefined set of responses for main execution paths ("bad request", "not found", etc.). Works out-of-the-box.
  • Has an option to tune each output independently.