Skip to content

Project structure

As your moderation bot grows, having a clear structure becomes important. Here’s what a typical Warden project looks like once you’ve built out a few mini-apps:

  • Directorysrc/
    • bootstrap.ts shared Bot — plugins + config
    • app.ts Discord runtime entry
    • cli.ts console kernel entry
    • Directorycommands/
      • PingCommand.ts
      • Directorymod/
        • ModCommand.ts entrypoint for /mod
        • ModWarnCommand.ts /mod warn
        • ModMuteCommand.ts /mod mute
        • ModBanCommand.ts /mod ban
        • ModHistoryCommand.ts /mod history
    • Directorycontext/
      • WarnUserContext.ts user context menu
    • Directorybuttons/
      • mod/ConfirmBanButton.ts dynamic custom ID handler
    • Directorymodals/
      • WarnReasonModal.ts modal handler with typed fields
    • Directorymenus/
      • mod/ActionSelectMenu.ts string-select menu
    • Directoryevents/
      • MemberJoinLogEvent.ts
      • RoleAddedLogEvent.ts
      • MemberTimedOutEvent.ts
      • AutoModFilterEvent.ts
    • Directoryerrors/
      • FallbackErrorHandler.ts catch-all error handler
    • Directorylifecycle/
      • LogOnReadyLifecycle.ts startup hook
    • Directoryjobs/
      • SweepExpiredMutesJob.ts scheduled job
    • Directorymiddleware/
      • IgnoreBots.ts
      • LogRequests.ts global middleware
    • Directoryrepositories/
      • ModRepository.ts query layer
    • Directorydb/
      • Directoryschema/ drizzle schema definitions
      • types.ts pinned Db alias for inference
      • index.ts
      • Directoryseeders/ optional, picked up by cli.ts via .load()
    • Directoryconsole/
      • CleanExpiredWarningsCommand.ts project-local console command
    • Directoryconfig/
      • discord.ts
      • moderation.ts
    • Directorylang/ i18n translation files (if you use @warden/i18n)
  • docker-compose.yml
  • drizzle.config.ts
  • .env
  • .env.example

This structure is the convention the starter ships with. Nothing is enforced — directory names matter only because that’s what your .discover(...) patterns reference.

Warden’s discovery model is kind-bucketed: each entry to .discover(...) in your app.ts declares both a glob and the kinds of decorators allowed inside it. A stray @event under .interactions(...) throws at boot, which catches a class of mistake that used to silently produce broken handlers.

src/app.ts
await bot
.discover(d => d
.interactions('./src/{commands,buttons,modals,menus}/**/*.ts')
.events('./src/events/**/*.ts')
.jobs('./src/jobs/**/*.ts')
.hooks('./src/{errors,lifecycle}/**/*.ts'))
// ...
.start();

The buckets line up with what they sound like:

BucketAllowed decorators
.interactions(...)@command, @context, @button, @modal, @menu
.events(...)@event, @decompose
.jobs(...)@job
.hooks(...)@errorHandler, @lifecycle
.config(...)none — files default-export Config.define(...)
.load(...)unchecked escape hatch — anything goes

.config(...) lives in bootstrap.ts (so both app.ts and cli.ts see it). .load(...) is for plugin-specific decorators that don’t fit a core bucket — @seeder() from @warden/drizzle is the canonical example, picked up in cli.ts.

Decorators are still what identify a class — but discovery decides where the framework even looks. Move a @command class anywhere under src/commands/ and it’s found; move it to src/jobs/ and the strict bucket rejects it.

The shared Bot instance. Plugins, config discovery, and the @warden/console side-effect import live here. Both app.ts and cli.ts import this file’s default export — module caching makes it a true singleton.

The Discord runtime entry. Everything that’s runtime-only — handler discovery, .intents(), .partials(), .middleware(), .registerTo() — stays here. This is what pnpm dev and node dist/app.js execute.

The console kernel entry. Boots plugins without Discord and dispatches a console command. This is what pnpm db:migrate, pnpm db:seed, and any other tsx src/cli.ts <name> invocation hits.

src/commands/, src/context/, src/buttons/, src/modals/, src/menus/

