Why modules matter
Every feature in your bot belongs to exactly one module. Each module declares what it provides, what it imports, and what it exposes. That boundary lets you grow the bot by adding new modules without touching existing ones.
Recommended module architecture
The standard layout isolates each feature behind a module boundary with its own folder containing at minimum a module file and a service file. Modules evolve independently from each other, and the DI container handles all the wiring between them at boot time without any manual configuration or factory functions.
Declare the root module
The bootstrap file main.ts is the entry point where you validate the environment, create the application instance, load the root module, and start listening for Discord events. The app.useModule(AppModule) call is the composition entry point, and from there Spraxium loads all imported modules recursively, resolves providers through dependency injection, and binds every registered listener to the Discord client automatically.
import { IntentPreset, Logger, SpraxiumFactory } from '@spraxium/core';
import { EnvValidator } from '@spraxium/env';
import { AppEnv } from './app.env';
import { AppModule } from './app.module';
async function main() {
const logger = new Logger('Main');
const environment = EnvValidator.validate(AppEnv);
const app = await SpraxiumFactory.create({ token: environment.token });
app.useModule(AppModule);
app.intents(IntentPreset.Standard);
logger.info('Starting application...');
await app.listen();
}
main();Build the composition root
The root module AppModule composes features by importing other modules but should never contain feature logic itself. Add a new domain by creating a feature module and importing it here.
import { Module } from '@spraxium/common';
import { AnalyticsModule } from '@spraxium/analytics';
import { ModuleNameModule } from './modules/module-name/module-name.module';
@Module({
imports: [AnalyticsModule, ModuleNameModule],
})
export class AppModule {}Module properties reference
Global modules
By default, a module's providers are only available to modules that explicitly import it, which is the desired behavior for most feature modules because it keeps dependencies visible and traceable. Marking a module with @Global() makes its exports available everywhere in the application without requiring explicit imports, but this should be reserved exclusively for cross-cutting concerns like logging, analytics, or shared configuration services that genuinely need to be accessed from every part of the codebase.
Use @Global() sparingly
Marking too many modules as global bypasses the encapsulation that makes the module system valuable and makes dependency tracking harder as the project grows. Reserve @Global() for infrastructure that every module genuinely needs, such as logging or analytics, and keep feature-specific services scoped to their own module with explicit exports.
import { Global, Module } from '@spraxium/common';
import { AnalyticsService } from './analytics.service';
@Global()
@Module({
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}Use global modules sparingly because they bypass the encapsulation boundaries that make the module system valuable in the first place. Feature-specific services should always stay local to their own module with explicit exports, and only truly universal infrastructure should be marked as global. If you find yourself marking several modules as global, it usually indicates that module boundaries need to be redesigned rather than bypassed.
Rules to keep modules maintainable
One business boundary per module folder. Export only what other modules must consume. Separate command and listener declarations from service internals. Prefer many small focused modules composed by AppModule over a few large shared ones.
When a single domain grows beyond its original scope, split it into two focused modules. The module system makes this straightforward because each module already owns its own folder and dependencies.