Skip to content

Registering artifacts ​

An IGNIS application registers its configurations, 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.

typescript
// 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,
};

On Bun you can skip the generated file entirely: auto artifacts build the same index in memory, at start and at build time.

This page is the how-to. The reference has every option, and the changelog has the migration from boot(). For the other binary @venizia/ignis-boot ships, see Stamping a build.

Before you start ​

  • @venizia/ignis 0.1.x or later, @venizia/ignis-boot as a devDependency (it ships the ignis-artifacts binary).
  • experimentalDecorators: true and useDefineForClassFields: false in the application's own tsconfig.json. Bun ignores an extends that names a package, so a flag inherited only through extends is not enough - see the gotcha.

1. Decorate every class the application owns ​

DecoratorMarksBinding keyScope
@configuration()a configurationconfigurations.<Class>SINGLETON
@datasource({ ... })a datasourcedatasources.<Class>SINGLETON
@component()a componentcomponents.<Class>SINGLETON
@repository({ model, dataSource })a repositoryrepositories.<Class>TRANSIENT
@service()a serviceservices.<Class>TRANSIENT
@controller({ path })a controllercontrollers.<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.

A @configuration() class extends BaseConfiguration and registers before everything else, so it is where setup that every other artifact depends on belongs. Order configurations against each other with after, never with order.

typescript
import { component, configuration, service } from '@venizia/ignis';

@service()
export class PricingService extends BaseService {}

@component()
export class MetricsComponent extends BaseComponent {}

@configuration({ after: [TenantConfiguration] })
export class BillingConfiguration extends BaseConfiguration {}

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:

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:

bash
bun run generate:artifacts

It scans src/ with the TypeScript compiler API and writes one file of plain static imports:

typescript
// 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.

NOTE

@configuration classes are picked up like any other stereotype and land in a configurations field, emitted first. Regenerate after upgrading @venizia/ignis-boot: an index written by an older generator has no such field, and check:artifacts reports it stale.

3. Pass the index in the config ​

typescript
import { ApiReferenceComponent, HealthCheckComponent } from '@venizia/ignis';
import { GeneratedArtifacts } from './generated/artifacts';

export const configs: IApplicationConfigs = {
  path: { base: '/api', isStrict: true },
  artifacts: [
    GeneratedArtifacts,
    { configurations: [BillingConfiguration] },
    { components: [HealthCheckComponent, ApiReferenceComponent] },
  ],
};

The framework components you turn on are listed once, by hand, next to the generated index. So is every @configuration class, until the generator learns to emit them. Order inside the array does not matter for dependencies: the kernel registers configurations first, then datasources, components, repositories, services, controllers, across every index it was given.

Registering means binding a class to its key; nothing is constructed at this step. Configurations are constructed at registerConfigurations, datasources at registerDataSources and components at registerComponents, all 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:

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

Auto artifacts on Bun ​

@venizia/ignis-boot/auto is the same index with no file, no script and no check: a Bun plugin scans src when the application starts, and inlines the list when you build a binary.

typescript
// src/application.ts
import AutoArtifacts from '@venizia/ignis-boot/auto';

export const configs: IApplicationConfigs = {
  path: { base: '/api', isStrict: true },
  artifacts: AutoArtifacts,
};

bun src/index.ts and bun test need nothing else. A binary adds the plugin to its build script:

typescript
// scripts/build.ts
import { IgnisArtifactsPlugin } from '@venizia/ignis-boot/bun';

await Bun.build({
  entrypoints: ['src/index.ts'],
  compile: { outfile: 'dist/app' },
  plugins: [IgnisArtifactsPlugin.create()],
});

A build that forgets the plugin fails with Could not resolve: "ignis:artifacts" - it never ships a binary with missing classes. The entry must also register its logger - see rule 3 below. On Node there is no plugin API: keep ignis-artifacts generate.

The scan follows re-exports, so a decorator imported from a framework that re-exports the kernel's (a frontend built on the kernel, a kit of your own) counts with no flag.

A browser bundle built with Vite ​

A frontend (an ARDOR application, a Web Worker running IGNIS) built with Vite uses the same AutoArtifacts import with the Vite plugin:

typescript
// vite.config.ts
import { IgnisArtifactsVitePlugin } from '@venizia/ignis-boot/vite';

