How to Use Middleware Patterns Outside HTTP in Go

Web
Implement middleware outside HTTP in Go by wrapping core handler functions with closures that execute logic before and after the main handler.

The wrapper pattern for any function

You are building a background worker that processes jobs from a queue. The core logic transforms data and saves it to a database. Suddenly, the requirements change. You need to log every job, retry failed jobs three times, and track how long each one takes. You could paste that boilerplate into every handler. Or you could wrap the handler.

The middleware pattern isn't just for HTTP servers. It's a general tool for composing cross-cutting concerns around any function. Cross-cutting concerns are behaviors that span multiple parts of your system, like logging, metrics, or error handling. Middleware lets you separate those behaviors from the business logic. You write the core function once. You wrap it with whatever extra behavior you need. The result is a new function that does the core work plus the extras.

What middleware actually is

Middleware is a wrapper. Think of a gift. The core is the present. The wrapper is the paper, the ribbon, and the tag. You can wrap the gift in paper, then wrap that in a box, then wrap the box in a bag. When you hand the bag to someone, they unwrap the bag, then the box, then the paper, and finally get the gift.

In code, the "unwrapping" happens as execution flows through the layers. Each layer can inspect the input, modify it, stop the process, or let it pass to the next layer. The core function only knows about the business logic. It doesn't care about logging or retries. This separation makes code easier to test and reuse. You can test the core logic without worrying about side effects. You can test the middleware by passing it a mock handler.

Go makes this easy because functions are first-class values. You can pass functions as arguments, return them from other functions, and store them in variables. A middleware function takes a handler and returns a new handler. The new handler captures the original handler in a closure and adds behavior around it.

Minimal example

Here is the simplest middleware: a function that prints before and after calling the next handler.

package main

import (
	"context"
	"fmt"
)

// Handler is the function type for business logic.
type Handler func(ctx context.Context) error

// LoggingMiddleware returns a handler that prints around next.
func LoggingMiddleware(next Handler) Handler {
	return func(ctx context.Context) error {
		fmt.Println("Start")
		err := next(ctx)
		fmt.Println("End")
		return err
	}
}

func main() {
	core := func(ctx context.Context) error {
		fmt.Println("Work")
		return nil
	}

	// Wrap core with logging middleware.
	final := LoggingMiddleware(core)
	final(context.Background())
}

The Handler type defines the signature for your business logic. It takes a context.Context and returns an error. This is a common pattern for background tasks. The context allows you to pass deadlines and cancellation signals. The error return lets the handler signal failure.

LoggingMiddleware takes a Handler named next and returns a new Handler. The returned function is a closure. It captures next from the outer scope. When you call the returned function, it prints "Start", calls next, prints "End", and returns the error.

In main, you define a core handler that prints "Work". You pass core to LoggingMiddleware. The middleware returns a wrapped handler. You call the wrapped handler with a background context. The output is:

# output:
Start
Work
End

How the chain executes

When you call LoggingMiddleware(core), the middleware function runs immediately. It creates the closure and returns it. The closure holds a reference to core. That reference keeps core alive in memory. The returned function is assigned to final.

When you call final(ctx), the closure executes. It prints "Start". Then it calls next(ctx). next is the captured core function. core runs and prints "Work". core returns nil. The closure receives the nil error. It prints "End". It returns nil.

This is composition. You build a new function by wrapping an old one. The wrapper adds behavior before and after the inner function. You can chain multiple wrappers. Each wrapper adds a layer. The execution flows through the layers like an onion. The outermost layer runs first. The innermost layer runs last. The return value flows back out through the layers.

Go functions are values. You can store them in variables, pass them to other functions, and return them. This flexibility makes middleware easy to implement. You don't need special syntax or libraries. You just use functions and closures.

Convention aside: context.Context always goes as the first parameter. Name it ctx. Functions that take a context should respect cancellation and deadlines. If the context is cancelled, the function should stop work and return an error. This convention makes it easy to compose functions and propagate control signals.

