Middleware
Introduction
Section titled “Introduction”Middleware is how you insert logic between a Discord interaction arriving and your handler’s execute() method running. Think of it as a chain of small, focused classes that each get to inspect what’s happening, maybe do something about it, and then either pass the request along or stop it in its tracks. If you’ve written middleware in a web framework before, you’ll feel right at home — Warden’s model is the same idea, minus the HTTP bits.
In a moderation bot, middleware is where you’ll naturally reach for concerns like ignoring bot messages, verifying a guild is set up in your database before a command touches it, or checking that the user actually has the mod role they’re trying to use. These are the things you’d otherwise sprinkle into every handler’s execute() — middleware lets you write them once and attach them where they’re needed.
Warden gives you a single Middleware<T> base class, and the only thing that changes between a guard attached to one command and a logger running on every handler is where you register it:
- Attach it to a specific handler via the decorator’s
middlewareoption and it’s per-handler. - Pass it to
.middleware([...])on theBotbuilder and it’s global.
Same class, two registration points. No separate base classes, no special cases.
Writing middleware
Section titled “Writing middleware”At its heart, a middleware is just a class that extends Middleware<T> and implements a single method:
import {Middleware} from '@warden/core';import type {NextFn, HandlerMeta} from '@warden/core';import {Message} from 'discord.js';
export default class IgnoreBots extends Middleware<Message> { public async handle(ctx: Message, _meta: HandlerMeta, next: NextFn): Promise<void> { if (ctx.author.bot) return; // short-circuit — don't call next await next(); }}That’s the whole contract. handle receives three arguments — the context, some metadata about the handler being dispatched, and a next function you call to continue down the chain. Calling next() passes control to the next middleware (or to your handler’s execute() if you’re the last one). Not calling next() — or throwing from inside handle — stops the chain cold.
The three arguments are worth understanding:
ctxis the typed payload flowing through the chain. For commands it’s aChatInputCommandInteraction, for buttons aButtonInteraction, for themessageCreateevent it’s aMessage. You set this via the generic onMiddleware<T>, and you get full TypeScript narrowing insidehandle.metais who’s being dispatched — aHandlerMetawith{type, name, target}. If your middleware doesn’t care about the handler’s identity (most guards don’t), just name it_metaand ignore it. Observability middleware lives and breathes by this field.nextis parameterless. Call it to proceed. That’s it.
Middleware with dependencies
Section titled “Middleware with dependencies”Middleware is just a class, so of course it can take whatever services it needs via constructor injection. Warden uses tsyringe under the hood, so annotate with @injectable() and you’re set:
import {Middleware, AuthorizationError} from '@warden/core';import type {NextFn, HandlerMeta} from '@warden/core';import type {ChatInputCommandInteraction} from 'discord.js';import {injectable} from 'tsyringe';
@injectable()export default class EnsureGuildIsAvailable extends Middleware<ChatInputCommandInteraction> { constructor(private guildService: GuildService) { super(); }
public async handle( ctx: ChatInputCommandInteraction, _meta: HandlerMeta, next: NextFn, ): Promise<void> { if (!ctx.guildId) { throw new AuthorizationError('This can only be used in a server.'); } await this.guildService.findOrCreate(ctx.guildId); await next(); }}Injected services, your own singletons, the Config service — whatever you’d inject into a command, you can inject into middleware.
Denying requests
Section titled “Denying requests”When a guard decides the handler shouldn’t run — wrong permissions, user on cooldown, DM-only command hit in a guild — you throw a typed error rather than calling interaction.reply() yourself. Warden ships two errors for this:
throw new AuthorizationError('You need Administrator permission.');throw new CooldownError('Slow down, try again in 10s.', 10);Two types, two stories. AuthorizationError is for “you’re not allowed.” CooldownError is for “not right now” — it also carries a remaining field so your own @errorHandler can format something more elaborate if you like.
Under the hood, Warden’s ErrorRouter catches these, notices they’re denials rather than genuine errors, and replies ephemerally with the error’s message. Your middleware never has to touch interaction.reply — and if you do want to take over and format denials yourself, an @errorHandler decorator hooks into the same pipeline.
This is the whole story for guards. No return values, no message objects, no branching on a {passed, message} tuple. Throw a typed error, or don’t.
Per-handler middleware
Section titled “Per-handler middleware”You’ll most often attach middleware to a specific handler via the decorator’s middleware option. Warden threads these into the dispatch chain automatically:
@event({ name: 'AutoModFilter', event: 'messageCreate', middleware: [IgnoreBots, EnsureGuildIsAvailable],})export default class AutoModFilterEvent implements Event<'messageCreate'> { ... }
@command({ name: 'mod-stats', description: 'Moderation statistics', middleware: [EnsureGuildIsAvailable],})export default class ModStatsCommand implements Command { ... }
@button({ customId: 'ban/:userId', middleware: [EnsureGuildIsAvailable],})export default class BanButton implements Button { ... }Middleware executes left to right. In the event example above, IgnoreBots runs first — if the message is from a bot, the chain stops there. Otherwise, EnsureGuildIsAvailable runs next, and only once it calls next() does your execute() get to run.
Global middleware
Section titled “Global middleware”Sometimes you want middleware that runs on everything — every command, every event, every button, every job. Logging is the canonical case. Metrics too. Request tracing, if you’re into that sort of thing. These are concerns that don’t care about what kind of interaction is flowing through; they care that something is flowing through.
Since the ctx varies across handler kinds, global middleware usually ignores it entirely and reads from meta instead:
import {Middleware, Logger} from '@warden/core';import type {NextFn, HandlerMeta} from '@warden/core';import {injectable} from 'tsyringe';
@injectable()export default class LogExecution extends Middleware<unknown> { constructor(private logger: Logger) { super(); }
public async handle(_ctx: unknown, meta: HandlerMeta, next: NextFn): Promise<void> { const start = Date.now(); this.logger.info(`→ ${meta.type}:${meta.name}`); await next(); this.logger.info(`← ${meta.type}:${meta.name} in ${Date.now() - start}ms`); }}Logger is an abstract class, so tsyringe resolves it by class reference — no @inject() decorator, no token import.
The meta object gives you everything you typically need for cross-cutting concerns:
type— the handler kind:'command' | 'context' | 'event' | 'button' | 'modal' | 'menu' | 'job'name— the handler’s name (the command name, button custom ID, event name, and so on)target— the decorated class itself, handy if you need to read its decorator metadata
Register globals on the Bot builder — pass the class reference and Warden will construct and inject it for you at boot time:
new Bot() .discover(d => d .interactions('./src/{commands,buttons,modals,menus}/**/*.ts') .events('./src/events/**/*.ts')) .middleware([LogExecution, TrackUsage]) .intents(i => i.defaults()) .partials(p => p.defaults()) .plugins([DrizzleServiceProvider]) .start();The execution order
Section titled “The execution order”When an interaction comes in and Warden dispatches it, the chain it assembles looks like this:
globals → preconditions → per-handler middleware → execute()Globals first, then preconditions (more on those in a second), then whatever you attached via the middleware option on the decorator, and finally your handler. Each layer can short-circuit — globally-registered logging still runs first even if a per-handler guard ends up rejecting the request.
“Preconditions” here means the middleware Warden compiles in from your @permissions() and @cooldown() decorators. Those look like plain metadata annotations, but behind the scenes they become middleware entries in the chain, right between the globals and your per-handler middleware. So a command with @permissions({user: ['ModerateMembers']}), a per-handler middleware: [EnsureGuildIsAvailable], and a global LogExecution actually runs as:
LogExecution → PermissionsMiddleware → EnsureGuildIsAvailable → execute()Everything composes through the same pipeline. There’s no separate “before” hook, no parallel precondition system — it’s middleware all the way down.
AND/OR composition
Section titled “AND/OR composition”Warden’s middleware spec supports more than flat lists. You can express AND/OR logic with nested arrays, which is handy when a handler should accept one of several roles:
@command({ name: 'ban', description: 'Ban a user', middleware: [EnsureGuildIsAvailable, [IsAdmin, IsModerator]],})The rule is simple: a flat entry is AND (every middleware must call next() for the chain to continue), and a nested array is OR (at least one of the middleware inside must call next()). Read left to right, the example above says “EnsureGuildIsAvailable AND (IsAdmin OR IsModerator)” — the guild must exist, and the user must be either an admin or a moderator.
A few more shapes you’ll see in the wild:
// All three must passmiddleware: [EnsureGuildIsAvailable, IgnoreBots, NotSelf]
// GuildAvailable AND (Admin OR Mod OR Helper)middleware: [EnsureGuildIsAvailable, [IsAdmin, IsModerator, IsHelper]]
// (Admin OR Owner) AND NotSelfmiddleware: [[IsAdmin, IsOwner], NotSelf]Sapphire users: this is the equivalent of Sapphire’s precondition system, but without the separate abstraction. It’s just middleware you’ve grouped into an array.
Testing middleware
Section titled “Testing middleware”Middleware classes are easy to test — they’re just classes with a handle method — but Warden ships a small test harness to make the common patterns a one-liner. runMiddleware wraps your middleware, calls handle with a supplied context and a stubbed HandlerMeta, and reports back whether next() was reached or an error was thrown:
import {describe, it, expect} from 'vitest';import {runMiddleware} from '@warden/core/testing';import {AuthorizationError} from '@warden/core';import GuildOnly from './GuildOnly';
it('rejects DMs', async () => { const ctx = {inGuild: () => false} as never; const {reached, error} = await runMiddleware(new GuildOnly(), ctx); expect(reached).toBe(false); expect(error).toBeInstanceOf(AuthorizationError);});runMiddleware returns {reached, error} — a boolean for whether the chain reached next(), and the thrown error if there was one. If your middleware reads from meta, pass a partial HandlerMeta as the third argument and the harness fills in sensible defaults for whatever you omit.