export default defineConfig({
  plugins: [IgnisArtifactsVitePlugin.create()],
  // a Worker bundle has its own list: worker: { plugins: () => [IgnisArtifactsVitePlugin.create()] },
});

The plugin scans the src beside the package.json of Vite's root (pass root to choose another), builds the class list into the bundle and inlines every ArtifactGroup call, as IgnisArtifactsPlugin does for Bun.build. It runs under Node or Bun. It pins the name of each scanned class (a static block setting name), because binding keys are built from Class.name and a minifier renames classes - the rest of the bundle minifies as usual - and it keeps @venizia/ignis-boot out of dependency pre-bundling. The plugin scans once, when the dev server starts (a later start reuses the scan cache). Editing a class keeps hot reload as usual; adding or removing a decorated class or a group needs a dev server restart - the same as a backend under bun --watch. Classes an application borrows through a group are pinned too, so a shared kit needs nothing extra; a kit built for the browser imports ArtifactGroup from @venizia/ignis-kernel/metadata, which carries no server code.

Keep the module that imports @venizia/ignis-boot/auto free of decorated classes - one class per file under src, and application.ts holding only the application. The built list imports every class back, so a class defined in that module, or a module it is imported by, is read before it exists; registration then stops with lists an entry that is not a class ... import cycle.

Start-up cost from source ​

The first start scans src (TypeScript parses every file). The answer is cached under the package's node_modules/.cache/ignis-artifacts/, keyed on the size and modification time of every .ts file under src, the package manifest and the lockfile, so the next start skips the scan and never loads TypeScript; any edit makes the next start scan again. A binary never scans.

An application built with tsc ​

An application that builds with tsc and runs dist (bun . with main in dist, tests on dist/__tests__, a binary bundled from dist/index.js) keeps doing so. The list of classes is read from src, and each class is loaded from dist, so the application registers the very classes its code runs. Rebuild before starting: a class that has no compiled copy stops the start with an error naming the rebuild. A binary needs only IgnisArtifactsPlugin.create() in its Bun.build plugins.

Several applications in one process ​

AutoArtifacts is one module per process, rooted at the entry module's package. A process that loads several applications - a test harness that boots every service, a gateway hosting modules - gives each application its own group, picked by where it is called:

typescript
// services/inventory/src/application.ts
import '@venizia/ignis-boot/auto'; // installs the resolver groups use from source - once per process is enough
import { ArtifactGroup } from '@venizia/ignis';

artifacts: [ArtifactGroup.application(), InventoryArtifacts],

The kernel never imports @venizia/ignis-boot, so a group with no inlined content needs that import somewhere in the process before artifacts are registered; without it registration stops with an error naming it. Put it in the application module that uses the group, not in a shared library: every Bun.build that pulls the auto module in needs IgnisArtifactsPlugin (without it the build fails instead of shipping a binary with no classes), so an import in a shared library forces the plugin on applications that never use it. Importing the module loads no class; the classes load when artifacts are registered. A build with IgnisArtifactsPlugin inlines the group, exactly like a borrowed one, and needs no resolver.

An application whose classes are partly registered elsewhere - repositories a component binds itself, controllers mounted by hand - leaves them out with ignore, the globs ignis-artifacts --ignore takes, relative to the package src:

typescript
artifacts: [
  ArtifactGroup.application({ ignore: ['repositories/clickhouse/**', 'repositories/postgres/**'] }),
],

A build reads the patterns from the call, so they must be string literals.

Borrowing from a shared package ​

In a monorepo, a shared package declares one group per domain, once, and each application borrows the domains it may use:

typescript
// packages/core/src/groups.ts
import { ArtifactGroup } from '@venizia/ignis';

export const InventoryArtifacts = ArtifactGroup.define({ folder: 'inventory' });
export const CommerceArtifacts = ArtifactGroup.define({ folder: 'commerce' });
typescript
// services/inventory/src/application.ts
import AutoArtifacts from '@venizia/ignis-boot/auto';
import { CategoryRepository, InventoryArtifacts } from '@acme/core';

artifacts: [AutoArtifacts, InventoryArtifacts, { repositories: [CategoryRepository] }],

