Skip to content

IGNIS Framework Patterns

All backend services are built on the IGNIS Framework (@venizia/ignis + @venizia/ignis-helpers). This page documents the patterns every service follows.

Application Hierarchy

  • IssuerApplication — only @nx/identity extends this (it signs JWTs and serves the JWKS endpoint)
  • VerifierApplication — all other services extend this (they verify JWTs by fetching identity's /jw-certs)

Application class pattern

Every service's src/application.ts follows this exact structure:

typescript
import { createAppConfig, VerifierApplication } from '@nx/core';

export const appConfig = createAppConfig();

export class Application extends VerifierApplication {
  override async boot() { return {}; }
  override getAppInfo() { return packageJson; }
  override getProjectRoot() { /* bind and return __dirname */ }

  override configureDatasources(): void {
    super.configureDatasources();
    this.dataSource(PostgresCoreDataSource);
  }

  override configureRepositories(): void {
    super.configureRepositories();
    this.repository(MyRepository);
  }

  override configureServices(): void {
    super.configureServices();
    this.service(MyService);
  }

  override configureControllers(): void {
    super.configureControllers();
    this.controller(MyController);
  }

  override configureComponents(): void {
    super.configureComponents();
    this.useCacheRedis({ bindingKey: BindingKeys.APPLICATION_REDIS_CACHE });
  }
}

Component Structure

Components group related DI registrations (repositories, services, controllers) that can be added as a unit:

typescript
export class MyComponent extends BaseComponent {
  constructor(
    @inject({ key: CoreBindings.APPLICATION_INSTANCE })
    protected application: BaseApplication,
  ) {
    super({
      scope: MyComponent.name,
      initDefault: { enable: true, container: application },
      bindings: {},
    });
  }

  override async binding(): Promise<void> {
    this.application.repository(MyRepository);
    this.application.service(MyService);
    this.application.controller(MyController);
  }
}

Register in Application.configureComponents():

typescript
this.component(MyComponent);

Dependency Injection

IGNIS uses decorator-based DI. Inject dependencies in service/controller constructors:

typescript
import { inject, BaseService, BindingKeys, BindingNamespaces } from '@venizia/ignis';

export class MyService extends BaseService {
  constructor(
    @inject({
      key: BindingKeys.build({
        namespace: BindingNamespaces.REPOSITORY,
        key: MyRepository.name,
      }),
    })
    private myRepository: MyRepository,

    @inject({ key: 'custom/binding/key' })
    private redis: DefaultRedisHelper,
  ) {
    super({ scope: MyService.name });
  }
}

Binding key patterns

PatternExample
RepositoriesBindingKeys.build({ namespace: BindingNamespaces.REPOSITORY, key: 'MyRepository' })
ServicesBindingKeys.build({ namespace: BindingNamespaces.SERVICE, key: 'MyService' })
CustomString literal, e.g. '@nx/licensing/redis/cache'

BaseService provides this.logger with this.logger.for(methodName) returning { info, error, warn, debug }.

Controller Factory (Auto CRUD)

ControllerFactory.defineCrudController() generates a full REST controller with standardized routes:

typescript
const _Controller = ControllerFactory.defineCrudController({
  repository: { name: MyRepository.name },
  authenticate: { strategies: [AuthenticateStrategy.JWT, AuthenticateStrategy.BASIC] },
  authorize: { action: AuthorizationActions.READ, resource: MyPermissions.FIND.code },
  controller: {
    name: 'MyController',
    basePath: '/my-entities',
    isStrict: { path: true, requestSchema: true },
  },
  entity: () => MyEntity,
  routes: {
    find:       { authorize: { action: AuthorizationActions.READ, resource: '...' } },
    findById:   { authorize: { ... } },
    count:      { authorize: { ... } },
    findOne:    { authorize: { ... } },
    create:     { request: { body: MyInsertSchema }, authorize: { ... } },
    updateById: { request: { body: MyUpdateSchema }, authorize: { ... } },
    deleteById: { authorize: { ... } },
    deleteBy:   { authorize: { ... } },
  },
});

@controller({ path: '/my-entities', transport: ControllerTransports.REST })
export class MyController extends _Controller {
  constructor(
    @inject({ key: 'repositories.MyRepository' })
    protected readonly repository: MyRepository,
  ) {
    super(repository);
  }
}

This generates 10 CRUD routes: find, findById, findOne, count, create, updateById, updateBy, deleteById, deleteBy. Custom routes are added via @get(), @post(), etc. decorators on additional methods.

Permission codes

crudPermissions(subject, labels) generates permission objects with codes:

CodeAction
Subject.findList
Subject.findByIdGet by ID
Subject.findOneFind one
Subject.countCount
Subject.createCreate
Subject.updateByIdUpdate by ID
Subject.updateByBatch update
Subject.deleteByIdDelete by ID
Subject.deleteByBatch delete

Custom action permissions are defined manually (see licensing's License.issue, License.suspend, etc.).

Bootstrap Helpers

Application entry point

typescript
// packages/*/src/index.ts
import { bootstrapApplication } from '@nx/core';
import { Application, appConfig } from './application';

bootstrapApplication({
  ApplicationClass: Application,
  config: appConfig,
  options: { bannerPath: resolve(__dirname, '../resources/banner.txt') },
});

Migration entry point

typescript
// packages/*/src/migrate.ts
import { bootstrapMigration } from '@nx/core';
import { Application } from './application';
import { getMigrationProcesses } from './migrations/processes/migration-process';

bootstrapMigration({ ApplicationClass: Application, getMigrationProcesses });

Migration process loader

typescript
// packages/*/src/migrations/processes/migration-process.ts
import { createMigrationProcessLoader } from '@nx/core';

export const getMigrationProcesses = createMigrationProcessLoader({
  seedPaths: ['my-package-0001-seed-permissions'],
  importFn: path => import(`../processes/${path}.js`),
});

Each seed file exports a TMigrationProcess with options: { alwaysRun?: boolean }, a name, and a migrateFn.

Database Patterns

Drizzle ORM with PostgreSQL

Every service shares a single PostgresCoreDataSource:

typescript
@datasource({ driver: 'node-postgres' })
export class PostgresCoreDataSource extends BaseDataSource {
  override configure(): void {
    const schema = this.getSchema();
    this.pool = new Pool(this.settings);
    this.connector = drizzle({ client: this.pool, schema });
  }
}

Soft-deletable repository

SoftDeletableRepository from @nx/core automatically filters out rows with deleted_at IS NOT NULL:

typescript
@repository({ dataSource: PostgresCoreDataSource, model: MyEntity })
export class MyRepository extends SoftDeletableRepository<TSchema, TEntity, TPersist> {
  // Inherited: deleteById sets deleted_at instead of hard-deleting
  // findById, find, findOne all exclude soft-deleted rows
}

Transactions

typescript
const tx = await this.repository.beginTransaction();

try {
  await this.repository.create({ data, options: { transaction: tx } });
  await this.otherRepository.updateById({ id, data, options: { transaction: tx } });
  await tx.commit();
} catch (err) {
  await tx.rollback();
  throw err;
}

Row-level locking for concurrency safety:

typescript
const row = await this.repository.findOne({
  filter: { where: { id } },
  options: { transaction: tx, lock: { strength: LockStrengths.UPDATE } },
});

Snowflake IDs

All primary keys are Snowflake IDs (64-bit integers as strings) generated by IdGenerator.getInstance().nextId(). Each service has a unique APP_ENV_SNOWFLAKE_WORKER_ID to prevent collisions.

Model Pattern

Drizzle schema + Zod validation:

typescript
// Schema definition (in @nx/core)
export const MyEntitySchema = licensingSchema.table('MyEntity', {
  ...generateCommonColumnDefs(),  // id, createdAt, modifiedAt, deletedAt
  name: text('name').notNull(),
  status: text('status').notNull().default('activated'),
  data: jsonb('data').$type<MyJsonType>(),
}, def => [
  index('IDX_MyEntity_status').on(def.status),
]);

// Type exports
export type TMyEntity = TTableObject<typeof MyEntitySchema>;
export type TMyEntityPersist = TTableInsert<typeof MyEntitySchema>;

// Zod schemas (auto-generated from Drizzle)
export const MyEntitySelectSchema = createSelectSchema(MyEntitySchema);
export const MyEntityInsertSchema = createInsertSchema(MyEntitySchema);
export const MyEntityUpdateSchema = MyEntityInsertSchema.partial();

generateCommonColumnDefs() adds: id (Snowflake, text PK), createdAt, modifiedAt, deletedAt (soft-delete).

For entities without soft-delete:

typescript
{
  ...generateIdColumnDefs({ id: { dataType: 'string' } }),
  ...generateTzColumnDefs({ deleted: { enable: false } }),
}

Entity Relations

typescript
@model({
  type: 'entity',
  settings: { hiddenProperties: ['createdAt', 'modifiedAt', 'deletedAt'] },
})
export class MyEntity extends BaseEntity<typeof MyEntitySchema> {
  static override TABLE_NAME = 'MyEntity';
  static override schema = MyEntitySchema;

  static override relations = (): TRelationConfig[] => [
    { type: RelationTypes.ONE, name: 'parent', schema: ParentSchema, ... },
    { type: RelationTypes.MANY, name: 'children', schema: ChildSchema, ... },
  ];
}

Relations are used by the CRUD controller for include queries — e.g., ?filter[include][0][relation]=children.

Environment Configuration

Each package uses dotenv-flow for environment management:

FilePurposeGitignored
.env.developmentDev server configNo
.env.testTest configUsually
.env.localLocal overridesYes

The NODE_ENV value determines which file is loaded. See Environment Reference for the complete variable catalog.

JWKS key generation (ES256, PKCS#8)

For the identity service only:

bash
# Generate keypair
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
  | tee private.pem | openssl pkey -pubout > public.pem

# Format for .env (single-line with \n)
awk 'NF {printf "%s\\n", $0}' private.pem
awk 'NF {printf "%s\\n", $0}' public.pem

Private key must be PKCS#8 format (-----BEGIN PRIVATE KEY-----), not SEC1.

Key Source Files

PatternFile path
Application hierarchypackages/core/src/application/{base,default,issuer,verifier}.ts
Soft-delete repositorypackages/core/src/repositories/soft-deletable.repository.ts
Snowflake ID generatorpackages/core/src/utilities/id-generator.utility.ts
Constants & rolespackages/core/src/common/constants.ts
Environment keyspackages/core/src/common/environments.ts
App config factorypackages/core/src/common/app-config.ts
Bootstrap helperspackages/core/src/helpers/bootstraps/application.ts
Migration helperspackages/core/src/helpers/migration/
CRUD permissionspackages/core/src/common/constants.tscrudPermissions()
Service applicationspackages/*/src/application.ts
PageDescription
Getting StartedLocal setup walkthrough
Build SystemMakefile targets and dependency graph
Environment ReferenceComplete env var catalog
Core Package@nx/core detailed reference

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.