Cross-Cutting Concerns in Vertical Slice Architecture

Cross-Cutting Concerns in Vertical Slice Architecture

6 min read··

dotnetsoftware-architecturevertical-slice-architecture

Handle cross-cutting concerns in Vertical Slice Architecture with pipeline behaviors or endpoint filters that wrap every handler automatically. Validation, logging, transactions, and caching live in one place, and each slice declares only what is unique to it, like its validator or cache key. Copy-pasting those concerns into every handler is how the pattern gets a bad name, and here is how I avoid it.

The Duplication Problem

In Vertical Slice Architecture, each feature is self-contained. Cross-cutting concerns are the behaviors that cut across every slice: validation, logging, caching, authorization, transaction management.

You don't want to copy-paste these into every handler. That would defeat the purpose. And this question is really a special case of a broader one I keep coming back to: where does the shared logic live in a sliced codebase?

MediatR Pipeline Behaviors

The most common solution is MediatR pipeline behaviors. They wrap every request handler automatically.

Validation Behavior

Validate every command before the handler executes:

public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        if (!_validators.Any())
        {
            return await next();
        }

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

        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count != 0)
        {
            throw new ValidationException(failures);
        }

        return await next();
    }
}

Each slice just defines a validator:

// Features/Orders/PlaceOrder.cs
public sealed class Validator : AbstractValidator<Command>
{
    public Validator()
    {
        RuleFor(x => x.CustomerId).NotEmpty();
        RuleFor(x => x.Items).NotEmpty();
    }
}

The behavior picks it up automatically - zero wiring per slice.

Logging Behavior

Log every request entry, exit, and duration:

public class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public LoggingBehavior(
        ILogger<LoggingBehavior<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var requestName = typeof(TRequest).Name;
        _logger.LogInformation("Handling {RequestName}", requestName);

        var sw = Stopwatch.StartNew();
        var response = await next();
        sw.Stop();

        _logger.LogInformation(
            "Handled {RequestName} in {ElapsedMs}ms",
            requestName, sw.ElapsedMilliseconds);

        return response;
    }
}

Transaction Behavior

Wrap commands in a database transaction. The ICommand constraint uses the marker interface from combining vertical slices with CQRS:

public class TransactionBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICommand<TResponse>
{
    private readonly ApplicationDbContext _db;

    public TransactionBehavior(ApplicationDbContext db)
    {
        _db = db;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        await using var transaction =
            await _db.Database.BeginTransactionAsync(ct);

        try
        {
            var response = await next();
            await transaction.CommitAsync(ct);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(ct);
            throw;
        }
    }
}

Notice the constraint ICommand<TResponse> - this behavior only wraps commands, not queries. Queries don't need transactions.

Caching Behavior

Cache query results:

public interface ICacheable
{
    string CacheKey { get; }
    TimeSpan? Expiration { get; }
}

public class CachingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>, ICacheable
{
    private readonly IDistributedCache _cache;

    public CachingBehavior(IDistributedCache cache)
    {
        _cache = cache;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        var cachedResult = await _cache.GetStringAsync(request.CacheKey, ct);
        if (cachedResult is not null)
        {
            return JsonSerializer.Deserialize<TResponse>(cachedResult)!;
        }

        var response = await next();

        await _cache.SetStringAsync(
            request.CacheKey,
            JsonSerializer.Serialize(response),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    request.Expiration ?? TimeSpan.FromMinutes(5)
            },
            ct);

        return response;
    }
}

Opt in per query:

public sealed record Query(Guid Id)
    : IRequest<ProductResponse>, ICacheable
{
    public string CacheKey => $"product-{Id}";
    public TimeSpan? Expiration => TimeSpan.FromMinutes(10);
}

Only queries that implement ICacheable get cached. The behavior ignores everything else.

Registration

Register all behaviors in order:

services.AddMediatR(config =>
{
    config.RegisterServicesFromAssembly(typeof(Program).Assembly);
    config.AddOpenBehavior(typeof(LoggingBehavior<,>));
    config.AddOpenBehavior(typeof(ValidationBehavior<,>));
    config.AddOpenBehavior(typeof(TransactionBehavior<,>));
    config.AddOpenBehavior(typeof(CachingBehavior<,>));
});

Order matters. Logging wraps validation, which wraps transaction, which wraps caching.

A request passing through nested Logging, Validation, Transaction, and Caching behaviors before reaching the handler and the database

