Skip to main content
SEMastery
Architectureintermediate

Balancing Cross-Cutting Concerns in Clean Architecture (.NET)

Learn how to handle logging, validation, caching, and security in Clean Architecture with .NET, using simple words, diagrams, and real code examples.

13 min readUpdated November 12, 2025

A school office that handles every form the same way

Picture a busy school office.

Every day, students hand in different forms. One form is a leave request. Another is a request for a new ID card. Another asks to change a bus route. Each form needs a different decision, made by a different teacher.

But before any teacher sees a form, the office clerk always does the same few steps, no matter which form it is:

  1. Stamp the date and time on it (so there is a record).
  2. Check that the student filled in every required box.
  3. Make sure the student is allowed to ask for that thing.
  4. Keep a photocopy in a file.

The clerk does not care what the form is about. The clerk only does these shared, repeated steps. The teacher behind the desk does the real thinking.

This shared work the clerk does is exactly what we call cross-cutting concerns in software. Logging is the date stamp. Validation is checking the boxes. Security is checking permission. Caching is keeping a copy so you do not redo work. These jobs are needed by almost every feature, yet they are not the real point of any single feature.

This article shows how to handle these shared jobs neatly in Clean Architecture with .NET, so you write them once and never copy-paste them into every feature.

What counts as a cross-cutting concern?

A cross-cutting concern is any job that many features need but none of them owns. It "cuts across" your whole app.

Here are the common ones:

ConcernWhat it doesReal-life clerk job
LoggingRecords what happened and whenStamping the date on every form
ValidationChecks the input is correctMaking sure every box is filled
SecurityChecks the user is allowedChecking the student's permission
CachingReuses a past result to save workKeeping a photocopy on file
Error handlingTurns crashes into safe messagesPolitely returning a wrong form
TransactionsSaves all changes together or noneFiling all pages as one packet

If you write this logic inside each feature, you end up with the same code copied dozens of times. Change one rule and you must hunt through every file. That is slow and easy to get wrong.

The better way is to write each concern once and have it wrap around every feature automatically.

A quick refresher on Clean Architecture layers

Clean Architecture splits an app into rings, like an onion. The inner rings know nothing about the outer rings. Dependencies always point inward.

The four layers of Clean Architecture. Arrows show which layer is allowed to depend on which. Everything points inward to the Domain.

Here is what each ring is for:

  • Domain: the pure business rules and entities. No database code, no web code.
  • Application: the use cases, like "Create Order" or "Get Invoice". It defines interfaces for things it needs.
  • Infrastructure: the real tools that fulfil those interfaces. Databases, email, file loggers, caches.
  • API: the outside edge. Controllers, minimal API endpoints, middleware.

The golden rule: the inner layers must not depend on the outer ones. The Domain does not know a database exists. The Application knows there is "a way to send email" but not which email service.

This rule is the whole reason cross-cutting concerns need careful thought. A logger that writes to a file is an outer-layer detail. But the decision to log every command is an application idea. So where does the code go? That is the balance we are learning.

The wrong way: copy-paste into every handler

Let us see the messy version first, so the clean version feels better.

Imagine every use case is a handler. Without a shared plan, each handler does its own logging and validation:

public class CreateOrderHandler
{
    public async Task<Result> Handle(CreateOrderCommand command)
    {
        // cross-cutting noise starts here
        _logger.LogInformation("Handling {Name}", nameof(CreateOrderCommand));
 
        if (command.Quantity <= 0)
            return Result.Fail("Quantity must be positive");
 
        if (string.IsNullOrWhiteSpace(command.ProductId))
            return Result.Fail("ProductId is required");
        // cross-cutting noise ends here
 
        // the ONE line of real business logic
        var order = Order.Create(command.ProductId, command.Quantity);
        await _repository.AddAsync(order);
 
        _logger.LogInformation("Finished {Name}", nameof(CreateOrderCommand));
        return Result.Ok(order.Id);
    }
}

See the problem? Only one or two lines are real business logic. The rest is repeated plumbing. Now imagine fifty handlers, each with this same plumbing. Changing the log format means editing fifty files.

The clean way: a pipeline that wraps every request

The fix is a pipeline. Think of it as a tunnel that every request passes through. Each shared job sits as a ring inside the tunnel. The request goes in, passes through logging, then validation, then security, and finally reaches the real handler. The response travels back out the same way.

A request travels through pipeline rings before reaching the handler, then the response travels back out. Each ring is one cross-cutting concern.

