Registering artifacts
An IGNIS application registers its datasources, components, repositories, services and controllers from one generated file. You decorate the class, ignis-artifacts generate writes the index, and configs.artifacts hands it to the boot sequence. application.ts no longer names a single class.
// src/services/product.service.ts
@service()
export class ProductService extends BaseService {}
// src/application.ts
import { GeneratedArtifacts } from './generated/artifacts';
export const configs: IApplicationConfigs = {
path: { base: '/api', isStrict: true },
artifacts: GeneratedArtifacts,
};This page is the how-to. The reference has every option, and the changelog has the migration from boot().
Before you start
@venizia/ignis0.1.x or later,@venizia/ignis-bootas a devDependency (it ships theignis-artifactsbinary).experimentalDecorators: truein the application's owntsconfig.json. Under bun, a flag inherited only throughextendsis not enough - see the gotcha.
1. Decorate every class the application owns
| Decorator | Marks | Binding key | Scope |
|---|---|---|---|
@datasource({ ... }) | a datasource | datasources.<Class> | SINGLETON |
@component() | a component | components.<Class> | SINGLETON |
@repository({ model, dataSource }) | a repository | repositories.<Class> | TRANSIENT |
@service() | a service | services.<Class> | TRANSIENT |
@controller({ path }) | a controller | controllers.<Class> | SINGLETON |
@datasource, @repository, @controller and @model already exist in your code. The new work is @service() on services and @component() on components. A @model class is referenced by its repository and is never registered on its own.
import { component, service } from '@venizia/ignis';
@service()
export class PricingService extends BaseService {}
@component()
export class MetricsComponent extends BaseComponent {}The class must be a named export (export class, not export default) and must not be abstract. Anything else is skipped with a warning at generate time.
2. Generate the index
Add two scripts to package.json:
{
"scripts": {
"generate:artifacts": "ignis-artifacts generate --root src --out src/generated/artifacts.ts",
"check:artifacts": "ignis-artifacts check --root src --out src/generated/artifacts.ts"
}
}Run the generator:
bun run generate:artifactsIt scans src/ with the TypeScript compiler API and writes one file of plain static imports:
// AUTO-GENERATED by @venizia/ignis-boot - do not edit. Regenerate: ignis-artifacts generate --root src --out src/generated/artifacts.ts
import { MetricsComponent } from '../components/metrics.component';
import { PostgresDataSource } from '../datasources/postgres.datasource';
import { ProductRepository } from '../repositories/product.repository';
import { PricingService } from '../services/pricing.service';
import { ProductController } from '../controllers/product.controller';
export const GeneratedArtifacts = {
dataSources: [PostgresDataSource],
components: [MetricsComponent],
repositories: [ProductRepository],
services: [PricingService],
controllers: [ProductController],
};Commit the file. Never edit it by hand - the next generate overwrites it.
3. Pass the index in the config
import { ApiReferenceComponent, HealthCheckComponent } from '@venizia/ignis';
import { GeneratedArtifacts } from './generated/artifacts';
export const configs: IApplicationConfigs = {
path: { base: '/api', isStrict: true },
artifacts: [
GeneratedArtifacts,
{ components: [HealthCheckComponent, ApiReferenceComponent] },
],
};The framework components you turn on are listed once, by hand, next to the generated index. Order inside the array does not matter for dependencies: the kernel registers datasources first, then components, repositories, services, controllers, across every index it was given.
Registering means binding a class to its key; nothing is constructed at this step. Datasources are constructed at registerDataSources and components at registerComponents, both after preConfigure(), so an option you bind in preConfigure() (or provide through @provide) is in place when the component starts. What the index cannot do is share a connection a hook already opened: a helper that preConfigure() connects and a component that connects again on construction collide, whichever way the component was registered.
Delete the registration calls from preConfigure(). Keep what is not a binding - a registry call such as AuthenticationStrategyRegistry.getInstance().register(...) stays where it was.
4. Provide the options a framework component reads
A framework component reads its options from a binding key at configure time. Put those values in a component of your own, one @provide method per key:
import { component, provide, HealthCheckBindingKeys } from '@venizia/ignis';
import type { IHealthCheckOptions } from '@venizia/ignis';
@component()
export class PlatformComponent extends BaseComponent {
constructor(
@inject({ key: CoreBindings.APPLICATION_INSTANCE }) private application: BaseApplication,
) {
super({ scope: PlatformComponent.name });
}
override binding(): void {
// Every option below is a provider; nothing to bind eagerly.
}
@provide({ key: HealthCheckBindingKeys.HEALTH_CHECK_OPTIONS })
healthCheckOptions(): IHealthCheckOptions {
return { restOptions: { path: '/health-check' } };
}
}Each @provide key is bound to a lazy provider when the component is registered. The component is resolved and the method called on the first get, so a provided value may read a datasource or a secret that does not exist yet at registration time. The value is a SINGLETON unless @provide({ scope }) says otherwise.
Conditional and ordered registration
Every stereotype accepts the same five options. Use them on the class, never at a call site.
| Option | Use it when | Example |
|---|---|---|
when | the class registers only in some deployments | @component({ when: () => process.env.KAFKA_BROKERS !== undefined }) |
order | two classes of one kind must register in a fixed order | @component({ order: -10 }) registers before the default 0 |
scope | the default scope is wrong for this class | @service({ scope: BindingScopes.SINGLETON }) |
binding | the key must differ from <namespace>.<Class> | @controller({ path: '/v2/users', binding: { namespace: 'controllers', key: 'UsersV2' } }) |
allowOverride | a same-key re-registration must throw instead of silently winning - or, under bootChecks.binding.allowOverride: false, this one class must be allowed to win | @repository({ model, dataSource, allowOverride: false }) |
when runs at the registerArtifacts boot step, before preConfigure, so it may read config and environment and nothing from the container. It may be async. A skipped class is logged at debug: Skipped by condition | kind: components | class: KafkaComponent.
@component({ when: () => process.env.KAFKA_BROKERS !== undefined })
export class KafkaComponent extends BaseComponent {}
@controller({ path: '/test', when: () => process.env.NODE_ENV !== Environment.PRODUCTION })
export class TestController extends BaseRestController {}Composing indexes across packages
A library ships its own index - generated the same way, or written by hand in the same shape - and the application lists it beside its own:
import { InventoryArtifacts } from '@acme/inventory';
import { GeneratedArtifacts } from './generated/artifacts';
artifacts: [InventoryArtifacts, GeneratedArtifacts, { components: [HealthCheckComponent] }],Arrays nest to any depth. A class listed twice registers once at its first position; a class registered by hand before the step keeps its earlier position. Both are same-key re-registrations, which bootChecks.binding.allowOverride: false refuses.
Keep the index fresh
A stale index registers yesterday's classes and passes every other gate. Put the check where lint runs:
lint: artifacts-check
bun run lint
artifacts-check:
bun run check:artifactscheck renders the index in memory and compares it with the committed file. It exits 1 and prints the generate command when they differ.
Verify
Start with debug logging and read the boot log:
Boot step 5/15 registerArtifacts
Skipped by condition | kind: controllers | class: TestControllerOr ask the container from a script, before start():
application.init();
await application.registerArtifacts(configs.artifacts!);
application.isBound({ key: 'services.PricingService' }); // trueA bound key is not yet a resolvable one: a dependency a when excluded, or an @inject key nobody binds, only fails at the first get. Turn on the boot checks while you migrate, and the boot itself proves all three:
export const configs: IApplicationConfigs = {
path: { base: '/api', isStrict: true },
artifacts: GeneratedArtifacts,
bootChecks: {
binding: {
doVerify: process.env.NODE_ENV !== 'production',
allowManual: false,
allowOverride: false,
},
},
};doVerify resolves every service and repository once at the end of the sequence and fails with the whole list of broken keys. allowManual: false fails on any registration call left in preConfigure() or postConfigure() - the hand call would otherwise run after the index step and override it silently. allowOverride: false fails on any two registrations behind one key, such as two run-mode datasources that share a binding and whose when conditions overlap; the class that must win says allowOverride: true on its decorator. The three are one group of required booleans: without the group nothing is checked. See bootChecks.
If bun runs your source directly
bun src/index.ts transpiles each file without type information. A decorated member whose type comes from a value import - @provide returning IHealthCheckOptions, a constructor taking IControllerOptions - keeps that import alive for design:* metadata, and the import then fails to link because the name is a type. Import such names with import type. An application that runs tsc first and then bun dist/index.js is not affected, because tsc elides type imports.
Compiling with bun build --compile
Four rules, each backed by a measurement:
- Pass
--env=disable. Without itprocess.env.NODE_ENVis a literal baked in at build time. Read the environment throughEnvironment.ambientorEnvironment.current, never the dot form. - Do not pass
--minify. It minifies identifiers and every class name becomes two letters. Use--minify-whitespace --minify-syntax. - Register a logger provider at the entrypoint:
LoggerFactory.use({ provider: WinstonLogger })from@venizia/ignis-helpers/winston. The winston default is loaded withcreateRequire, which cannot resolve inside a binary. - Build every binding key from
Class.name. bun renames a bundled decorated class (UserService->UserService2), so a literal'services.UserService'matches nothing.
"compile:linux": "bun build --compile --minify-whitespace --minify-syntax --sourcemap --env=disable --target=bun-linux-x64 ./src/index.ts --outfile ./dist/app"To prove a binary, run it against refused ports: APP_ENV_POSTGRES_HOST=127.0.0.1 APP_ENV_POSTGRES_PORT=1 ./dist/app. It must reach postConfigure and fail with ECONNREFUSED, never at import.
Migrating from boot()
The runtime boot system - Bootstrapper, the four booters, BootMixin, bootOptions, boot() - is fully removed. A compiled binary (bun build --compile) cannot glob files at runtime; a generated index is plain imports it can see.
- Decorate services and components, generate the index, pass
artifacts(steps 1-3 above). - In
index.ts, replace the boot chain with one awaitedstart():
// Before
application.boot().then(() => application.start());
// After
await application.start();- Delete
bootOptionsfrom the config and anyoverride boot()- both are removed, so the code will not compile until they're gone.
One production application went from 286 lines and 99 this.controller(...)-style calls in application.ts to a config entry and two lifecycle methods.
See also
- Artifact registration reference - every option, the CLI, detection rules
- Application reference - the 15-step boot sequence
- Components - writing a component
- Changelog 2026-09-02 - what changed and who is affected
- Changelog 2026-09-03 - the deprecated boot API removed