Pitfalls to Watch For

A few ways this setup bites in practice:

  • Behavior order bugs are silent. If you register CachingBehavior before TransactionBehavior, a cached response can be returned without the transaction ever opening - which is correct for queries but masks a misconfigured command that accidentally implements ICacheable. Review the registration order whenever you add a behavior.
  • Caching failures, not just successes. The caching behavior above serializes whatever the handler returns, including a failed Result. Add a check so only successful responses get cached, or you'll serve a cached error for ten minutes.
  • Transactions around everything. Wrapping every command in an explicit transaction is redundant when the handler makes a single SaveChangesAsync call (EF Core already wraps that in a transaction). Reserve the transaction behavior for handlers that perform multiple save operations.
  • Behavior sprawl. Every behavior runs on every matching request. Ten behaviors deep, debugging a request means stepping through ten wrappers. Keep the pipeline short and boring.

Can You Handle Cross-Cutting Concerns Without MediatR?

If you've moved off MediatR (or never adopted it), the same pattern works as decorators over your own handler interfaces. Define ICommandHandler<TCommand, TResponse>, then register decorators (Scrutor's Decorate makes this one line per behavior). The mechanics change; the idea (cross-cutting logic wraps the handler, slices stay clean) doesn't.

Endpoint Filters (Alternative)

If you prefer not to use MediatR, ASP.NET Core endpoint filters handle cross-cutting concerns at the HTTP layer:

public class ValidationFilter<TRequest> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var request = context.Arguments
            .OfType<TRequest>()
            .FirstOrDefault();

        if (request is null)
        {
            return await next(context);
        }

        var validator = context.HttpContext.RequestServices
            .GetService<IValidator<TRequest>>();

        if (validator is not null)
        {
            var result = await validator.ValidateAsync(request);
            if (!result.IsValid)
            {
                return Results.ValidationProblem(
                    result.ToDictionary());
            }
        }

        return await next(context);
    }
}

Base Handler Classes (Use Sparingly)

Another option - a base class for shared handler logic:

public abstract class BaseHandler
{
    protected readonly ApplicationDbContext Db;
    protected readonly ICurrentUserService CurrentUser;

    protected BaseHandler(
        ApplicationDbContext db,
        ICurrentUserService currentUser)
    {
        Db = db;
        CurrentUser = currentUser;
    }
}

I generally avoid this. It creates coupling and makes the inheritance hierarchy grow. Pipeline behaviors are more flexible.

The Pattern Summary

Here's the mapping I use, concern by concern:

  • Validation: pipeline behavior + FluentValidation. Applies to every request that has a validator; slices without one pass through untouched.
  • Logging: pipeline behavior. Applies to all requests.
  • Transactions: pipeline behavior constrained to ICommand. Write operations only.
  • Caching: pipeline behavior + ICacheable marker. Opt-in per query.
  • Authorization: pipeline behavior or endpoint filter, declared per request.
  • Error handling: a global exception handler at the HTTP boundary, so handlers never need try-catch blocks for presentation concerns.

Summary

Cross-cutting concerns in Vertical Slice Architecture are solved with:

  1. Pipeline behaviors - wrap every handler automatically
  2. Marker interfaces - opt in to specific behaviors (ICacheable, ICommand)
  3. Endpoint filters - HTTP-level concerns
  4. Convention over configuration - validators are discovered, not registered manually

Each slice stays focused on its feature. The pipeline handles everything else.

Thanks for reading, and stay awesome!


Frequently Asked Questions

How do you avoid code duplication in Vertical Slice Architecture?

Push cross-cutting concerns (validation, logging, transactions, caching) into pipeline behaviors or endpoint filters that wrap every handler automatically. Each slice only declares what is unique to it, like its validator or cache key.

What are MediatR pipeline behaviors?

Pipeline behaviors are middleware for MediatR requests. Each behavior wraps the handler and can run logic before and after it, which makes them the standard place for validation, logging, and transaction management.

Can I handle cross-cutting concerns without MediatR?

Yes. ASP.NET Core endpoint filters cover validation and authorization at the HTTP layer, middleware handles logging and error handling globally, and decorators around your own handler interfaces replicate the behavior pipeline.

Should every slice get wrapped in a database transaction?

No. Constrain the transaction behavior to commands. Queries do not modify state, so wrapping them in transactions only adds overhead.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that 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.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.