Building a Custom Middleware Pipeline in .NET MAUI
π Building a Custom Middleware Pipeline in .NET MAUI
When developers hear the word middleware, the first thing that usually comes to mind is ASP.NET Core. And for good reason. ASP.NET Core middleware gives us an elegant way to construct a processing pipeline where every component can:
- Inspect an operation.
- Execute logic before it.
- Pass control to the next component.
- Execute logic after it.
- Short-circuit the pipeline entirely. That architecture is incredibly useful on the server. But the underlying pattern is not inherently tied to HTTP servers. A sufficiently large .NET MAUI application also contains operations that repeatedly pass through the same cross-cutting concerns:
Validation
β
Authentication
β
Connectivity
β
Logging
β
Telemetry
β
Caching
β
Retry / Resilience
β
Actual Operation
Without a common abstraction, those concerns tend to spread throughout ViewModels, services, repositories, commands, and API clients. A ViewModel eventually starts looking like this:
public async Task LoadOrdersAsync()
{
if (IsBusy)
return;
if (Connectivity.Current.NetworkAccess != NetworkAccess.Internet)
{
await Shell.Current.DisplayAlert(
"Error",
"No internet connection.",
"OK");
return;
}
try
{
IsBusy = true;
_logger.LogInformation("Loading orders");
var token = await _tokenService.GetTokenAsync();
if (string.IsNullOrWhiteSpace(token))
{
await _navigationService.NavigateToLoginAsync();
return;
}
var orders = await _orderService.GetOrdersAsync(token);
Orders.Clear();
foreach (var order in orders)
Orders.Add(order);
_logger.LogInformation(
"Loaded {Count} orders",
orders.Count);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to load orders");
await Shell.Current.DisplayAlert(
"Error",
"Something went wrong.",
"OK");
}
finally
{
IsBusy = false;
}
}
Nothing here is particularly unusual. The problem is that much of this code isn't actually about loading orders. The real business operation is essentially:
var orders = await _orderService.GetOrdersAsync();
Everything else is infrastructure. As the application grows, the same patterns appear around dozensβor hundredsβof operations. This article explores how we can build a custom middleware pipeline for .NET MAUI that centralizes those concerns while remaining lightweight, asynchronous, dependency-injection friendly, testable, and completely independent of ASP.NET Core's HTTP pipeline.
π Table of Contents
- Why Middleware Makes Sense in .NET MAUI
- The Problem with Scattered Cross-Cutting Concerns
- Understanding the Middleware Pattern
- Designing the Pipeline Architecture
- Creating the Pipeline Context
- Defining the Middleware Contract
- Implementing the Pipeline Builder
- Registering Middleware with Dependency Injection
- Building Logging Middleware
- Building Connectivity Middleware
- Building Authentication Middleware
- Building Validation Middleware
- Building Performance Middleware
- Building Exception Handling Middleware
- Short-Circuiting the Pipeline
- Passing Data Between Middleware Components
- Building Typed Pipelines
- Middleware Ordering
- Integrating the Pipeline with MVVM
- Using Pipelines in Application Services
- Creating Multiple Pipelines
- Cancellation Support
- Threading Considerations
- Testing Middleware in Isolation
- Testing the Complete Pipeline
- Performance Considerations
- Common Mistakes
- When Middleware Is the Wrong Abstraction
- Production Architecture
- Final Thoughts
- π§ Why Middleware Makes Sense in .NET MAUI =============================================
A mobile application isn't an HTTP server, but it still executes operations through predictable stages. Consider a payment operation:
User taps "Pay"
β
Validate payment information
β
Check authentication
β
Check connectivity
β
Record telemetry
β
Execute payment
β
Record duration
β
Handle errors
β
Update UI
Or synchronization:
Synchronization requested
β
Check connectivity
β
Check current session
β
Prevent duplicate synchronization
β
Record diagnostics
β
Execute synchronization
β
Update local database
Or navigation:
Navigation requested
β
Validate route
β
Check authentication
β
Check navigation guard
β
Record telemetry
β
Navigate
The underlying structure is remarkably similar. We have:
An operation surrounded by reusable behaviors.
That is exactly the problem middleware solves.
- β οΈ The Problem with Scattered Cross-Cutting Concerns =======================================================
Suppose we have three application services:
public class ProductService
{
public async Task LoadProductsAsync()
{
// connectivity
// logging
// exception handling
// telemetry
// operation
}
}
public class OrderService
{
public async Task LoadOrdersAsync()
{
// connectivity
// logging
// exception handling
// telemetry
// operation
}
}
public class ProfileService
{
public async Task LoadProfileAsync()
{
// connectivity
// logging
// exception handling
// telemetry
// operation
}
}
This creates several problems.
π΄ Duplication
The same infrastructure logic appears everywhere.
π΄ Inconsistent behavior
One developer checks connectivity before logging. Another logs before checking connectivity. Another forgets connectivity completely.
π΄ Difficult testing
Testing the actual operation requires navigating through unrelated infrastructure code.
π΄ Difficult evolution
Suppose the application suddenly needs to attach correlation IDs to important operations. Without a pipeline:
50 operations
β
50 modifications
With middleware:
1 new middleware
β
Pipeline registration
That difference becomes significant in enterprise applications.
- π Understanding the Middleware Pattern ==========================================
The fundamental idea is surprisingly small. Imagine this delegate:
public delegate Task PipelineDelegate(PipelineContext context);
A middleware component receives:
- The current context.
- A delegate representing the next component. Conceptually:
public interface IPipelineMiddleware
{
Task InvokeAsync(
PipelineContext context,
PipelineDelegate next);
}
A middleware can execute code before and after next:
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
// Before
await next(context);
// After
}
This gives us nested execution. Suppose the pipeline contains:
ExceptionMiddleware
LoggingMiddleware
PerformanceMiddleware
Operation
Execution becomes:
Exception START
Logging START
Performance START
Operation
Performance END
Logging END
Exception END
That nesting is one of the most powerful characteristics of middleware.
- ποΈ Designing the Pipeline Architecture ==========================================
We'll build the following architecture:
βββββββββββββββββββββββ
β View / ViewModel β
ββββββββββββ¬βββββββββββ
β
βΌ
βββββββββββββββββββββββ
β PipelineExecutor β
ββββββββββββ¬βββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Middleware Pipeline β
β β
β Exception β Logging β Auth β Connectivity β Timing β
β β
βββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββ
β Final Operation β
βββββββββββββββββββ
We'll intentionally avoid depending on ASP.NET Core middleware types. This is our own application pipeline.
- π¦ Creating the Pipeline Context ===================================
Every pipeline needs some representation of the operation currently being executed. Start with:
public sealed class PipelineContext
{
public PipelineContext(
string operationName,
CancellationToken cancellationToken = default)
{
OperationName = operationName;
CancellationToken = cancellationToken;
}
public string OperationName { get; }
public CancellationToken CancellationToken { get; }
public IDictionary<string, object?> Items { get; } =
new Dictionary<string, object?>();
public bool IsCompleted { get; set; }
public object? Result { get; set; }
}
This context contains a few important concepts.
OperationName
Useful for:
- Logging.
- Metrics.
- Diagnostics.
- Filtering middleware behavior.
CancellationToken
Allows cancellation to flow through the complete pipeline.
Items
Provides a flexible mechanism for sharing information between middleware components.
IsCompleted
Can indicate that a middleware has intentionally terminated processing.
Result
Allows middleware or the final operation to provide a result.
- π Defining the Middleware Contract ======================================
Now define the pipeline delegate:
public delegate Task PipelineDelegate(
PipelineContext context);
And the middleware interface:
public interface IPipelineMiddleware
{
Task InvokeAsync(
PipelineContext context,
PipelineDelegate next);
}
This contract is intentionally minimal. Every middleware has exactly one decision to make:
Do something and call
next, or stop processing.
- π Implementing the Pipeline Builder =======================================
We now need something capable of assembling multiple middleware components into a single executable delegate.
public sealed class PipelineBuilder
{
private readonly IList<Func<PipelineDelegate, PipelineDelegate>>
_components = new List<Func<PipelineDelegate, PipelineDelegate>>();
public PipelineBuilder Use(
Func<PipelineDelegate, PipelineDelegate> middleware)
{
_components.Add(middleware);
return this;
}
public PipelineDelegate Build(
PipelineDelegate terminal)
{
var pipeline = terminal;
for (var i = _components.Count - 1; i >= 0; i--)
{
pipeline = _components[i](pipeline);
}
return pipeline;
}
}
Why iterate backwards? Suppose registration is:
builder
.Use(A)
.Use(B)
.Use(C);
We expect execution to be:
A
βββ B
βββ C
βββ Terminal
Therefore construction happens from the terminal operation outward.
- π Registering Middleware with Dependency Injection ======================================================
In real applications, middleware will usually have dependencies. For example:
public sealed class LoggingMiddleware
{
private readonly ILogger<LoggingMiddleware> _logger;
public LoggingMiddleware(
ILogger<LoggingMiddleware> logger)
{
_logger = logger;
}
}
So we want middleware to participate in MAUI's dependency injection container. In MauiProgram.cs:
builder.Services.AddTransient<LoggingMiddleware>();
builder.Services.AddTransient<ConnectivityMiddleware>();
builder.Services.AddTransient<AuthenticationMiddleware>();
builder.Services.AddTransient<PerformanceMiddleware>();
builder.Services.AddTransient<ExceptionHandlingMiddleware>();
We can then create an executor responsible for resolving them.
public interface IPipelineExecutor
{
Task ExecuteAsync(
string operationName,
Func<PipelineContext, Task> operation,
CancellationToken cancellationToken = default);
}
Implementation:
public sealed class PipelineExecutor : IPipelineExecutor
{
private readonly IServiceProvider _serviceProvider;
public PipelineExecutor(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task ExecuteAsync(
string operationName,
Func<PipelineContext, Task> operation,
CancellationToken cancellationToken = default)
{
var context = new PipelineContext(
operationName,
cancellationToken);
var builder = new PipelineBuilder();
AddMiddleware<ExceptionHandlingMiddleware>(builder);
AddMiddleware<LoggingMiddleware>(builder);
AddMiddleware<PerformanceMiddleware>(builder);
var pipeline = builder.Build(operation);
await pipeline(context);
}
private void AddMiddleware<TMiddleware>(
PipelineBuilder builder)
where TMiddleware : IPipelineMiddleware
{
builder.Use(next =>
{
return async context =>
{
var middleware =
_serviceProvider.GetRequiredService<TMiddleware>();
await middleware.InvokeAsync(context, next);
};
});
}
}
Register it:
builder.Services.AddSingleton<IPipelineExecutor, PipelineExecutor>();
We now have the foundation.
- π Building Logging Middleware =================================
Logging is one of the simplest examples of a cross-cutting concern.
public sealed class LoggingMiddleware : IPipelineMiddleware
{
private readonly ILogger<LoggingMiddleware> _logger;
public LoggingMiddleware(
ILogger<LoggingMiddleware> logger)
{
_logger = logger;
}
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
_logger.LogInformation(
"Starting operation {OperationName}",
context.OperationName);
await next(context);
_logger.LogInformation(
"Completed operation {OperationName}",
context.OperationName);
}
}
Notice something important. Logging doesn't know whether the operation:
- Loads products.
- Makes a payment.
- Synchronizes data.
- Navigates somewhere.
- Reads SQLite. It doesn't care. That is exactly what we want from infrastructure.
- π Building Connectivity Middleware =======================================
Mobile applications frequently need to prevent network-dependent operations while offline.
public sealed class ConnectivityMiddleware : IPipelineMiddleware
{
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
if (Connectivity.Current.NetworkAccess !=
NetworkAccess.Internet)
{
throw new InvalidOperationException(
"Internet connectivity is required.");
}
await next(context);
}
}
Now any operation passing through this middleware automatically receives connectivity validation. However, not every operation requires internet. We need metadata. Add:
public bool RequiresInternet { get; init; }
to PipelineContext. Then:
public sealed class ConnectivityMiddleware : IPipelineMiddleware
{
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
if (!context.RequiresInternet)
{
await next(context);
return;
}
if (Connectivity.Current.NetworkAccess !=
NetworkAccess.Internet)
{
throw new InvalidOperationException(
"Internet connectivity is required.");
}
await next(context);
}
}
This is already much more flexible.
- π Building Authentication Middleware =========================================
Authentication is another common cross-cutting concern. Extend the context:
public bool RequiresAuthentication { get; init; }
Middleware:
public sealed class AuthenticationMiddleware : IPipelineMiddleware
{
private readonly IAuthenticationService _authenticationService;
public AuthenticationMiddleware(
IAuthenticationService authenticationService)
{
_authenticationService = authenticationService;
}
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
if (!context.RequiresAuthentication)
{
await next(context);
return;
}
if (!await _authenticationService.IsAuthenticatedAsync())
{
throw new UnauthorizedAccessException(
"Authentication is required.");
}
await next(context);
}
}
Now authentication policy is separated from the actual operation.
- β Building Validation Middleware ====================================
Validation can also happen before the terminal operation. Suppose the context allows an input object:
public object? Request { get; init; }
We could create:
public interface IRequestValidator
{
bool CanValidate(Type requestType);
Task ValidateAsync(
object request,
CancellationToken cancellationToken);
}
Middleware:
public sealed class ValidationMiddleware : IPipelineMiddleware
{
private readonly IEnumerable<IRequestValidator> _validators;
public ValidationMiddleware(
IEnumerable<IRequestValidator> validators)
{
_validators = validators;
}
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
if (context.Request is null)
{
await next(context);
return;
}
var requestType = context.Request.GetType();
foreach (var validator in _validators)
{
if (!validator.CanValidate(requestType))
continue;
await validator.ValidateAsync(
context.Request,
context.CancellationToken);
}
await next(context);
}
}
The operation itself no longer needs to orchestrate validation.
- β±οΈ Building Performance Middleware ======================================
Mobile performance matters. We can measure every pipeline operation automatically:
public sealed class PerformanceMiddleware : IPipelineMiddleware
{
private readonly ILogger<PerformanceMiddleware> _logger;
public PerformanceMiddleware(
ILogger<PerformanceMiddleware> logger)
{
_logger = logger;
}
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
var stopwatch = Stopwatch.StartNew();
try
{
await next(context);
}
finally
{
stopwatch.Stop();
_logger.LogInformation(
"Operation {OperationName} completed in {ElapsedMilliseconds} ms",
context.OperationName,
stopwatch.ElapsedMilliseconds);
}
}
}
Now every operation can produce timing information without knowing anything about performance instrumentation. Example output:
Starting operation LoadOrders
Operation LoadOrders completed in 284 ms
Completed operation LoadOrders
- π‘οΈ Building Exception Handling Middleware ==============================================
Exception handling is particularly interesting because middleware can surround the entire downstream pipeline.
public sealed class ExceptionHandlingMiddleware : IPipelineMiddleware
{
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
public ExceptionHandlingMiddleware(
ILogger<ExceptionHandlingMiddleware> logger)
{
_logger = logger;
}
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
try
{
await next(context);
}
catch (OperationCanceledException)
when (context.CancellationToken.IsCancellationRequested)
{
_logger.LogInformation(
"Operation {OperationName} was cancelled",
context.OperationName);
throw;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Operation {OperationName} failed",
context.OperationName);
throw;
}
}
}
If this middleware is registered first:
ExceptionHandling
β
Logging
β
Authentication
β
Connectivity
β
Performance
β
Operation
it can observe exceptions originating anywhere downstream.
- β Short-Circuiting the Pipeline ===================================
Middleware doesn't have to call next. That enables short-circuiting. Consider caching.
public sealed class CacheMiddleware : IPipelineMiddleware
{
private readonly ICacheService _cache;
public CacheMiddleware(ICacheService cache)
{
_cache = cache;
}
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
if (!context.Items.TryGetValue(
"CacheKey",
out var cacheKeyObject))
{
await next(context);
return;
}
var cacheKey = cacheKeyObject?.ToString();
if (string.IsNullOrWhiteSpace(cacheKey))
{
await next(context);
return;
}
var cached = await _cache.GetAsync(cacheKey);
if (cached is not null)
{
context.Result = cached;
context.IsCompleted = true;
return;
}
await next(context);
if (context.Result is not null)
{
await _cache.SetAsync(
cacheKey,
context.Result);
}
}
}
On a cache hit:
Request
β
CacheMiddleware
β
CACHE HIT
β
Return result
The expensive operation never executes. This is one of the major differences between middleware and simple event handlers. Middleware controls continuation.
- π€ Passing Data Between Middleware Components =================================================
The Items dictionary allows middleware to cooperate without tightly coupling themselves. For example, correlation middleware:
public sealed class CorrelationMiddleware : IPipelineMiddleware
{
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
context.Items["CorrelationId"] =
Guid.NewGuid().ToString("N");
await next(context);
}
}
Logging middleware can retrieve it:
context.Items.TryGetValue(
"CorrelationId",
out var correlationId);
_logger.LogInformation(
"Executing {OperationName}. CorrelationId: {CorrelationId}",
context.OperationName,
correlationId);
The final operation can also access it.
await _pipeline.ExecuteAsync(
"CreateOrder",
async context =>
{
var correlationId =
context.Items["CorrelationId"]?.ToString();
await _orderService.CreateAsync(
order,
correlationId,
context.CancellationToken);
});
- 𧬠Building Typed Pipelines ===============================
An object? Result works, but strongly typed APIs are much nicer. Define:
public sealed class PipelineContext<TRequest, TResponse>
{
public required string OperationName { get; init; }
public required TRequest Request { get; init; }
public TResponse? Response { get; set; }
public CancellationToken CancellationToken { get; init; }
public IDictionary<string, object?> Items { get; } =
new Dictionary<string, object?>();
}
Delegate:
public delegate Task<TResponse> PipelineDelegate<TRequest, TResponse>(
PipelineContext<TRequest, TResponse> context);
Middleware:
public interface IPipelineMiddleware<TRequest, TResponse>
{
Task<TResponse> InvokeAsync(
PipelineContext<TRequest, TResponse> context,
PipelineDelegate<TRequest, TResponse> next);
}
Now a request might be:
public sealed record GetOrdersRequest(
int CustomerId);
And response:
public sealed record GetOrdersResponse(
IReadOnlyList<Order> Orders);
The compiler can enforce the contract throughout the pipeline. This is preferable for larger applications because we eliminate casts such as:
var result = (OrderResponse)context.Result!;
- π§ Middleware Ordering ==========================
Ordering is critical. Consider:
Logging
Exception Handling
Operation
versus:
Exception Handling
Logging
Operation
They are not equivalent. A useful default order might be:
1. Correlation
2. Exception Handling
3. Logging
4. Authentication
5. Validation
6. Connectivity
7. Cache
8. Performance
9. Operation
Why?
Correlation first
Everything downstream can access the same operation identifier.
Exception handling early
It surrounds most of the pipeline.
Logging early
It observes most pipeline behavior.
Authentication and validation before expensive work
Invalid operations should terminate early.
Cache before terminal operation
A hit can avoid unnecessary work.
Performance close to the operation
Depending on what you want to measure, this gives more precise timings. But there is no universally correct order. Your ordering defines application semantics. That means this:
.Use<AuthenticationMiddleware>()
.Use<CacheMiddleware>()
means something different from:
.Use<CacheMiddleware>()
.Use<AuthenticationMiddleware>()
In the second case, cached protected data could theoretically be returned before authentication executes. That's an architectural decision, not merely a formatting difference.
- π§© Integrating the Pipeline with MVVM =========================================
Now we reach the real payoff. Instead of:
[RelayCommand]
private async Task LoadOrdersAsync()
{
// authentication
// connectivity
// logging
// telemetry
// exception handling
// business operation
}
we can write:
public partial class OrdersViewModel : ObservableObject
{
private readonly IPipelineExecutor _pipeline;
private readonly IOrderService _orderService;
public OrdersViewModel(
IPipelineExecutor pipeline,
IOrderService orderService)
{
_pipeline = pipeline;
_orderService = orderService;
}
[ObservableProperty]
private ObservableCollection<Order> orders = [];
[RelayCommand]
private async Task LoadOrdersAsync(
CancellationToken cancellationToken)
{
await _pipeline.ExecuteAsync(
"LoadOrders",
async context =>
{
var result =
await _orderService.GetOrdersAsync(
context.CancellationToken);
Orders = new ObservableCollection<Order>(
result);
},
cancellationToken);
}
}
The ViewModel now expresses intent.
Execute LoadOrders
β
Call OrderService
β
Update Orders
Infrastructure is elsewhere. That's a major improvement in readability.
- π’ Using Pipelines in Application Services ==============================================
Middleware shouldn't be considered a ViewModel-only pattern. Suppose we have synchronization:
public sealed class SynchronizationService
{
private readonly IPipelineExecutor _pipeline;
private readonly IRemoteService _remoteService;
private readonly ILocalDatabase _database;
public SynchronizationService(
IPipelineExecutor pipeline,
IRemoteService remoteService,
ILocalDatabase database)
{
_pipeline = pipeline;
_remoteService = remoteService;
_database = database;
}
public Task SynchronizeAsync(
CancellationToken cancellationToken)
{
return _pipeline.ExecuteAsync(
"Synchronization",
async context =>
{
var remoteChanges =
await _remoteService.GetChangesAsync(
context.CancellationToken);
await _database.ApplyChangesAsync(
remoteChanges,
context.CancellationToken);
},
cancellationToken);
}
}
This becomes particularly useful for operations triggered by:
- Pull-to-refresh.
- Push notifications.
- Background processing.
- Connectivity restoration.
- Manual synchronization. All of them can use the same execution infrastructure.
- π£οΈ Creating Multiple Pipelines ===================================
One global pipeline isn't always desirable. An application may have:
API Pipeline
Navigation Pipeline
Synchronization Pipeline
Payment Pipeline
Background Job Pipeline
For example:
API pipeline
Correlation
Authentication
Connectivity
Retry
Logging
HTTP operation
Navigation pipeline
Logging
Authentication Guard
Unsaved Changes Guard
Navigation operation
Payment pipeline
Correlation
Exception Handling
Authentication
Connectivity
Payment Validation
Idempotency
Telemetry
Payment operation
This is often cleaner than forcing every operation through every middleware. We could define:
public interface IApiPipeline
{
Task<T> ExecuteAsync<T>(
string operationName,
Func<CancellationToken, Task<T>> operation,
CancellationToken cancellationToken = default);
}
And:
public interface INavigationPipeline
{
Task ExecuteAsync(
string route,
Func<CancellationToken, Task> navigation,
CancellationToken cancellationToken = default);
}
Different concerns, different pipelines.
- π Cancellation Support ===========================
Cancellation must be treated as a first-class concern. The context already contains:
public CancellationToken CancellationToken { get; }
Middleware should respect it.
context.CancellationToken.ThrowIfCancellationRequested();
For example:
public sealed class CancellationMiddleware : IPipelineMiddleware
{
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
context.CancellationToken.ThrowIfCancellationRequested();
await next(context);
context.CancellationToken.ThrowIfCancellationRequested();
}
}
More importantly, downstream services should receive the token:
await _api.GetOrdersAsync(
context.CancellationToken);
rather than:
await _api.GetOrdersAsync();
A pipeline makes cancellation propagation much easier to standardize.
- π§΅ Threading Considerations ===============================
A middleware pipeline doesn't automatically imply background execution. This:
await pipeline(context);
executes according to normal .NET async semantics. Do not automatically wrap the pipeline in:
Task.Run(...)
That can create unnecessary thread-pool work and complicate UI interactions. For I/O operations:
await _api.GetDataAsync();
is normally sufficient. If a middleware needs to update MAUI UI state, it should explicitly marshal to the UI thread:
await MainThread.InvokeOnMainThreadAsync(() =>
{
// UI operation
});
But infrastructure middleware ideally shouldn't directly manipulate UI controls. A better architecture is:
Middleware
β
Result / Exception
β
ViewModel
β
UI state
Keep the pipeline UI-independent whenever possible.
- π§ͺ Testing Middleware in Isolation ======================================
One major advantage of middleware is testability. Consider connectivity middleware. We should avoid hard-coding Connectivity.Current if we want clean unit tests. Define:
public interface INetworkStatus
{
bool HasInternetAccess { get; }
}
Implementation:
public sealed class MauiNetworkStatus : INetworkStatus
{
public bool HasInternetAccess =>
Connectivity.Current.NetworkAccess ==
NetworkAccess.Internet;
}
Middleware:
public sealed class ConnectivityMiddleware : IPipelineMiddleware
{
private readonly INetworkStatus _networkStatus;
public ConnectivityMiddleware(
INetworkStatus networkStatus)
{
_networkStatus = networkStatus;
}
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
if (!_networkStatus.HasInternetAccess)
{
throw new InvalidOperationException(
"Internet connectivity is required.");
}
await next(context);
}
}
Now the test can verify short-circuit behavior:
[Fact]
public async Task InvokeAsync_WhenOffline_DoesNotExecuteNext()
{
var networkStatus = new FakeNetworkStatus
{
HasInternetAccess = false
};
var middleware =
new ConnectivityMiddleware(networkStatus);
var nextExecuted = false;
PipelineDelegate next = _ =>
{
nextExecuted = true;
return Task.CompletedTask;
};
var context =
new PipelineContext("TestOperation");
await Assert.ThrowsAsync<InvalidOperationException>(
() => middleware.InvokeAsync(context, next));
Assert.False(nextExecuted);
}
And online behavior:
[Fact]
public async Task InvokeAsync_WhenOnline_ExecutesNext()
{
var networkStatus = new FakeNetworkStatus
{
HasInternetAccess = true
};
var middleware =
new ConnectivityMiddleware(networkStatus);
var nextExecuted = false;
PipelineDelegate next = _ =>
{
nextExecuted = true;
return Task.CompletedTask;
};
await middleware.InvokeAsync(
new PipelineContext("TestOperation"),
next);
Assert.True(nextExecuted);
}
These tests are small, deterministic, and fast.
- π§ͺ Testing the Complete Pipeline ====================================
Individual middleware tests aren't enough. Ordering should also be tested. We can create diagnostic middleware:
public sealed class TrackingMiddleware : IPipelineMiddleware
{
private readonly string _name;
private readonly IList<string> _events;
public TrackingMiddleware(
string name,
IList<string> events)
{
_name = name;
_events = events;
}
public async Task InvokeAsync(
PipelineContext context,
PipelineDelegate next)
{
_events.Add($"{_name}:Before");
await next(context);
_events.Add($"{_name}:After");
}
}
Given:
A
B
Terminal
we expect:
A:Before
B:Before
Terminal
B:After
A:After
Test:
[Fact]
public async Task Pipeline_ExecutesInExpectedOrder()
{
var events = new List<string>();
var middlewareA =
new TrackingMiddleware("A", events);
var middlewareB =
new TrackingMiddleware("B", events);
var builder = new PipelineBuilder();
builder.Use(next =>
context =>
middlewareA.InvokeAsync(context, next));
builder.Use(next =>
context =>
middlewareB.InvokeAsync(context, next));
var pipeline = builder.Build(context =>
{
events.Add("Terminal");
return Task.CompletedTask;
});
await pipeline(
new PipelineContext("Test"));
Assert.Equal(
new[]
{
"A:Before",
"B:Before",
"Terminal",
"B:After",
"A:After"
},
events);
}
This verifies the most fundamental pipeline guarantee.
- β‘ Performance Considerations ================================
Middleware introduces indirection. Instead of:
Method
we now have:
Middleware
β Middleware
β Middleware
β Method
For normal mobile operationsβHTTP calls, SQLite queries, navigation, file I/Oβthe overhead is usually tiny compared with the operation itself. But careless implementations can create unnecessary allocations.
Avoid rebuilding static pipelines unnecessarily
Don't do this thousands of times:
var builder = new PipelineBuilder();
builder.Use(...);
builder.Use(...);
builder.Use(...);
var pipeline = builder.Build(...);
if the middleware chain itself never changes. Consider compiling the chain once and reusing it.
Avoid excessive dictionaries
Items is convenient:
context.Items["CorrelationId"]
but typed context properties are preferable for frequently used values.
Keep middleware focused
A middleware doing this:
Logging
Caching
Authentication
Navigation
Analytics
Retry
UI alerts
is no longer middleware in the architectural sense. It's becoming another god object. Prefer:
LoggingMiddleware
CacheMiddleware
AuthenticationMiddleware
TelemetryMiddleware
Small components compose better.
- π¨ Common Mistakes ======================
β Turning Middleware into Business Logic
Avoid:
public class OrderMiddleware
{
// Calculate totals
// Apply discounts
// Validate inventory
// Save order
}
Those are domain responsibilities. Middleware should generally handle cross-cutting behavior.
β Making Everything Middleware
Not every service needs to become a pipeline component. Bad:
DatabaseMiddleware
CustomerMiddleware
ProductMiddleware
OrderMiddleware
ViewModelMiddleware
NavigationMiddleware
EverythingMiddleware
Middleware works best when behavior surrounds many different operations.
β Ignoring Order
This:
Cache
Authentication
may accidentally expose cached information before authentication. This:
Authentication
Cache
doesn't. Order is part of the architecture.
β Catching and Hiding Every Exception
Avoid:
catch
{
return;
}
This destroys diagnostic information. If exception middleware transforms errors, make the transformation explicit.
β Coupling Middleware to Pages
Avoid:
await Shell.Current.DisplayAlert(...);
inside every infrastructure component.
It makes middleware difficult to:
- Unit test.
- Reuse.
- Execute from background operations.
- Execute before the UI exists.
Prefer exceptions, results, events, or dedicated abstractions.
- π€ When Middleware Is the Wrong Abstraction ===============================================
Middleware is powerful, but it isn't automatically the best solution.
Use it when:
- Multiple operations share cross-cutting behavior.
- Execution ordering matters.
- A component may short-circuit execution.
- Before/after behavior is required.
- You need standardized observability.
- You want composable policies.
Don't use it merely because you have:
ServiceA
ServiceB
ServiceC
Normal dependency injection is often sufficient. If you simply need:
ViewModel
β
Service
β
Repository
keep it that way.
A middleware pipeline should solve complexity, not manufacture it.
- ποΈ Production Architecture ===============================
A larger .NET MAUI application could ultimately look like this:
βββββββββββββββββββββββββββββββββββββββββββββββββ
β UI β
β β
β Views / Pages / Reusable Components β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββ
β ViewModels β
β β
β Commands / Presentation State β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Layer β
β β
β Use Cases / Commands / Application Services β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββ
β Middleware Pipelines β
β β
β Correlation β
β Exception Handling β
β Logging β
β Authentication β
β Validation β
β Connectivity β
β Caching β
β Resilience β
β Telemetry β
β Performance β
ββββββββββββββββββββββββ¬βββββββββββββββββββββββββ
β
βββββββββββΌβββββββββββ
βΌ βΌ βΌ
REST API SQLite Platform APIs
You could go further and maintain specialized pipelines:
Application
β
ββββββββββββββββββΌβββββββββββββββββ
β β β
βΌ βΌ βΌ
API Pipeline Navigation Pipeline Sync Pipeline
β β β
βΌ βΌ βΌ
Backend Shell Local/Remote
And a payment application could add:
Payment Pipeline
β
βββ Correlation
βββ Authentication
βββ Connectivity
βββ Validation
βββ Idempotency
βββ Telemetry
βββ Performance
β
βββ Payment Gateway
The important architectural shift is that application operations no longer need to manually orchestrate every infrastructure concern.
Instead, the operation says what it wants to do.
The pipeline defines how that operation must be executed.
- π― Final Thoughts =====================
Middleware is normally associated with ASP.NET Core, but the architectural pattern is much broader than HTTP request processing.
A .NET MAUI application contains many operations that benefit from exactly the same characteristics:
- Ordered processing.
- Before/after execution.
- Short-circuiting.
- Shared execution context.
- Dependency injection.
- Cancellation.
- Centralized diagnostics.
- Reusable cross-cutting concerns.
Instead of repeating:
Check authentication
Check network
Validate
Start timer
Log
Try
Execute operation
Catch
Log error
Finally
Stop timer
throughout an application, we can define those policies once:
Operation
β
Pipeline
β
Authentication
β
Validation
β
Connectivity
β
Logging
β
Performance
β
Actual Work
The result isn't simply less code.
It's a clearer separation between business intent and execution policy.
For small .NET MAUI applications, introducing a middleware pipeline may be unnecessary.
But once an application grows to include multiple APIs, authentication, synchronization, caching, telemetry, resilience policies, background operations, and complex business workflows, a pipeline can become a powerful architectural boundary.
And perhaps the most useful part of the pattern is how little infrastructure it actually requires.
At its core, everything starts with just this:
public delegate Task PipelineDelegate(
PipelineContext context);
public interface IPipelineMiddleware
{
Task InvokeAsync(
PipelineContext context,
PipelineDelegate next);
}
From those two abstractions, we can build a composable execution model capable of supporting increasingly sophisticated .NET MAUI applications. π
Was this useful?
Sign in to react. Guest comments are still welcome.




Comments (0)
No approved comments yet.