An Architecture Guide to a Dotnet Web Api

Clean Architecture

The clean architecture is my preferred way of archtecting a web api. In the future, I will write about alternative architectures for a comparison.

Key Principles

  1. Independence of frameworks
  2. Testability
  3. Independence of UI
  4. Independence of Database
  5. Independence of any external agency

Layers

  1. Core: contains entities and their business rules and has no dependencies on other projects or external frameworks.
  2. Application: contains business rules that may require access to data from a dependency and also interfaces for dependencies used by the application. An example of such a business rule is checking for duplicates in the database when a user signs up with an email. This layer depends on the entities layer.
  3. Infrastructure: implements interfaces defined in the application layer that connect to external dependencies (including communicating with other apis). This layer depends on the application layer.
  4. Interface Adapters: this is the entry point for the application and it contains logic to connect to the external world such as controllers in a web api and view models for a server-side rendered application. This layer depends on the infrastructure and application layers.

Project Structure

In a c# application, installed nuget packages are typically scoped to a project. Since each layer has a different responsibility, each layer will also have its own dependencies. So, it makes sense to create a project per layer to make clear which dependency is used by which layer. For example, in the infrastructure layer, you may have references to the 3rd party api nuget packages that are not used anywhere else.

  • Solution
    • src
      • Project.Core
      • Project.Application
      • Project.Infrastructure
      • Project.WebApi
    • tests
      • Project.UnitTests
      • Project.IntegrationTests

Making HTTP Requests

The most naive way to make HTTP requests in a dotnet web application is to use the HttpClient class and instantiate it wherever you need to use it, making sure to use using to ensure it gets disposed of after use. There are two problems with this approach:

  • When the HttpClient object gets disposed of, the underlying socket is not immediately released. If many connections are made at a close time period, this can lead to socket exhaustion. To avoid this, we instantiate the HttpClient once and reuse it throughout the life of the application rather than instantiating the HttpClient per request. You can do this by creating a static or singleton HttpClient instance.
  • The HttpClient does not handle DNS changes. This is because its PooledConnectionLifetime property is set to InfiniteTimeSpan by default so the connection never releases. If the DNS changes due to a failover, then the connection will release, but in the case of a blue-green deployment slot switch, it will point to the wrong IP address. Set PooledConnectionLifetime to a short interval to allow DNS changes to be detected.

By now, we've resolved all issues relating to how HttpClient performs, but usage in this way does not allow for its mocking in a test. To resolve this, we need to use HttpClientFactory. HttpClientFactory is an abstraction that handles all the above issues and can also be dependency injected into your service via an interface by using AddHttpClient.

A brief note on how HttpClientFactory works: HttpClientFactory exposes a CreateClient method that creates a new HttpClient. Unlike directly creating HttpClient instances with new HttpClient, this does not create a large number of connections. This is because, under the hood, HttpClients reference a HttpClientHandler that is actually responsible for making connections and HttpClients will reuse any available HttpClientHandler connections. HttpClientFactory also pools the HttpClientHandler instances and manages their lifetime. Thus, whenever a HttpClient is created using HttpClientFactory.CreateClient, a new HttpClient is created that may or may not use an existing HttpClientHandler.

There are 3 ways to use HttpClientFactory:

  • Directly injecting the IHttpClientFactory dependency into your code (typically to the application service layer).
  • Using a named client. This is useful for when you need HttpClients to be configured in different ways, but it requires a magic string and does not enable encapsulation of logic.
  • Using a typed client. This has the same benefits as named clients, but allows encapsulation of the logic when working with the third party api.

Typed clients are typically the ideal choice as they allow you to encapsulate the data returned by the api.

Filters

Filters allow code to run before or after certain parts of the request processing pipeline.

Authorization Filter

This filter is used to determine whether the current user is authorized to access the requested resource. It's often overridden to implement custom authorization logic.

This is an example (code is currently untested)

public class RoleBasedAuthorizationFilter : IAuthorizationFilter
{
    private readonly string[] _allowedRoles;

    public RoleBasedAuthorizationFilter(params string[] allowedRoles)
    {
        _allowedRoles = allowedRoles;
    }

    public void OnAuthorization(AuthorizationFilterContext context)
    {
        var user = context.HttpContext.User;

        if (!user.Identity.IsAuthenticated)
        {
            context.Result = new UnauthorizedResult();
            return;
        }

        if (!_allowedRoles.Any(role => user.IsInRole(role)))
        {
            context.Result = new ForbidResult();
        }
    }
}

// Attribute to use the filter
public class AuthorizeRolesAttribute : TypeFilterAttribute
{
    public AuthorizeRolesAttribute(params string[] roles) : base(typeof(RoleBasedAuthorizationFilter))
    {
        Arguments = new object[] { roles };
    }
}

