When an application is small, adding a little validation or logging directly inside a handler doesn't look particularly dangerous.

Handle request
  → validate
  → log
  → try
      → execute business logic
    catch
      → log exception

Do this once and it looks harmless.

Do it across dozens of use cases and something changes. The business logic is still there, but now you have to find it underneath infrastructure concerns repeated throughout the application.

While revisiting cross-cutting concerns in a .NET application, I was reminded of a principle that sounds obvious but has significant architectural consequences:

A use case should describe what the application does. Cross-cutting concerns should describe how the application consistently behaves while doing it.

Those responsibilities don't necessarily belong in the same place.


Put application-wide policy around the use case

One approach is to build a request pipeline around the application handler. Validation, logging, and exception handling can execute consistently without every handler having to implement those concerns itself.

Request
   ↓
Exception handling
   ↓
Request logging
   ↓
Validation
   ↓
Use-case handler
   ↓
Result

The important idea isn't MediatR itself. MediatR pipeline behaviors are one implementation mechanism. The architectural lesson is that policies applying across many use cases can live around those use cases rather than inside each one.

That keeps the handler focused on the behavior it actually owns.


Validation is a policy, not the use case

A command can have rules describing what a valid request looks like. FluentValidation is one way to express those rules:

RuleFor(c => c.Title).NotEmpty();
RuleFor(c => c.Description).NotEmpty();
RuleFor(c => c.Location).NotEmpty();

RuleFor(c => c.EndsAtUtc)
    .Must((cmd, endsAt) => endsAt > cmd.StartsAtUtc)
    .When(c => c.EndsAtUtc.HasValue);

But the handler doesn't also need to be responsible for remembering to invoke validation.

If every use case has to remember:

Validate
Log
Handle exceptions
Measure?
Authorize?
Execute business logic

then application policy depends on every developer implementing every concern correctly every time.

A pipeline changes that model. The application establishes the policy once and applies it consistently.

The less a guardrail depends on someone remembering it, the stronger that guardrail becomes.


Logging becomes more useful when it carries context

Centralized request logging also makes it easier to attach consistent context to application activity.

Instead of scattering messages such as Starting request... throughout handlers, a request pipeline can enrich logs with information such as the module, request name, outcome, and failure details.

Module  = Events
Request = CreateEventCommand
Status  = Success

That is an important distinction between merely having logs and creating useful operational evidence.

Scattered logging tends to produce messages. Structured logging produces context.

And context is what becomes valuable when something fails in production.


Expected failures and unexpected failures are different

Another lesson that stood out to me is the separation between expected application failures and unexpected exceptions.

An expected failure can be represented deliberately:

Validation failure → 400
Application problem → 400
Not found           → 404
Conflict            → 409

The presentation layer can translate those outcomes into a consistent HTTP representation such as ProblemDetails.

An unexpected exception is different. It can be logged and handled globally, ultimately becoming a server error rather than pretending to be a normal business outcome.

That leads to a distinction worth keeping:

Not every failure is an exception, and not every exception should become a business result.

A missing entity can be expected. A validation error can be expected. A business conflict can be expected. A database connection unexpectedly disappearing is a different category of failure.

Using one mechanism for everything makes those categories harder to reason about.


Cross-cutting doesn't mean “put everything in middleware”

ASP.NET Core middleware and application pipeline behaviors can both surround work, but they operate at different boundaries.

HTTP Request
     ↓
ASP.NET Core middleware
     ↓
Endpoint
     ↓
Application request pipeline
     ↓
Use-case handler

HTTP-specific concerns naturally belong closer to the HTTP pipeline. Application-request concerns naturally belong closer to the application pipeline.

This keeps the application from becoming unnecessarily aware of HTTP while also keeping HTTP middleware from needing to understand every command and query.

So the useful question isn't simply:

Should this be middleware?

It's:

At which boundary does this concern belong?


The endpoint becomes pleasantly boring

Once these policies are established elsewhere, an endpoint can become an adapter between HTTP and the application:

HTTP request
    ↓
Create command
    ↓
Send
    ↓
Match result
    ↓
HTTP response

That's good boring.

The endpoint doesn't need to understand validation mechanics. It doesn't need a large try/catch. It doesn't implement application logging or manually translate every possible failure.

The surrounding policies have already been established.


There is still such a thing as too much abstraction

There is a trade-off here.

Once we discover pipeline behaviors, it is tempting to put everything into them:

Validation
Logging
Transactions
Caching
Authorization
Performance
Retries
Auditing
Metrics
...

Before long, understanding what happens before a handler executes can become difficult.

Cross-cutting abstractions remove repetition, but they also introduce implicit behavior. The handler becomes cleaner while some runtime behavior becomes less visible locally.

So the rule I'm taking away isn't “put everything in a pipeline.” It's:

Centralize a concern when consistency across many use cases is more valuable than keeping that behavior explicit inside each use case.

That's a trade-off, not a commandment.


What I'm taking away

Cross-cutting concerns aren't primarily about reducing duplicated code. They're about establishing consistent application policies.

Validation says every applicable request must satisfy its rules before execution. Logging says application requests should leave useful operational evidence. Exception handling says unexpected failures should be handled consistently. Result mapping says expected application failures should have predictable HTTP representations.

Once those policies are centralized, individual use cases become easier to read because they describe the behavior they're actually responsible for.

Which brings me back to the principle I want to remember:

A use case should describe what the application does. Cross-cutting concerns should describe how the application consistently behaves while doing it.

The goal isn't abstraction for abstraction's sake.

The goal is to keep business behavior visible while making application-wide policies difficult to forget.


Further Learning

This article grew out of concepts I revisited while studying Milan Jovanović's Modular Monolith Architecture material. I'm documenting the ideas that stood out to me, how I understand them, and how they connect with engineering practices I've encountered over the years.

The course provides the learning context; the observations and conclusions here are my own interpretation.

Explore Milan Jovanović's Modular Monolith Architecture course →

Disclosure: The course link above is an affiliate link. If you purchase through it, I may earn a commission at no additional cost to you. I've personally enrolled in Milan's architecture courses and use them as part of my continuing software architecture learning.

Learning the Next Thing.