Skip to main content
SEMastery
ASP.NETbeginner

3 Ways To Create Middleware In ASP.NET Core (Beginner Guide)

Learn the 3 ways to create middleware in ASP.NET Core: inline request delegates, convention-based classes, and factory-based IMiddleware, with simple diagrams and code.

12 min readUpdated October 9, 2025

Think of an airport security line

Picture yourself at an airport. Before you reach your plane, you walk through a line of checkpoints. First someone checks your ticket. Then you put your bag through a scanner. Then a guard checks your passport. Each station does one small job and then passes you along to the next one. If any station says "no," you do not go further.

Middleware in ASP.NET Core works in exactly the same way. Every web request that reaches your app walks through a line of checkpoints. Each checkpoint is a middleware. One might write a log line. One might check if you are logged in. One might catch errors. Each one does its small job, then hands the request to the next.

The nice part is this: each checkpoint also sees you on the way back. Just like the airport staff might stamp your boarding pass as you leave, middleware can change the response on the way out. This in-and-out shape is the heart of how ASP.NET Core handles every request.

In this guide we will learn the three ways to create your own middleware. They all do the same job, but each one fits a different need. By the end you will know which one to pick and why.

What the request pipeline looks like

Before we write any code, let us see the path a request takes. The list of middleware is called the request pipeline. The request goes down the line, hits the endpoint (your actual page or API), and comes back up.

A request travels down through each middleware, reaches the endpoint, and the response travels back up through the same middleware in reverse.

Notice the same middleware appears twice: once on the way in, once on the way out. This is because of one key word: next. Each middleware calls the next one and then waits. When the next one finishes, control comes back. That is what makes the "return trip" possible.

A middleware has two choices. It can call next to keep the line moving, or it can stop early and send a response itself. Stopping early is called short-circuiting. A good example is a checkpoint that says "you are not logged in, go back" and never lets you reach the plane.

The life of one request

Receive
Do work
Call next or stop
Handle response

Steps

1

Receive

Middleware gets the HttpContext

2

Do work

Log, check, or change the request

3

Call next or stop

Pass along, or short-circuit

4

Handle response

Change the response on the way back

What each middleware decides as a request flows through the pipeline.

Now let us build middleware three different ways.

Way 1: Inline middleware with a request delegate

The simplest way needs no new class at all. You write the logic right inside Program.cs using app.Use. You pass a small function (a lambda) that gets two things: the HttpContext (everything about the request and response) and next (the next middleware in line).

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
// Inline middleware using a request delegate
app.Use(async (HttpContext context, RequestDelegate next) =>
{
    // Work BEFORE the next middleware runs
    Console.WriteLine($"Request starting: {context.Request.Path}");
 
    await next(context); // hand control to the next middleware
 
    // Work AFTER the next middleware returns
    Console.WriteLine($"Request finished: {context.Response.StatusCode}");
});
 
app.MapGet("/", () => "Hello from the endpoint!");
 
app.Run();

Read the order carefully. The first Console.WriteLine runs on the way in. Then await next(context) sends the request deeper into the pipeline. The second Console.WriteLine runs on the way out, after everything below has finished.

If you want to stop early, just do not call next. For example, you can block a request and return right away:

app.Use(async (context, next) =>
{
    if (!context.Request.Headers.ContainsKey("X-Api-Key"))
    {
        context.Response.StatusCode = 401; // Unauthorized
        await context.Response.WriteAsync("Missing API key");
        return; // short-circuit: do NOT call next
    }
 
    await next(context);
});

There is also app.Run. It is a terminal middleware, which means it never calls next. It always ends the line. Anything you add after app.Run is silently ignored, so use it carefully.

Inline middleware either calls next to continue, or returns early to short-circuit the pipeline.

When to use this: small, quick logic that lives in one place. It is perfect for a tiny check or a quick log line. The downside is that if you add many of these, your Program.cs becomes long and messy. For anything bigger, move to a class.

Way 2: Convention-based middleware (a class)

The second way is the most common in real projects. Instead of writing logic inside Program.cs, you make a separate class. This keeps your code tidy and easy to test.

ASP.NET Core does not force you to implement any interface here. Instead it follows a convention (a name pattern). Your class needs two things:

  • A constructor that takes a RequestDelegate (this is the next middleware).
  • A public method named Invoke or InvokeAsync that takes an HttpContext and returns a Task.
public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestTimingMiddleware> _logger;
 
    public RequestTimingMiddleware(
        RequestDelegate next,
        ILogger<RequestTimingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }
 
    public async Task InvokeAsync(HttpContext context)
    {
        var start = System.Diagnostics.Stopwatch.GetTimestamp();
 
        await _next(context); // continue down the pipeline
 
        var elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(start);
        _logger.LogInformation(
            "{Path} took {Ms} ms",
            context.Request.Path,
            elapsed.TotalMilliseconds);
    }
}

To turn this on, register it in Program.cs with UseMiddleware:

app.UseMiddleware<RequestTimingMiddleware>();

Many teams also add a small extension method so the registration reads nicely, like app.UseRequestTiming(). That is just sugar; it calls UseMiddleware under the hood.

The one big trap: services and lifetimes

Here is the most important thing to learn about convention-based middleware. It is created once, when your app starts, and the same instance is reused for every request. In other words, it behaves like a singleton.

This causes a real problem with scoped services. A scoped service, like an Entity Framework DbContext, is meant to be created fresh for each request. If you inject it into the constructor, you would freeze a single one for the whole life of the app. That can cause crashes and strange bugs.

The fix is simple: do not inject scoped services in the constructor. Inject them as a parameter of InvokeAsync instead. ASP.NET Core fills these in fresh for each request.

