โ† Back to Course

OCP Series ยท 3 of 5

The Middleware Pattern

Where middleware comes from, why it is the convention that runs the entire Rails request stack, and how Sidekiq uses the same shape to let teams add behavior to every job without modifying the worker.

Where this rule comes from

The middleware pattern in Ruby comes from Rack, the web server interface specification written by Christian Neukirchen in 2007. Rack defines what it means for a Ruby object to be a "web application": it must respond to #call(env) and return a three-element array of [status, headers, body]. Every Rack-compatible framework, Rails, Sinatra, Roda, Hanami, speaks this protocol.

The trick Rack pulled off was making the interface composable. A middleware is itself a Rack app: it implements #call(env) and returns a status/headers/body triple. The only difference is that it wraps another Rack app and gets a chance to act before and after the inner app runs. A middleware that logs requests calls the inner app, measures the time, and adds a header to the response. A middleware that handles authentication checks the request, and either short-circuits with a 401 or delegates to the inner app.

When you boot a Rails application, you are not starting "Rails." You are starting a stack of middlewares, with Rails as the innermost layer. bin/rails middleware lists them, and the list typically has fifteen to twenty entries: ActionDispatch::HostAuthorization, Rack::Sendfile, ActionDispatch::Static, Rack::Runtime, ActionDispatch::ShowExceptions, and so on. Each one is a small file that adds one responsibility to every request, without the inner app knowing.

The OCP move the middleware pattern makes is unusually pure. The request pipeline is open for extension (any team can add a new middleware) and closed for modification (no existing middleware needs to change when a new one is added). The inner Rails app does not know what is wrapped around it; the outer wrappers do not know about each other beyond passing the call through. Adding new behavior means adding a new class. Same shape, different concern.

The anti-pattern

Picture a Rails app where the team wants to add request-level concerns one by one. No middleware machinery, only inline logic. The application controller starts collecting before-actions:

class ApplicationController < ActionController::Base
  before_action :start_timer
  before_action :log_request_id
  before_action :detect_locale_from_header
  before_action :enforce_https_in_production
  before_action :rate_limit_unauthenticated
  before_action :measure_db_query_count
  before_action :wrap_in_request_store

  after_action :log_response_time
  after_action :emit_metrics
  after_action :clear_request_store

  rescue_from StandardError, with: :log_and_reraise
  rescue_from ActionController::RoutingError, with: :handle_404

  # ... 25 methods implementing each of those
end

The application controller is now the world's largest pipeline. Every team that wants something to happen on every request opens this file. The frontend team adds locale detection, the SRE team adds metrics, the security team adds HTTPS enforcement, the observability team adds request tracking. Six teams editing one file means six reasons that file changes, and six sources of regression every time someone touches it.

Worse, several of these concerns should run before Rails even gets the request. HTTPS enforcement, host authorization, and request ID tagging are not controller concerns, they belong at the boundary of the application, not inside it. Putting them in the controller means they run after Rails has already routed the request, parsed parameters, instantiated the controller, and loaded the user session. By the time the HTTPS check fails, work has been done that did not need to happen.

Same shape, different domain: background jobs. Without middleware, adding "log every job's duration" or "tag every job with the current request ID" or "skip jobs for soft-deleted users" means editing every worker class. The cross-cutting concern shotgun-surgeries across the codebase.

How Sidekiq solves it

Sidekiq borrows the Rack middleware shape almost directly. Every job goes through a chain of middlewares before and after the actual perform method runs. A middleware in Sidekiq is any class that responds to #call(worker, msg, queue) and yields:

# Original illustrative code (Sidekiq's middleware shape, MIT)

class LogJobDuration
  def call(worker, msg, queue)
    started = Time.current
    yield
  ensure
    Rails.logger.info(
      worker:   worker.class.name,
      queue:    queue,
      duration: (Time.current - started).round(3),
      jid:      msg["jid"]
    )
  end
end

class TagCurrentRequestId
  def call(worker, msg, queue)
    Thread.current[:request_id] = msg["request_id"]
    yield
  ensure
    Thread.current[:request_id] = nil
  end
end

class SkipDeletedUserJobs
  def call(worker, msg, queue)
    user_id = msg["args"].first
    return if User.where(id: user_id, deleted_at: nil).none?
    yield
  end
