DEV Community

Cover image for From service classes to request pipelines
Nicolai Ringbæk for IT Minds

Posted on • Originally published at insights.it-minds.dk

From service classes to request pipelines

Not only platforms and frameworks change over time. The application may be separated into multiple sub-applications (microservices) or have parts of its logic shared with other applications.

Defining and integrating architectural values can help keep the system stand the test of time. This post will focus on the values testability, modularity, development time. In addition to this, the overall goal is a way to enforce input validation and authorization of the executed logic, without relying on platform/framework features. The more declarative, transparent and easy to implement these steps are, the better.

The software pattern mediator has a lot to say in designing the solution for this case. The gist of it is, that it encapsulates logic and decreases coupling inside the system. A side effect is, that it enables us to build a request pipeline, which can process the request and perform several checks, before calling the actual logic. One downside is discoverability of request classes compared to a service classes. Shout-out to the library MediatR for a great implementation.

The pipeline

The model validation and authorization checks could be a part of the method's initial statements. However, it decreases transparency of the method, when what we're actually interested in, is a way to perform pre-flight checks before lift-off.

As inspiration, we can look at how ASPNET handle its request pipeline in a simplified manner. The pipeline itself consist of ordered middlewares. Each request received calls the initial middleware after which it traverses through the registered stack of middlewares. For each middleware, it has the ability to modify the context, return a response or call the next middleware in line.

The idea is to build a logical pipeline when executing code/requests, where each part makes sure that the request continues to be valid and can be performed. The pipeline implementations for Ramble is ValidationPipeline and AuthorizationPipeline.

The integration

When a solution grows in size and large projects gets divided into multiple sub-projects, it can get hard to keep track on the dependencies and what each of them require to operate. With improvements to .NET's Host-concepts and how well-integrated DI has become, it's a natural step to utilize some of these concepts to simplify registration, even outside the usual applications like ASPNET.

The RambleServiceBuilder gives us our own extension point to simplify discoverability between projects. The AddRambleCoreServices method sets up the pipeline, including any other needed services for that specific layer. Other projects, while referencing our pipeline defining project, can then continue to add service registrations using this builder. An example on this is the AddStandaloneFeature() extension method from the separate project.

public static RambleServiceBuilder AddRambleCoreServices<TContext>(
    this IServiceCollection services,
    Action<RambleServiceCoreOptions> options) where TContext : IRequestContext
{
    var rambleOptions = new RambleServiceCoreOptions();
    options.Invoke(rambleOptions);

    services.AddSingleton(options);
    services.AddScoped(typeof(IRequestContext), typeof(TContext));

    services.AddMediatR(typeof(RambleCoreBuilderExtensions));

    if (rambleOptions.Pipeline.EnableRequestValidation)
        services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationPipeline<,>));

    if (rambleOptions.Pipeline.EnableRequestAuthorization)
        services.AddScoped(typeof(IPipelineBehavior<,>), typeof(AuthorizationPipeline<,>));

    return new RambleServiceBuilder(services);
}
Enter fullscreen mode Exit fullscreen mode
public void ConfigureServices(IServiceCollection services)
{
    // Rest of the setup removed for brevity

    services.AddRambleCoreServices<HttpRequestContext>(options =>
    {
        options.Pipeline.GlobalAuthorizationRules.Add(new IsAuthenticatedRule());
    })
    .AddStandaloneFeature();
}
Enter fullscreen mode Exit fullscreen mode

The IRequestContext is a per-application implementation. It defines how the application can construct the request context, which contains a reference to the IServiceProvider and the RequestIdentity. The RequestIdentity instance can be seen as an alias for the current authenticated user. If it's an ASPNET application, it's usually going to be constructed via IHttpContextAccessor. CLI and other application types may have other ways of defining the context. Besides, the goal was to reduce coupling with platforms/frameworks. This means that utilizing IHttpContextAccessor via DI inside the requests, will limit the code to environments where the instance is available (ASPNET).

public class HttpRequestContext : IRequestContext
{
    public RequestIdentity Identity { get; }
    public IServiceProvider ServiceProvider { get; }

    public HttpRequestContext(IHttpContextAccessor contextAccessor)
    {
        ServiceProvider = contextAccessor.HttpContext.RequestServices;
        Identity = contextAccessor.HttpContext.User.Identity.IsAuthenticated
            ? GetAuthenticatedIdentity(contextAccessor)
            : RequestIdentity.Anonymous();
    }

    private RequestIdentity GetAuthenticatedIdentity(IHttpContextAccessor contextAccessor)
    {
        var userId = contextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);

        var roles = contextAccessor.HttpContext.User.Claims
            .Where(e => e.Type == ClaimTypes.Role)
            .Select(e => e.Value)
            .ToList();

