One Host, Many Engines
The third paper in the Universal Web Processing Model series. This paper proposes that a single web host can serve multiple handler engines simultaneously — each engine implementing a different web framework philosophy, coexisting under one roof, dispatched by configuration.
Paper 1: The Universal Web Processing Model — What Every Web Framework Actually Does
Paper 2: Rewriting the ASP.NET Web Forms Engine — Freeing the Pattern from the Container
Paper 3: The Composable Web Host — One Host, Many Engines
Paper 4: The .NET Web Host
Paper 5: The Universal Web Host
What the First Two Papers Established
In the first paper — The Universal Web Processing Model — we observed that every web application, in every language, on every platform, performs the same seven operations: Accept, Parse, Route, Execute, Respond, Persist, Release. We named two roles: the Host (which owns operations 1, 2, 5, 6, and 7 — the infrastructure) and the Handler Engine (which owns operations 3 and 4 — the application logic). Every web server is a host. Every web framework is a handler engine.
In the second paper — Rewriting the ASP.NET Web Forms Engine — we demonstrated that a retired framework can be reborn as a handler engine on modern .NET, hosted by Kestrel, running on any platform. The pattern is separable from the container. The engine is separable from the host.
This third paper asks the natural next question: if the host and the engine are separate roles, can a single host serve more than one engine?
The answer is yes. And the architecture is remarkably simple.
The Observation
Today, web frameworks assume exclusive ownership of the host. When you build an ASP.NET Core MVC application, MVC owns the entire middleware pipeline. When you build a Blazor application, Blazor owns the pipeline. When you build a Minimal API application, Minimal APIs own the pipeline.
Each framework behaves as though it is the only tenant in the building.
But the host — Kestrel, in the .NET world — does not require this exclusivity. Kestrel receives HTTP requests and passes them into a middleware pipeline. It does not care what the middleware does. It does not care how many middleware components exist. It does not care whether those components implement the same framework philosophy or radically different ones.
The middleware pipeline is a chain. Each link in the chain can inspect the request and decide: this is mine, I will handle it — or — this is not mine, pass it to the next link.
This means the pipeline can hold multiple handler engines. Each engine inspects the request, determines whether it should handle it, and either processes it or passes it along. The engines coexist. They do not interfere. They share the same host, the same socket, the same HTTP parsing, the same TLS termination — but each one implements its own routing and execution logic.
Multiple frameworks. One host. One pipeline. Configuration determines which engine handles which request.
The Dispatcher
Between the host and the engines sits a thin layer: the Engine Dispatcher. Its role is simple. It reads a configuration — a mapping of URL path patterns to engine identifiers — and for each incoming request, it routes to the correct engine.
The configuration is a dispatch table:
| Path Pattern | Engine |
|---|---|
| /legacy/* | WebForms Engine |
| /api/* | Minimal API Engine |
| /admin/* | Blazor Engine |
| /app/* | MVC Engine |
| (default) | WebForms Engine |
A default engine handles all requests that do not match any specific pattern. Specific path patterns override the default and direct requests to alternate engines.
This dispatch table is not a routing framework. It contains no application logic, no conditions, no transformations. It answers exactly one question: for this request path, which engine processes it?
The simplicity is deliberate. The complexity belongs inside the engines, not in the dispatch layer.
The Engine Contract
For engines to be interchangeable and composable, they must share a common contract with the host. That contract is minimal — an engine must be able to declare whether it can handle a given request, and if so, process it:
public interface IWebEngine
{
string Name { get; }
void Initialize(IServiceProvider services, EngineOptions options);
bool CanHandle(HttpContext context);
Task ProcessRequest(HttpContext context);
Task ProcessWebSocket(HttpContext context, WebSocket socket);
Task ProcessServerSentEvents(HttpContext context);
}Six members. That is the entire contract.
Every web framework ever built — past, present, or future — can be expressed as an implementation of this interface. The interface does not prescribe how routing works internally. It does not prescribe how execution is structured. It does not prescribe state management, rendering strategy, or architectural philosophy. It simply asks: can you handle this request? If so, handle it.
The WebForms engine implements this interface by parsing .aspx files, building control trees, and walking the page lifecycle. An MVC engine implements it by resolving controllers, binding models, and rendering views. A Minimal API engine implements it by matching route patterns to lambda functions. A Blazor engine implements it by maintaining a SignalR circuit and a server-side render tree.
Different philosophies. Same contract. Same host.
The Pipeline
The dispatcher registers itself as ASP.NET Core middleware. When a request arrives, Kestrel passes it through the pipeline. The dispatcher inspects the path, consults the dispatch table, and forwards the request to the matched engine:
public class EngineDispatcherMiddleware
{
private readonly IReadOnlyList<EngineRoute> _routes;
private readonly IWebEngine _defaultEngine;
private readonly Dictionary<string, IWebEngine> _engines;
public async Task InvokeAsync(HttpContext context)
{
// Find the engine for this request
IWebEngine engine = ResolveEngine(context.Request.Path);
// Detect connection type and dispatch
if (context.WebSockets.IsWebSocketRequest)
{
var socket = await context.WebSockets.AcceptWebSocketAsync();
await engine.ProcessWebSocket(context, socket);
}
else if (IsServerSentEventsRequest(context))
{
await engine.ProcessServerSentEvents(context);
}
else
{
await engine.ProcessRequest(context);
}
}
private IWebEngine ResolveEngine(PathString path)
{
foreach (var route in _routes)
{
if (path.StartsWithSegments(route.PathPrefix))
return _engines[route.EngineName];
}
return _defaultEngine;
}
private bool IsServerSentEventsRequest(HttpContext context)
{
return context.Request.Headers["Accept"]
.ToString().Contains("text/event-stream");
}
}The entire dispatcher fits in a single class. The routing logic is a loop over path prefixes. The dispatch logic is a method call on the matched engine. There is nothing hidden. There is nothing complex. The architecture is transparent.
The Registration
The host application’s entry point — Program.cs — registers the engines and the dispatch configuration:
var builder = WebApplication.CreateBuilder(args);
// Register engines
builder.Services.AddWebFormsEngine();
builder.Services.AddMvcEngine();
builder.Services.AddBlazorEngine();
builder.Services.AddMinimalApiEngine();
var app = builder.Build();
// Static files — served directly by Kestrel
app.UseStaticFiles();
// Engine dispatcher — routes to the correct engine
app.UseEngineDispatcher(config =>
{
config.Default("webforms");
config.Map("/api/*", "minimal-api");
config.Map("/admin/*", "blazor");
config.Map("/dashboard/*","mvc");
config.Map("/legacy/*", "webforms");
});
app.Run();Each Add*Engine() call registers an IWebEngine implementation with the dependency injection container. The UseEngineDispatcher() call installs the dispatcher middleware and configures the dispatch table. Kestrel starts listening. Requests flow.
A developer who needs only one engine ignores the dispatcher entirely and registers a single engine — exactly as they do today. The composition layer is additive. It imposes nothing on single-engine applications.
The Configuration
The dispatch table can live in code, as shown above, or in a configuration file:
{
"engineDispatcher": {
"default": "webforms",
"routes": [
{ "path": "/api/*", "engine": "minimal-api" },
{ "path": "/admin/*", "engine": "blazor" },
{ "path": "/dashboard/*", "engine": "mvc" },
{ "path": "/legacy/*", "engine": "webforms" }
]
}
}Configuration-file dispatch allows the routing to change without recompilation. An operations team can redirect traffic between engines by editing a JSON file and restarting the host. The engines themselves remain unchanged.
What This Enables
Legacy and modern coexistence. An organization with a fifteen-year-old WebForms application can introduce new features in Blazor or Minimal APIs without rewriting the existing codebase. New routes go to the new engine. Old routes stay on the WebForms engine. Both serve the same users, from the same host, on the same domain.
Incremental migration. Instead of a risky all-at-once rewrite, teams migrate one route at a time. Move /api/* to Minimal APIs this quarter. Move /admin/* to Blazor next quarter. The WebForms engine continues serving everything else. The migration is gradual, reversible, and low-risk.
Framework evaluation. A team considering a new framework can deploy it alongside their existing application on a single route prefix. Real production traffic, real performance data, real developer experience — without committing the entire application.
Future-proofing. When a new web framework philosophy emerges — and one will — it can be implemented as an IWebEngine and deployed alongside existing engines. No migration required. No rewrite. Just a new engine registered with the dispatcher and a new route in the configuration.
Shared infrastructure. All engines share the same TLS certificates, the same logging, the same health checks, the same deployment pipeline. The host provides the operational infrastructure once. The engines focus purely on application logic.
What the Engines Share
The engines are independent in their routing and execution logic, but they share the infrastructure provided by the host:
| Shared (provided by the Host) | Independent (per Engine) |
|---|---|
| HTTP parsing | Routing strategy |
| TLS termination | Execution model |
| Connection management | State management |
| WebSocket infrastructure | Rendering philosophy |
| SSE infrastructure | Page/component lifecycle |
| Static file serving | Data binding approach |
| Logging and diagnostics | Template/view system |
| Health checks | Authentication flow |
The left column is universal — the same for every engine. The right column is where frameworks differ. The composable host draws a clean line between the two, exactly as the Universal Web Processing Model describes.
The Architecture

The host handles the infrastructure. The dispatcher routes to the engine. The engine handles the application logic. The host writes the response. The connection is released.
Seven operations. Two roles. One host. Many engines. Configuration-driven dispatch.
The Universal Web Processing Model, fully realized.
The Engines Are Unnamed
This paper deliberately does not prescribe which engines should exist or how they should be built. The composable host is engine-agnostic by design.
An engine could implement a page lifecycle with server controls. An engine could implement a controller-view pattern. An engine could implement server-side component rendering. An engine could implement direct function-to-route mapping. An engine could implement a pattern that has not yet been imagined.
The host does not care. The dispatcher does not care. The contract is six members wide. Any engine that fulfills the contract can participate.
The composable host is not a framework. It is a hosting standard — an open specification that says: here is how a handler engine registers itself with a host, here is how requests are dispatched, here is the contract. Build whatever engine serves your purpose. The host will run it.
This is the architectural principle that makes the ecosystem composable rather than disposable. Frameworks come and go. The host endures. The engines are plugins. The developer chooses.
The first paper named the universal pattern — seven operations, two roles. The second paper demonstrated that a retired engine can be reborn on the modern platform. This third paper completes the vision: a host that serves any engine, a dispatcher that routes by configuration, and a contract simple enough that any framework — past, present, or future — can fulfill it.
The .NET team built a remarkable host in Kestrel and a flexible pipeline in ASP.NET Core middleware. The community can now build engines that plug into that pipeline — not one engine, but many, coexisting, each serving the developers and applications that need it.
One host. Many engines. The developer chooses.
The web was always this simple. We just forgot.
Now we remember.
Photo by Chris Carzoli on Unsplash