The beauty is that each handler now contains only business logic. The shared jobs live in their own small classes, written once.

How one request flows through the pipeline

Request in
Log
Validate
Authorise
Handle
Response out

Steps

1

Request in

A command or query arrives

2

Log

Record start and timing

3

Validate

Reject bad input early

4

Authorise

Check the user may do this

5

Handle

Run the real business rule

6

Response out

Return the result

Each step does one job, then passes control to the next step with next().

Building a pipeline without a paid library

For years, the most popular way to build this pipeline in .NET was MediatR, using its IPipelineBehavior. But there is important news: as of July 2025, MediatR (version 13 and later) became commercially licensed for many professional uses. The same happened to MassTransit. Older MediatR versions stay free, but new ones need a paid tier for commercial work.

The good news is that you do not need any library. The pipeline is just an idea, and you can build it yourself in a small amount of code. Free alternatives also exist if you prefer a library.

Here is a tiny home-grown behavior interface and a logging behavior. No external package needed:

// A behavior wraps a request and calls the next step in the chain.
public interface IPipelineBehavior<TRequest, TResponse>
{
    Task<TResponse> Handle(
        TRequest request,
        Func<Task<TResponse>> next,
        CancellationToken ct);
}
 
// Logging behavior: one place, used by every request.
public sealed class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
 
    public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
        => _logger = logger;
 
    public async Task<TResponse> Handle(
        TRequest request,
        Func<Task<TResponse>> next,
        CancellationToken ct)
    {
        var name = typeof(TRequest).Name;
        _logger.LogInformation("Starting {Request}", name);
 
        var watch = System.Diagnostics.Stopwatch.StartNew();
        var response = await next();        // call the next ring (or the handler)
        watch.Stop();
 
        _logger.LogInformation(
            "Finished {Request} in {Ms}ms", name, watch.ElapsedMilliseconds);
 
        return response;
    }
}

Notice the key trick: next(). Each behavior does its bit, then calls next() to pass control deeper. The last next() in the chain calls the real handler. This is the same pattern ASP.NET Core middleware uses.

A validation behavior looks similar. It checks the request and stops the chain early if the input is bad, so the handler never sees rubbish data:

public sealed 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> Handle(
        TRequest request,
        Func<Task<TResponse>> next,
        CancellationToken ct)
    {
        foreach (var validator in _validators)
        {
            var result = await validator.ValidateAsync(request, ct);
            if (!result.IsValid)
            {
                // Stop here. Do not call next(). The handler is skipped.
                throw new ValidationException(result.Errors);
            }
        }
 
        return await next();   // input is clean, continue down the pipeline
    }
}

With these two behaviors registered, the CreateOrderHandler from before shrinks to just its real job. No logging lines. No validation if checks. Clean and focused.

The big question: which layer holds each concern?

This is where most people get stuck. The answer is not "put everything in one place". Different concerns belong in different layers. Here is a simple way to decide.

Ask: does this concern depend on a specific tool or on HTTP?

A decision flow for placing a cross-cutting concern in the right layer.

Let us walk through the common concerns with this rule:

ConcernBest homeWhy
Validation rulesApplicationPure logic, no external tool
Logging policyApplication (behavior)The decision to log is app-level
The actual log writerInfrastructureA file or Seq sink is a tool
Caching policyApplication (behavior)"Cache this query" is an app choice
The cache storeInfrastructureRedis or memory is a tool
Authorisation checksApplication"User must own this order" is a rule
CORS, rate limitingAPI middlewareThese only make sense over HTTP
Global error formattingAPI middlewareTurning errors into HTTP responses

The pattern is clear. The policy (the rule about when and what) often lives as a behavior in the Application layer. The implementation (the real file, cache, or database) lives in Infrastructure, hidden behind an interface. This keeps the Application layer pure while still letting it command real tools.

Behaviors versus middleware: not the same thing

Beginners often mix these up. Both wrap requests, but they live at different depths.

Two wrapping layers, two jobs

HTTP request
API middleware
Endpoint
Behaviors
Handler

Steps

1

HTTP request

Browser or client sends data

2

API middleware

CORS, auth token, errors

3

Endpoint

Controller or minimal API

4

Behaviors

Logging, validation per use case

5

Handler

Pure business logic

Middleware guards the HTTP door. Behaviors guard each use case, even non-HTTP ones.

