# Cross-Cutting Concerns in Vertical Slice Architecture

> One criticism of Vertical Slice Architecture is code duplication across slices. Here is how to handle cross-cutting concerns like validation, logging, and caching without breaking feature isolation.

Published: 2026-08-13. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/cross-cutting-concerns-in-vertical-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**](https://milanjovanovic.tech/blog/vertical-slice-architecture-dotnet), 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**](https://milanjovanovic.tech/blog/vertical-slice-architecture-where-does-the-shared-logic-live) in a sliced codebase?

## MediatR Pipeline Behaviors

The most common solution is [**MediatR pipeline behaviors**](https://milanjovanovic.tech/blog/mediatr-pipeline-behaviors). They wrap every request handler automatically.

### Validation Behavior

Validate every command before the handler executes:

```csharp
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:

```csharp
// 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:

```csharp
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**](https://milanjovanovic.tech/blog/combining-vertical-slices-cqrs):

```csharp
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:

```csharp
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:

```csharp
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:

```csharp
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](https://milanjovanovic.tech/blogs/articles/cross-cutting-concerns-in-vertical-slice-architecture/behavior-pipeline.png)

## 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:

```csharp
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:

```csharp
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**](https://milanjovanovic.tech/blog/global-error-handling-in-aspnetcore-from-middleware-to-modern-handlers) 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.