A group is every decorated class under a directory named folder in the package that defined it (repositories/inventory/, services/inventory/, ...). Adding a class to that directory adds it to the group. Auto artifacts never scan a dependency wholesale: an application gets only the groups and classes it names, which is the allow-list a shared package needs. The start-up log names each group's classes, and a group that resolves to no class stops the start.

WARNING

A function that walks configs.artifacts and reads entry.services throws on a group or on AutoArtifacts - their classes exist only when artifacts are registered. To split artifacts by run mode, slice a group with ArtifactGroup.pick instead of reading its kinds:

typescript
const slice = (entry: TArtifactIndexInput, kinds: Array<keyof IArtifactIndex>): TArtifactIndexInput =>
  ArtifactGroup.isGroup(entry) ? ArtifactGroup.pick({ group: entry, kinds }) : /* an index: pick its kinds */ entry;

artifacts: [
  slice(AutoArtifacts, ['configurations', 'dataSources', 'repositories']),
  { when: () => runMode !== 'migrate', index: slice(AutoArtifacts, ['components', 'services']) },
  { when: () => runMode === 'server', index: slice(AutoArtifacts, ['controllers']) },
],

Conditional and ordered registration ​

Every stereotype accepts the same six options. Use them on the class, never at a call site.

OptionUse it whenExample
whenthe class registers only in some deployments@component({ when: () => process.env.KAFKA_BROKERS !== undefined })
ordertwo classes of one kind must register in a fixed order@component({ order: -10 }) registers before the default 0
afterone configuration must run after another@configuration({ after: [TenantConfiguration] })
scopethe default scope is wrong for this class@service({ scope: BindingScopes.SINGLETON })
bindingthe key must differ from <namespace>.<Class>@controller({ path: '/v2/users', binding: { namespace: 'controllers', key: 'UsersV2' } })
allowOverridea 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 })

after applies to configurations only, and it replaces the order sort for that kind. Name an unregistered class and the boot throws; make a cycle and it throws too, naming every class in it.

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.

typescript
@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:

typescript
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:

makefile
lint: artifacts-check
	bun run lint

artifacts-check:
	bun run check:artifacts

check 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/16 registerArtifacts
Skipped by condition | kind: controllers | class: TestController

Or ask the container from a script, before start():

typescript
application.init();
await application.registerArtifacts(configs.artifacts!);
application.isBound({ key: 'services.PricingService' }); // true

A 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:

typescript
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:

  1. Keep process.env.NODE_ENV a runtime read. The CLI needs --env=disable; Bun.build needs define: { 'process.env.NODE_ENV': 'process.env.NODE_ENV' } - its env: 'disable' alone still bakes the build-time value in (measured on Bun 1.4.3). Read the environment through Environment.ambient or Environment.current, never the dot form: a destructured read stays a runtime read either way.

  2. Do not pass --minify. It minifies identifiers and every class name becomes two letters. Use --minify-whitespace --minify-syntax.

  3. Register a logger provider at the top of the entrypoint. The winston default is loaded with createRequire, which cannot resolve inside a binary, so name the class yourself:

    typescript
    // src/index.ts
    import { LoggerFactory } from '@venizia/ignis-helpers';
    import { WinstonLogger } from '@venizia/ignis-helpers/winston';
    
    LoggerFactory.use({ provider: WinstonLogger });

    Without it the first log call throws [LoggerFactory] No logger provider is registered and the default (winston) could not be loaded.

  4. Build every binding key from Class.name. bun renames a bundled decorated class (UserService -> UserService2), so a literal 'services.UserService' matches nothing.

  5. Use a Postgres datasource. SQLite and PGlite load native or WebAssembly files a binary does not embed; run them from source.

BunCompiler compiles with the minify choice of rule 2. The preset app runs it from .configs/builders/compile.ts through the compile script, which also registers the artifacts plugin and bakes in the build stamp:

json
"compile": "bun .configs/builders/compile.ts"

To prove a binary, run it against refused ports: APP_ENV_POSTGRES_HOST=127.0.0.1 APP_ENV_POSTGRES_PORT=1 ./dist/bin. 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.

  1. Decorate services and components, generate the index, pass artifacts (steps 1-3 above).
  2. In index.ts, replace the boot chain with one awaited start():
typescript
// Before
application.boot().then(() => application.start());

// After
await application.start();
  1. Delete bootOptions from the config and any override 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 ​