The key difference: middleware only runs for HTTP requests. If a background job or a queue message triggers the same use case, middleware never runs. But a pipeline behavior runs no matter what triggered the use case, because it wraps the command itself, not the HTTP call.

So a good rule is:

  • Put HTTP-only concerns (CORS, rate limiting, response compression) in middleware.
  • Put use-case concerns (validation, app logging, caching, transactions) in behaviors, so they protect every trigger, not just web calls.

Many real apps use both happily. They are partners, not rivals.

Keeping the balance: do not overdo it

Pipeline behaviors are powerful, which makes them easy to abuse. Here is how to stay balanced.

Order matters. Behaviors run in the order you register them. Logging should usually be first (so it records everything), then validation (so you fail fast on bad input), then security, then the handler. Putting validation after an expensive step wastes work.

// Registration order = execution order. Outer to inner.
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>));
// Handlers run last, in the centre of the onion.

Do not hide business rules in behaviors. A behavior is for shared jobs. If a rule belongs to only one feature, keep it in that feature's handler. Hiding real logic inside a generic behavior makes the app hard to understand.

Watch the cost. Every behavior runs for every request. A heavy behavior slows the whole app. Keep them light. For caching, only cache queries (reads), never commands (writes), or you risk serving stale or wrong data.

Keep the Domain clean. Never let logging, caching, or HTTP code leak into the Domain layer. The Domain should stay pure business rules. If you see ILogger inside an entity, something has drifted out of balance.

Here is a small state view of how a single request moves through the pipeline and where it can stop early:

The life of a request inside the pipeline. Validation can end it early before any business work happens.

A practical placement checklist

When you add a new shared concern, run through this short list:

  1. Is it needed by many features? If only one, keep it local.
  2. Does it depend on HTTP only? Then use middleware.
  3. Does it touch a real tool (db, cache, email)? Hide the tool in Infrastructure behind an interface.
  4. Is the policy pure and tool-free? Put it as a behavior in the Application layer.
  5. Will it run on every request? Keep it fast and simple.
  6. Does it belong to business rules? Then it is not cross-cutting; it is domain logic.

Follow this and your layers stay clean, your features stay focused, and your shared jobs live in exactly one place each.

References and further reading

Quick recap

  • A cross-cutting concern is a shared job many features need: logging, validation, security, caching, error handling, transactions.
  • Copying this code into every handler creates repetition and bugs. Write it once.
  • A pipeline wraps every request in rings of shared jobs, so each handler keeps only its real business logic.
  • You do not need a paid library. MediatR went commercial in 2025, but you can build a tiny pipeline yourself or use a free alternative.
  • Place each concern by asking: does it need a real tool or HTTP? Policy lives in the Application layer; the real tool lives in Infrastructure behind an interface; HTTP-only jobs live in API middleware.
  • Behaviors wrap use cases (work for any trigger). Middleware wraps HTTP calls only. Use both together.
  • Keep behaviors light and ordered (log, then validate, then authorise). Never hide real business rules inside them, and keep the Domain layer pure.

Related Posts

Clean Architecture Folder Structure in .NET: A Simple Guide

Learn how to organise a .NET solution with Clean Architecture. Understand the Domain, Application, Infrastructure, and Presentation layers, the dependency rule, and the exact folders, with diagrams and examples.

Read more

Vertical Slice Architecture in .NET: The Easy Guide

Learn Vertical Slice Architecture in .NET in simple words. Organise code by feature instead of by layer, with diagrams, real examples, a comparison with Clean Architecture, and when to use each.

Read more

What Is a Modular Monolith? A Beginner-Friendly Guide for .NET

Understand the modular monolith in simple words: one app, strong internal walls. Learn how it compares to monoliths and microservices, why it is the 2026 default for most .NET teams, and how to build one.

Read more

Why Clean Architecture Is Great for Complex .NET Projects

A friendly guide to why Clean Architecture shines on big, complex .NET projects: testable business rules, swappable infrastructure, and code that stays kind to change.

Read more

How to Avoid Code Duplication in Vertical Slice Architecture in .NET

Learn how to avoid code duplication in Vertical Slice Architecture in .NET without breaking your slices. Rule of three, pipeline behaviors, shared infrastructure, and clear examples.

Read more

Clean Architecture in .NET: The Benefits of Structured Software Design

A beginner-friendly guide to Clean Architecture in .NET. Learn the four layers, the dependency rule, and why structured software design keeps your code easy to change.

Read more