C# / .NET: The Cleanest Way to Introduce a New Cross-Cutting Concern

Logging, validation, caching, retries, authorization, telemetry, auditing. Every non-trivial .NET application accumulates cross-cutting concerns — behavior that applies broadly but doesn’t belong in the core business logic of any single feature.

The question isn’t whether you need them. It’s where they belong and which mechanism keeps them clean as the system grows.

Here’s a practical decision framework to help with building and maintaining .NET systems: the right home for a cross-cutting concern depends on the layer it naturally touches, how broadly it applies, and whether it needs access to business context.

Pick the wrong mechanism and you end up with scattered try/catch blocks, duplicated if checks, or mysterious behavior buried in base classes nobody reads.

There are more tips and techniques, but this article contains a few of the most commonly used.


The Decision Framework

Before writing any code, ask three questions about the concern:

  1. Scope — Does it apply to every HTTP request, every service call, or only certain operations?
  2. Context needed — Does it require access to HTTP details, method signatures, domain objects, or runtime configuration?
  3. Composability — Should it stack with other concerns transparently, or does it need explicit opt-in?

Those answers point to one of five mechanisms in modern .NET. The first two are specific to ASP.NET web applications, while the remaining three work in any .NET host — console apps, worker services, Azure Functions, and web apps alike.

Mechanism Best For Scope
Middleware Request-level concerns (logging, correlation IDs, global error handling, security headers) Every HTTP request
Action Filters Controller-level concerns (model validation, authorization attributes, response shaping) Specific controllers or actions
Decorators / Wrappers Service-level concerns (caching, retries, circuit breakers, telemetry around business operations) Specific service interfaces
Generic Pipeline (Chain of Responsibility) Handler-level concerns (validation, logging, transactions per command/query) Every or selected CQRS operations
Domain Services / Interceptors Business-rule concerns (auditing domain events, enforcing invariants) Core domain operations

Let’s walk through each one with practical .NET 8+ examples.

Middleware: The HTTP Boundary (ASP.NET)

Middleware is a pipeline component in ASP.NET that wraps every HTTP request and response. Each middleware component receives the request, optionally does work, calls the next component in the chain, and then optionally does more work on the way back out. It’s the outermost layer of your application — the first code that touches an incoming request and the last code that touches the outgoing response.

Middleware is right when the concern applies to every HTTP request and only needs HttpContext.

public class CorrelationIdMiddleware
{
    private readonly RequestDelegate _next;

    public CorrelationIdMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers["X-Correlation-Id"].FirstOrDefault()
            ?? Guid.NewGuid().ToString();

        context.Items["CorrelationId"] = correlationId;
        context.Response.Headers["X-Correlation-Id"] = correlationId;

        using (Serilog.Context.LogContext.PushProperty("CorrelationId", correlationId))
        {
            await _next(context);
        }
    }
}

Registration in Program.cs:

app.UseMiddleware<CorrelationIdMiddleware>();

Use middleware when: the concern applies uniformly to all HTTP traffic and only needs request/response-level context. Good examples include request/response logging, correlation ID propagation, global exception handling, rate limiting, security headers, and request timing.

Not a good fit when: you need access to the action method, model binding results, or service-layer context. If the concern only applies to certain endpoints or needs to inspect deserialized arguments, look at filters or decorators instead.

Action Filters: The Controller Boundary (ASP.NET MVC)

Action filters are attributes or services in ASP.NET MVC that execute code before or after a controller action runs. Unlike middleware, filters operate inside the MVC pipeline — after routing, model binding, and authentication have already happened. This means they have access to the ActionExecutingContext, including route data, action arguments, and model state.

public class ValidateModelAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            context.Result = new BadRequestObjectResult(context.ModelState);
        }
    }
}

Apply it globally, per controller, or per action:

// Global registration
builder.Services.AddControllers(options =>
{
    options.Filters.Add<ValidateModelAttribute>();
});

// Or per action
[ValidateModel]
[HttpPost]
public IActionResult CreateOrder(CreateOrderRequest request) { ... }

Use filters when: the concern needs MVC-level context such as model state or action metadata, or when you want to apply behavior selectively to specific controllers or actions. Good examples include input validation, authorization policies, response caching headers, audit logging of controller actions, and idempotency enforcement.

Not a good fit when: the concern belongs around a service call that isn’t triggered by an HTTP request (background jobs, message handlers), or when it should apply regardless of whether MVC is in the pipeline.

Minimal APIs note: If you’re using Minimal APIs instead of controllers, the equivalent concept is IEndpointFilter. The pattern is similar — you get access to EndpointFilterInvocationContext and can short-circuit or modify behavior per endpoint or globally.

Decorators: The Service Boundary

A decorator is a class that implements the same interface as another class, wraps it, and adds behavior before or after delegating to the inner implementation. It’s a classic object-oriented design pattern — and in .NET, it’s one of the cleanest ways to add cross-cutting behavior to a specific service interface without modifying the original implementation. Decorators compose transparently and respect the single responsibility principle.

Here’s a caching decorator for an order repository:

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(int id, CancellationToken ct = default);
}

