NuGet for decoupling domain operation results from IActionResult and IResult types of ASP.NET Web API
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.
- Basic use-case
- Quick start
- 'DomainResult.Common' package. Returning result from Domain Layer method
- 'DomainResult' package
- Custom Problem Details output
- Alternative solutions
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 ofInvoiceResponseDto
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 to422 Unprocessable Entity
). - HTTP code
404 Not Found
for incorrect invoice IDs.
- HTTP code
- 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).
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)> |
// 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 aTask
(e.g.SuccessTask()
,FailedTask()
,NotFoundTask()
,UnauthorizedTask()
). - The
Failed()
andNotFound()
methods take as input parameters:string
,string[]
.Failed()
can also take ValidationResult.
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.
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.
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() |
// 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();
For the modern minimal API (for .NET 6+), call ToResult()
extension method on a IDomainResult
value to return the corresponding IResult
instance.
// 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();
There is a way to tune the Problem Details output case-by-case.
When returning a standard 200
or 204
HTTP code is not enough, there are extension methods to knock yourself out:
ToCustomActionResult()
andToCustomActionResultOfT()
for returningIActionResult
ToCustomResult()
for returningIResult
Examples of returning 201 Created along with a location header field pointing to the created resource (as per RFC7231):
[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:
- AcceptedAtAction and AcceptedAtRoute for HTTP code 202 Accepted;
- File or PhysicalFile for returning
200 OK
with the specifiedContent-Type
, and the specified file name; - Redirect, RedirectToRoute, RedirectToAction for returning 302 Found with various details.
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))
)
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:
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...";
}))
The problem solved here is not unique, so how does DomainResult stand out?
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.
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.