From 5df3122bb1a3b9966a2aa55a19432d841f4355c9 Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Tue, 1 Sep 2026 12:26:00 -0700 Subject: [PATCH 1/2] Add a resolve middleware example Closes the Examples half of autofac/Documentation#160, which asked for real world middleware beyond the logging snippet already in the documentation. Five scenarios, one class each: audit every registration, time a resolve, short circuit from a cache, inject ambient state as a parameter, and reject a resolve that came straight from the root scope. The first of those is the Autofac#1337 request, which wanted a hook invoked for every registered component instead of an extension method call on each registration. The correlation ID scenario earns its place by being the one that does not work the obvious way. Parameter selection is a registration pipeline phase, so registering it as service middleware throws with a message listing the phases a service pipeline accepts. It is attached with ConfigurePipeline instead, and the example says why, because that error is the clearest available explanation of why there are two pipelines. Part of #32 --- .vscode/launch.json | 10 ++ Examples.slnx | 1 + README.md | 1 + .../Middleware/ActivationAuditMiddleware.cs | 29 ++++ .../Middleware/CachingMiddleware.cs | 33 +++++ .../Middleware/CorrelationIdMiddleware.cs | 26 ++++ .../Middleware/RootScopeGuardMiddleware.cs | 26 ++++ .../Middleware/TimingMiddleware.cs | 26 ++++ .../MiddlewarePipelineExample.csproj | 13 ++ src/MiddlewarePipelineExample/Program.cs | 138 ++++++++++++++++++ src/MiddlewarePipelineExample/README.md | 11 ++ .../Services/IReportService.cs | 6 + .../Services/IRequestHandler.cs | 6 + .../Services/IScopedResource.cs | 6 + .../Services/ISlowService.cs | 6 + .../Services/ReportService.cs | 6 + .../Services/RequestHandler.cs | 14 ++ .../Services/ScopedResource.cs | 6 + .../Services/SlowService.cs | 12 ++ 19 files changed, 376 insertions(+) create mode 100644 src/MiddlewarePipelineExample/Middleware/ActivationAuditMiddleware.cs create mode 100644 src/MiddlewarePipelineExample/Middleware/CachingMiddleware.cs create mode 100644 src/MiddlewarePipelineExample/Middleware/CorrelationIdMiddleware.cs create mode 100644 src/MiddlewarePipelineExample/Middleware/RootScopeGuardMiddleware.cs create mode 100644 src/MiddlewarePipelineExample/Middleware/TimingMiddleware.cs create mode 100644 src/MiddlewarePipelineExample/MiddlewarePipelineExample.csproj create mode 100644 src/MiddlewarePipelineExample/Program.cs create mode 100644 src/MiddlewarePipelineExample/README.md create mode 100644 src/MiddlewarePipelineExample/Services/IReportService.cs create mode 100644 src/MiddlewarePipelineExample/Services/IRequestHandler.cs create mode 100644 src/MiddlewarePipelineExample/Services/IScopedResource.cs create mode 100644 src/MiddlewarePipelineExample/Services/ISlowService.cs create mode 100644 src/MiddlewarePipelineExample/Services/ReportService.cs create mode 100644 src/MiddlewarePipelineExample/Services/RequestHandler.cs create mode 100644 src/MiddlewarePipelineExample/Services/ScopedResource.cs create mode 100644 src/MiddlewarePipelineExample/Services/SlowService.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index d5db6fc..97fae70 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -95,6 +95,16 @@ "stopAtEntry": false, "type": "coreclr" }, + { + "console": "integratedTerminal", + "cwd": "${workspaceFolder}/src/MiddlewarePipelineExample", + "name": "MiddlewarePipelineExample", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/MiddlewarePipelineExample/bin/Debug/net10.0/MiddlewarePipelineExample.dll", + "request": "launch", + "stopAtEntry": false, + "type": "coreclr" + }, { "cwd": "${workspaceFolder}/src/MultitenantExample.AspNetCore", "launchSettingsProfile": "MultitenantExample.AspNetCore", diff --git a/Examples.slnx b/Examples.slnx index d07123f..664e268 100644 --- a/Examples.slnx +++ b/Examples.slnx @@ -13,6 +13,7 @@ + diff --git a/README.md b/README.md index 9afac27..4fd6f27 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Each example has its own README with what it shows and how to run it. | [DynamicProxyExample](src/DynamicProxyExample/README.md) | Method interception, wired both on the registration and by attribute | [`Autofac.Extras.DynamicProxy`](https://github.com/autofac/Autofac.Extras.DynamicProxy) | | [PoolingExample](src/PoolingExample/README.md) | Reusing expensive instances across scopes, with a reset-on-return policy | [`Autofac.Pooling`](https://github.com/autofac/Autofac.Pooling) | | [DotGraphExample](src/DotGraphExample/README.md) | Capturing a resolve operation as a Graphviz graph for troubleshooting | [`Autofac.Diagnostics.DotGraph`](https://github.com/autofac/Autofac.Diagnostics.DotGraph) | +| [MiddlewarePipelineExample](src/MiddlewarePipelineExample/README.md) | Resolve middleware for auditing, timing, caching, ambient state, and scope guards | [`Autofac`](https://github.com/autofac/Autofac) | ### .NET Framework diff --git a/src/MiddlewarePipelineExample/Middleware/ActivationAuditMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/ActivationAuditMiddleware.cs new file mode 100644 index 0000000..b24b9c4 --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/ActivationAuditMiddleware.cs @@ -0,0 +1,29 @@ +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Applied to every registration in the container rather than named on each one. +/// This is the answer to wanting a hook that runs for all registered components +/// without calling an extension method on each registration in turn. +/// +public sealed class ActivationAuditMiddleware : IResolveMiddleware +{ + private readonly List _log; + + public ActivationAuditMiddleware(List log) => _log = log; + + public PipelinePhase Phase => PipelinePhase.RegistrationPipelineStart; + + public void Execute(ResolveRequestContext context, Action next) + { + next(context); + + // The instance is available after next() returns, because that is when + // the rest of the pipeline has run and activation has happened. + if (context.NewInstanceActivated) + { + _log.Add(context.Instance!.GetType().Name); + } + } +} diff --git a/src/MiddlewarePipelineExample/Middleware/CachingMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/CachingMiddleware.cs new file mode 100644 index 0000000..01c4bbd --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/CachingMiddleware.cs @@ -0,0 +1,33 @@ +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Short-circuits the pipeline. Setting +/// and never calling next means activation is skipped entirely, so nothing +/// downstream runs. Useful for a cache, and the reason middleware can be cheaper +/// than a registration that has to construct something to decide it wasn't needed. +/// +public sealed class CachingMiddleware : IResolveMiddleware +{ + private readonly Action _report; + private object? _cached; + + public CachingMiddleware(Action report) => _report = report; + + public PipelinePhase Phase => PipelinePhase.ResolveRequestStart; + + public void Execute(ResolveRequestContext context, Action next) + { + if (_cached is not null) + { + _report($"cache hit for {context.Service}, activation skipped"); + context.Instance = _cached; + return; + } + + next(context); + _cached = context.Instance; + _report($"cache miss for {context.Service}, instance stored"); + } +} diff --git a/src/MiddlewarePipelineExample/Middleware/CorrelationIdMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..6cb0ada --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/CorrelationIdMiddleware.cs @@ -0,0 +1,26 @@ +using Autofac; +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Injects ambient state into a resolve without every caller having to pass it. +/// The parameter has to be added before the constructor is chosen, which is why +/// this sits in the parameter selection phase. +/// +public sealed class CorrelationIdMiddleware : IResolveMiddleware +{ + private readonly Func _currentCorrelationId; + + public CorrelationIdMiddleware(Func currentCorrelationId) => _currentCorrelationId = currentCorrelationId; + + public PipelinePhase Phase => PipelinePhase.ParameterSelection; + + public void Execute(ResolveRequestContext context, Action next) + { + context.ChangeParameters( + context.Parameters.Concat([new NamedParameter("correlationId", _currentCorrelationId())])); + + next(context); + } +} diff --git a/src/MiddlewarePipelineExample/Middleware/RootScopeGuardMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/RootScopeGuardMiddleware.cs new file mode 100644 index 0000000..41c016b --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/RootScopeGuardMiddleware.cs @@ -0,0 +1,26 @@ +using Autofac.Core; +using Autofac.Core.Lifetime; +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Fails fast with a readable message when a service that expects a unit of work +/// is resolved straight from the root scope, where it would be captured for the +/// lifetime of the application. +/// +public sealed class RootScopeGuardMiddleware : IResolveMiddleware +{ + public PipelinePhase Phase => PipelinePhase.ScopeSelection; + + public void Execute(ResolveRequestContext context, Action next) + { + if (Equals(context.ActivationScope.Tag, LifetimeScope.RootTag)) + { + throw new DependencyResolutionException( + $"{context.Service} must be resolved from a child lifetime scope, not the root container."); + } + + next(context); + } +} diff --git a/src/MiddlewarePipelineExample/Middleware/TimingMiddleware.cs b/src/MiddlewarePipelineExample/Middleware/TimingMiddleware.cs new file mode 100644 index 0000000..655e9d0 --- /dev/null +++ b/src/MiddlewarePipelineExample/Middleware/TimingMiddleware.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; +using Autofac.Core.Resolving.Pipeline; + +namespace MiddlewarePipelineExample.Middleware; + +/// +/// Measures how long a resolve takes. Attached as service middleware, so it runs +/// for every resolve of the service regardless of which registration answers it. +/// +public sealed class TimingMiddleware : IResolveMiddleware +{ + private readonly Action _report; + + public TimingMiddleware(Action report) => _report = report; + + public PipelinePhase Phase => PipelinePhase.ResolveRequestStart; + + public void Execute(ResolveRequestContext context, Action next) + { + var stopwatch = Stopwatch.StartNew(); + next(context); + stopwatch.Stop(); + + _report($"{context.Service} took {stopwatch.ElapsedMilliseconds}ms"); + } +} diff --git a/src/MiddlewarePipelineExample/MiddlewarePipelineExample.csproj b/src/MiddlewarePipelineExample/MiddlewarePipelineExample.csproj new file mode 100644 index 0000000..9d67923 --- /dev/null +++ b/src/MiddlewarePipelineExample/MiddlewarePipelineExample.csproj @@ -0,0 +1,13 @@ + + + + Exe + net10.0 + enable + + + + + + + diff --git a/src/MiddlewarePipelineExample/Program.cs b/src/MiddlewarePipelineExample/Program.cs new file mode 100644 index 0000000..c823a37 --- /dev/null +++ b/src/MiddlewarePipelineExample/Program.cs @@ -0,0 +1,138 @@ +using Autofac; +using Autofac.Core; +using Autofac.Core.Resolving.Pipeline; +using MiddlewarePipelineExample.Middleware; +using MiddlewarePipelineExample.Services; + +namespace MiddlewarePipelineExample; + +internal static class Program +{ + public static void Main() + { + AuditEveryRegistration(); + TimeAResolve(); + ShortCircuitWithACache(); + InjectAmbientState(); + RejectRootScopeResolves(); + } + + /// + /// Attaches middleware to every registration at once, without naming it on + /// each one. Autofac issue #1337 asked for exactly this hook. + /// + private static void AuditEveryRegistration() + { + Header("Middleware on every registration"); + + var activated = new List(); + var builder = new ContainerBuilder(); + + builder.ComponentRegistryBuilder.Registered += (sender, args) => + args.ComponentRegistration.PipelineBuilding += (sender2, pipeline) => + pipeline.Use(new ActivationAuditMiddleware(activated)); + + builder.RegisterType().As(); + builder.RegisterType().As(); + + using var container = builder.Build(); + using var scope = container.BeginLifetimeScope(); + scope.Resolve(); + scope.Resolve(); + + Console.WriteLine($" activated: {string.Join(", ", activated)}"); + Console.WriteLine(" neither registration mentions the middleware."); + } + + private static void TimeAResolve() + { + Header("Timing a resolve"); + + var builder = new ContainerBuilder(); + builder.RegisterType().As(); + builder.RegisterServiceMiddleware(new TimingMiddleware(m => Console.WriteLine($" {m}"))); + + using var container = builder.Build(); + using var scope = container.BeginLifetimeScope(); + scope.Resolve(); + } + + private static void ShortCircuitWithACache() + { + Header("Short-circuiting the pipeline"); + + var builder = new ContainerBuilder(); + builder.RegisterType().As(); + builder.RegisterServiceMiddleware(new CachingMiddleware(m => Console.WriteLine($" {m}"))); + + using var container = builder.Build(); + for (var i = 0; i < 2; i++) + { + using var scope = container.BeginLifetimeScope(); + scope.Resolve(); + } + + Console.WriteLine(" the second resolve never reached activation."); + } + + private static void InjectAmbientState() + { + Header("Injecting ambient state"); + + var correlationId = "req-001"; + var builder = new ContainerBuilder(); + // Parameter selection is a registration pipeline phase, so this one goes + // on the registration. Adding it as service middleware throws, because a + // service pipeline has already finished by the time parameters matter. + builder.RegisterType() + .As() + .ConfigurePipeline(pipeline => pipeline.Use(new CorrelationIdMiddleware(() => correlationId))); + + using var container = builder.Build(); + + using (var scope = container.BeginLifetimeScope()) + { + Console.WriteLine($" {scope.Resolve().Describe()}"); + } + + correlationId = "req-002"; + using (var scope = container.BeginLifetimeScope()) + { + Console.WriteLine($" {scope.Resolve().Describe()}"); + } + + Console.WriteLine(" no caller passed the correlation id."); + } + + private static void RejectRootScopeResolves() + { + Header("Failing fast on a root scope resolve"); + + var builder = new ContainerBuilder(); + builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterServiceMiddleware(new RootScopeGuardMiddleware()); + + using var container = builder.Build(); + + using (var scope = container.BeginLifetimeScope()) + { + Console.WriteLine($" from a child scope: {scope.Resolve().Use()}"); + } + + try + { + container.Resolve(); + } + catch (DependencyResolutionException ex) + { + Console.WriteLine($" from the root scope: {ex.Message.Split(" ---> ")[^1]}"); + } + } + + private static void Header(string title) + { + Console.WriteLine(); + Console.WriteLine(title); + Console.WriteLine(new string('-', title.Length)); + } +} diff --git a/src/MiddlewarePipelineExample/README.md b/src/MiddlewarePipelineExample/README.md new file mode 100644 index 0000000..e5c9315 --- /dev/null +++ b/src/MiddlewarePipelineExample/README.md @@ -0,0 +1,11 @@ +# MiddlewarePipelineExample + +Five things resolve middleware is actually useful for, each in its own class: auditing every registration without naming the middleware on any of them, timing a resolve, short-circuiting the pipeline from a cache, injecting ambient state as a parameter, and rejecting a resolve that came from the root scope. + +The correlation ID scenario is the one worth reading twice. It runs in the parameter selection phase, which belongs to the registration pipeline, so it has to be attached with `ConfigurePipeline` rather than `RegisterServiceMiddleware`. Registering it as service middleware throws, and that error is the clearest explanation of why Autofac has two pipelines rather than one. + +Packages: [`Autofac`](https://github.com/autofac/Autofac) + +Run `dotnet run --project src/MiddlewarePipelineExample`. Each scenario prints a short section showing what the middleware did. + +See [Resolve Pipelines](https://autofac.readthedocs.io/en/latest/advanced/pipelines.html) for the documentation this example follows. diff --git a/src/MiddlewarePipelineExample/Services/IReportService.cs b/src/MiddlewarePipelineExample/Services/IReportService.cs new file mode 100644 index 0000000..d245a9b --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/IReportService.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public interface IReportService +{ + string Run(); +} diff --git a/src/MiddlewarePipelineExample/Services/IRequestHandler.cs b/src/MiddlewarePipelineExample/Services/IRequestHandler.cs new file mode 100644 index 0000000..ffb0609 --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/IRequestHandler.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public interface IRequestHandler +{ + string Describe(); +} diff --git a/src/MiddlewarePipelineExample/Services/IScopedResource.cs b/src/MiddlewarePipelineExample/Services/IScopedResource.cs new file mode 100644 index 0000000..9334f57 --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/IScopedResource.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public interface IScopedResource +{ + string Use(); +} diff --git a/src/MiddlewarePipelineExample/Services/ISlowService.cs b/src/MiddlewarePipelineExample/Services/ISlowService.cs new file mode 100644 index 0000000..ee69687 --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/ISlowService.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public interface ISlowService +{ + string Fetch(); +} diff --git a/src/MiddlewarePipelineExample/Services/ReportService.cs b/src/MiddlewarePipelineExample/Services/ReportService.cs new file mode 100644 index 0000000..e5ba61f --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/ReportService.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public sealed class ReportService : IReportService +{ + public string Run() => "report"; +} diff --git a/src/MiddlewarePipelineExample/Services/RequestHandler.cs b/src/MiddlewarePipelineExample/Services/RequestHandler.cs new file mode 100644 index 0000000..ea3165b --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/RequestHandler.cs @@ -0,0 +1,14 @@ +namespace MiddlewarePipelineExample.Services; + +/// +/// Takes a correlation identifier it has no way to know at registration time. +/// Middleware supplies it per resolve. +/// +public sealed class RequestHandler : IRequestHandler +{ + private readonly string _correlationId; + + public RequestHandler(string correlationId) => _correlationId = correlationId; + + public string Describe() => $"handling request {_correlationId}"; +} diff --git a/src/MiddlewarePipelineExample/Services/ScopedResource.cs b/src/MiddlewarePipelineExample/Services/ScopedResource.cs new file mode 100644 index 0000000..92f892b --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/ScopedResource.cs @@ -0,0 +1,6 @@ +namespace MiddlewarePipelineExample.Services; + +public sealed class ScopedResource : IScopedResource +{ + public string Use() => "scoped work"; +} diff --git a/src/MiddlewarePipelineExample/Services/SlowService.cs b/src/MiddlewarePipelineExample/Services/SlowService.cs new file mode 100644 index 0000000..74ed47a --- /dev/null +++ b/src/MiddlewarePipelineExample/Services/SlowService.cs @@ -0,0 +1,12 @@ +namespace MiddlewarePipelineExample.Services; + +/// +/// Expensive to construct, which is what makes the timing and caching middleware +/// worth looking at. +/// +public sealed class SlowService : ISlowService +{ + public SlowService() => Thread.Sleep(75); + + public string Fetch() => "data"; +} From d2f8f5eb815881adef817a0664dc1a5859397eda Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Tue, 1 Sep 2026 12:51:10 -0700 Subject: [PATCH 2/2] Document each middleware scenario and fix comment spacing Every method in Program now carries a short summary of the problem its scenario illustrates, so the file reads as a tour rather than five similar-looking blocks. Also drops an unused Autofac.Core.Resolving.Pipeline using. Nothing flagged it, because IDE0005 does not fire without GenerateDocumentationFile; I confirmed it was dead by compiling without it. Part of #32 --- src/MiddlewarePipelineExample/Program.cs | 30 +++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/MiddlewarePipelineExample/Program.cs b/src/MiddlewarePipelineExample/Program.cs index c823a37..c87605e 100644 --- a/src/MiddlewarePipelineExample/Program.cs +++ b/src/MiddlewarePipelineExample/Program.cs @@ -1,6 +1,5 @@ using Autofac; using Autofac.Core; -using Autofac.Core.Resolving.Pipeline; using MiddlewarePipelineExample.Middleware; using MiddlewarePipelineExample.Services; @@ -8,6 +7,10 @@ namespace MiddlewarePipelineExample; internal static class Program { + /// + /// Runs each scenario in turn. They are independent, so they each build their + /// own container rather than sharing one. + /// public static void Main() { AuditEveryRegistration(); @@ -44,6 +47,10 @@ private static void AuditEveryRegistration() Console.WriteLine(" neither registration mentions the middleware."); } + /// + /// Service middleware wraps every resolve of a service, so it can measure what + /// activation actually costs without the service or its registration knowing. + /// private static void TimeAResolve() { Header("Timing a resolve"); @@ -57,6 +64,12 @@ private static void TimeAResolve() scope.Resolve(); } + /// + /// Setting Instance and returning without calling next ends the + /// pipeline early, so activation never happens. That is what makes a middleware + /// cache cheaper than a registration which has to construct something before it + /// can decide the work was unnecessary. + /// private static void ShortCircuitWithACache() { Header("Short-circuiting the pipeline"); @@ -75,12 +88,18 @@ private static void ShortCircuitWithACache() Console.WriteLine(" the second resolve never reached activation."); } + /// + /// Supplies a constructor argument no caller passes. Watch which pipeline this + /// one attaches to: parameter selection belongs to the registration pipeline, so + /// service middleware is rejected outright. + /// private static void InjectAmbientState() { Header("Injecting ambient state"); var correlationId = "req-001"; var builder = new ContainerBuilder(); + // Parameter selection is a registration pipeline phase, so this one goes // on the registration. Adding it as service middleware throws, because a // service pipeline has already finished by the time parameters matter. @@ -104,6 +123,12 @@ private static void InjectAmbientState() Console.WriteLine(" no caller passed the correlation id."); } + /// + /// Turns a captured dependency into an immediate, readable failure. Resolving a + /// per-scope service straight from the root container would otherwise keep it + /// alive for the life of the application, and the symptom usually shows up a + /// long way from the cause. + /// private static void RejectRootScopeResolves() { Header("Failing fast on a root scope resolve"); @@ -129,6 +154,9 @@ private static void RejectRootScopeResolves() } } + /// + /// Writes a section title so each scenario's output is easy to tell apart. + /// private static void Header(string title) { Console.WriteLine();