        var properties = new Dictionary<string, object>
        {
            { "name", contextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.Name) },
            { "email", contextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.Email) }
        };

        return RequestIdentity.Authenticated(
            userId: userId,
            roles: roles,
            properties: properties
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

In action

Defining a request

The request class defines the method signature with its properties as arguments. To extend the behavior of the request, the Validator and Authorizer sub-classes define requirements in a declarative, without cluttering the actual business logic (deleting the wall). Such separation simplifies development, since authorization rules can be shared between multiple requests. Each step (validation, authorization and method execution) can be tested individually, making the testing process more focused and easier to write and validate.

As a side-note to the request and its authorizer. Some authorization checks can't be declared solely on its own, since it plays a central part in the business logic. An example on this is, if we wanted to get a list of all posts, but we only had access to see text entries, not images. This would require filtering of the result, which could be implemented as a post-processing action of the logic. However, I think that in most (all) cases, this adds unneeded overhead and complexity, including the possibility to degrade system performance (fetching all entries, then filtering locally).

public class DeleteWall : Request<DeleteWall>
{
    public int WallId { get; }

    public DeleteWall(int wallId)
    {
        WallId = wallId;
    }

    public class Validator : RequestValidator<DeleteWall>
    {
        public Validator()
        {
            RuleFor(e => e.WallId).GreaterThan(0);
        }
    }

    public class Authorizer : RequestAuthorizer<DeleteWall>
    {
        public Authorizer()
        {
            AddRule(wall => new CanAdministerWallRule
            {
                WallId = wall.WallId
            });
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Validation and Authorization

To add validation and/or authorization checks, a class inheriting from RequestValidator or RequestAuthorizer can be created. Both classes share the same declarative way of defining the rules via the constructor.

Behind the scene, the validator uses the library FluentValidation. Works great and can handle advanced cases where DataAnnotation attributes fall short.

Regarding the authorizer, the implementation can be divided into rule arguments (the instance added in the Authorizer class) and rule engines, which contains the authorization logic. Mediator pattern to the rescue, since we need to add rules without complex constructors, while keeping the ability to transform a Request object to the given Rule for that sweet reusability value. The engine itself is then created when needed and includes the needed DI capabilities - neat.

public class CanAdministerWallRule : IAuthorizationRule
{
    public int WallId { get; set; }
}

public class CanAdministerWallRuleEngine : AuthorizationRuleEngine<CanAdministerWallRule>
{
    private readonly RambleDbContext _dbContext;
    private readonly IRequestContext _requestContext;

    public CanAdministerWallRuleEngine(RambleDbContext dbContext, IRequestContext requestContext)
    {
        _dbContext = dbContext;
        _requestContext = requestContext;
    }

    public override async Task<bool> IsAuthorized(CanAdministerWallRule rule)
    {
        var wall = await _dbContext.Walls.FirstOrDefaultAsync(e => e.Id == rule.WallId);
        return wall != null && wall.CreatorId == _requestContext.Identity.UserId;
    }
}
Enter fullscreen mode Exit fullscreen mode

Request logic

When the request has been created and the validation/authorization steps passed, the time for executing the actual request logic begins. Since this part has been guarded before being executed, we know that the request is valid and the identity executing it is authorized. This simplifies our code as seen below. In addition to this, since each RequestHandler is based on its own class, we can DI only what's needed. Compared to traditional service classes, which needs to DI everything needed to construct the instance at once, regardless of what the called method(s) needs. This helps to increase transparency of the code.

It may initially seem like we're hitting the DB twice, both times asking for the same object. However, Entity Framework caches the Wall entity with the initial request, after which it is fetched locally for the proceeding requests. This is due to the context having a scoped lifetime in our DI container and its query tracking behavior enabled, which is on by default.

public class DeleteWallHandler : RequestHandler<DeleteWall>
{
    private readonly RambleDbContext _dbContext;

    public DeleteWallHandler(
        RambleDbContext dbContext, ILogger<DeleteWallHandler> logger) : base(logger)
    {
        _dbContext = dbContext;
    }

    public override async Task<RequestResult> Handle(
        DeleteWall request, CancellationToken cancellationToken)
    {
        var wall = await _dbContext.Walls.FirstOrDefaultAsync(e => e.Id == request.WallId);
        if (wall == null)
            return Error(RequestResultErrorCode.NotFound);

        _dbContext.Walls.Remove(wall);
        await _dbContext.SaveChangesAsync();

        return Success();
    }
}
Enter fullscreen mode Exit fullscreen mode

The application interface

With everything in place, the pipeline is ready to be used. In this case, we have an ASPNET API controller which handles the creation of walls.

The controller knows the interface/protocol between the application and the user, not our pipeline. By that definition, it's the applications responsibility to create the correct form of response to return to the user. The result from our pipeline is wrapped in a Result object which, besides the result itself, contains basic information regarding the state. Do consider using a DTO instead of the Request class directly as API argument to separate concerns.

[HttpPost("")]
public async Task<ActionResult<int>> CreateWall(CreateWall model)
{
    var result = await Mediator.Send(model);
    if (result.IsError)
        return BadRequest();

    return Ok(result.Value);
}
Enter fullscreen mode Exit fullscreen mode

Testability

So how does this hold up regarding testability? First of all, the separation of authorization and request logic simplifies writing the tests and makes it easier to accommodate the possible outcomes of the code being tested.

When an authorization rule has been created and tested, it can safely be used for other requests when needed. No need to test for those outcomes again.

public class DeleteWallTests : IDisposable
{
    private readonly RambleDbContext _dbContext;

    public DeleteWallTests()
    {
        _dbContext = new RambleDbContext(new DbContextOptionsBuilder<RambleDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);

        _dbContext.Walls.Add(new WallEntity
        {
            Id = 1,
            Name = "Test",
            CreatorId = Guid.NewGuid().ToString()
        });

        _dbContext.SaveChanges();
    }

    [Fact]
    public async Task DeleteWall_FoundEntity_Succesful()
    {
        var handler = new DeleteWallHandler(_dbContext, NullLogger<DeleteWallHandler>.Instance);
        var result = await handler.Handle(new DeleteWall(1), default);

        Assert.True(result.IsSuccess);
        Assert.Empty(await _dbContext.Walls.ToListAsync());
    }

    [Fact]
    public async Task DeleteWall_NotFoundEntity_Error()
    {
        var handler = new DeleteWallHandler(_dbContext, NullLogger<DeleteWallHandler>.Instance);
        var result = await handler.Handle(new DeleteWall(2), default);

        Assert.True(result.IsError);
        Assert.NotEmpty(await _dbContext.Walls.ToListAsync());
    }

    // IDisposable support removed for brevity
}
Enter fullscreen mode Exit fullscreen mode
public class CanAdministerWallRuleTests : IDisposable
{
    private readonly RambleDbContext _dbContext;

    public CanAdministerWallRuleTests()
    {
        _dbContext = new RambleDbContext(new DbContextOptionsBuilder<RambleDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options);

        _dbContext.Walls.Add(new WallEntity
        {
            Id = 1,
            Name = "Test",
            CreatorId = Guid.NewGuid().ToString()
        });

        _dbContext.SaveChanges();
    }

    [Fact]
    public async Task WallNotFound_ReturnsFalse()
    {
        var requestContextMock = new Mock<IRequestContext>();
        var ruleEngine = new CanAdministerWallRuleEngine(_dbContext, requestContextMock.Object);
        var result = await ruleEngine.IsAuthorized(new CanAdministerWallRule { WallId = 2 });

        Assert.False(result);
    }

    [Fact]
    public async Task IdentityIsCreator_ReturnsTrue()
    {
        var requestContextMock = new Mock<IRequestContext>();
        var userId = await _dbContext.Walls.Select(e => e.CreatorId).FirstAsync();
        requestContextMock.Setup(e => e.Identity)
            .Returns(RequestIdentity.Authenticated(userId, new List<string>()));

        var ruleEngine = new CanAdministerWallRuleEngine(_dbContext, requestContextMock.Object);
        var result = await ruleEngine.IsAuthorized(new CanAdministerWallRule { WallId = 1 });

        Assert.True(result);
    }

    [Fact]
    public async Task IdentityNotCreator_ReturnsFalse()
    {
        var requestContextMock = new Mock<IRequestContext>();
        requestContextMock.Setup(e => e.Identity)
            .Returns(RequestIdentity.Authenticated(Guid.NewGuid().ToString(), new List<string>()));

        var ruleEngine = new CanAdministerWallRuleEngine(_dbContext, requestContextMock.Object);
        var result = await ruleEngine.IsAuthorized(new CanAdministerWallRule { WallId = 1 });

        Assert.False(result);
    }

    // IDisposable support removed for brevity
}
Enter fullscreen mode Exit fullscreen mode

Source code

Interested to see more? Ramble is a personal, public faced project of mine, used to experiment and validate my thoughts related to software (and architecture), in the land of .NET and Angular. In the end, the successful parts get introduced/integrated in real world-projects.

Ramble

Readme content to be added

Build Status

Environment setup

Required

.NET Core 3.1 SDK

NodeJS (includes NPM)

Install the Angular CLI

  • Run the command npm install -g @angular/cli

Optional

Get both Visual Studio and Visual Studio Code.

Git (and optionally a GUI client)

Build the project

Building the Angular client

  • Navigate to the folder \src\Ramble.Web\ClientApp
  • Run the command npm install
  • Run the command ng build

Building the .NET Backend

  • Navigate to the folder \src\Ramble.Web
  • Run the command dotnet build

Top comments (0)