Best Practices
Production patterns, security tips, and anti-patterns for building bots with @igniter-js/bot. Keep your code maintainable, secure, and performant.
Best Practices
This page captures patterns that work well (and pitfalls to avoid) when building production bots with @igniter-js/bot.
Architecture & Organization
✅ Do: Keep Bot Configuration in a Single File
// lib/bot.ts — Single source of truth for your bot
import { IgniterBot, telegram, whatsapp, memoryStore } from '@igniter-js/bot';
import { commands } from './commands';
import { middlewares } from './middlewares';
import { plugins } from './plugins';
export const bot = IgniterBot.create()
.withHandle('@mybot')
.withSessionStore(memoryStore())
.addAdapters({ telegram: telegram({ token: env.TELEGRAM_TOKEN }) })
.addCommands(commands)
.addMiddlewares(middlewares)
.build();❌ Don't: Scatter Bot Configuration Across Multiple Files
Avoid building different parts of the bot in separate modules and merging them later — the builder pattern is designed for one clear configuration path.
✅ Do: Extract Commands into Separate Files
// commands/start.ts
import { Bot } from '@igniter-js/bot';
export const startCommand = Bot.command({
name: 'start',
aliases: ['hello'],
description: 'Greets the user',
help: 'Use /start to receive a welcome',
async handle(ctx) {
await ctx.reply('Welcome!');
},
});
// commands/index.ts
export { startCommand } from './start';
export { helpCommand } from './help';
export { searchCommand } from './search';
// lib/bot.ts
import * as commands from './commands';
builder.addCommands(commands);✅ Do: Isolate Business Logic from Command Handlers
// services/search.service.ts — Pure business logic, testable
export async function searchCatalog(query: string, category?: string) {
// Database queries, API calls, etc.
return results;
}
// commands/search.ts — Thin handler, delegates to service
import { searchCatalog } from '../services/search.service';
export const searchCommand = Bot.command({
name: 'search',
aliases: ['find'],
description: 'Search the catalog',
help: 'Use /search <query>',
async handle(ctx) {
const results = await searchCatalog(ctx.args.query);
await ctx.reply(formatResults(results)); // Separate formatting
},
});Middleware Ordering
✅ Do: Order Middlewares by Criticality
1. Logging (outside timing)
2. Rate limiting (protect resources)
3. Authentication (block unauthorized)
4. Context enrichment (load user data)
5. Business logic middlewares❌ Don't: Place Auth After Business Logic
// BAD: Rate limiting won't apply to unauthorized users
builder
.addMiddleware(authMiddleware({ ... })) // 1st
.addMiddleware(rateLimitMiddleware({ ... })) // 2nd — useless if auth blocks
// GOOD: Rate limit first
builder
.addMiddleware(rateLimitMiddleware({ ... })) // 1st: always runs
.addMiddleware(authMiddleware({ ... })) // 2ndSessions
✅ Do: Use Session Update for Partial Changes
// GOOD: Merge — other session data is preserved
await ctx.session.update({ step: 'checkout' });
// BAD: Direct mutation might not save
ctx.session.data.step = 'checkout'; // May not persist✅ Do: Clear Sessions When Flows Complete
async handle(ctx) {
if (ctx.session.data.step === 'complete') {
await saveData(ctx.session.data);
await ctx.session.delete(); // Clean up
await ctx.reply('All done! 🎉');
}
}❌ Don't: Store Large Objects in Sessions
// BAD: Sessions are for conversational state, not data storage
await ctx.session.update({ allProducts: hugeProductArray });
// GOOD: Store IDs, fetch data when needed
await ctx.session.update({ lastSearchQuery: query });Error Handling
✅ Do: Always Have a Global Error Handler
builder.onError(async (ctx) => {
console.error(`[${ctx.provider}] Error for user ${ctx.message.author.id}:`, ctx.error);
// Don't reveal internal details to users
await ctx.reply('⚠️ Something went wrong. Our team has been notified.');
});✅ Do: Use try/catch in Individual Handlers for Recoverable Errors
async handle(ctx) {
try {
const data = await externalApi.fetch();
await ctx.reply(formatData(data));
} catch (error) {
await ctx.reply('⚠️ Could not fetch data. Please try again.');
// Don't re-throw — it was handled gracefully
}
}❌ Don't: Expose Internal Error Details to Users
// BAD: Leaks internal structure
await ctx.reply(`Error: ${error.message}\nStack: ${error.stack}`);
// GOOD: User-friendly message
await ctx.reply('Something went wrong. Try again or contact support.');Security
✅ Do: Validate All User Input
// Zod validation at the command level
args: z.object({
userId: z.string().regex(/^[a-zA-Z0-9_-]+$/), // Sanitized
amount: z.number().int().positive().max(10000),
})✅ Do: Use Environment Variables for Secrets
// GOOD
telegram({ token: process.env.TELEGRAM_TOKEN! })
// BAD: Never hardcode tokens
telegram({ token: '123456:ABC' })✅ Do: Verify Webhook Signatures
// Discord adapter does this automatically if publicKey is set
discord({
token: '...',
publicKey: process.env.DISCORD_PUBLIC_KEY!,
})❌ Don't: Trust ctx.message.author.id Without Verification
The adapter parses this from the platform — it's trustworthy for platform IDs, but don't use it directly for authorization without additional checks (database lookup, subscription status).
Performance
✅ Do: Initialize Bot at Module Level
// GOOD: Created once on import, reused across requests
export const bot = IgniterBot.create() /* ... */ .build();
// BAD: Created per-request (serverless cold start only)
export function getBot() {
return IgniterBot.create() /* ... */ .build();
}✅ Do: Batch Database Queries in Middleware
// Enrich context once, use everywhere
builder.addMiddleware(async (ctx, next) => {
const [user, preferences, permissions] = await Promise.all([
db.users.findById(ctx.message.author.id),
db.preferences.get(ctx.message.author.id),
db.permissions.get(ctx.message.author.id),
]);
await next();
return { user, preferences, permissions };
});❌ Don't: Block the Middleware Pipeline with Slow Operations
// BAD: 500ms delay on every message
builder.addMiddleware(async (ctx, next) => {
await heavySyncOperation();
await next();
});
// GOOD: Fire-and-forget or queue
builder.addMiddleware(async (ctx, next) => {
// Don't await non-critical operations
analytics.track('message.received', { ... });
await next();
});Testing
✅ Do: Test Commands in Isolation
import { Bot } from '@igniter-js/bot';
test('start command returns welcome message', async () => {
const ctx = createMockContext({
provider: 'telegram',
author: { id: 'user_1', name: 'Test', username: 'test' },
});
let replyText = '';
ctx.reply = async (text) => { replyText = text; };
await startCommand.handle(ctx, {});
expect(replyText).toContain('Welcome');
});✅ Do: Use Memory Store in Tests
const bot = IgniterBot.create()
.withSessionStore(memoryStore())
.addAdapter('telegram', mockTelegramAdapter)
.build();Production Checklist
- Environment variables for all tokens and secrets
- Global error handler via
onError() - Rate limiting configured for your expected traffic
- Persistent session store (Redis, Prisma) — not memory
- Webhook signature verification enabled (Discord)
- Logging middleware with
includeContent: false(privacy) - Bot initialized at module level (not per-request)
- Commands extracted into separate files for maintainability
- Business logic isolated from command handlers
- Webhook URLs configured in platform dashboards