Skip to content

Dependency injection

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.

When Warden boots, it scans your project and processes every decorated class. Each decorator does two things:

  1. Registers the class with the DI container so it can be resolved
  2. 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.

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.

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.

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 class
const caseService = app(CaseService);
const warnings = await caseService.getWarningCount(userId);
// Resolve in a factory function
function 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 registered

Warden provides several services that are always available for injection, regardless of which plugins you have installed:

ServiceDescription
ConfigAccess configuration values via dot notation
LoggerStructured 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});
// ...
}
}

When you register a plugin, its services become available for injection throughout your application. Here are some common examples:

PluginServicesWhat they provide
@warden/drizzleDrizzleServiceType-safe database queries
@warden/cacheCacheServiceRedis-backed caching
@warden/permissionsGateAuthorization policies
@warden/auditAuditLogAudit 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));
}
}

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 mock
container.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 mock

This 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.