Middleware¶
minimal-lambda uses a middleware model similar to ASP.NET Core: each component gets a context
object, runs code before/after the next component, and can short-circuit the pipeline. If you're new
to the pattern, the
ASP.NET Core middleware overview
is a helpful primer. This guide focuses on Lambda-specific behavior: invocation scopes, feature
access, and composition tips that keep middleware and handlers decoupled without extra DI plumbing.
Pipeline Basics¶
Register middleware before calling MapHandler. Components execute in registration order and unwind in
reverse order:
var builder = LambdaApplication.CreateBuilder();
var lambda = builder.Build();
lambda.UseMiddleware(async (context, next) =>
{
Console.WriteLine("[Logging] Before handler");
await next(context);
Console.WriteLine("[Logging] After handler");
});
lambda.UseMiddleware(async (context, next) =>
{
Console.WriteLine("[Metrics] Before handler");
await next(context);
Console.WriteLine("[Metrics] After handler");
});
lambda.MapHandler(([FromEvent] Request request) => new Response("ok"));
await lambda.RunAsync();
Output:
ILambdaInvocationContext¶
Every middleware receives the same ILambdaInvocationContext, which is scoped to the invocation.
lambda.UseMiddleware(async (context, next) =>
{
var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>();
if (context.CancellationToken.IsCancellationRequested)
{
logger.LogWarning("Invocation cancelled before handler");
return;
}
context.Items["RequestId"] = Guid.NewGuid().ToString();
context.Items["Start"] = DateTimeOffset.UtcNow;
context.Properties["Version"] ??= "1.0.0"; // safe cross-invocation value
await next(context);
var started = (DateTimeOffset)context.Items["Start"];
logger.LogInformation("Completed in {Duration}ms", (DateTimeOffset.UtcNow - started).TotalMilliseconds);
});
Key members:
ServiceProvider– resolve scoped services for the invocation.CancellationToken– fires before Lambda termination (buffer controlled byLambdaHostOptions.InvocationCancellationBuffer). Pass it to downstream async work.Items– per-invocation storage shared by middleware/handler.Properties– cross-invocation storage.Features– typed capabilities such asIEventFeature<T>andIResponseFeature<T>that let middleware collaborate without injecting each other.
Middleware Approaches¶
MinimalLambda supports two middleware styles:
Inline delegates – Best for simple, application-specific middleware that orchestrates services. Quick to write and keeps logic visible in the pipeline configuration.
Class-based middleware – Best for complex, reusable middleware with dependencies, state management, or disposal needs. Easier to test and share across projects.
Most applications use both: inline delegates for orchestration, class-based for heavy lifting.
Inline Middleware¶
Inline middleware uses delegates registered directly in Program.cs. Despite the inline syntax,
you have full access to the invocation context and all its capabilities:
lambda.UseMiddleware(async (context, next) =>
{
// Resolve services from DI
var cache = context.ServiceProvider.GetRequiredService<ICache>();
var logger = context.ServiceProvider.GetRequiredService<ILogger<Program>>();
// Access event data using type-safe helpers
if (context.TryGetEvent<OrderRequest>(out var request))
{
logger.LogInformation("Processing order {OrderId}", request.OrderId);
// Check cache before continuing
if (cache.TryGet(request.OrderId, out OrderResponse cached))
{
context.Features.Get<IResponseFeature<OrderResponse>>()!.Response = cached;
return; // Short-circuit
}
}
// Store per-invocation data
context.Items["RequestId"] = Guid.NewGuid().ToString();
context.Items["StartTime"] = DateTimeOffset.UtcNow;
await next(context);
// Access response after handler executes
var response = context.GetResponse<OrderResponse>();
if (response is not null && request is not null)
{
await cache.SetAsync(request.OrderId, response);
}
});
What inline middleware can access:
- Dependency Injection -
context.ServiceProvider.GetRequiredService<T>() - Event/Response Data -
GetEvent<T>(),GetResponse<T>(),TryGetEvent<T>()( see Type-Safe Feature Access) - Features -
context.Features.Get<IEventFeature<T>>()( see Working with Features) - Per-Invocation State -
context.Itemsfor temporary data within the request - Cross-Invocation State -
context.Propertiesfor data shared across Lambda invocations - Cancellation -
context.CancellationTokenfor cooperative cancellation - AWS Context - All standard
ILambdaContextproperties (AwsRequestId,RemainingTime, etc.)
When to use inline middleware:
- Application-specific orchestration logic
- Simple logging, metrics, or tracing
- One-off middleware that won't be reused
- Quick prototyping before extracting to a class
- Gluing together services without needing a separate file
Best practice: Keep inline middleware thin. Push complex logic into services registered in DI so the middleware stays readable and testable. Treat inline middleware as the glue between services.
Class-Based Middleware¶
Class-based middleware promotes reusability, testability, and clean separation of concerns. Define a
class implementing ILambdaMiddleware, then register it with UseMiddleware<T>().
var builder = LambdaApplication.CreateBuilder();
var lambda = builder.Build();
lambda.UseMiddleware<LoggingMiddleware>();
lambda.MapHandler(([FromEvent] Request req) => new Response("OK"));
await lambda.RunAsync();
How it works: Source generators intercept UseMiddleware<T>() at compile-time, generating code
that instantiates your middleware and resolves constructor parameters automatically. No reflection,
no runtime overhead.
Reusable packages
Class-based middleware is a good fit for shared packages: ship the middleware type and attributes, and the consuming app's build generates the wiring code. The generated code lives in the application's build output, not in your package.
Dependency Injection¶
Constructor parameters are automatically resolved from the DI container:
Default resolution behavior:
- Parameters without attributes first check args passed to
UseMiddleware<T>(), then fall back to DI - Services must be registered in
builder.Servicesbefore callingbuilder.Build() - Use
[FromServices]to skip args and resolve directly from DI ( see Parameter Sources)
For more on service lifetimes and DI patterns, see Dependency Injection.
Factory-Based Middleware¶
When middleware construction needs to be customized or deferred, register a factory that implements
ILambdaMiddlewareFactory and use UseMiddleware<TFactory>(). The factory is resolved from the
invocation's ServiceProvider and executed per invocation. If the created middleware implements
IDisposable or IAsyncDisposable, it is disposed after the invocation completes.
| CachingMiddlewareFactory.cs | |
|---|---|
var builder = LambdaApplication.CreateBuilder();
builder.Services.AddSingleton<ICache, RedisCache>();
builder.Services.AddSingleton<ILambdaMiddlewareFactory, CachingMiddlewareFactory>();
var lambda = builder.Build();
lambda.UseMiddleware<CachingMiddlewareFactory>();
lambda.MapHandler(([FromEvent] OrderRequest req) => ProcessOrder(req));
await lambda.RunAsync();
Parameter Sources¶
Control how constructor parameters are resolved using attributes:
| Attribute | Source | Behavior |
|---|---|---|
| (none) | Args, then DI | Try args first, fall back to DI if no match |
[FromServices] |
DI only | Resolve from DI container, skip args |
[FromKeyedServices] |
Keyed DI | Resolve keyed service from DI (e.g., "primary" cache) |
[FromArguments] |
Args only | Require value from args; throw if not found |
Example: Mixed Parameter Sources
When to use [FromArguments]
Use [FromArguments] for configuration values that vary per middleware registration (like
cache keys, API endpoints, or feature flags). This makes the middleware reusable with
different configurations.
Multiple Constructors¶
When a middleware class has multiple constructors, the source generator selects the one with the
most parameters by default. Override this behavior with [MiddlewareConstructor]:
Warning
Only one constructor can have [MiddlewareConstructor]. Applying it to multiple constructors
triggers compile-time diagnostic LH0005.
Lifecycle and Disposal¶
Middleware instances are created per invocation. Each Lambda invocation gets a fresh instance, which is disposed after the invocation completes.
IDisposable and IAsyncDisposable:
Disposal timing:
Dispose()orDisposeAsync()is called afterInvokeAsync()completes- Even if an exception occurs, disposal is guaranteed (wrapped in try/finally)
- The generated code prefers
IAsyncDisposableoverIDisposableif both are implemented
Singleton vs. Per-Invocation
Unlike services registered in DI (which can be singleton, scoped, or transient), middleware instances are always per-invocation. For shared state, inject singleton services into the middleware constructor.
For more on lifecycle hooks, see Lifecycle Management.
Testing Class-Based Middleware¶
Class-based middleware is straightforward to test: create instances directly and verify behavior.
Testing with parameter resolution:
Testing Strategy
Test middleware in isolation by mocking ILambdaInvocationContext and the next delegate.
This keeps tests fast and focused on middleware behavior without spinning up the entire pipeline.
For more testing patterns, see Testing.
Real-World Examples¶
JWT Authentication:
Request/Response Transformation (Envelopes):
Distributed Tracing (OpenTelemetry):
Common Patterns¶
Pattern: Conditional Middleware
Run middleware only for specific event types:
internal sealed class ConditionalValidationMiddleware : ILambdaMiddleware
{
private readonly IValidator<OrderRequest> _validator;
public ConditionalValidationMiddleware(IValidator<OrderRequest> validator)
{
_validator = validator;
}
public async Task InvokeAsync(ILambdaInvocationContext context, LambdaInvocationDelegate next)
{
// Only validate if event is OrderRequest
if (context.TryGetEvent<OrderRequest>(out var order))
{
var result = await _validator.ValidateAsync(order);
if (!result.IsValid)
{
// Set error response and short-circuit
context.Features.Get<IResponseFeature<ErrorResponse>>()!.Response
= new ErrorResponse(result.Errors);
return;
}
}
await next(context);
}
}
Pattern: Shared State via DI
Share state across invocations using singleton services:
internal sealed class RateLimitMiddleware : ILambdaMiddleware
{
private readonly IRateLimiter _rateLimiter; // Singleton service
public RateLimitMiddleware(IRateLimiter rateLimiter)
{
_rateLimiter = rateLimiter; // Shared across invocations
}
public async Task InvokeAsync(ILambdaInvocationContext context, LambdaInvocationDelegate next)
{
var clientId = context.Items["ClientId"]?.ToString() ?? "unknown";
if (!await _rateLimiter.AllowRequestAsync(clientId))
{
// Rate limit exceeded
context.Features.Get<IResponseFeature<ErrorResponse>>()!.Response
= new ErrorResponse("Rate limit exceeded");
return;
}
await next(context);
}
}
builder.Services.AddSingleton<IRateLimiter, MemoryRateLimiter>(); // Singleton!
var lambda = builder.Build();
lambda.UseMiddleware<RateLimitMiddleware>();
Pattern: Early Response
Set a response and skip the handler:
internal sealed class MaintenanceModeMiddleware : ILambdaMiddleware
{
private readonly IFeatureFlagService _featureFlags;
public MaintenanceModeMiddleware(IFeatureFlagService featureFlags)
{
_featureFlags = featureFlags;
}
public async Task InvokeAsync(ILambdaInvocationContext context, LambdaInvocationDelegate next)
{
if (await _featureFlags.IsEnabledAsync("maintenance-mode"))
{
context.Features.Get<IResponseFeature<MaintenanceResponse>>()!.Response
= new MaintenanceResponse("Service unavailable during maintenance");
return; // Don't call next
}
await next(context);
}
}
Diagnostics¶
The source generator validates middleware at compile-time:
| Diagnostic | Severity | Description |
|---|---|---|
| LH0005 | Error | Multiple constructors have [MiddlewareConstructor] (only one allowed) |
| LH0006 | Error | Middleware type must be a concrete class (not interface/abstract) |
Example: LH0006
// ❌ This triggers LH0006
lambda.UseMiddleware<ILambdaMiddleware>(); // Interface, not allowed
// ❌ This triggers LH0006
lambda.UseMiddleware<AbstractMiddleware>(); // Abstract class, not allowed
// ✅ This is correct
lambda.UseMiddleware<ConcreteMiddleware>(); // Concrete class
Compile-Time Safety
These diagnostics catch configuration errors during build, not at runtime. This prevents deployment of misconfigured middleware.
Working with Features¶
Features are type-keyed adapters stored inside ILambdaInvocationContext.Features (an
IFeatureCollection). They decouple middleware from handlers: a handler (or the framework) populates a
feature, middleware reads or mutates it, and nobody needs to inject each other through DI. The
collection lazily creates features by asking every registered IFeatureProvider to build them when
first requested.
using MinimalLambda.Abstractions.Features;
lambda.UseMiddleware(async (context, next) =>
{
var eventFeature = context.Features.Get<IEventFeature<OrderRequest>>();
if (eventFeature is { Event: { } request })
Console.WriteLine($"Processing {request.OrderId}");
await next(context);
var responseFeature = context.Features.Get<IResponseFeature<OrderResponse>>();
if (responseFeature?.Response is { } response)
Console.WriteLine($"Result: {response.Status}");
});
Common features:
| Feature | Purpose |
|---|---|
IEventFeature<TEvent> |
Access the deserialized event payload |
IResponseFeature<TResponse> |
Inspect or replace the handler response before serialization |
IInvocationDataFeature |
Access raw event/response streams for envelopes |
Why features matter:
- Middleware can extract values set by handlers (or other middleware) without DI fan-out.
- Handlers remain free of middleware-specific dependencies; they just work with the event/response types.
- Custom features are easy to add—register an implementation of
IFeatureProviderand it becomes available to all middleware.
Type-Safe Feature Access¶
The framework provides convenient extension methods on ILambdaInvocationContext for type-safe event and response access, simplifying the feature access pattern shown above:
lambda.UseMiddleware(async (context, next) =>
{
// Nullable access - returns null if not found
var request = context.GetEvent<OrderRequest>();
if (request is not null)
Console.WriteLine($"Processing order {request.OrderId}");
// Try pattern - safe null checking
if (context.TryGetEvent<OrderRequest>(out var order))
{
// Use order safely without additional null checks
Console.WriteLine($"Order {order.OrderId} has {order.Items.Count} items");
}
await next(context);
// Required access - throws if not found
var response = context.GetRequiredResponse<OrderResponse>();
Console.WriteLine($"Status: {response.Status}");
});
Available Methods:
| Method | Description | Returns |
|---|---|---|
GetEvent<T>() |
Returns event or null if not found |
T? |
GetResponse<T>() |
Returns response or null if not found |
T? |
TryGetEvent<T>(out T) |
Try-pattern for safe event access | bool |
TryGetResponse<T>(out T) |
Try-pattern for safe response access | bool |
GetRequiredEvent<T>() |
Returns event or throws | T (throws InvalidOperationException) |
GetRequiredResponse<T>() |
Returns response or throws | T (throws InvalidOperationException) |
When to use each:
- Nullable methods (
GetEvent<T>()) – When the event/response might not exist and you'll handle null gracefully - Try pattern (
TryGetEvent<T>()) – When you want explicit null checking without additional conditionals - Required methods (
GetRequiredEvent<T>()) – When the event/response must exist and missing it is an error condition
These methods are equivalent to calling context.Features.Get<IEventFeature<T>>() and accessing the event/response, but provide cleaner syntax and better null-safety annotations.
Feature Providers in Practice¶
When context.Features.Get<T>() runs, MinimalLambda walks through every registered IFeatureProvider
until one returns the requested feature. Built-in providers handle common cases such as response
serialization. Use the same pattern for your features.
Registering a provider is just another DI call:
builder.Services.AddSingleton<IFeatureProvider, MyCorrelationFeatureProvider>(); // implements IFeatureProvider
Your provider can return singleton instances (for stateless metadata) or create fresh objects per invocation.
Short-Circuiting and Error Handling¶
Middleware can stop the pipeline early:
lambda.UseMiddleware(async (context, next) =>
{
var cache = context.ServiceProvider.GetRequiredService<ICache>();
var request = context.Features.Get<IEventFeature<OrderRequest>>()?.Event;
if (request is not null && cache.TryGet(request.OrderId, out OrderResponse cached))
{
context.Features.Get<IResponseFeature<OrderResponse>>()!.Response = cached;
return; // skip handler
}
await next(context);
});
Wrap the pipeline to catch and translate exceptions:
lambda.UseMiddleware(async (context, next) =>
{
try
{
await next(context);
}
catch (ValidationException ex)
{
var response = context.Features.Get<IResponseFeature<OrderResponse>>();
if (response is not null)
response.Response = new("invalid", ex.Message);
return; // handled
}
});
Ordering Strategy¶
Register middleware from outermost to innermost. Mix inline delegates and class-based middleware freely:
lambda.UseMiddleware<ErrorHandlingMiddleware>(); // Class-based: catches everything
lambda.UseMiddleware<LoggingMiddleware>(); // Class-based: logs every request
lambda.UseMiddleware(async (context, next) => // Inline: quick metric
{
var sw = Stopwatch.StartNew();
await next(context);
Console.WriteLine($"Duration: {sw.ElapsedMilliseconds}ms");
});
lambda.UseMiddleware<AuthenticationMiddleware>(); // Class-based: auth first
lambda.UseMiddleware<AuthorizationMiddleware>(); // Class-based: then authorization
lambda.UseMiddleware<ValidationMiddleware>(); // Class-based: validate payloads
lambda.MapHandler(/* handler */);
Guidelines:
- Error/diagnostics (logging, metrics) go first so they see every request.
- Authentication/authorization should wrap validation and business logic.
- Response caching happens late so only valid, authorized responses are stored.
- Inline and class-based middleware execute in registration order - no difference in behavior.
Configuration and Options¶
Inline middleware resolves services via context.ServiceProvider:
lambda.UseMiddleware(async (context, next) =>
{
var options = context.ServiceProvider.GetRequiredService<IOptions<MyOptions>>().Value;
// Use options...
await next(context);
});
Class-based middleware injects services via constructor:
internal sealed class ConfiguredMiddleware : ILambdaMiddleware
{
private readonly MyOptions _options;
public ConfiguredMiddleware(IOptions<MyOptions> options)
{
_options = options.Value;
}
public async Task InvokeAsync(ILambdaInvocationContext context, LambdaInvocationDelegate next)
{
// Use _options...
await next(context);
}
}
Both approaches access the same options registered in builder.Services.Configure<MyOptions>(...).
Best Practices¶
General:
- Keep middleware focused. One responsibility per component (logging, metrics, caching, etc.).
- Always call
await next(context)unless you intentionally short-circuit; forgetting it prevents the handler from running. - Never swallow exceptions silently. If you handle an error, set a response or log it so Lambda doesn't report success unintentionally.
- Use per-invocation state wisely.
Itemsis cleared after each request;Propertieslive for the life of the container and must be thread-safe. - Make cancellation cooperative. Honor
context.CancellationTokenin middleware and pass it to downstream I/O.
Inline Middleware:
- Push complex logic into services so inline middleware stays thin and readable
- Use inline for orchestration, not implementation
- Great for prototyping before extracting to a class
Class-Based Middleware:
- Implement
IDisposableorIAsyncDisposableif you acquire resources (connections, spans, etc.) - Use
[FromArguments]for configuration that varies per registration - Inject dependencies via constructor for testability
- Share state across invocations via singleton services, not middleware fields
- Write unit tests by mocking
ILambdaInvocationContextand thenextdelegate
Choosing Between Inline and Class-Based:
| Use Inline When... | Use Class-Based When... |
|---|---|
| Middleware is application-specific | Middleware will be reused across projects |
| Logic is simple orchestration | Logic is complex or has multiple responsibilities |
| No disposal or lifecycle management needed | Need IDisposable or IAsyncDisposable support |
| Quickly prototyping or experimenting | Ready to formalize and test thoroughly |
| Tight coupling to app logic is acceptable | Clean separation of concerns is important |
With these patterns, you can build rich, testable pipelines around your Lambda handlers while keeping business logic small and focused.