Convention-based auto-discovery and bootstrapping for the Ignis Framework
Discovers artifact files (controllers, services, repositories, datasources) by glob patterns and registers them into the IoC container during application startup. Three-phase lifecycle (configure, discover, load) with the Template Method pattern. Inspired by LoopBack 4's boot system.
| Feature | ||
|---|---|---|
| 1 | Three-Phase Lifecycle | Configure, discover, load -- structured and predictable |
| 2 | Convention Over Configuration | Default dirs and extensions just work out of the box |
| 3 | 4 Built-in Booters | Controllers, services, repositories, and datasources |
| 4 | Template Method Pattern | Extend BaseArtifactBooter for custom artifact types |
| 5 | Performance Timing | Built-in performance.now() boot report |
- Installation
- Quick Start
- Core Concepts
- Configuration
- Complete Lifecycle Walkthrough
- BaseArtifactBooter Internals
- Pattern Generation Deep Dive
- Built-in Booters Deep Dive
- BootMixin Deep Dive
- Bootstrapper Internals
- Advanced Usage
- Integration with BaseApplication
- Error Scenarios
- Debugging Boot
- Performance Tuning
- File Naming Conventions
- Boot Utilities
- Complete Type Reference
- Constants Reference
- Testing Patterns
- License
bun add @venizia/ignis-bootPeer dependencies:
bun add @venizia/ignis-inversion @venizia/ignis-helpersThe fastest way to use @venizia/ignis-boot is through BootMixin, which enhances any Container subclass with auto-discovery capabilities:
import { BootMixin, IBootOptions } from '@venizia/ignis-boot';
import { Container } from '@venizia/ignis-inversion';
class MyApplication extends BootMixin(Container) {
bootOptions: IBootOptions = {
controllers: { dirs: ['controllers'], isNested: true },
services: { dirs: ['services'], isNested: true },
repositories: { dirs: ['repositories'] },
datasources: { dirs: ['datasources'] },
};
}
// Bootstrap and start
const app = new MyApplication();
const report = await app.boot();
// All controllers, services, repositories, and datasources
// are now discovered and registered in the IoC container.When used with @venizia/ignis (the core framework), BaseApplication already applies the BootMixin internally, so you only need to pass bootOptions through your application configs:
import { BaseApplication, IApplicationConfigs } from '@venizia/ignis';
const appConfigs: IApplicationConfigs = {
name: 'MyApp',
bootOptions: {
controllers: { dirs: ['controllers'], isNested: true },
services: { dirs: ['services'], isNested: true },
repositories: { dirs: ['repositories'] },
datasources: { dirs: ['datasources'] },
},
};
class Application extends BaseApplication {
constructor() {
super(appConfigs);
}
}Every booter follows a strict three-phase execution order. Phases run sequentially, and all registered booters execute within each phase before the next phase begins.
Phase 1: CONFIGURE Phase 2: DISCOVER Phase 3: LOAD
+-----------------+ +-----------------+ +-----------------+
| Merge user opts | | Build glob | | Dynamic import |
| with defaults |-->| pattern from |-->| discovered |
| (dirs, exts, | | configured opts | | files, filter |
| isNested, glob)| | and scan FS | | class exports, |
+-----------------+ +-----------------+ | bind to DI |
+-----------------+
Phase 1 -- Configure: Merges user-provided options (dirs, extensions, isNested, glob) with the booter's defaults. If you provide dirs: ['custom-controllers'], it replaces the default ['controllers']. If you provide nothing, defaults are used.
Phase 2 -- Discover: Builds a glob pattern from the configured options and scans the filesystem relative to the project root. The result is an array of absolute file paths stored in discoveredFiles.
Phase 3 -- Load: Dynamically imports each discovered file, filters all exports to keep only class constructors (using isClass), and then calls bind() to register each class in the IoC container with the appropriate namespace and scope.
The BaseArtifactBooter implements the Template Method pattern. It defines the algorithm skeleton (configure, discover, load) while deferring booter-specific behavior to subclasses through abstract methods.
BaseArtifactBooter (extends BaseHelper, implements IBooter)
|
|-- configure() # Phase 1: merge options with defaults
|-- discover() # Phase 2: glob filesystem for files
|-- load() # Phase 3: import files + call bind()
|
|-- abstract getDefaultDirs() # Subclass: default directories
|-- abstract getDefaultExtensions() # Subclass: default file extensions
|-- abstract bind() # Subclass: register classes in container
|
|-- getPattern() # Build glob pattern from options
|
|-- (protected) root # Project root directory
|-- (protected) artifactOptions # Merged configuration
|-- (protected) discoveredFiles # Result of discover phase
|-- (protected) loadedClasses # Result of load phase
The base class handles all the common logic (option merging, glob execution, file importing, class filtering), so subclasses only need to declare their conventions and binding strategy.
The package ships with four built-in booters covering the most common artifact types:
| Booter | Default Directory | Default Extension | Namespace | Scope | Binding Key Example |
|---|---|---|---|---|---|
ControllerBooter |
controllers/ |
.controller.js |
controllers |
transient | controllers.UserController |
ServiceBooter |
services/ |
.service.js |
services |
transient | services.AuthService |
RepositoryBooter |
repositories/ |
.repository.js |
repositories |
transient | repositories.UserRepository |
DatasourceBooter |
datasources/ |
.datasource.js |
datasources |
singleton | datasources.PostgresDataSource |
Why are datasources singletons? Datasources manage connection pools and shared resources (database connections, Redis clients, etc.). Creating new instances per injection would leak connections and defeat pool sharing. All other artifact types default to transient scope.
Each built-in booter receives its configuration via constructor injection:
export class ControllerBooter extends BaseArtifactBooter {
constructor(
@inject({ key: '@app/project_root' }) root: string,
@inject({ key: '@app/instance' }) private readonly application: IApplication,
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
) {
super({
scope: ControllerBooter.name,
root,
artifactOptions: bootOptions.controllers ?? {},
});
}
protected override getDefaultDirs(): string[] {
return ['controllers'];
}
protected override getDefaultExtensions(): string[] {
return ['.controller.js'];
}
protected override async bind(): Promise<void> {
for (const cls of this.loadedClasses) {
const key = BindingKeys.build({ namespace: 'controllers', key: cls.name });
this.application.bind({ key }).toClass(cls).setTags('controllers');
}
}
}The Bootstrapper is the orchestrator that coordinates all booters through all phases. It is responsible for:
- Discovering booters -- finds all bindings tagged with
'booter'in the container - Running phases -- executes each phase sequentially on all discovered booters
- Error handling -- wraps errors with context (phase name + booter class name)
- Performance timing -- tracks
performance.now()timestamps per phase
export class Bootstrapper extends BaseHelper implements IBootstrapper {
constructor(
@inject({ key: '@app/instance' }) private readonly application: IApplication,
) {
super({ scope: Bootstrapper.name });
}
async boot(opts: IBootExecutionOptions): Promise<IBootReport> {
const { phases = BOOT_PHASES, booters } = opts;
await this.discoverBooters();
for (const phase of phases) {
await this.runPhase({ phase, booterNames: booters });
}
return this.generateReport();
}
}Execution flow:
boot()
|-> discoverBooters() # Find all 'booter'-tagged bindings
|-> runPhase('configure') # All booters: configure()
|-> runPhase('discover') # All booters: discover()
|-> runPhase('load') # All booters: load()
|-> generateReport() # Return performance data
Within each phase, booters are executed sequentially in registration order. This is important because the registration order in BootMixin is:
DatasourceBooter-- datasources first (others may depend on them)RepositoryBooter-- repositories second (depend on datasources)ServiceBooter-- services third (depend on repositories)ControllerBooter-- controllers last (depend on services)
BootMixin is a mixin function that enhances any Container subclass with boot capabilities. It handles all the wiring automatically:
import { BootMixin } from '@venizia/ignis-boot';
import { Container } from '@venizia/ignis-inversion';
class MyApp extends BootMixin(Container) {
bootOptions = { /* ... */ };
}What it registers in the constructor:
| Binding Key | Value | Tags | Scope |
|---|---|---|---|
@app/boot-options |
User's bootOptions |
-- | -- |
booter.DatasourceBooter |
DatasourceBooter class |
booter |
transient |
booter.RepositoryBooter |
RepositoryBooter class |
booter |
transient |
booter.ServiceBooter |
ServiceBooter class |
booter |
transient |
booter.ControllerBooter |
ControllerBooter class |
booter |
transient |
bootstrapper |
Bootstrapper class |
-- | singleton |
What it adds to the class:
bootOptions?: IBootOptions-- property for user configurationboot(): Promise<IBootReport>-- method that resolves theBootstrappersingleton and callsboot({})
The top-level configuration object maps artifact type names to their discovery options. It includes four built-in keys and supports arbitrary extension:
interface IBootOptions {
controllers?: IArtifactOptions;
services?: IArtifactOptions;
repositories?: IArtifactOptions;
datasources?: IArtifactOptions;
[artifactType: string]: IArtifactOptions | undefined; // Extensible for custom booters
}Example:
const bootOptions: IBootOptions = {
controllers: {
dirs: ['controllers'],
isNested: true,
},
services: {
dirs: ['services'],
extensions: ['.service.js'],
},
repositories: {
dirs: ['repositories'],
},
datasources: {
dirs: ['datasources'],
},
// Custom booter options
handlers: {
dirs: ['handlers'],
extensions: ['.handler.js'],
},
};Configuration for a single artifact type's discovery behavior:
interface IArtifactOptions {
dirs?: string[]; // Directories to scan (relative to project root)
extensions?: string[]; // File extensions to match (e.g., '.controller.js')
isNested?: boolean; // Scan subdirectories? Default: true
glob?: string; // Custom glob pattern (overrides dirs/extensions entirely)
}| Option | Type | Default | Description |
|---|---|---|---|
dirs |
string[] |
Booter-specific | Directories to scan, relative to project root |
extensions |
string[] |
Booter-specific | File extension patterns to match |
isNested |
boolean |
true |
Whether to recurse into subdirectories |
glob |
string |
undefined |
Custom glob pattern; if set, dirs and extensions are ignored |
When no custom glob is provided, BaseArtifactBooter.getPattern() builds a glob pattern from dirs, extensions, and isNested:
Single directory, single extension:
dirs: ['repositories']
extensions: ['.repository.js']
isNested: true
Result: repositories/{**/*,*}.repository.js
Multiple directories or extensions:
dirs: ['dir1', 'dir2']
extensions: ['.ext1.js', '.ext2.js']
isNested: true
Result: {dir1,dir2}/{**/*,*}.{ext1.js,ext2.js}
Non-nested (single level only):
dirs: ['controllers']
extensions: ['.controller.js']
isNested: false
Result: controllers/*.controller.js
Custom glob (overrides everything):
glob: 'custom/glob/pattern/**/*.js'
Result: custom/glob/pattern/**/*.js
The leading dot in extensions is automatically stripped during pattern generation. For example, .controller.js becomes controller.js in the glob pattern.
This section walks through what happens step-by-step when you call app.boot() on an application with the following configuration:
const bootOptions: IBootOptions = {
controllers: { dirs: ['controllers'], isNested: true },
services: { dirs: ['services'] },
repositories: { dirs: ['repositories'] },
datasources: { dirs: ['datasources'] },
};Assume the project's dist/ directory has this structure:
dist/
controllers/
user.controller.js (exports UserController)
admin/
admin.controller.js (exports AdminController)
services/
auth.service.js (exports AuthService)
repositories/
user.repository.js (exports UserRepository)
datasources/
postgres.datasource.js (exports PostgresDataSource)
The boot() method (from BootMixin or BaseApplication) resolves the Bootstrapper singleton from the container:
boot(): Promise<IBootReport> {
const bootstrapper = this.get<Bootstrapper>({ key: 'bootstrapper' });
return bootstrapper.boot({});
}The Bootstrapper is instantiated (or retrieved from singleton cache) and runs:
[Bootstrapper][boot] Starting boot | Number of booters: 4
The Bootstrapper calls this.application.findByTag({ tag: 'booter' }) to find all bindings tagged 'booter'. It resolves each binding (instantiating the booter class via the IoC container), which triggers constructor injection of @app/project_root, @app/instance, and @app/boot-options.
[Bootstrapper][discoverBooters] Discovered booter: booter.DatasourceBooter
[Bootstrapper][discoverBooters] Discovered booter: booter.RepositoryBooter
[Bootstrapper][discoverBooters] Discovered booter: booter.ServiceBooter
[Bootstrapper][discoverBooters] Discovered booter: booter.ControllerBooter
The booters are stored in an array in registration order. This order matters -- datasources are discovered and loaded before repositories, which are discovered before services, etc.
The Bootstrapper calls configure() on each booter sequentially.
Each booter merges user-provided options with its defaults. If the user specified dirs: ['controllers'], that is used. If the user did not specify extensions, the booter falls back to its getDefaultExtensions() return value.
[Bootstrapper][runPhase] Starting phase: CONFIGURE
[DatasourceBooter][configure] Configured: {"dirs":["datasources"],"extensions":[".datasource.js"],"isNested":true}
[RepositoryBooter][configure] Configured: {"dirs":["repositories"],"extensions":[".repository.js"],"isNested":true}
[ServiceBooter][configure] Configured: {"dirs":["services"],"extensions":[".service.js"],"isNested":true}
[ControllerBooter][configure] Configured: {"dirs":["controllers"],"extensions":[".controller.js"],"isNested":true}
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms
Each booter calls getPattern() to build a glob string, then uses the glob library to scan the filesystem from the project root.
[Bootstrapper][runPhase] Starting phase: DISCOVER
[DatasourceBooter][discover] Root: /app/dist | Using pattern: datasources/{**/*,*}.datasource.js | Discovered file: ["/app/dist/datasources/postgres.datasource.js"]
[RepositoryBooter][discover] Root: /app/dist | Using pattern: repositories/{**/*,*}.repository.js | Discovered file: ["/app/dist/repositories/user.repository.js"]
[ServiceBooter][discover] Root: /app/dist | Using pattern: services/{**/*,*}.service.js | Discovered file: ["/app/dist/services/auth.service.js"]
[ControllerBooter][discover] Root: /app/dist | Using pattern: controllers/{**/*,*}.controller.js | Discovered file: ["/app/dist/controllers/user.controller.js","/app/dist/controllers/admin/admin.controller.js"]
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms
Note how controllers/admin/admin.controller.js was discovered because isNested: true generates the pattern {**/*,*} which matches both the root level and subdirectories.
Each booter dynamically imports its discovered files using await import(file), iterates all named exports, filters for class constructors using isClass(), and then calls its bind() method to register classes in the IoC container.
[Bootstrapper][runPhase] Starting phase: LOAD
[DatasourceBooter][bind] Bound key: datasources.PostgresDataSource (scope: singleton)
[RepositoryBooter][bind] Bound key: repositories.UserRepository (scope: transient)
[ServiceBooter][bind] Bound key: services.AuthService (scope: transient)
[ControllerBooter][bind] Bound key: controllers.UserController (scope: transient)
[ControllerBooter][bind] Bound key: controllers.AdminController (scope: transient)
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 45.3 ms
The Bootstrapper returns an IBootReport object. The current implementation returns an empty object ({}), but the infrastructure for phase timing is in place for future extension.
[Bootstrapper][generateReport] Boot report: {}
After boot completes, the IoC container holds:
| Binding Key | Class | Scope | Tags |
|---|---|---|---|
datasources.PostgresDataSource |
PostgresDataSource |
singleton | datasources |
repositories.UserRepository |
UserRepository |
transient | repositories |
services.AuthService |
AuthService |
transient | services |
controllers.UserController |
UserController |
transient | controllers |
controllers.AdminController |
AdminController |
transient | controllers |
These can now be resolved anywhere via @inject({ key: 'services.AuthService' }) or app.get({ key: 'controllers.UserController' }).
This section documents every protected property, method, and internal behavior of BaseArtifactBooter.
export abstract class BaseArtifactBooter extends BaseHelper implements IBooter {
protected root: string = '';
protected artifactOptions: IArtifactOptions = {};
protected discoveredFiles: string[] = [];
protected loadedClasses: TClass<any>[] = [];
// Inherited from BaseHelper:
// scope: string
// identifier: string
// logger: Logger
}| Property | Type | Initial Value | Description |
|---|---|---|---|
root |
string |
'' |
Absolute path to the project root directory. Set from constructor opts.root. Glob patterns are resolved relative to this path. |
artifactOptions |
IArtifactOptions |
{} |
The merged configuration after configure() runs. Before configure(), holds whatever the user passed (or {}). After configure(), has all fields populated with defaults. |
discoveredFiles |
string[] |
[] |
Array of absolute file paths populated by discover(). Reset to [] at the start of each discover() call. |
loadedClasses |
TClass<any>[] |
[] |
Array of class constructors extracted from discovered files by load(). Reset to [] at the start of each load() call. |
constructor(opts: IBooterOptions) {
super({ scope: opts.scope });
this.artifactOptions = opts.artifactOptions;
this.root = opts.root;
}The constructor receives an IBooterOptions object with three fields:
| Field | Type | Description |
|---|---|---|
scope |
string |
Logger scope name, typically BooterClassName.name (e.g., "ControllerBooter") |
root |
string |
Absolute path to project root (injected as @app/project_root) |
artifactOptions |
IArtifactOptions |
User-provided options from bootOptions.controllers (or whichever artifact key) |
The super({ scope }) call initializes the BaseHelper which sets up scoped logging via LoggerFactory.getLogger([scope]).
async configure(): Promise<void> {
this.artifactOptions = {
dirs: this.artifactOptions?.dirs ?? this.getDefaultDirs(),
extensions: this.artifactOptions?.extensions ?? this.getDefaultExtensions(),
isNested: this.artifactOptions?.isNested ?? true,
glob: this.artifactOptions?.glob,
...this.artifactOptions,
};
this.logger.for(this.configure.name).debug(`Configured: %j`, this.artifactOptions);
}Merge behavior in detail:
- A new object is created with defaults computed from
getDefaultDirs()andgetDefaultExtensions() - The spread
...this.artifactOptionsat the end means any user-provided values override the defaults - If the user provided
dirs: ['custom'], the default is computed as['controllers']but then overwritten by the spread - If the user provided nothing for
dirs,this.artifactOptions?.dirsisundefined, sogetDefaultDirs()is used isNesteddefaults totrueif not providedglobis passed through as-is (no default)
Important: Because of the spread at the end, user options always win. This means if you pass { dirs: ['custom-controllers'], extensions: ['.ctrl.js'] }, both fields override defaults.
async discover(): Promise<void> {
const pattern = this.getPattern();
try {
this.discoveredFiles = []; // Reset discovered files
this.discoveredFiles = await discoverFiles({ root: this.root, pattern });
this.logger
.for(this.discover.name)
.debug(
`Root: %s | Using pattern: %s | Discovered file: %j`,
this.root,
pattern,
this.discoveredFiles,
);
} catch (error) {
throw getError({
message: `[discover] Failed to discover files using pattern: ${pattern} | Error: ${(error as Error)?.message}`,
});
}
}Key behaviors:
discoveredFilesis reset to[]before each discovery run -- this means callingdiscover()multiple times replaces previous resultsgetPattern()is called to build the glob string (see next section)discoverFiles()uses theglobnpm package with{ cwd: root, absolute: true }options- Results are absolute file paths (e.g.,
/app/dist/controllers/user.controller.js) - If the glob pattern matches zero files,
discoveredFilesis simply an empty array -- this is not an error - If the
globlibrary itself throws (e.g., invalid pattern syntax), the error is caught and re-thrown with context
async load(): Promise<void> {
if (!this.discoveredFiles.length) {
this.logger.for(this.load.name).debug(`No files discovered to load.`);
return;
}
try {
this.loadedClasses = []; // Reset loaded classes
this.loadedClasses = await loadClasses({ files: this.discoveredFiles, root: this.root });
await this.bind();
} catch (error) {
throw getError({
message: `[load] Failed to load classes from discovered files | Error: ${(error as Error)?.message}`,
});
}
}Key behaviors:
- If
discoveredFilesis empty,load()returns immediately with a debug log -- no error thrown loadedClassesis reset to[]before each loadloadClasses()iterates each file, callsawait import(file), and checks every named export withisClass()isClass()checks:typeof target === 'function' && target.prototype !== undefined- Arrow functions fail the
isClasscheck because they have noprototypeproperty - After loading classes, the abstract
bind()method is called -- this is where subclasses register classes in the container - If any file import fails or
bind()throws, the error is caught and re-thrown with context
protected getPattern(): string {
// Use custom glob if provided
if (this.artifactOptions.glob) {
return this.artifactOptions.glob;
}
if (!this.artifactOptions.dirs?.length) {
throw getError({
message: `[getPattern] No directories specified for artifact discovery`,
});
}
if (!this.artifactOptions.extensions?.length) {
throw getError({
message: `[${this.scope}][getPattern] No file extensions specified for artifact discovery`,
});
}
const dirs = this.artifactOptions.dirs.join(',');
const exts = this.artifactOptions.extensions
.map(e => (e.startsWith('.') ? e.slice(1) : e))
.join(',');
const nested = this.artifactOptions.isNested ? '{**/*,*}' : '*';
if (this.artifactOptions.dirs.length > 1 || this.artifactOptions.extensions.length > 1) {
return `{${dirs}}/${nested}.{${exts}}`;
} else {
return `${dirs}/${nested}.${exts}`;
}
}Decision tree:
- If
globis set, return it immediately (no validation of dirs/extensions) - If no
dirsprovided, throw an error - If no
extensionsprovided, throw an error - Strip leading dots from extensions:
.controller.jsbecomescontroller.js - If there is exactly one dir AND exactly one extension, use the simple format:
dir/nested.ext - If there are multiple dirs OR multiple extensions, use brace expansion:
{dirs}/nested.{exts}
Why {**/*,*} for nested? This glob alternation matches files in both subdirectories (**/file) and the root directory (file). Without the ,* part, files directly in the target directory (not in a subdirectory) would not be matched.
Input:
{ dirs: ['controllers'], extensions: ['.controller.js'], isNested: true }Processing:
dirs.join(',')=>"controllers"- Extensions:
.controller.js=> strip dot =>"controller.js" dirs.length === 1 && extensions.length === 1=> simple formatnested = '{**/*,*}'
Output:
controllers/{**/*,*}.controller.js
Matches: controllers/user.controller.js, controllers/admin/user.controller.js, controllers/a/b/c/deep.controller.js
Does NOT match: controllers/user.service.js, other-dir/user.controller.js
Input:
{ dirs: ['private-controllers', 'public-controllers'], extensions: ['.controller.js', '.ctrl.js'], isNested: true }Processing:
dirs.join(',')=>"private-controllers,public-controllers"- Extensions:
.controller.js=>controller.js,.ctrl.js=>ctrl.js=>"controller.js,ctrl.js" dirs.length === 2 || extensions.length === 2=> brace expansion format
Output:
{private-controllers,public-controllers}/{**/*,*}.{controller.js,ctrl.js}
Matches: private-controllers/user.controller.js, public-controllers/admin/settings.ctrl.js
Input:
{ dirs: ['api', 'admin'], extensions: ['.controller.js'], isNested: true }Processing:
dirs.length === 2=> brace expansion triggered
Output:
{api,admin}/{**/*,*}.controller.js
Input:
{ dirs: ['services'], extensions: ['.service.js', '.svc.js'], isNested: true }Processing:
extensions.length === 2=> brace expansion triggered
Output:
{services}/{**/*,*}.{service.js,svc.js}
Nested (default, isNested: true):
{ dirs: ['repositories'], extensions: ['.repository.js'], isNested: true }Output:
repositories/{**/*,*}.repository.js
The {**/*,*} alternation ensures files are matched at any depth, including the root of the directory.
Non-nested (isNested: false):
{ dirs: ['repositories'], extensions: ['.repository.js'], isNested: false }Output:
repositories/*.repository.js
Only matches files directly inside repositories/ -- no subdirectory scanning.
Input:
{ dirs: ['ignored'], extensions: ['.ignored.js'], glob: 'modules/**/handlers/*.handler.js' }Output:
modules/**/handlers/*.handler.js
When glob is set, dirs, extensions, and isNested are all completely ignored. The custom glob is returned as-is.
The getPattern() method strips the leading dot from each extension before building the pattern:
const exts = this.artifactOptions.extensions
.map(e => (e.startsWith('.') ? e.slice(1) : e))
.join(',');| Input Extension | After Stripping | In Pattern |
|---|---|---|
.controller.js |
controller.js |
*.controller.js |
.service.js |
service.js |
*.service.js |
handler.js |
handler.js (no dot) |
*.handler.js |
.my.custom.ext.js |
my.custom.ext.js |
*.my.custom.ext.js |
This means files need a dot before the extension in their filename. For example, the pattern *.controller.js matches user.controller.js but does NOT match usercontrollerjs or user-controller.js.
| dirs | extensions | isNested | glob | Generated Pattern |
|---|---|---|---|---|
['controllers'] |
['.controller.js'] |
true |
-- | controllers/{**/*,*}.controller.js |
['controllers'] |
['.controller.js'] |
false |
-- | controllers/*.controller.js |
['api', 'admin'] |
['.controller.js'] |
true |
-- | {api,admin}/{**/*,*}.controller.js |
['services'] |
['.service.js', '.svc.js'] |
true |
-- | {services}/{**/*,*}.{service.js,svc.js} |
['a', 'b'] |
['.x.js', '.y.js'] |
true |
-- | {a,b}/{**/*,*}.{x.js,y.js} |
['a', 'b'] |
['.x.js', '.y.js'] |
false |
-- | {a,b}/*.{x.js,y.js} |
| any | any | any | 'custom/**/*.js' |
custom/**/*.js |
All four built-in booters share the same structure. They differ only in their default directories, default extensions, binding namespace, and binding scope. Each one receives three constructor-injected values:
| Injection Key | Type | Description |
|---|---|---|
@app/project_root |
string |
Absolute path to the project's build output directory |
@app/instance |
IApplication |
The application container instance (for binding classes) |
@app/boot-options |
IBootOptions |
The user's boot options object |
Source: src/booters/controller.booter.ts
export class ControllerBooter extends BaseArtifactBooter {
constructor(
@inject({ key: '@app/project_root' }) root: string,
@inject({ key: '@app/instance' }) private readonly application: IApplication,
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
) {
super({ scope: ControllerBooter.name, root, artifactOptions: bootOptions.controllers ?? {} });
}
protected override getDefaultDirs(): string[] {
return ['controllers'];
}
protected override getDefaultExtensions(): string[] {
return ['.controller.js'];
}
protected override async bind(): Promise<void> {
for (const cls of this.loadedClasses) {
const key = BindingKeys.build({ namespace: 'controllers', key: cls.name });
this.application.bind({ key }).toClass(cls).setTags('controllers');
this.logger.for(this.bind.name).debug('Bound key: %s', key);
}
}
}| Property | Value |
|---|---|
| Default dirs | ['controllers'] |
| Default extensions | ['.controller.js'] |
| Namespace | controllers |
| Binding key format | controllers.{ClassName} (e.g., controllers.UserController) |
| Scope | transient (default) |
| Tags | controllers |
Source: src/booters/service.booter.ts
export class ServiceBooter extends BaseArtifactBooter {
constructor(
@inject({ key: '@app/project_root' }) root: string,
@inject({ key: '@app/instance' }) protected application: IApplication,
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
) {
super({ scope: ServiceBooter.name, root, artifactOptions: bootOptions.services ?? {} });
}
protected override getDefaultDirs(): string[] {
return ['services'];
}
protected override getDefaultExtensions(): string[] {
return ['.service.js'];
}
protected override async bind(): Promise<void> {
for (const cls of this.loadedClasses) {
const key = BindingKeys.build({ namespace: 'services', key: cls.name });
this.application.bind({ key }).toClass(cls).setTags('services');
this.logger.for(this.bind.name).debug('Bound key: %s', key);
}
}
}| Property | Value |
|---|---|
| Default dirs | ['services'] |
| Default extensions | ['.service.js'] |
| Namespace | services |
| Binding key format | services.{ClassName} (e.g., services.AuthService) |
| Scope | transient (default) |
| Tags | services |
Source: src/booters/repository.booter.ts
export class RepositoryBooter extends BaseArtifactBooter {
constructor(
@inject({ key: '@app/project_root' }) root: string,
@inject({ key: '@app/instance' }) protected application: IApplication,
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
) {
super({ scope: RepositoryBooter.name, root, artifactOptions: bootOptions.repositories ?? {} });
}
protected override getDefaultDirs(): string[] {
return ['repositories'];
}
protected override getDefaultExtensions(): string[] {
return ['.repository.js'];
}
protected override async bind(): Promise<void> {
for (const cls of this.loadedClasses) {
const key = BindingKeys.build({ namespace: 'repositories', key: cls.name });
this.application.bind({ key }).toClass(cls).setTags('repositories');
this.logger.for(this.bind.name).debug('Bound key: %s', key);
}
}
}| Property | Value |
|---|---|
| Default dirs | ['repositories'] |
| Default extensions | ['.repository.js'] |
| Namespace | repositories |
| Binding key format | repositories.{ClassName} (e.g., repositories.UserRepository) |
| Scope | transient (default) |
| Tags | repositories |
Source: src/booters/datasource.booter.ts
export class DatasourceBooter extends BaseArtifactBooter {
constructor(
@inject({ key: '@app/project_root' }) root: string,
@inject({ key: '@app/instance' }) private readonly application: IApplication,
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
) {
super({ scope: DatasourceBooter.name, root, artifactOptions: bootOptions.datasources ?? {} });
}
protected override getDefaultDirs(): string[] {
return ['datasources'];
}
protected override getDefaultExtensions(): string[] {
return ['.datasource.js'];
}
protected override async bind(): Promise<void> {
for (const cls of this.loadedClasses) {
const key = BindingKeys.build({ namespace: 'datasources', key: cls.name });
this.application.bind({ key }).toClass(cls).setTags('datasources').setScope('singleton');
this.logger.for(this.bind.name).debug('Bound key: %s', key);
}
}
}| Property | Value |
|---|---|
| Default dirs | ['datasources'] |
| Default extensions | ['.datasource.js'] |
| Namespace | datasources |
| Binding key format | datasources.{ClassName} (e.g., datasources.PostgresDataSource) |
| Scope | singleton |
| Tags | datasources |
The DatasourceBooter is the only built-in booter that sets .setScope('singleton') on its bindings. This is a critical design decision:
-
Connection pooling. A datasource typically creates a connection pool to the database (e.g., a
pg.Poolwith 10-20 connections). Creating a new pool per injection would exhaust database connections under load. -
Resource sharing. Multiple repositories share the same datasource instance. If
UserRepositoryandOrderRepositoryboth referencePostgresDataSource, they share one connection pool rather than each managing their own. -
Schema discovery. Datasources use
discoverSchema()to collect all table schemas from their associated repositories. This discovery runs once during startup and caches the result. Multiple instances would repeat this work needlessly. -
Transaction coordination. When two repositories participate in the same transaction, they must share the same underlying connection. Singleton datasources make this possible.
All other booters use transient scope because controllers, services, and repositories are lightweight wrappers with no expensive resources to share. They are instantiated fresh for each container resolution.
Source: src/boot.mixin.ts
The full source of BootMixin:
import {
Bootstrapper,
ControllerBooter,
DatasourceBooter,
IBootableApplication,
IBootOptions,
IBootReport,
RepositoryBooter,
ServiceBooter,
} from '@venizia/ignis-boot';
import { TMixinTarget } from '@venizia/ignis-helpers';
import { BindingScopes, Container } from '@venizia/ignis-inversion';
export const BootMixin = <T extends TMixinTarget<Container>>(baseClass: T) => {
class Mixed extends baseClass implements IBootableApplication {
constructor(...args: any[]) {
super(...args);
this.bind({ key: `@app/boot-options` }).toValue(this.bootOptions ?? {});
this.bind({ key: 'booter.DatasourceBooter' }).toClass(DatasourceBooter).setTags('booter');
this.bind({ key: 'booter.RepositoryBooter' }).toClass(RepositoryBooter).setTags('booter');
this.bind({ key: 'booter.ServiceBooter' }).toClass(ServiceBooter).setTags('booter');
this.bind({ key: 'booter.ControllerBooter' }).toClass(ControllerBooter).setTags('booter');
this.bind({ key: 'bootstrapper' }).toClass(Bootstrapper).setScope(BindingScopes.SINGLETON);
}
bootOptions?: IBootOptions | undefined;
boot(): Promise<IBootReport> {
const bootstrapper = this.get<Bootstrapper>({ key: 'bootstrapper' });
return bootstrapper.boot({});
}
}
return Mixed;
};When the BootMixin constructor runs, it performs exactly 6 binding registrations in this order:
1. Boot options binding:
this.bind({ key: '@app/boot-options' }).toValue(this.bootOptions ?? {});Binds the user's bootOptions object (or {} if undefined) as a plain value. This is injected into every booter's constructor.
2-5. Booter class registrations (in dependency order):
this.bind({ key: 'booter.DatasourceBooter' }).toClass(DatasourceBooter).setTags('booter');
this.bind({ key: 'booter.RepositoryBooter' }).toClass(RepositoryBooter).setTags('booter');
this.bind({ key: 'booter.ServiceBooter' }).toClass(ServiceBooter).setTags('booter');
this.bind({ key: 'booter.ControllerBooter' }).toClass(ControllerBooter).setTags('booter');Each booter is:
- Bound as a class (not an instance) -- the container instantiates it on resolution with constructor injection
- Tagged with
'booter'-- this is how theBootstrapperdiscovers them viafindByTag({ tag: 'booter' }) - Registered in dependency order -- datasources before repositories before services before controllers
The registration order determines execution order within each phase. This ensures that during the LOAD phase, datasources are bound to the container before repositories try to resolve them.
6. Bootstrapper singleton:
this.bind({ key: 'bootstrapper' }).toClass(Bootstrapper).setScope(BindingScopes.SINGLETON);The Bootstrapper is a singleton because it maintains internal state (the booter list, phase timings). Multiple boot() calls resolve the same Bootstrapper instance.
boot(): Promise<IBootReport> {
const bootstrapper = this.get<Bootstrapper>({ key: 'bootstrapper' });
return bootstrapper.boot({});
}The boot() method:
- Resolves the
Bootstrappersingleton from the container (which triggers@inject({ key: '@app/instance' })constructor injection) - Calls
bootstrapper.boot({})with an empty options object, which means all phases run on all booters - Returns the
IBootReportpromise
Source: src/bootstrapper.ts
export class Bootstrapper extends BaseHelper implements IBootstrapper {
private booters: IBooter[] = [];
private phaseStartTimings: Map<string, number> = new Map();
private phaseEndTimings: Map<string, number> = new Map();
constructor(@inject({ key: '@app/instance' }) private readonly application: IApplication) {
super({ scope: Bootstrapper.name });
}
async boot(opts: IBootExecutionOptions): Promise<IBootReport> {
const { phases = BOOT_PHASES, booters } = opts;
await this.discoverBooters();
this.logger
.for(this.boot.name)
.debug(`Starting boot | Number of booters: %d`, this.booters.length);
for (const phase of phases) {
await this.runPhase({ phase, booterNames: booters });
}
return this.generateReport();
}
// ...
}private async discoverBooters(): Promise<void> {
const booterBindings = this.application.findByTag<IBooter>({ tag: 'booter' });
for (const binding of booterBindings) {
this.booters.push(binding.getValue(this.application));
this.logger.for(this.discoverBooters.name).debug(`Discovered booter: %s`, binding.key);
}
}This method:
- Calls
this.application.findByTag({ tag: 'booter' })which returns allBindingobjects that have the'booter'tag - For each binding, calls
binding.getValue(this.application)which:- Instantiates the booter class (since booters are bound via
.toClass()) - Performs constructor injection, resolving
@app/project_root,@app/instance, and@app/boot-optionsfrom the container
- Instantiates the booter class (since booters are bound via
- Pushes each instantiated booter into the
this.bootersarray - Logs the binding key for each discovered booter
The order of booterBindings matches the order they were registered in the container. Since BootMixin registers them in the order Datasource -> Repository -> Service -> Controller, that is the execution order.
private async runPhase(opts: { phase: TBootPhase; booterNames?: string[] }): Promise<void> {
const { phase } = opts;
this.phaseStartTimings.set(phase, performance.now());
this.logger.for(this.runPhase.name).debug(`Starting phase: %s`, phase.toUpperCase());
for (const booter of this.booters) {
const phaseMethod = booter[phase];
if (!phaseMethod) {
this.logger
.for(this.runPhase.name)
.debug(
`SKIP not implemented booter | Phase: %s | Booter: %s`,
phase,
booter.constructor.name,
);
continue;
}
if (typeof phaseMethod !== 'function') {
this.logger
.for(this.runPhase.name)
.debug(
`SKIP not a function booter | Phase: %s | Booter: %s`,
phase,
booter.constructor.name,
);
continue;
}
try {
this.logger
.for(this.runPhase.name)
.debug(`Running | Phase: %s | Booter: %s`, phase, booter.constructor.name);
await phaseMethod.call(booter);
} catch (error) {
const errorMessage = (error as Error)?.message || String(error);
throw getError({
message: `[Bootstrapper][runPhase] Error during phase '${phase}' on booter '${booter.constructor.name}': ${errorMessage}`,
});
}
}
this.phaseEndTimings.set(phase, performance.now());
const start = this.phaseStartTimings.get(phase) ?? 0;
const end = this.phaseEndTimings.get(phase) ?? 0;
const duration = end - start;
this.logger
.for(this.runPhase.name)
.debug(`Completed phase: %s | Took: %d ms`, phase.toUpperCase(), duration);
}Key behaviors:
-
Phase method lookup: For each booter, accesses
booter[phase](e.g.,booter['configure']). This is a dynamic property lookup using the phase string as a key. -
Guard checks: Two guards skip booters that do not implement the phase:
- If
phaseMethodis falsy (undefined/null), the booter is skipped with a debug log - If
phaseMethodis not a function, the booter is skipped with a debug log - This allows custom booters to omit phases they do not need
- If
-
Method invocation: Uses
phaseMethod.call(booter)to ensure the correctthiscontext when calling the method. -
Sequential execution: Booters within a phase are executed sequentially (not in parallel). This guarantees ordering -- datasource bindings are available before repository loading begins.
-
booterNames filtering: The
opts.booterNamesparameter is accepted but not yet implemented (marked with a TODO comment). Future versions will allow running specific booters by name.
When a phase method throws an error, the Bootstrapper catches it and wraps it with context:
throw getError({
message: `[Bootstrapper][runPhase] Error during phase '${phase}' on booter '${booter.constructor.name}': ${errorMessage}`,
});Example error message:
[Bootstrapper][runPhase] Error during phase 'discover' on booter 'ControllerBooter': [discover] Failed to discover files using pattern: controllers/{**/*,*}.controller.js | Error: ENOENT: no such file or directory
This multi-layer error wrapping makes it clear:
- Which component threw (
Bootstrapper) - Which phase was running (
discover) - Which booter failed (
ControllerBooter) - What the original error was
The Bootstrapper uses two Map<string, number> instances to track timing:
private phaseStartTimings: Map<string, number> = new Map();
private phaseEndTimings: Map<string, number> = new Map();At the start of each phase, performance.now() is recorded. At the end, another timestamp is captured and the duration is logged:
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 45.3 ms
These timings are currently logged but not included in the IBootReport. The data is available in the Bootstrapper instance for future use.
export interface IBootReport {}The current IBootReport interface is intentionally empty. The generateReport() method returns {}:
private generateReport(): IBootReport {
const report: IBootReport = {};
this.logger.for(this.generateReport.name).debug(`Boot report: %j`, report);
return report;
}This is a deliberate extension point. Future versions can add fields like:
// Potential future shape (not yet implemented)
interface IBootReport {
phases?: {
configure?: { startTime: number; endTime: number; duration: number };
discover?: { startTime: number; endTime: number; duration: number };
load?: { startTime: number; endTime: number; duration: number };
};
booters?: Array<{
name: string;
discoveredFiles: number;
loadedClasses: number;
}>;
totalDuration?: number;
}To support a new artifact type, extend BaseArtifactBooter and implement the three abstract methods:
import { BaseArtifactBooter, IApplication, IBootOptions } from '@venizia/ignis-boot';
import { BindingKeys, inject } from '@venizia/ignis-inversion';
export class HandlerBooter extends BaseArtifactBooter {
constructor(
@inject({ key: '@app/project_root' }) root: string,
@inject({ key: '@app/instance' }) private readonly application: IApplication,
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
) {
super({
scope: HandlerBooter.name,
root,
artifactOptions: bootOptions['handlers'] ?? {},
});
}
protected override getDefaultDirs(): string[] {
return ['handlers'];
}
protected override getDefaultExtensions(): string[] {
return ['.handler.js'];
}
protected override async bind(): Promise<void> {
for (const cls of this.loadedClasses) {
const key = BindingKeys.build({ namespace: 'handlers', key: cls.name });
this.application.bind({ key }).toClass(cls).setTags('handlers');
}
}
}Register the custom booter in your application before calling boot():
class MyApp extends BootMixin(Container) {
bootOptions: IBootOptions = {
controllers: { dirs: ['controllers'] },
handlers: { dirs: ['handlers'], isNested: true },
};
constructor() {
super();
// Register custom booter with the 'booter' tag so Bootstrapper discovers it
this.bind({ key: 'booter.HandlerBooter' })
.toClass(HandlerBooter)
.setTags('booter');
}
}The 'booter' tag is critical -- the Bootstrapper uses findByTag({ tag: 'booter' }) to discover all booters at runtime.
Discover and register Hono middleware files automatically:
import { BaseArtifactBooter, IApplication, IBootOptions } from '@venizia/ignis-boot';
import { BindingKeys, inject } from '@venizia/ignis-inversion';
export class MiddlewareBooter extends BaseArtifactBooter {
constructor(
@inject({ key: '@app/project_root' }) root: string,
@inject({ key: '@app/instance' }) private readonly application: IApplication,
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
) {
super({
scope: MiddlewareBooter.name,
root,
artifactOptions: bootOptions['middlewares'] ?? {},
});
}
protected override getDefaultDirs(): string[] {
return ['middlewares'];
}
protected override getDefaultExtensions(): string[] {
return ['.middleware.js'];
}
protected override async bind(): Promise<void> {
for (const cls of this.loadedClasses) {
const key = BindingKeys.build({ namespace: 'middlewares', key: cls.name });
// Middlewares are singletons -- they are stateless functions typically
this.application.bind({ key }).toClass(cls).setTags('middlewares').setScope('singleton');
this.logger.for(this.bind.name).debug('Bound middleware: %s', key);
}
}
}Registration:
class MyApp extends BootMixin(Container) {
bootOptions: IBootOptions = {
middlewares: { dirs: ['middlewares'], isNested: false },
controllers: { dirs: ['controllers'] },
};
constructor() {
super();
this.bind({ key: 'booter.MiddlewareBooter' }).toClass(MiddlewareBooter).setTags('booter');
}
}Discover Drizzle migration files for programmatic migration execution:
import { BaseArtifactBooter, IApplication, IBootOptions } from '@venizia/ignis-boot';
import { BindingKeys, inject } from '@venizia/ignis-inversion';
export class MigrationBooter extends BaseArtifactBooter {
constructor(
@inject({ key: '@app/project_root' }) root: string,
@inject({ key: '@app/instance' }) private readonly application: IApplication,
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
) {
super({
scope: MigrationBooter.name,
root,
artifactOptions: bootOptions['migrations'] ?? {},
});
}
protected override getDefaultDirs(): string[] {
return ['migrations'];
}
protected override getDefaultExtensions(): string[] {
return ['.migration.js'];
}
protected override async bind(): Promise<void> {
for (const cls of this.loadedClasses) {
const key = BindingKeys.build({ namespace: 'migrations', key: cls.name });
// Migrations are transient -- each resolution creates a fresh instance
this.application.bind({ key }).toClass(cls).setTags('migrations');
this.logger.for(this.bind.name).debug('Bound migration: %s', key);
}
}
}Registration with custom extensions:
class MyApp extends BootMixin(Container) {
bootOptions: IBootOptions = {
controllers: { dirs: ['controllers'] },
migrations: {
dirs: ['migrations'],
extensions: ['.migration.js'],
isNested: false, // Migrations are usually flat
},
};
constructor() {
super();
this.bind({ key: 'booter.MigrationBooter' }).toClass(MigrationBooter).setTags('booter');
}
}import { BaseArtifactBooter, IApplication, IBootOptions } from '@venizia/ignis-boot';
import { BindingKeys, inject } from '@venizia/ignis-inversion';
export class CronJobBooter extends BaseArtifactBooter {
constructor(
@inject({ key: '@app/project_root' }) root: string,
@inject({ key: '@app/instance' }) private readonly application: IApplication,
@inject({ key: '@app/boot-options' }) bootOptions: IBootOptions,
) {
super({
scope: CronJobBooter.name,
root,
artifactOptions: bootOptions['crons'] ?? {},
});
}
protected override getDefaultDirs(): string[] {
return ['crons'];
}
protected override getDefaultExtensions(): string[] {
return ['.cron.js'];
}
protected override async bind(): Promise<void> {
for (const cls of this.loadedClasses) {
const key = BindingKeys.build({ namespace: 'crons', key: cls.name });
// Cron jobs are singletons -- they maintain internal timer state
this.application.bind({ key }).toClass(cls).setTags('crons').setScope('singleton');
this.logger.for(this.bind.name).debug('Bound cron job: %s', key);
}
}
}When the default pattern generation does not fit your project structure, use the glob option to take full control:
const bootOptions: IBootOptions = {
controllers: {
// Scan multiple top-level directories with specific depth
glob: '{api,admin}/**/controllers/*.controller.js',
},
services: {
// Only scan a specific subdirectory
glob: 'modules/**/services/*.service.js',
},
};When glob is set, dirs, extensions, and isNested are all ignored.
Each artifact type's options can be customized independently. Here are common override patterns:
const bootOptions: IBootOptions = {
controllers: {
dirs: ['api-controllers', 'admin-controllers'],
// extensions and isNested use defaults
},
};Generated pattern: {api-controllers,admin-controllers}/{**/*,*}.controller.js
const bootOptions: IBootOptions = {
services: {
extensions: ['.service.js', '.provider.js'],
// dirs uses default ['services'], isNested uses default true
},
};Generated pattern: {services}/{**/*,*}.{service.js,provider.js}
const bootOptions: IBootOptions = {
repositories: {
isNested: false,
// Only discover repositories at the root level, no subdirectories
},
};Generated pattern: repositories/*.repository.js
const bootOptions: IBootOptions = {
datasources: {
glob: 'config/datasources/*.datasource.js',
// dirs, extensions, isNested are all ignored when glob is set
},
};Generated pattern: config/datasources/*.datasource.js
const bootOptions: IBootOptions = {
controllers: {
dirs: ['controllers'],
isNested: true, // Deep scan for nested module controllers
},
services: {
dirs: ['services', 'providers'],
extensions: ['.service.js', '.provider.js'],
},
repositories: {
isNested: false, // Flat repository directory
},
datasources: {
dirs: ['datasources'],
// Defaults are fine -- singleton scope is set by the DatasourceBooter
},
};The Bootstrapper.boot() method accepts an IBootExecutionOptions object that lets you run only specific phases:
const bootstrapper = app.get<Bootstrapper>({ key: 'bootstrapper' });
// Run only configure and discover (skip loading/binding)
const report = await bootstrapper.boot({
phases: ['configure', 'discover'],
});
// Run all phases (default)
const fullReport = await bootstrapper.boot({});This is useful for testing scenarios where you want to verify discovery results before loading, or for dry-run analysis of what would be discovered.
The Bootstrapper tracks performance.now() timestamps for the start and end of each phase. Phase timing is logged at debug level:
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 45.3 ms
The boot() method returns an IBootReport object, which can be extended in future versions to include detailed timing and discovery statistics.
When using @venizia/ignis (the core framework), BaseApplication does not use BootMixin directly. Instead, it implements the same logic with additional framework-specific behavior:
// In BaseApplication (packages/core/src/base/applications/base.ts)
booter<Base extends IBooter, Args extends AnyObject = any>(
ctor: TClass<Base>,
opts?: TMixinOpts<Args>,
): Binding<Base> {
return this.bind<Base>({
key: BindingKeys.build(
opts?.binding ?? { namespace: BindingNamespaces.BOOTERS, key: ctor.name },
),
})
.toClass(ctor)
.setTags('booter');
}
async registerBooters() {
await executeWithPerformanceMeasure({
logger: this.logger,
scope: this.registerDataSources.name,
description: 'Register application data sources',
task: async () => {
this.bind({ key: '@app/boot-options' }).toValue(this.configs.bootOptions ?? {});
this.bind({ key: 'bootstrapper' }).toClass(Bootstrapper).setScope(BindingScopes.SINGLETON);
// Define default booters
this.booter(DatasourceBooter);
this.booter(RepositoryBooter);
this.booter(ServiceBooter);
this.booter(ControllerBooter);
},
});
}
async boot(): Promise<IBootReport> {
await this.registerBooters();
const bootstrapper = this.get<Bootstrapper>({ key: 'bootstrapper' });
return bootstrapper.boot({});
}Key differences from BootMixin:
-
Namespace-aware binding keys:
BaseApplicationusesBindingNamespaces.BOOTERS(which resolves to"booters") as the namespace, producing keys likebooters.DatasourceBooterinstead ofbooter.DatasourceBooter. This is consistent with the core framework's namespace convention. -
Helper method: The
booter()method provides a shorthand for registering booters with the correct namespace and tag. -
Performance measurement:
registerBooters()is wrapped inexecuteWithPerformanceMeasure()which logs execution time. -
Deferred registration: In
BaseApplication, booter registration happens during theinitialize()lifecycle, not in the constructor. This allows the application to complete its own setup before boot runs. -
Custom booter registration: You can call
this.booter(MyCustomBooter)inpreConfigure()to register additional booters before boot runs:
class MyApplication extends BaseApplication {
async preConfigure() {
// Register custom booters alongside the defaults
this.booter(HandlerBooter);
this.booter(CronJobBooter);
}
}Full application lifecycle with boot:
1. constructor() --> Bind app configs, project root, etc.
2. initialize() --> Called by start()
2a. registerDefaultMiddlewares()
2b. staticConfigure()
2c. preConfigure() --> User registers controllers/services/components
2d. registerBooters() --> Registers booter bindings
2e. boot() --> Discovers and loads all artifacts
2f. registerDataSources() --> Configures datasources with discovered schemas
2g. registerComponents() --> Initializes components
2h. registerControllers() --> Mounts controller routes to Hono
2i. postConfigure() --> User post-setup hooks
2j. setupMiddlewares() --> User middleware registration
3. start() --> Start HTTP server (Bun.serve or @hono/node-server)
If a booter's glob pattern matches no files, discoveredFiles will be an empty array. This is not an error. The load() method handles it gracefully:
async load(): Promise<void> {
if (!this.discoveredFiles.length) {
this.logger.for(this.load.name).debug(`No files discovered to load.`);
return;
}
// ...
}Debug output:
[ServiceBooter][discover] Root: /app/dist | Using pattern: services/{**/*,*}.service.js | Discovered file: []
[ServiceBooter][load] No files discovered to load.
This is common during early development when you may not yet have files for every artifact type.
If a discovered file exports only non-class values (arrow functions, constants, objects, primitives), loadClasses() will filter them all out. The loadedClasses array will be empty, and bind() will execute but do nothing (it iterates an empty array).
Example file (non.repository.ts):
// LET THIS FILE BE EMPTYOr a file that exports only constants:
export const CONFIG = { host: 'localhost' };
export const helper = () => 'helper';Both result in zero loaded classes -- no error, no binding.
If your glob pattern is too broad (e.g., **/*.js), you may discover files that are not artifact classes. The isClass() check prevents non-class exports from being bound, but unexpected classes could still be registered.
Prevention strategies:
- Use specific file extensions:
.controller.js,.service.js - Use dedicated directories:
controllers/,services/ - Avoid overly broad custom globs
- Test discovery results using selective phase execution (see Selective Phase Execution)
If configure() runs and neither the user options nor the defaults provide dirs, getPattern() will throw:
Error: [getPattern] No directories specified for artifact discovery
This only happens if a custom booter's getDefaultDirs() returns an empty array AND the user does not provide dirs.
If configure() runs and neither the user options nor the defaults provide extensions, getPattern() will throw:
Error: [ControllerBooter][getPattern] No file extensions specified for artifact discovery
This only happens if a custom booter's getDefaultExtensions() returns an empty array AND the user does not provide extensions.
If any booter's phase method throws, the Bootstrapper catches the error, wraps it with context, and re-throws. This stops the entire boot process:
Error: [Bootstrapper][runPhase] Error during phase 'load' on booter 'RepositoryBooter': [load] Failed to load classes from discovered files | Error: Cannot find module '/app/dist/repositories/broken.repository.js'
The boot process does NOT continue to the next booter or the next phase after an error. If RepositoryBooter fails during LOAD, ServiceBooter and ControllerBooter will not execute their LOAD phases.
If a discovered file cannot be imported (syntax error, missing dependency, runtime error during module evaluation), loadClasses() throws:
Error: Failed to load file: /app/dist/controllers/broken.controller.js | Error: SyntaxError: Unexpected token
This error is then wrapped by BaseArtifactBooter.load() and then again by Bootstrapper.runPhase().
The boot system uses BaseHelper's logger (Winston-based) with debug level. To see all boot debug output, set the DEBUG environment variable or configure the logger level:
# Enable debug logging
DEBUG=* bun run server:dev
# Or set logger level in your app configuration
APP_ENV_LOGGER_FORMAT=textWith debug logging enabled, a typical boot produces this output:
[Bootstrapper][discoverBooters] Discovered booter: booter.DatasourceBooter
[Bootstrapper][discoverBooters] Discovered booter: booter.RepositoryBooter
[Bootstrapper][discoverBooters] Discovered booter: booter.ServiceBooter
[Bootstrapper][discoverBooters] Discovered booter: booter.ControllerBooter
[Bootstrapper][boot] Starting boot | Number of booters: 4
[Bootstrapper][runPhase] Starting phase: CONFIGURE
[Bootstrapper][runPhase] Running | Phase: configure | Booter: DatasourceBooter
[DatasourceBooter][configure] Configured: {"dirs":["datasources"],"extensions":[".datasource.js"],"isNested":true}
[Bootstrapper][runPhase] Running | Phase: configure | Booter: RepositoryBooter
[RepositoryBooter][configure] Configured: {"dirs":["repositories"],"extensions":[".repository.js"],"isNested":true}
[Bootstrapper][runPhase] Running | Phase: configure | Booter: ServiceBooter
[ServiceBooter][configure] Configured: {"dirs":["services"],"extensions":[".service.js"],"isNested":true}
[Bootstrapper][runPhase] Running | Phase: configure | Booter: ControllerBooter
[ControllerBooter][configure] Configured: {"dirs":["controllers"],"extensions":[".controller.js"],"isNested":true}
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms
[Bootstrapper][runPhase] Starting phase: DISCOVER
[Bootstrapper][runPhase] Running | Phase: discover | Booter: DatasourceBooter
[DatasourceBooter][discover] Root: /app/dist | Using pattern: datasources/{**/*,*}.datasource.js | Discovered file: [...]
[Bootstrapper][runPhase] Running | Phase: discover | Booter: RepositoryBooter
[RepositoryBooter][discover] Root: /app/dist | Using pattern: repositories/{**/*,*}.repository.js | Discovered file: [...]
...
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms
[Bootstrapper][runPhase] Starting phase: LOAD
...
[DatasourceBooter][bind] Bound key: datasources.PostgresDataSource
[RepositoryBooter][bind] Bound key: repositories.UserRepository
...
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 45.3 ms
[Bootstrapper][generateReport] Boot report: {}
You can access the Bootstrapper directly to inspect state:
const bootstrapper = app.get<Bootstrapper>({ key: 'bootstrapper' });
// Run only configure + discover phases
await bootstrapper.boot({ phases: ['configure', 'discover'] });
// Now inspect what was discovered before loading
// (requires access to the booter instances via the container)
const controllerBooter = app.get<ControllerBooter>({ key: 'booter.ControllerBooter' });
console.log('Discovered controller files:', controllerBooter['discoveredFiles']);Using bracket notation (since properties are protected), you can inspect the internal state of any booter:
const repoBooter = app.get<RepositoryBooter>({ key: 'booter.RepositoryBooter' });
// After boot
console.log('Options:', repoBooter['artifactOptions']);
console.log('Discovered:', repoBooter['discoveredFiles']);
console.log('Loaded:', repoBooter['loadedClasses'].map(cls => cls.name));// Check all registered controllers
const controllerBindings = app.findByTag({ tag: 'controllers' });
for (const binding of controllerBindings) {
console.log('Controller:', binding.key);
}
// Check all registered datasources
const datasourceBindings = app.findByTag({ tag: 'datasources' });
for (const binding of datasourceBindings) {
console.log('Datasource:', binding.key, '| Scope:', binding.scope);
}For large codebases with many files, overly broad glob patterns can slow down the DISCOVER phase. Here are strategies to optimize:
1. Disable nested scanning when not needed:
repositories: {
isNested: false, // Only scan the top-level directory
}This changes the pattern from repositories/{**/*,*}.repository.js to repositories/*.repository.js, which is significantly faster for directories with deep structures.
2. Use specific directory lists instead of nested scanning:
controllers: {
dirs: ['controllers/api', 'controllers/admin'],
isNested: false,
// Targets specific subdirectories without recursive scanning
}3. Use custom glob patterns for surgical targeting:
controllers: {
glob: 'controllers/*.controller.js',
// Most precise -- only top-level controller files
}The LOAD phase's cost comes from await import(file) calls. Each file import triggers module evaluation, which may cascade to importing dependencies. To reduce this:
1. Keep artifact files lean. Controller/service/repository files should export a single class. Avoid heavy initialization in module scope.
2. Use barrel files carefully. If a repository file re-exports from a model file that re-exports from a schema file, the import chain is longer. Keep imports direct.
3. Avoid side effects in module scope. Database connections, API calls, or file I/O in module-level code will run during import() and slow boot.
Use the debug log output to identify slow phases:
[Bootstrapper][runPhase] Completed phase: CONFIGURE | Took: 0.42 ms -- Fast
[Bootstrapper][runPhase] Completed phase: DISCOVER | Took: 12.8 ms -- Normal
[Bootstrapper][runPhase] Completed phase: LOAD | Took: 245.3 ms -- Potentially slow
If DISCOVER is slow, your glob patterns may be too broad. If LOAD is slow, your imported files may have heavy module-level initialization.
The boot system discovers files in the built output directory (e.g., dist/cjs/ or dist/esm/), not in the source directory. TypeScript source files (.ts) are compiled to JavaScript (.js) by tsc before the application runs. Therefore:
- Source files:
src/controllers/user.controller.ts - Built files:
dist/cjs/controllers/user.controller.js - Boot discovers:
dist/cjs/controllers/user.controller.js
The default extensions use .js because that is what exists at runtime. If you are running TypeScript directly (e.g., via bun run which supports direct .ts execution), you would need to override the extensions:
controllers: {
extensions: ['.controller.ts'],
}However, the standard Ignis workflow uses compiled output, so .js is the correct default.
The convention follows the pattern: {name}.{artifact-type}.{extension}
| Artifact Type | Convention | Example |
|---|---|---|
| Controller | {name}.controller.ts |
user.controller.ts |
| Service | {name}.service.ts |
auth.service.ts |
| Repository | {name}.repository.ts |
user.repository.ts |
| DataSource | {name}.datasource.ts |
postgres.datasource.ts |
These conventions are not enforced by the framework -- they are what the default extensions expect. You can use any naming scheme by overriding extensions or glob in your boot options.
The default directory names match the artifact types:
| Artifact Type | Default Directory |
|---|---|
| Controllers | controllers/ |
| Services | services/ |
| Repositories | repositories/ |
| DataSources | datasources/ |
These are relative to the project root (the @app/project_root binding). In a typical Ignis application built with tsc, the project root is the dist/cjs/ directory.
The @venizia/ignis-boot package exports three utility functions used internally by the boot system. These are also available for direct use:
Discover files matching a glob pattern relative to a root directory. Returns absolute file paths.
import { discoverFiles } from '@venizia/ignis-boot';
const files = await discoverFiles({
root: '/path/to/project/dist',
pattern: 'controllers/{**/*,*}.controller.js',
});
// => ['/path/to/project/dist/controllers/user.controller.js', ...]Full signature:
const discoverFiles: (opts: {
pattern: string;
root: string;
}) => Promise<string[]>;Internally uses the glob npm package with { cwd: root, absolute: true } options. Throws a wrapped error if the glob operation fails.
Dynamically import an array of files and extract all class constructor exports. Non-class exports (arrow functions, objects, primitives) are filtered out.
import { loadClasses } from '@venizia/ignis-boot';
const classes = await loadClasses({
files: ['/abs/path/to/user.controller.js', '/abs/path/to/auth.controller.js'],
root: '/abs/path/to',
});
// => [class UserController, class AuthController]Full signature:
const loadClasses: (opts: {
files: string[];
root: string;
}) => Promise<AnyType[]>;For each file, calls await import(file) and iterates all named exports. Uses isClass() to filter. If any file import fails, throws a wrapped error with the file path.
Type guard that checks whether a value is a class constructor (a function with a prototype property). Used internally by loadClasses to filter exports.
import { isClass } from '@venizia/ignis-boot';
class MyService {}
const arrowFn = () => {};
function RegularFunction() {}
abstract class AbstractClass {}
isClass(MyService); // true
isClass(RegularFunction); // true (has prototype)
isClass(AbstractClass); // true
isClass(arrowFn); // false (arrow functions have no prototype)
isClass('string'); // false
isClass(42); // false
isClass(null); // false
isClass(undefined); // false
isClass({}); // falseFull signature:
const isClass: <T>(target: AnyType) => target is TClass<T>;Implementation:
export const isClass = <T>(target: AnyType): target is TClass<T> => {
return typeof target === 'function' && target.prototype !== undefined;
};Note: Regular function declarations and function expressions return true because they have a prototype property. Only arrow functions return false. This is intentional -- traditional function constructors are valid class-like constructs in JavaScript.
All types are exported from @venizia/ignis-boot:
// =============================================================================
// Artifact Configuration
// =============================================================================
/**
* Configuration for a single artifact type's discovery behavior.
*/
interface IArtifactOptions {
/** Directories to scan, relative to project root */
dirs?: string[];
/** File extensions to match (e.g., '.controller.js') */
extensions?: string[];
/** Recurse into subdirectories. Default: true */
isNested?: boolean;
/** Custom glob pattern. If set, dirs and extensions are ignored */
glob?: string;
}
/**
* Top-level boot options mapping artifact type names to their discovery config.
* Includes four built-in keys and supports arbitrary extension via index signature.
*/
interface IBootOptions {
controllers?: IArtifactOptions;
services?: IArtifactOptions;
repositories?: IArtifactOptions;
datasources?: IArtifactOptions;
/** Extensible for custom booters */
[artifactType: string]: IArtifactOptions | undefined;
}
// =============================================================================
// Boot Phases
// =============================================================================
/**
* The three boot phases, derived as const string values from BootPhases class.
*/
type TBootPhase = 'configure' | 'discover' | 'load';
/**
* Ordered array of all boot phases.
* Used as the default value when no specific phases are requested.
*/
const BOOT_PHASES: TBootPhase[] = ['configure', 'discover', 'load'];
// =============================================================================
// Booter Interfaces
// =============================================================================
/**
* Interface that all booters must implement.
* Each method corresponds to one boot phase.
*/
interface IBooter {
configure(): ValueOrPromise<void>;
discover(): ValueOrPromise<void>;
load(): ValueOrPromise<void>;
}
/**
* Constructor options for BaseArtifactBooter.
*/
interface IBooterOptions {
/** Logger scope name (typically the booter class name) */
scope: string;
/** Absolute path to project root directory */
root: string;
/** User-provided artifact discovery options */
artifactOptions: IArtifactOptions;
}
// =============================================================================
// Bootstrapper
// =============================================================================
/**
* Options for controlling which phases and booters to execute.
*/
interface IBootExecutionOptions {
/** Phases to execute. Default: ['configure', 'discover', 'load'] */
phases?: TBootPhase[];
/** Specific booters to run by name. Default: all discovered booters */
booters?: string[];
}
/**
* Interface for the Bootstrapper orchestrator.
*/
interface IBootstrapper {
boot(opts: IBootExecutionOptions): Promise<IBootReport>;
}
/**
* Boot execution report. Currently empty; designed as an extension point
* for future timing and statistics data.
*/
interface IBootReport {}
// =============================================================================
// Application
// =============================================================================
/**
* Minimal application interface required by the boot system.
* Extends Container with a method to get the project root path.
*/
interface IApplication extends Container {
getProjectRoot(): string;
}
/**
* Interface for applications that support the boot() method.
* Implemented by BootMixin and BaseApplication.
*/
interface IBootableApplication {
boot(): Promise<IBootReport>;
}/**
* Abstract base class for all artifact booters.
* Implements the Template Method pattern for three-phase boot lifecycle.
*
* Protected properties:
* root: string -- Project root path
* artifactOptions: IArtifactOptions -- Merged discovery config
* discoveredFiles: string[] -- Files found during discover phase
* loadedClasses: TClass[] -- Classes extracted during load phase
*
* Abstract methods (must be implemented by subclasses):
* getDefaultDirs(): string[]
* getDefaultExtensions(): string[]
* bind(): Promise<void>
*/
abstract class BaseArtifactBooter extends BaseHelper implements IBooter { }
/**
* Orchestrates the boot process across all discovered booters.
*
* Private properties:
* booters: IBooter[]
* phaseStartTimings: Map<string, number>
* phaseEndTimings: Map<string, number>
*
* Constructor injection:
* @inject({ key: '@app/instance' }) application: IApplication
*/
class Bootstrapper extends BaseHelper implements IBootstrapper { }
/**
* Built-in booter for controller artifacts.
* Default dir: 'controllers', extension: '.controller.js', scope: transient
*/
class ControllerBooter extends BaseArtifactBooter { }
/**
* Built-in booter for service artifacts.
* Default dir: 'services', extension: '.service.js', scope: transient
*/
class ServiceBooter extends BaseArtifactBooter { }
/**
* Built-in booter for repository artifacts.
* Default dir: 'repositories', extension: '.repository.js', scope: transient
*/
class RepositoryBooter extends BaseArtifactBooter { }
/**
* Built-in booter for datasource artifacts.
* Default dir: 'datasources', extension: '.datasource.js', scope: SINGLETON
*/
class DatasourceBooter extends BaseArtifactBooter { }/**
* Mixin that enhances a Container subclass with boot capabilities.
* Registers all built-in booters and the Bootstrapper in the constructor.
* Adds bootOptions property and boot() method to the class.
*/
const BootMixin: <T extends TMixinTarget<Container>>(baseClass: T) => T & IBootableApplication;
/**
* Discover files matching a glob pattern relative to a root directory.
* Returns absolute file paths. Uses the 'glob' npm package internally.
*/
const discoverFiles: (opts: { pattern: string; root: string }) => Promise<string[]>;
/**
* Dynamically import files and extract class constructor exports.
* Non-class exports are filtered out using isClass().
*/
const loadClasses: (opts: { files: string[]; root: string }) => Promise<AnyType[]>;
/**
* Type guard: checks if a value is a class constructor
* (a function with a prototype property).
*/
const isClass: <T>(target: AnyType) => target is TClass<T>;/**
* Static class providing boot phase name constants.
* Values match the TBootPhase union type.
*/
class BootPhases {
static readonly CONFIGURE = 'configure';
static readonly DISCOVER = 'discover';
static readonly LOAD = 'load';
}
/**
* Ordered array of all boot phases.
* Used as the default when Bootstrapper.boot() is called without specific phases.
*/
const BOOT_PHASES: TBootPhase[] = ['configure', 'discover', 'load'];| Key | Bound By | Type | Description |
|---|---|---|---|
@app/project_root |
Application | string |
Absolute path to project's build output root |
@app/instance |
Application | IApplication |
The application container itself |
@app/boot-options |
BootMixin / BaseApplication | IBootOptions |
User's boot configuration |
booter.DatasourceBooter |
BootMixin | DatasourceBooter class |
Tagged 'booter' |
booter.RepositoryBooter |
BootMixin | RepositoryBooter class |
Tagged 'booter' |
booter.ServiceBooter |
BootMixin | ServiceBooter class |
Tagged 'booter' |
booter.ControllerBooter |
BootMixin | ControllerBooter class |
Tagged 'booter' |
bootstrapper |
BootMixin / BaseApplication | Bootstrapper class |
Singleton scope |
Note: When used with BaseApplication, the booter keys follow the pattern booters.{ClassName} (using the BindingNamespaces.BOOTERS namespace) instead of booter.{ClassName}.
The boot package tests run on built output (dist/cjs/) since the boot system discovers compiled .js files, not .ts source files.
src/__tests__/fixtures/
repositories/
model1.repository.ts (exports Model1Repository class)
model2.repository.ts (exports Model2Repository class)
sub-repositories/
model3.repository.ts (exports Model3Repository class)
non-repositories/
non.repository.ts (empty file -- no class exports)
The non-repositories/ directory contains a file with no class exports, used to verify that loadClasses() correctly filters non-class modules and returns an empty array.
Create a concrete test booter by extending BaseArtifactBooter:
import { BaseArtifactBooter } from '@venizia/ignis-boot';
import { beforeAll, describe, expect, test } from 'bun:test';
import path from 'node:path';
class TestBooter extends BaseArtifactBooter {
protected override getDefaultDirs(): string[] {
return ['repositories'];
}
protected override getDefaultExtensions(): string[] {
return ['.repository.js'];
}
protected override bind(): Promise<void> {
return Promise.resolve();
}
}
describe('TestBooter', () => {
let booter: TestBooter;
const root = path.resolve(process.cwd(), 'dist/cjs/__tests__/fixtures');
beforeAll(() => {
booter = new TestBooter({ root, artifactOptions: {}, scope: TestBooter.name });
});
test('should use default options after configure', async () => {
await booter.configure();
expect(booter['artifactOptions'].dirs).toEqual(['repositories']);
expect(booter['artifactOptions'].extensions).toEqual(['.repository.js']);
expect(booter['artifactOptions'].isNested).toEqual(true);
});
test('should discover files after discover', async () => {
await booter.configure();
await booter.discover();
expect(booter['discoveredFiles'].length).toBeGreaterThan(0);
});
test('should load classes after load', async () => {
await booter.configure();
await booter.discover();
await booter.load();
expect(booter['loadedClasses'].length).toBeGreaterThan(0);
});
});test('should generate pattern with defaults', async () => {
await booter.configure();
const pattern = booter['getPattern']();
expect(pattern).toBe('repositories/{**/*,*}.repository.js');
});
test('should generate pattern with multiple dirs and extensions', async () => {
const multiBooter = new TestBooter({
scope: TestBooter.name,
root,
artifactOptions: {
dirs: ['dir1', 'dir2'],
extensions: ['.ext1.js', '.ext2.js'],
},
});
await multiBooter.configure();
const pattern = multiBooter['getPattern']();
expect(pattern).toBe('{dir1,dir2}/{**/*,*}.{ext1.js,ext2.js}');
});
test('should use custom glob if provided', async () => {
const globBooter = new TestBooter({
scope: TestBooter.name,
root,
artifactOptions: {
glob: 'custom/glob/pattern/**/*.js',
},
});
await globBooter.configure();
const pattern = globBooter['getPattern']();
expect(pattern).toBe('custom/glob/pattern/**/*.js');
});import { discoverFiles, isClass, loadClasses } from '@venizia/ignis-boot';
describe('isClass', () => {
test('should return true for class constructors', () => {
class TestClass {}
abstract class AbstractClass {}
function FunctionClass() {}
expect(isClass(TestClass)).toBe(true);
expect(isClass(AbstractClass)).toBe(true);
expect(isClass(FunctionClass)).toBe(true);
});
test('should return false for non-class types', () => {
const ArrowFunction = () => {};
expect(isClass(ArrowFunction)).toBe(false);
expect(isClass({})).toBe(false);
expect(isClass('string')).toBe(false);
expect(isClass(null)).toBe(false);
expect(isClass(undefined)).toBe(false);
});
});
describe('discoverFiles', () => {
test('should return files matching the glob pattern', async () => {
const files = await discoverFiles({
pattern: '**/*.repository.js',
root: '/path/to/dist/__tests__/fixtures',
});
expect(files.length).toBeGreaterThan(0);
});
test('should return empty array for no matches', async () => {
const files = await discoverFiles({
pattern: '**/*.nonexistent',
root: process.cwd(),
});
expect(files).toEqual([]);
});
});
describe('loadClasses', () => {
test('should load classes from files', async () => {
const files = await discoverFiles({
pattern: 'repositories/*.repository.js',
root: '/path/to/dist/__tests__/fixtures',
});
const classes = await loadClasses({ files, root: '/path/to/dist/__tests__/fixtures' });
expect(classes.length).toBeGreaterThan(0);
});
test('should return empty array when no classes exported', async () => {
const files = await discoverFiles({
pattern: 'non-repositories/*.repository.js',
root: '/path/to/dist/__tests__/fixtures',
});
const classes = await loadClasses({ files, root: '/path/to/dist/__tests__/fixtures' });
expect(classes).toEqual([]);
});
});Key testing conventions:
- Use bracket notation to access protected members:
booter['artifactOptions'],booter['discoveredFiles'] - Test each phase independently and sequentially (configure before discover, discover before load)
- Use fixture directories with simple class exports for predictable discovery results
- Non-class fixtures (empty files, constant exports) verify that filtering works correctly
- Tests use
dist/cjs/__tests__/fixtures/(built output) as the root, not thesrc/directory - The
pretestscript (bun run rebuild) ensures fixtures are compiled before tests run
MIT