Realistic scenario: retries and order

Here is a retry wrapper. It captures the retry count and the next handler. It loops, calling the handler. If the handler returns an error, it sleeps and tries again. If it succeeds, it returns immediately. If all attempts fail, it returns the last error.

// RetryMiddleware wraps next to retry on error up to maxRetries times.
func RetryMiddleware(maxRetries int, next Handler) Handler {
	return func(ctx context.Context) error {
		var err error
		// Loop allows up to maxRetries + 1 attempts.
		for i := 0; i <= maxRetries; i++ {
			err = next(ctx)
			if err == nil {
				return nil
			}
			// Backoff before retrying to avoid hammering the service.
			time.Sleep(time.Duration(i) * 100 * time.Millisecond)
		}
		return err
	}
}

You can chain middleware. The order matters. If you wrap logging inside retry, the log prints once per call. If you wrap retry inside logging, the log prints per attempt.

// Chain: Retry wraps Logging wraps Core.
// Retry runs first, then Logging, then Core.
final := RetryMiddleware(3, LoggingMiddleware(core))

In this chain, RetryMiddleware is the outermost layer. It calls LoggingMiddleware(core). The logging wrapper calls core. If core fails, the logging wrapper returns the error. The retry wrapper catches the error, sleeps, and calls the logging wrapper again. The logging wrapper prints "Start" and "End" for each attempt.

If you reverse the order, the behavior changes.

// Chain: Logging wraps Retry wraps Core.
// Logging runs first, then Retry, then Core.
final := LoggingMiddleware(RetryMiddleware(3, core))

Now LoggingMiddleware is the outermost layer. It calls RetryMiddleware(core). The retry wrapper loops and calls core multiple times if needed. The logging wrapper prints "Start" once, waits for the retry wrapper to finish, then prints "End" once. The log output doesn't show the individual attempts.

Order defines behavior. Choose the order based on what you want to observe and control. If you want to log every attempt, put logging inside retry. If you want to log the overall result, put logging outside retry.

Pitfalls and compiler errors

Middleware is simple, but there are traps. The most common mistake is forgetting to return the error. If the middleware calls next(ctx) but doesn't return the result, the error vanishes. The caller thinks the operation succeeded. This breaks error handling. Always return the error from next.

Another mistake is ignoring context cancellation. If the context is cancelled, the middleware should check ctx.Err() before doing work. Otherwise, you waste resources. For example, a retry middleware should check ctx.Err() before sleeping. If the context is cancelled, it should return immediately.

// Check context before sleeping.
if ctx.Err() != nil {
	return ctx.Err()
}
time.Sleep(...)

Compiler errors help catch mistakes. If you try to assign a function with the wrong signature to Handler, the compiler rejects it. You get an error like cannot use func(...) (type func(...)) as type Handler in assignment. This happens if the function takes extra arguments or returns a different type. Fix the signature to match Handler.

If you forget to capture the loop variable in older Go versions, you get a bug where all closures share the same variable. Go 1.22 fixed this by making loop variables unique per iteration. The compiler used to warn with loop variable i captured by func literal. Now it's safe, but good to know the history.

Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't try to hide errors. Return them immediately. This makes debugging easier.

Memory leaks can happen if middleware spawns goroutines that never finish. If a goroutine waits on a channel that never gets closed, it leaks. Always have a cancellation path. Use the context to signal goroutines to stop. If the context is cancelled, the goroutine should exit.

When to use middleware

Use middleware when you have cross-cutting concerns like logging, metrics, or retries that apply to multiple handlers. Use a struct-based decorator when the wrapper needs persistent state across calls, like a rate limiter counter. Use a simple function call when the behavior is specific to one handler and doesn't need to be shared. Use an interface-based approach when you need to mock the behavior for testing or swap implementations at runtime. Use sequential code when you don't need composition: the simplest thing that works is usually the right thing.

Middleware is composition. Order defines behavior. Don't over-engineer. Wrap only what you need.

Where to go next