end

Three middlewares, three concerns, three separate files. Each one wraps the inner job call. The first measures duration. The second tags thread-local state for the duration of the job. The third short-circuits the chain entirely if a precondition fails, the inner job never even runs.

Registering them is one configuration block:

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  config.server_middleware do |chain|
    chain.add SkipDeletedUserJobs
    chain.add TagCurrentRequestId
    chain.add LogJobDuration
  end
end

The order matters. SkipDeletedUserJobs runs first because there is no point doing anything else if the user is gone. LogJobDuration runs last because it measures the duration of everything that happened inside the chain. The composition is the design, each middleware is short, focused, and ignorant of the others.

The worker class itself does not know any of these middlewares exist. A team adds a new cross-cutting concern by adding a new middleware file, not by editing every worker.

Why this design holds up

Four benefits, each one solving a real pain in the inline version.

Cross-cutting concerns live in their own files. Logging is one file. Metrics is one file. Request ID tagging is one file. Each team that owns one of these concerns owns one file, not a section of a shared controller or a shared worker class.

Adding a new concern is one new file. A new compliance requirement to log the user ID for every request is one new LogUserId middleware, registered in one configuration line. The application controller, the workers, the routes, none of them change. The blast radius of the new feature is exactly the new file.

Order is explicit. In the application-controller version, the order of before_action calls is implicit and a frequent source of subtle bugs. In a middleware stack, the order is the literal order of the chain.add calls. Reading the configuration file tells you exactly what happens, in what order, on every request.

Short-circuiting is built in. If a middleware decides the request should not continue, it returns without yielding. The inner stack does not run. In the application-controller version, this requires explicit return false and careful coordination. In middleware, it is the natural flow.

Where Rails uses this pattern

Middleware shows up in three places in a typical Rails app, all with the same shape:

  • Rack middleware wraps every HTTP request. bin/rails middleware lists them. Add yours with config.middleware.use MyMiddleware.
  • Sidekiq middleware wraps every background job, both on the client (when enqueueing) and on the server (when executing).
  • ActionDispatch middleware is the Rails-specific layer that handles things like cookies, sessions, and parameter parsing. Same shape, configured separately.

The fact that all three use the same conceptual shape is not an accident. It is Rack's interface design propagating outward into adjacent systems, because the pattern works.

Use middleware when the concern is genuinely cross-cutting. Logging, metrics, security checks, request tracking, locale detection, these apply to every request or every job, regardless of which controller or worker is handling it. Do not use middleware when the concern is specific to a few endpoints. A "verify the seller is approved" check for the three seller-facing routes is not middleware; it is a controller filter or a service-object call.

The principle at play

The Open/Closed Principle is at its most visible in middleware. The inner application is closed: it does not know what is wrapped around it. The outer system is open: any number of middlewares can be added or removed without touching the inner code. The composition is configuration, not modification.

The deeper move is the recognition that some concerns are not attributes of the things they affect. Logging is not an attribute of a controller. Authentication is not an attribute of a route. Job timing is not an attribute of a worker. They are concerns that wrap the thing, and wrapping is exactly what middleware does.

The pragmatic value is teamwork. When five teams each own a cross-cutting concern, the middleware shape gives each of them their own file, their own commit history, their own deployment cadence. The application controller belongs to "the app." The observability middleware belongs to the SRE team. The locale middleware belongs to the frontend team. Same request flow, five owners, no shared file to merge into.

Practice exercise

  1. Run bin/rails middleware on your app. Read the list top to bottom. Each entry is one cross-cutting concern, one file, one responsibility. Notice how Rails itself is composed this way.
  2. Open app/controllers/application_controller.rb. Count the before_action, after_action, and rescue_from declarations. For each one, ask: does this apply to every request? If yes, it might belong in middleware instead.
  3. If you use Sidekiq, list your server middleware (Sidekiq.default_configuration.server_middleware.to_a). Compare it to what your workers actually do at the top of perform. Anything that every worker does belongs in middleware.
  4. Bonus: sketch a middleware for the most-duplicated cross-cutting concern you found. Three to ten lines is usually enough. Notice how the change to every worker collapses into one new file.