Naarmate een Discord-bot groeit, wordt het bewaren van alle commando's in één gigantische index.js al snel een nachtmerrie. Een goed ontworpen Discord command handler houdt elk commando en elk event in een eigen bestand en scant die mappen bij het opstarten om ze automatisch te laden. Het voordeel: een nieuwe functie toevoegen betekent één bestand aanmaken — je raakt het instappunt nooit meer aan. In dit artikel bouwen we met discord.js v14 een bestandsgebaseerde, modulaire en echt schaalbare structuur.
Waarom een bestandsgebaseerde handler?
Voor een kleine bot lijkt een reeks if (command === 'ping') misschien prima. Maar zodra je 30-40 commando's hebt, stort die aanpak in: het bestand groeit naar honderden regels, conflicten stapelen zich op en teamwerk wordt onmogelijk. Een modulaire handler levert duidelijke voordelen op:
- Scheiding van verantwoordelijkheden: elk commando doet één ding en leeft in een eigen bestand.
- Automatische ontdekking: zet er een nieuw commandobestand bij en de handler vindt het vanzelf.
- Testbaarheid: commando's gedragen zich als bijna pure functies, wat geïsoleerd testen makkelijk maakt.
- Teamvriendelijk: twee mensen kunnen tegelijk aan verschillende commando's werken zonder conflicten.
Projectstructuur
Een nette mappenstructuur is de helft van de handler. Commando's opsplitsen in categoriesubmappen houdt het overzichtelijk en goed schaalbaar:
src/
├─ index.js # instappunt, start de client
├─ handlers/
│ ├─ commands.js # laadt commando's
│ └─ events.js # laadt events
├─ commands/
│ ├─ utility/
│ │ └─ ping.js
│ └─ moderation/
│ └─ ban.js
└─ events/
├─ ready.js
└─ interactionCreate.js
De anatomie van één commando
Elk commandobestand volgt hetzelfde contract: het exporteert een data (de Slash-commandodefinitie) en een execute-functie. Door die consistentie kan de handler elk commando op dezelfde manier behandelen.
// commands/utility/ping.js
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Meet de latency van de bot'),
async execute(interaction) {
await interaction.reply(`Pong! ${interaction.client.ws.ping}ms`);
},
};
Commando's automatisch laden
De handler doorloopt de map commands/ en de submappen, doet een require van elk bestand en bewaart de geldige in een Collection. De ingebouwde Node.js-modules fs en path zijn alles wat je nodig hebt.
// handlers/commands.js
const { Collection } = require('discord.js');
const fs = require('node:fs');
const path = require('node:path');
module.exports = (client) => {
client.commands = new Collection();
const root = path.join(__dirname, '..', 'commands');
for (const folder of fs.readdirSync(root)) {
const dir = path.join(root, folder);
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js'));
for (const file of files) {
const command = require(path.join(dir, file));
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
} else {
console.warn(`[WAARSCHUWING] ${file} mist "data" of "execute".`);
}
}
}
};
Het belangrijke detail is die controle if ('data' in command && 'execute' in command): een onjuist bestand levert een waarschuwing op in plaats van de hele bot te laten crashen.
Events laden met dezelfde logica
Het patroon dat we voor commando's bouwden, geldt onverkort voor events. Elk eventbestand exporteert een name, een optionele once-vlag en een execute-functie. De handler koppelt ze met client.on of client.once.
// events/ready.js
const { Events } = require('discord.js');
module.exports = {
name: Events.ClientReady,
once: true,
execute(client) {
console.log(`Ingelogd als ${client.user.tag}`);
},
};
// handlers/events.js
const fs = require('node:fs');
const path = require('node:path');
module.exports = (client) => {
const dir = path.join(__dirname, '..', 'events');
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js'));
for (const file of files) {
const event = require(path.join(dir, file));
if (event.once) client.once(event.name, (...a) => event.execute(...a));
else client.on(event.name, (...a) => event.execute(...a));
}
};
De brug die commando's uitvoert: interactionCreate
Slash-commando's zijn eigenlijk interacties. Eén enkel interactionCreate-event ontvangt de binnenkomende interactie, zoekt het bijbehorende commando op in client.commands en roept de execute ervan aan. Fouten hier centraal opvangen is heel belangrijk.
// events/interactionCreate.js
const { Events } = require('discord.js');
module.exports = {
name: Events.InteractionCreate,
async execute(interaction) {
if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(interaction.commandName);
if (!command) return;
try {
await command.execute(interaction);
} catch (err) {
console.error(err);
const msg = { content: 'Er ging iets mis bij het uitvoeren van dit commando.', ephemeral: true };
if (interaction.replied || interaction.deferred) {
await interaction.followUp(msg);
} else {
await interaction.reply(msg);
}
}
},
};
Slash-commando's registreren bij Discord
Een belangrijk onderscheid: het laden van de commandobestanden maakt de bot ervan bewust, maar om ze in de Discord-interface te laten verschijnen, moeten de commando's apart geregistreerd (gedeployd) worden bij de Discord-API. Dat doe je doorgaans met een apart deploy-commands.js-script. Tijdens de ontwikkeling werkt registreren bij één server (guild) direct; een globale registratie kan tot een uur duren om door te komen.
const { REST, Routes } = require('discord.js');
// loop door de commands-map en verzamel elke command.data.toJSON() in een array
const rest = new REST().setToken(process.env.TOKEN);
await rest.put(
Routes.applicationGuildCommands(CLIENT_ID, GUILD_ID),
{ body: commands },
);
Alles samenbrengen in index.js
// index.js
require('dotenv').config();
const { Client, GatewayIntentBits } = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
require('./handlers/commands')(client);
require('./handlers/events')(client);
client.login(process.env.TOKEN);
Zoals je ziet is het instapbestand nu slank en stabiel. Een nieuw commando nodig? Voeg een bestand toe onder commands/. Een nieuw event? Voeg een bestand toe onder events/. Dat is precies de schoonheid van deze architectuur: de kern blijft vast terwijl de functies eindeloos kunnen groeien.
Veelgestelde vragen
Moet ik berichtcommando's of slash-commando's gebruiken?
Voor nieuwe bots verdienen slash-commando's (application commands) de voorkeur: ze zijn ontdekbaar, bieden gevalideerde parameters en vereisen niet de bevoorrechte message-content intent. Hetzelfde handlerpatroon kan beide aan, maar focussen op slash-commando's is de gezondste keuze voor een nieuw project.
Kan ik een commando hot-reloaden terwijl de bot draait?
Ja. Door de require-cache van Node.js te wissen en het bestand opnieuw te requiren, kun je een "reload"-commando schrijven. Tijdens de ontwikkeling is dat erg handig, maar bij structurele wijzigingen blijft een volledige herstart van de bot de veiligste weg.
Waarom moeten commandobestanden een gedeeld contract volgen?
Doordat elk bestand dezelfde data- en execute-vorm exporteert, kan de handler elk commando op dezelfde manier behandelen. Die consistentie is het basiscontract dat automatisch laden, validatie en foutafhandeling mogelijk maakt.
Wordt je bot rommelig naarmate hij groeit? Met een modulaire command handler, een schone event-architectuur en een solide deploy-flow zetten we je bot op een professionele basis. Neem contact op en laten we je project samen schaalbaar maken.