// Example usage in a controller
public class SomeController : Controller
{
    [AuthorizeRoles("Admin", "Manager")]
    public IActionResult AdminOrManagerAction()
    {
        return Ok("You have access to this action.");
    }

    [AuthorizeRoles("Employee")]
    public IActionResult EmployeeOnlyAction()
    {
        return Ok("This is for employees only.");
    }
}

Resource Filter

Resource filters run code before and after the rest of the filter pipeline. They're commonly overridden for caching or to short-circuit request processing for performance optimization.

This is an example (code is currently untested)

using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using System;

public class ImprovedCacheResourceFilter : IResourceFilter
{
    private readonly IMemoryCache _cache;
    private readonly MemoryCacheEntryOptions _cacheOptions;

    public ImprovedCacheResourceFilter(IMemoryCache cache)
    {
        _cache = cache;
        _cacheOptions = new MemoryCacheEntryOptions()
            .SetSlidingExpiration(TimeSpan.FromMinutes(10))
            .SetAbsoluteExpiration(TimeSpan.FromHours(1));
    }

    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        string cacheKey = context.HttpContext.Request.Path;
        if (_cache.TryGetValue(cacheKey, out object cachedResult))
        {
            context.Result = cachedResult as IActionResult;
        }
    }

    public void OnResourceExecuted(ResourceExecutedContext context)
    {
        string cacheKey = context.HttpContext.Request.Path;
        if (context.Result != null && !_cache.TryGetValue(cacheKey, out _))
        {
            _cache.Set(cacheKey, context.Result, _cacheOptions);
        }
    }
}

// Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddMemoryCache();
    services.AddControllers(options =>
    {
        options.Filters.Add<ImprovedCacheResourceFilter>();
    });
}

Action Filter

Action filters run code immediately before and after an action method is called. They're frequently overridden to manipulate the arguments passed to an action or to modify the result returned from an action.

Exception Filter

Exception filters are used to handle exceptions that occur within the ASP.NET Core pipeline. Overriding these allows for custom exception handling and logging.

Usually middleware is preferred for exception handling. Exception filters should only be used when error handling differs based on the action method called.

Result Filter

Result filters run code before and after the execution of action results. They only run if the action method executes successfully and are often overridden to modify or replace action results before they're executed.

This is an example (code is currently untested)

public class UnprocessableResultFilter : IAlwaysRunResultFilter
{
    public void OnResultExecuting(ResultExecutingContext context)
    {
        if (context.Result is StatusCodeResult statusCodeResult
            && statusCodeResult.StatusCode == StatusCodes.Status415UnsupportedMediaType)
        {
            context.Result = new ObjectResult("Unprocessable")
            {
                StatusCode = StatusCodes.Status422UnprocessableEntity
            };
        }
    }

    public void OnResultExecuted(ResultExecutedContext context) { }
}

Middleware

Middleware is a component that sits between the swe server and the application's request pipeline and are used to handle cross-cutting concerns like authentication.

Common examples of middleware include:

  • UseHttpsRedirection
  • UseRouting
  • UseCors
  • UseAuthentication
  • UseAuthorization

How does middleware differ from filters?

Middleware and filters differ in that filters have access to context and constructs since they are part of the runtime.

Controllers

Concepts to keep in mind

  • Main responsibility is routing: the controller should focus on handling HTTP requests rather than business logic, so most of the business logic should belong in a different layer.
  • Separate controller class per endpoint: creating a separate controller class per endpoint reduces complexity by ensuring that all dependencies injected into the controller are used by the action method. Having multiple action methods in a controller increase complexity. A package that helps with this ApiEndpoints. Using minimal apis is a better alternative if supported by your version of dotnet core.
  • Using a custom response object: creating a custom response type object that contains the response data and any errors will help api consumers work with the api.
  • Api versioning: use versioning if backwards compatibility is required, such as when building a public api with many consumers.
  • Api documentation: using a tool like Swagger can help autogenerate documentation based on code comments.

Implementation

Web api controllers should inherit from ControllberBase, which adds support for handling HTTP requests such as methods that return a particular status code (e.g. BadRequest).

Including the ApiController attribute enables the following behaviours for your controller:

  • Attribute routing requirement (e.g. [Route("[controller]")])
  • Automatic HTTP 400 responses when the model fails validation from validation attributes on the model (e.g. [Required]).
  • Allows usage of model binding with attributes. (e.g. [FromBody] and [FromQuery]).
  • Multipart/form-data request inference
  • Problem details for error status codes, which follows RFC 7807 specification to provide machine-readable error details in a HTTP response.

Useful Links