public class OrderRepository : IOrderRepository
{
    public async Task<Order?> GetByIdAsync(int id, CancellationToken ct = default)
    {
        // Database call
    }
}

public class CachedOrderRepository : IOrderRepository
{
    private readonly IOrderRepository _inner;
    private readonly IMemoryCache _cache;

    public CachedOrderRepository(IOrderRepository inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<Order?> GetByIdAsync(int id, CancellationToken ct = default)
    {
        var key = $"order:{id}";
        if (_cache.TryGetValue(key, out Order? cached))
            return cached;

        var order = await _inner.GetByIdAsync(id, ct);
        if (order is not null)
            _cache.Set(key, order, TimeSpan.FromMinutes(5));

        return order;
    }
}

Wire it up in DI:

builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<IOrderRepository>(sp =>
    new CachedOrderRepository(
        sp.GetRequiredService<OrderRepository>(),
        sp.GetRequiredService<IMemoryCache>()));

For retries and circuit breakers, you can decorate HttpClient handlers using Polly with Microsoft.Extensions.Http.Resilience:

builder.Services.AddHttpClient<IPaymentGateway, PaymentGateway>()
    .AddStandardResilienceHandler();

Use decorators when: you need to add behavior to a specific service contract transparently — meaning the caller doesn’t know or care that the behavior exists. Good examples include caching, retries, circuit breakers, telemetry, and logging around specific operations.

Strength: They stack. You can wrap CachedOrderRepository around LoggingOrderRepository around OrderRepository without any of them knowing about each other.

Not a good fit when: the concern applies broadly across many unrelated services (a generic pipeline or middleware may be simpler), or when the interface has too many members to wrap practically.

Generic Pipeline: The Handler Boundary

A generic pipeline is the chain of responsibility pattern applied to command or query handlers. Instead of calling a handler directly, you pass the request through a series of behaviors — each one can inspect or modify the request, short-circuit execution, or add logic before and after the next step runs. You don’t need a third-party library for this. A simple generic interface and the built-in DI container give you composable cross-cutting power using only native C#.

Define a pipeline behavior contract:

public delegate Task<TResponse> PipelineDelegate<TResponse>();

public interface IPipelineBehavior<TRequest, TResponse>
{
    Task<TResponse> HandleAsync(
        TRequest request,
        PipelineDelegate<TResponse> next,
        CancellationToken cancellationToken);
}

Now write a validation behavior:

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

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

    public async Task<TResponse> HandleAsync(
        TRequest request,
        PipelineDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var failures = _validators
            .Select(v => v.Validate(request))
            .SelectMany(r => r.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next();
    }
}

Build the pipeline by resolving behaviors from DI and composing them around the handler:

public class Pipeline<TRequest, TResponse>
{
    private readonly IEnumerable<IPipelineBehavior<TRequest, TResponse>> _behaviors;

    public Pipeline(IEnumerable<IPipelineBehavior<TRequest, TResponse>> behaviors)
    {
        _behaviors = behaviors;
    }

