Skip to content

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.

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

This page is the how-to. The reference has every option, and the changelog has the migration from boot().

Before you start

  • @venizia/ignis 0.1.x or later, @venizia/ignis-boot as a devDependency (it ships the ignis-artifacts binary).
  • experimentalDecorators: true in the application's own tsconfig.json. Under bun, a flag inherited only through extends is not enough - see the gotcha.

1. Decorate every class the application owns

DecoratorMarksBinding keyScope
@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.

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

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.

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,
    { 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:

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.

Conditional and ordered registration

Every stereotype accepts the same five 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
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 })

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/15 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. Pass --env=disable. Without it process.env.NODE_ENV is a literal baked in at build time. Read the environment through Environment.ambient or Environment.current, never the dot form.
  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 entrypoint: LoggerFactory.use({ provider: WinstonLogger }) from @venizia/ignis-helpers/winston. The winston default is loaded with createRequire, which cannot resolve inside a binary.
  4. Build every binding key from Class.name. bun renames a bundled decorated class (UserService -> UserService2), so a literal 'services.UserService' matches nothing.
json
"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.

  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