Dependency injection
Introduction
Section titled “Introduction”Warden uses tsyringe as its dependency injection container. You declare what your class needs in its constructor, and the framework provides it automatically.
The good news is that in most cases, you don’t need to think about DI at all. Warden’s decorators (@command(), @event(), @button(), @modal(), @menu(), @errorHandler(), etc.) automatically register your classes as injectable. Just add parameters to your constructor and they’re resolved for you.
How it works
Section titled “How it works”When Warden boots, it scans your project and processes every decorated class. Each decorator does two things:
- Registers the class with the DI container so it can be resolved
- Stores metadata about the handler (name, options, middleware, etc.)
Because every decorator handles registration, you never need to manually add @injectable() to your commands, events, or components. It’s done for you.
Under the hood, the container uses TypeScript’s emitDecoratorMetadata to read constructor parameter types at runtime. This is why your tsconfig.json needs both experimentalDecorators and emitDecoratorMetadata enabled — they power the automatic type resolution that makes constructor injection work.
Constructor injection
Section titled “Constructor injection”Adding a dependency is as simple as adding a constructor parameter. The container reads the type and resolves it:
import {command, Command, CommandInteraction, Params, reply, Embed} from '@warden/core';
@command({name: 'mod-stats', description: 'View moderation statistics'})export default class ModStatsCommand implements Command { constructor( private caseService: CaseService, private config: Config, private logger: Logger, ) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const stats = await this.caseService.getStats(interaction.guildId!); this.logger.info('Stats requested', {guild: interaction.guildId});
await reply(interaction, Embed.info('Moderation Statistics') .addFields( {name: 'Warnings', value: String(stats.warnings), inline: true}, {name: 'Mutes', value: String(stats.mutes), inline: true}, {name: 'Bans', value: String(stats.bans), inline: true}, ) ); }}This works across every handler type — commands, events, buttons, modals, select menus, middleware, error handlers, jobs, and decomposers. If it has a Warden decorator, it has constructor injection.
Singletons
Section titled “Singletons”By default, the container creates a new instance of a class each time it’s resolved. For stateless handlers like commands and events, this is exactly what you want — no shared state between executions.
But for services that maintain state or manage connections — a database service, a cache client, an analytics tracker — you want exactly one instance shared across your entire application. That’s what @singleton() is for:
import {singleton} from 'tsyringe';
@singleton()export class CaseService { private warningThresholds = new Map<string, number>();
async createWarning(guildId: string, userId: string, reason: string, modId: string): Promise<number> { // ... }
async getStats(guildId: string): Promise<ModerationStats> { // ... }}Now every command, event, or middleware that injects CaseService gets the same instance. The case service’s internal cache is shared, connections are reused, and you never accidentally create duplicate resources.
The app() helper
Section titled “The app() helper”Most of the time, constructor injection is all you need. But occasionally you’ll need to resolve a service outside of a class constructor — perhaps in a standalone function, a script, or a factory.
The app() helper gives you direct access to the DI container:
import {app} from '@warden/core';
// Resolve a service by classconst caseService = app(CaseService);const warnings = await caseService.getWarningCount(userId);
// Resolve in a factory functionfunction createModLogEmbed(action: string): EmbedBuilder { const config = app(Config); const guildName = config.get('moderation.guildName') as string; // ...}You may also use app() to check if a service is registered:
const cache = app(Cache); // throws if Cache isn't registeredFramework services
Section titled “Framework services”Warden provides several services that are always available for injection, regardless of which plugins you have installed:
| Service | Description |
|---|---|
Config | Access configuration values via dot notation |
Logger | Structured logging with levels (debug, info, warn, error) |
These are registered at boot and available everywhere:
@command({name: 'status', description: 'Bot status'})export default class StatusCommand implements Command { constructor(private config: Config, private logger: Logger) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const logChannel = this.config.get('moderation.logChannel') as string; this.logger.debug('Status command executed', {guild: interaction.guildId}); // ... }}Plugin services
Section titled “Plugin services”When you register a plugin, its services become available for injection throughout your application. Here are some common examples:
| Plugin | Services | What they provide |
|---|---|---|
@warden/drizzle | DrizzleService | Type-safe database queries |
@warden/cache | CacheService | Redis-backed caching |
@warden/permissions | Gate | Authorization policies |
@warden/audit | AuditLog | Audit trail logging |
Once a plugin is registered in your bootstrap file, you can inject its services like any other dependency:
import {command, Command, CommandInteraction, Params, reply, Embed} from '@warden/core';
@command({name: 'case', description: 'Look up a moderation case'})export default class CaseCommand implements Command { constructor( private db: DrizzleService, private cache: CacheService, private auditLog: AuditLog, ) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const caseId = interaction.options.getInteger('id')!;
// Check cache first const cached = await this.cache.get(`case:${caseId}`); if (cached) { await reply(interaction, this.formatCase(cached)); return; }
// Query the database const modCase = await this.db.query .select() .from(cases) .where(eq(cases.id, caseId)) .first();
if (!modCase) { await reply(interaction, Embed.error('Case not found.')); return; }
await this.cache.set(`case:${caseId}`, modCase, 300); await this.auditLog.log('case.viewed', {caseId, viewedBy: interaction.user.id}); await reply(interaction, this.formatCase(modCase)); }}Testing with DI
Section titled “Testing with DI”One of the biggest benefits of dependency injection is testability. Instead of hitting a real database or Discord API in your tests, you can swap out services with mocks by overriding them in the container:
import {TestBot} from '@warden/core/testing';import {container} from 'tsyringe';
const bot = TestBot.create().discover(d => d.load('./src/**/*.ts'));
// Override CaseService with a mockcontainer.register(CaseService, { useValue: { createWarning: vi.fn().mockResolvedValue(42), getWarningCount: vi.fn().mockResolvedValue(3), getStats: vi.fn().mockResolvedValue({warnings: 10, mutes: 5, bans: 2}), },});
// Now any command that injects CaseService gets the mockThis approach lets you test your command logic in isolation, without spinning up a database or connecting to Discord. For a deeper look at testing strategies, see the Testing guide.