Section titled “src/commands/, src/context/, src/buttons/, src/modals/, src/menus/”

The five interaction kinds. Each gets its own top-level directory because the .interactions(...) glob expands to all of them at once. Subcommand groups still nest into a subdirectory — src/commands/mod/ModCommand.ts and friends.

Each event handler gets its own file. Even when multiple handlers listen to the same Discord event, they should be separate classes.

Decorated @job() classes for scheduled and queued background work — covered in detail in the Jobs guide.

Both go in the .hooks() bucket. Error handlers (@errorHandler()) catch exceptions thrown from any handler kind; lifecycle classes (@lifecycle()) listen to bot-level events like ready and shutdown.

Reusable middleware classes. Attach them per-handler via the decorator’s middleware: [...] option, or globally via .middleware([...]) on the Bot builder.

Optional but recommended. Thin classes wrapping drizzle queries — TasksRepository, ModRepository. Commands inject repositories instead of writing queries inline.

Drizzle schema, types, and seeders. db/types.ts typically pins your Db alias to your schema:

src/db/types.ts
import type {BetterSQLite3Database} from 'drizzle-orm/better-sqlite3';
import type * as schema from './schema';
export type Db = BetterSQLite3Database<typeof schema>;

Repositories and seeders import this to get full row inference from drizzle.get<Db>().

Project-local console commands. Drop a class that extends ConsoleCommand here and it’s runnable as pnpm cli <signature> (or whatever scripts you alias in package.json). See Writing your own console commands.

Typed configuration files. Each file default-exports Config.define(SomeProvider, {...}) and becomes accessible through the Config service:

// src/config/moderation.ts → config.get('moderation.logChannel')
import {env} from '@warden/core';
export default {
logChannel: env('MOD_LOG_CHANNEL_ID'),
muteRole: env('MUTE_ROLE_ID'),
maxWarnings: env.number('MAX_WARNINGS', 3),
autoMod: {
enabled: env.boolean('AUTO_MOD_ENABLED', true),
bannedWords: env('BANNED_WORDS', '').split(',').filter(Boolean),
},
};

The convention above organizes by type (commands, events, middleware). This works well for small to medium bots. As your bot grows beyond moderation into other features, you may prefer to organize by feature instead:

  • Directorysrc/
    • Directoryfeatures/
      • Directorymoderation/
        • ModCommand.ts
        • ModWarnCommand.ts
        • ModBanCommand.ts
        • MemberJoinLogEvent.ts
        • AutoModFilterEvent.ts
        • IgnoreBots.ts
      • Directorylogging/
        • MemberLeaveLogEvent.ts
        • RoleAddedLogEvent.ts
        • MessageDeleteLogEvent.ts
      • Directorywelcome/
        • WelcomeCommand.ts
        • MemberJoinWelcomeEvent.ts
    • Directorymiddleware/
      • EnsureGuildIsAvailable.ts
    • Directoryconfig/
      • moderation.ts
      • logging.ts
    • app.ts

This costs you the strict kind buckets — a feature folder mixes commands and events, which the bucketed .interactions(...) and .events(...) won’t accept. The escape hatch is .load(...): it accepts any decorator kind without enforcement.

await bot
.discover(d => d
.load('./src/features/**/*.ts')
.events('./src/features/**/*Event.ts')) // optional, if you want strict validation back
.start();

Choose whichever structure keeps your codebase easy to navigate — .load(...) is a deliberate trade. You give up boot-time bucket validation in exchange for feature-cohesive folders. Since there are no registration files, moving files between directories is just a matter of moving the file.

When you’re ready to deploy, tsup compiles each file individually to preserve the directory structure:

  • Directorydist/
    • Directorycommands/
      • PingCommand.js
      • Directorymod/
        • ModCommand.js
        • ModWarnCommand.js
    • Directoryevents/
      • MemberJoinLogEvent.js
    • Directoryconfig/
      • moderation.js
    • app.js

This is important because the same .discover(...) calls — pointed at ./dist/...js instead of ./src/...ts — find the exact same handlers in production. There are no surprises when you deploy.