public class AuditMiddleware
{
    private readonly RequestDelegate _next;
 
    public AuditMiddleware(RequestDelegate next) => _next = next;
 
    // AppDbContext is scoped, so take it here, NOT in the constructor
    public async Task InvokeAsync(HttpContext context, AppDbContext db)
    {
        db.AuditLogs.Add(new AuditLog { Path = context.Request.Path });
        await db.SaveChangesAsync();
 
        await _next(context);
    }
}

This table makes the difference clear.

Where you injectLifetime that is safeWhy
ConstructorSingleton onlyRuns once at startup, instance is shared
InvokeAsync parameterScoped, transient, singletonResolved fresh for every request

When to use this: most of the time. It is clean, testable, and the standard choice for logging, headers, error handling, and similar work. Just remember the lifetime rule.

Way 3: Factory-based middleware with IMiddleware

The third way fixes the lifetime trap in a different, very tidy manner. Here your class implements the built-in IMiddleware interface. This interface has just one method: InvokeAsync.

The magic is that this middleware is built by a factory for each request. Because a new instance is made per request, you can safely inject scoped services straight into the constructor. No special tricks needed.

public class FactoryAuditMiddleware : IMiddleware
{
    private readonly AppDbContext _db; // scoped service is fine here!
 
    public FactoryAuditMiddleware(AppDbContext db) => _db = db;
 
    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        _db.AuditLogs.Add(new AuditLog { Path = context.Request.Path });
        await _db.SaveChangesAsync();
 
        await next(context);
    }
}

There is one extra step. Because a factory builds it, the middleware must be registered in the dependency injection container. You add it as a service, then turn it on with UseMiddleware.

// Register the middleware as a service (scoped is common)
builder.Services.AddScoped<FactoryAuditMiddleware>();
 
var app = builder.Build();
 
// Turn it on
app.UseMiddleware<FactoryAuditMiddleware>();

If you forget the AddScoped line, the app will throw an error at startup telling you the middleware was not registered. That is a helpful reminder, not a silent failure.

With IMiddleware, a factory builds a fresh middleware instance per request, so scoped services flow in safely.

When to use this: when your middleware needs scoped or transient services and you want them right in the constructor for clean, simple code. It is the best fit for middleware that talks to a database, a user manager, or any per-request dependency.

Putting the three side by side

All three approaches end up as the same thing inside the pipeline: a function that takes a context and a next. The difference is about structure and services.

ApproachNew class?Implements interface?LifetimeScoped services
Inline (request delegate)NoNoN/AResolve from context.RequestServices
Convention-basedYesNoSingletonVia InvokeAsync parameters
Factory-based (IMiddleware)YesYes (IMiddleware)Per requestVia constructor

Here is a simple way to choose, drawn as a flow.

Which middleware style should I pick?

Tiny one-off?
Need a class?
Need scoped DI in constructor?
Pick a style

Steps

1

Tiny one-off?

Use inline app.Use

2

Need a class?

Use convention-based

3

Need scoped DI in constructor?

Use IMiddleware factory

4

Pick a style

Match the job to the tool

A quick decision path from simplest to most structured.

Order matters a lot

No matter which way you build middleware, the order you register it in is very important. The pipeline runs in the order you write it. A few rules from the official docs are worth memorizing:

  • Put error handling near the top, so it can catch problems from everything below.
  • UseRouting must come before UseAuthorization, because authorization needs to know which endpoint was matched.
  • UseAuthentication must come before UseAuthorization. You must know who the user is before you check what they can do.
  • UseCors should come before authentication so browser preflight checks pass.

Getting the order wrong is one of the most common beginner mistakes. If a feature "does nothing," check its position in the line first.

A typical middleware order: exceptions first, then routing, CORS, auth, and finally your endpoints.

A small note on branching with Map

There is a handy helper called Map that lets you create a branch in the pipeline based on the request path. If the path matches, that branch runs; otherwise it is skipped. It is not a fourth "way" to build middleware, but it pairs nicely with all three.

app.Map("/admin", adminApp =>
{
    adminApp.Use(async (context, next) =>
    {
        // Only runs for paths that start with /admin
        await next(context);
    });
});

This is great when one part of your site needs special handling that the rest does not.

Common mistakes to avoid

A few simple slips trip up almost everyone at the start. Keep this short list handy.

  • Forgetting await next. If you do not call next, the rest of the pipeline never runs. Sometimes that is on purpose (short-circuit), but often it is a bug.
  • Writing to the response after it has started. Once the response body begins sending, you cannot change the status code or headers. Do that work before calling next.
  • Injecting scoped services into a convention-based constructor. Remember: that class is a singleton. Use an InvokeAsync parameter or switch to IMiddleware.
  • Adding middleware after app.Run. Anything after a terminal middleware is ignored.
  • Wrong order. Auth before routing, or CORS after auth, leads to features that silently fail.

Quick recap

  • Middleware is a checkpoint in the request pipeline. Each one can act before and after calling next, just like an airport security line.
  • Way 1 — Inline (app.Use): quick logic with a lambda, no class. Best for tiny, one-off checks.
  • Way 2 — Convention-based class: a class with InvokeAsync, registered with UseMiddleware. The standard choice. It is a singleton, so pass scoped services through InvokeAsync, not the constructor.
  • Way 3 — Factory-based (IMiddleware): a class that implements IMiddleware, built per request. You must register it in DI, and you can inject scoped services right into the constructor.
  • Order matters. Put error handling first, authentication before authorization, and routing before authorization.
  • Call next to continue, or skip it to short-circuit and respond early.

References and further reading

Related Posts