Project structure
Directory layout
Section titled “Directory layout”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
Dbalias 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.
How discovery works
Section titled “How discovery works”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.
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:
| Bucket | Allowed 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.
Directory conventions
Section titled “Directory conventions”src/bootstrap.ts
Section titled “src/bootstrap.ts”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.
src/app.ts
Section titled “src/app.ts”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.
src/cli.ts
Section titled “src/cli.ts”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.
src/events/
Section titled “src/events/”Each event handler gets its own file. Even when multiple handlers listen to the same Discord event, they should be separate classes.
src/jobs/
Section titled “src/jobs/”Decorated @job() classes for scheduled and queued background work — covered in detail in the Jobs guide.
src/errors/, src/lifecycle/
Section titled “src/errors/, src/lifecycle/”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.
src/middleware/
Section titled “src/middleware/”Reusable middleware classes. Attach them per-handler via the decorator’s middleware: [...] option, or globally via .middleware([...]) on the Bot builder.
src/repositories/
Section titled “src/repositories/”Optional but recommended. Thin classes wrapping drizzle queries — TasksRepository, ModRepository. Commands inject repositories instead of writing queries inline.
src/db/
Section titled “src/db/”Drizzle schema, types, and seeders. db/types.ts typically pins your Db alias to your schema:
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>().
src/console/
Section titled “src/console/”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.
src/config/
Section titled “src/config/”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), },};Organizing by feature
Section titled “Organizing by feature”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.
Build output
Section titled “Build output”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.