    public Task<TResponse> ExecuteAsync(
        TRequest request,
        Func<TRequest, CancellationToken, Task<TResponse>> handler,
        CancellationToken ct)
    {
        PipelineDelegate<TResponse> current = () => handler(request, ct);

        foreach (var behavior in _behaviors.Reverse())
        {
            var next = current;
            current = () => behavior.HandleAsync(request, next, ct);
        }

        return current();
    }
}

Register behaviors in DI:

builder.Services.AddTransient(
    typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddTransient(typeof(Pipeline<,>));

Now every command and query gets validated before the handler runs — no third-party packages, no attributes, no manual calls.

Use a generic pipeline when: the same cross-cutting concerns (validation, logging, performance measurement, transactions, authorization) need to apply to every command or query handler in a consistent way — especially in CQRS-style architectures where you have many small handlers.

Strength: This pattern uses only native C# generics and Microsoft’s DI container. You can add as many behaviors as needed and they compose in registration order.

Not a good fit when: your application only has a handful of service methods with different signatures. In that case, decorators or explicit calls may be simpler than building a generic pipeline infrastructure.

Domain Services: The Business Boundary

A domain service is a class that encapsulates business logic that doesn’t naturally belong on a single entity. Unlike decorators or pipelines — which are invisible wrappers — domain services make cross-cutting behavior explicit in the code because the behavior is genuinely part of the business rules. Auditing a financial transaction, enforcing a domain invariant, or publishing a domain event — these belong close to the domain, not hidden in infrastructure wrappers.

public class TransferService
{
    private readonly IAccountRepository _accounts;
    private readonly IAuditLog _audit;
    private readonly IEventPublisher _events;

    public TransferService(
        IAccountRepository accounts, 
        IAuditLog audit,
        IEventPublisher events)
    {
        _accounts = accounts;
        _audit = audit;
        _events = events;
    }

    public async Task ExecuteTransferAsync(
        TransferCommand command, CancellationToken ct)
    {
        var source = await _accounts.GetByIdAsync(command.SourceAccountId, ct);
        var destination = await _accounts.GetByIdAsync(command.DestinationAccountId, ct);

        source.Debit(command.Amount);
        destination.Credit(command.Amount);

        await _accounts.SaveAsync(source, ct);
        await _accounts.SaveAsync(destination, ct);

        await _audit.RecordAsync(new TransferAuditEntry(command, DateTimeOffset.UtcNow), ct);
        await _events.PublishAsync(new TransferCompleted(command), ct);
    }
}

(Transaction handling is omitted here for clarity — in production you’d wrap the debits, credits, and saves in a unit of work or database transaction.)

This isn’t middleware magic or a hidden decorator. The auditing is explicit because it’s part of the business requirement, not a technical convenience.

Use domain services when: the concern is inseparable from the business operation, when hiding it would obscure important behavior, or when the rules around it are complex enough to warrant explicit visibility.

Beyond the Web Host: Cross-Cutting in Any .NET App

Not every .NET application is a web app. Console applications, worker services, Azure Functions, and class libraries need cross-cutting concerns too — and they don’t have middleware or filters available.

Here are the patterns that work across any .NET 8+ application:

DI + Decorators (Universal)

The decorator pattern shown earlier works everywhere Microsoft.Extensions.DependencyInjection is available — which is virtually every modern .NET host. Console apps, background workers, and Functions all support it.

IHostedService and Background Workers

For concerns like periodic health checks, telemetry flushing, or cache warming in a worker service:

public class TelemetryFlushService : BackgroundService
{
    private readonly TelemetryClient _telemetry;

    public TelemetryFlushService(TelemetryClient telemetry)
    {
        _telemetry = telemetry;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
            _telemetry.Flush();
        }
    }
}

Source Generators and Compile-Time Interception

.NET 8 introduced interceptors (experimental) and mature source generators. Libraries like Microsoft.Extensions.Logging already use source-generated logging methods that add structured logging with zero reflection overhead:

public static partial class Log
{
    [LoggerMessage(Level = LogLevel.Information, Message = "Processing order {OrderId}")]
    public static partial void ProcessingOrder(this ILogger logger, int orderId);
}

This approach works in any .NET application — no web host required.

ActivitySource and OpenTelemetry (Universal)

Distributed tracing via System.Diagnostics.ActivitySource is framework-agnostic. Use it in console apps, libraries, and services alike:

private static readonly ActivitySource s_source = new("MyApp.Orders");

public async Task<Order> ProcessOrderAsync(int orderId, CancellationToken ct)
{
    using var activity = s_source.StartActivity("ProcessOrder");
    activity?.SetTag("order.id", orderId);

    // Business logic here
}

Key Takeaway for Non-Web .NET

If your app doesn’t have an HTTP pipeline, your primary tools are:

  • Decorators for composable service-level concerns
  • DI registration to wire them up
  • Source generators for zero-overhead logging and serialization
  • ActivitySource for telemetry
  • Generic pipelines for command/query cross-cutting

These all work identically in a console app, a worker service, or an ASP.NET web app.

Choosing the Right Mechanism: A Quick Checklist

When adding a new cross-cutting concern, walk through this:

  1. Does it apply to all HTTP traffic regardless of what’s behind it? → Middleware (ASP.NET)
  2. Does it need MVC context (model state, route data, action metadata)? → Filter (ASP.NET MVC)
  3. Does it wrap a specific service contract transparently? → Decorator (any .NET app)
  4. Does it apply to every command/query in a CQRS pipeline? → Generic Pipeline (any .NET app)
  5. Is it a genuine business rule that should be visible in domain code? → Domain Service (any .NET app)

If you’re unsure, lean toward the most specific mechanism that still covers your use case. A concern placed too broadly (middleware for something that only matters on one endpoint) creates unnecessary coupling and makes the behavior harder to reason about.

Common Mistakes to Avoid

  • Base class inheritance for cross-cutting logic. A BaseController or BaseService that does logging, validation, and authorization becomes a dumping ground. Prefer composition.
  • Static helpers sprinkled everywhere. Calling Logger.Log(...) directly in business code couples your logic to infrastructure and makes testing harder.
  • Doing too much in middleware. If your middleware is reading request bodies, deserializing JSON, and making database calls, it’s probably a service pretending to be middleware.
  • Hiding critical business behavior. Auditing a compliance-sensitive operation should not be invisible. If a regulator asks “where does this get logged?”, the answer should be findable without tracing decorator chains.

Wrapping Up

Cross-cutting concerns are inevitable. The discipline is in placing each one at the right layer with the right mechanism — so that your core business logic stays clean, your infrastructure stays composable, and the next developer who joins the team can understand where behavior lives without a treasure map.

Start with the decision framework: scope, context needed, composability. The answer usually becomes obvious once you ask those three questions.

Chris Pietschmann
Chris Pietschmann
Microsoft MVP (Azure & Dev Tools) | HashiCorp Ambassador | IBM Champion | MCT | Developer | Author

I am a solution architect, developer, SRE, trainer, author, and more. With 25 years of experience in the Software Development industry that includes working as a Consultant and Trainer in a wide array of different industries.