Balancing Cross-Cutting Concerns in Clean Architecture

Balancing Cross-Cutting Concerns in Clean Architecture

Cross-cutting concerns are software aspects that affect the entire application. These are your common application-level functionalities that span several layers and tiers. Cross-cutting concerns should be centralized in one location. This prevents code duplication and tight coupling between components.

A few examples of cross-cutting concerns are:

  • Authentication & Authorization

  • Logging and tracing

  • Exception handling

  • Validation

  • Caching

In today's newsletter, I'll show you how to integrate cross-cutting concerns in Clean Architecture.

Cross-Cutting Concerns in Clean Architecture

In Clean Architecture, cross-cutting concerns play an essential role in ensuring the maintainability and scalability of your system. Ideally, these concerns should be handled separately from the core business logic. This aligns with Clean Architecture's principles, emphasizing the decoupling of concerns and modularity. Your core business rules remain uncluttered, and the architecture stays clean and adaptable.

Ideally, you want to implement cross-cutting concerns in the Infrastructure layer. You can use ASP.NET Core middleware, decorators, or MediatR pipeline behaviors. Whichever approach you decide to use, the guiding idea remains the same.

Let's see how to implement logging, validation, and caching as cross-cutting concerns.

Cross-Cutting Concern #1 - Logging

Logging is a fundamental aspect of software development, allowing you to look into an application's behavior. It's vital for debugging, monitoring application health, and tracking user activities and system anomalies. In the context of Clean Architecture, logging must be implemented in a way that maintains the separation of concerns.

An elegant way to achieve this is with MediatR's IPipelineBehavior. By encapsulating the logging logic inside a pipeline behavior, we ensure that logging is treated as a distinct concern, separate from business logic. This approach enables us to capture detailed information about requests flowing through the application.

Effective logging should be consistent, context-rich, and non-intrusive. Using Serilog's structured logging capabilities, we can create logs that are not only informative but also easily queryable. This is essential for understanding the state of the application at any given moment.

When done correctly, structured logging provides invaluable insights into your application without cluttering the core logic. It's a balance of granularity and clarity, ensuring that your logs are a helpful tool rather than a source of noise.

using Serilog.Context;

internal sealed class RequestLoggingPipelineBehavior<TRequest, TResponse>(
    ILogger<RequestLoggingPipelineBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : class
    where TResponse : Result
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        string requestName = typeof(TRequest).Name;

        logger.LogInformation(
            "Processing request {RequestName}",
            requestName);

        TResponse result = await next();

        if (result.IsSuccess)
        {
            logger.LogInformation(
                "Completed request {RequestName}",
                requestName);
        }
        else
        {
            using (LogContext.PushProperty("Error", result.Error, true))
            {
                logger.LogError(
                    "Completed request {RequestName} with error",
                    requestName);
            }
        }

        return result;
    }
}

Cross-Cutting Concern #2 - Validation

Validation is a critical cross-cutting concern in software engineering. It serves as the first line of defense against incorrect data entering your system. Validation guards the application against inconsistent data states and potential security vulnerabilities.

In the example below, I'm creating a validation pipeline behavior. This setup allows for a clean separation of validation logic from business logic. The pipeline behavior ensures that each request is validated before it reaches the core processing logic.

In approaching validation, it's crucial to distinguish between two types:

  • Input validation

  • Business rule validation

Input validation checks for the correctness and format of the data (like string length, number ranges, and date formats), ensuring it meets the basic criteria before processing.

On the other hand, business rule validation is more about ensuring that the data adheres to your domain's specific rules and logic.

Effective validation practices significantly contribute to the resilience and reliability of an application. By enforcing validation rules, you can maintain a high data quality standard and ensure a better user experience.

internal sealed class ValidationPipelineBehavior<TRequest, TResponse>(
    IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : class
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        ValidationFailure[] validationFailures = await ValidateAsync(request);

        if (validationFailures.Length != 0)
        {
            throw new ValidationException(validationFailures);
        }

        return await next();
    }

    private async Task<ValidationFailure[]> ValidateAsync(TRequest request)
    {
        if (!validators.Any())
        {
            return [];
        }

        var context = new ValidationContext<TRequest>(request);

        ValidationResult[] validationResults = await Task.WhenAll(
            validators.Select(validator => validator.ValidateAsync(context)));

        ValidationFailure[] validationFailures = validationResults
            .Where(validationResult => !validationResult.IsValid)
            .SelectMany(validationResult => validationResult.Errors)
            .ToArray();

        return validationFailures;
    }
}

Cross-Cutting Concern #3: Caching

Caching is an essential cross-cutting concern in software development. It's primarily aimed at enhancing performance and scalability. Caching involves temporarily storing data in a fast-access layer. This reduces the need to fetch or calculate the same information repeatedly.

The caching pipeline behavior, which you see below, implements the Cache Aside pattern. This pattern involves checking the cache before processing the request and updating the cache with new data as needed. It's a popular caching strategy due to its simplicity and effectiveness. Here's a video tutorial if you want to see how I implemented this.

When implementing caching, it's crucial to consider:

  • What to Cache: Identify data that is expensive to compute or retrieve and stable enough to be cached.

  • Cache Invalidations: Determine when and how cached data should be invalidated.

  • Cache Configuration: Configure cache settings like expiration and size appropriately.

Effective caching improves response times and reduces the load on your system, making it a critical strategy for building scalable .NET applications.

internal sealed class QueryCachingPipelineBehavior<TRequest, TResponse>(
    ICacheService cacheService,
    ILogger<QueryCachingPipelineBehavior<TRequest, TResponse>> logger)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICachedQuery
    where TResponse : Result
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        TResponse? cachedResult = await cacheService.GetAsync<TResponse>(
            request.CacheKey,
            cancellationToken);

        string requestName = typeof(TRequest).Name;
        if (cachedResult is not null)
        {
            logger.LogInformation("Cache hit for {RequestName}", requestName);

            return cachedResult;
        }

        logger.LogInformation("Cache miss for {RequestName}", requestName);

        TResponse result = await next();

        if (result.IsSuccess)
        {
            await cacheService.SetAsync(
                request.CacheKey,
                result,
                request.Expiration,
                cancellationToken);
        }

        return result;
    }
}

What To Do Next

Managing cross-cutting concerns such as logging, caching, validation, and exception handling is not just about technical implementation. It's about aligning these aspects with the core principles of Clean Architecture. By adopting the decoupling techniques we discussed, you can ensure that your .NET projects are robust and maintainable.

Each step you take towards refining your handling of cross-cutting concerns is a step towards a better software architecture. I encourage you to experiment with these strategies in your own .NET projects. If you want a structured guide covering these aspects in-depth, take a look at Pragmatic Clean Architecture.

Remember, the beauty of software development lies in the continuous evolution and relentless pursuit of improvement.

Hope this was helpful.

See you next week.


P.S. Whenever you’re ready, there are 2 ways I can help you:

  1. Pragmatic Clean Architecture: This comprehensive course will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture. Join 1,900+ students here.

  2. Patreon Community: Think like a senior software engineer with access to the source code I use in my YouTube videos and exclusive discounts for my courses. Join 980+ engineers here.