Skip to content

Core Architecture

1. Overview

@nx/core implements a layered architecture built on top of the IGNIS Framework. Every backend microservice in the BANA monorepo extends the shared BaseApplication from this package - through IssuerApplication (the @nx/identity service) or VerifierApplication (the other 13 services) - inheriting a consistent set of infrastructure concerns: authentication, CORS, health checks, Swagger documentation, database connectivity, and cross-service communication.

This document covers:

  • The layered architecture and how IGNIS primitives map to each layer
  • Application lifecycle and the BaseApplication / IssuerApplication / VerifierApplication base classes
  • Dependency injection patterns
  • Component, controller, repository, and service patterns
  • Event-driven architecture (Kafka + Debezium CDC; BullMQ for async jobs)
  • Bootstrap helpers for application and migration entry points
  • Cross-service communication via IdentityNetworkService
  • Authentication flow (JWT + Basic)
  • Package dependency chain

2. Layered Architecture

All backend services follow a five-layer architecture. Each layer has a single responsibility and communicates only with its immediate neighbors.

LayerComponentDescription
ApplicationBaseApplicationpreConfigure() lifecycle · CORS · BodyLimit 100 MB · Swagger UI · subclassed by IssuerApplication / VerifierApplication
Bootstrap HelpersbootstrapApplication() · bootstrapMigration() · createAppConfig() · createMigrationProcessLoader()
Built-in ComponentsHealthCheck GET /health · Swagger UI /explorer · Authenticate JWKS · Basic → Identity
Package ComponentsSignal · Payment · Commerce · Finance · Inventory · Search · Asset
ServiceBusiness ServicesDomain logic · Validation · Orchestration
IdentityNetworkServicesignIn() HTTP → Identity service · Cross-service Basic auth delegation
Event HandlersKafka consumers (cross-service) · in-process EventEmitter (commerce/helpdesk) · IEventBus/RedisPubSubAdapter legacy/unused
Queue ConsumersBullMQ - 4 systems · 8 types · 3 partitions · Commerce · Finance · Inventory · Sale
BaseSocketEventServicebroadcast() · sendToRoom() · sendToClient() · Redis-backed cross-instance delivery
RepositorySoftDeletableRepositorySoft delete · Restore · Default filter deletedAt IS NULL auto-applied
DefaultCRUDRepositoryfind · create · updateById · deleteById · count · withTransaction
InfrastructurePostgresCoreDataSourceDrizzle ORM · node-postgres Pool · 14 schemas · 151 entities
RedisConnectionFactoryCache · Pub/Sub · WebSocket · Single mode · Cluster mode
BullMQScheduler · Confirmation · Processing · 12 queues with hash-based partition routing
LayerIGNIS Base ClassResponsibility
ApplicationBaseApplicationLifecycle management, middleware setup, DI container root
ComponentBaseComponentFeature module registration (repositories, services, controllers)
ServiceBaseServiceBusiness logic, transaction orchestration, external communication
RepositoryDefaultCRUDRepositoryData access abstraction, CRUD operations, soft-delete
DataSourceBaseDataSourceDatabase driver configuration, connection pooling

3. Application Lifecycle

3.1 Application Base Classes

@nx/core defines one abstract base, BaseApplication (which extends the IGNIS BaseApplication), plus three concrete subclasses. A service picks a subclass based on its role in the JWT trust chain.

Base classExtended byRole
BaseApplication (abstract)-Shared CORS, body-limit, Swagger UI, health check, IdentityNetworkService, the preConfigure() flow, and configureSecurity() orchestration
IssuerApplication@nx/identity onlySigns JWTs (JWKS issuer) and serves the public key set at /jw-certs
VerifierApplicationthe other 13 servicesVerify JWTs against identity's JWKS endpoint
DefaultApplicationnothing (legacy)Symmetric JWS authentication with no authorization; superseded by the Issuer/Verifier split and kept only for reference

The abstract BaseApplication drives initialization via preConfigure(). In MIGRATE run mode it stops after datasources and repositories; otherwise it continues through services, components, security, and controllers.

typescript
export abstract class BaseApplication extends IgnisBaseApplication {
  protected applicationRoles: string[] = [];

  protected abstract configureAuthentication(): ValueOrPromise<void>;
  protected abstract configureAuthorization(): ValueOrPromise<void>;

  preConfigure() {
    this.applicationRoles = this.getApplicationRoles();

    this.configureDatasources();
    this.configureRepositories();

    if (this.getApplicationRunMode() !== ApplicationRunModes.MIGRATE) {
      this.configureServices();
      this.configureComponents();
      this.configureSecurity(); // -> configureAuthentication() + configureAuthorization()
      this.configureControllers();
    }
  }
}

3.2 Lifecycle Sequence

The following diagram shows the full startup sequence, from the entry point (index.ts) through to a running HTTP server.

3.3 preConfigure() Method Order

The order of method calls inside preConfigure() is significant. Each step depends on the previous one being complete.

OrderMethodWhat It Does
1configureDatasources()Registers PostgreSQL connection pool (no-op by default, datasources are auto-discovered from glob)
2configureRepositories()Registers repository classes into the DI container (no-op by default, repositories are auto-discovered from glob)
3configureServices()Registers IdentityNetworkService for cross-service auth
4configureComponents()Binds HealthCheckComponent at /health and SwaggerComponent at /doc
5configureSecurity()Configures JWT and Basic authentication strategies via AuthenticateComponent
6configureControllers()Registers HTTP controllers (no-op by default, overridden per package)

3.4 Middleware Setup

setupMiddlewares() configures two global Hono middlewares:

MiddlewareConfiguration
CORSOrigin: *, all methods, 86400s max-age, credentials enabled
Body Limit100 MB max, returns 413 Content Too Large on overflow

3.5 Overriding in Downstream Packages

Each backend service declares an Application class that extends VerifierApplication (or IssuerApplication for @nx/identity) and overrides the relevant configure* methods, always calling super first:

typescript
// packages/sale/src/application.ts
export class Application extends VerifierApplication {
  override configureDatasources(): void {
    super.configureDatasources();
    this.dataSource(PostgresCoreDataSource);
  }

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

  override configureServices(): void {
    super.configureServices(); // IdentityNetworkService
    this.service(SaleOrderService);
    this.service(CheckoutService);
  }

  override async postConfigure(): Promise<void> {
    await super.postConfigure();
    // Package-specific post-boot logic
  }
}

4. Dependency Injection

The IGNIS DI container uses constructor injection with the @inject() decorator. Dependencies are identified by string-based binding keys.

4.1 Injection Pattern

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

export class SaleOrderService extends BaseService {
  constructor(
    @inject({ key: 'repositories.SaleOrderRepository' })
    private saleOrderRepository: SaleOrderRepository,

    @inject({ key: 'repositories.SaleOrderItemRepository' })
    private saleOrderItemRepository: SaleOrderItemRepository,
  ) {
    super({ scope: SaleOrderService.name });
  }
}

4.2 Binding Key Convention

Binding keys follow the pattern {namespace}.{ClassName}:

NamespaceExample KeyRegistered Via
repositoriesrepositories.SaleOrderRepositorythis.application.repository(SaleOrderRepository)
servicesservices.IdentityNetworkServicethis.application.service(IdentityNetworkService)
controllerscontrollers.SaleOrderControllerthis.application.controller(SaleOrderController)
datasourcesdatasources.PostgresCoreDataSourcethis.application.datasource(PostgresCoreDataSource)

You can also build keys explicitly using the IGNIS BindingKeys utility:

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

const key = BindingKeys.build({
  namespace: BindingNamespaces.REPOSITORY,
  key: 'SaleOrderRepository',
});
// Result: 'repositories.SaleOrderRepository'

4.3 Value Binding

For configuration objects, use the bind().toValue() pattern:

typescript
this.bind<IHealthCheckOptions>({
  key: HealthCheckBindingKeys.HEALTH_CHECK_OPTIONS,
}).toValue({
  restOptions: { path: '/health' },
});

5. Component Pattern

Components are the primary mechanism for organizing feature modules. Each component extends BaseComponent and registers its repositories, services, and controllers during the binding() lifecycle method.

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

  override async binding(): Promise<void> {
    this.application.repository(SaleOrderRepository);
    this.application.repository(SaleOrderItemRepository);

    this.application.service(SaleOrderService);
    this.application.service(CheckoutService);

    this.application.controller(SaleOrderController);
  }
}

A component is registered into the application via:

typescript
this.component(ApplicationSaleComponent);

5.1 Component Composition

Components can load other components, creating a composition tree:

6. Controller Pattern

6.1 ControllerFactory (Auto CRUD)

The IGNIS ControllerFactory generates a full set of CRUD endpoints from a single declaration. This is the standard pattern for most controllers in BANA.

typescript
import { controller, inject, ControllerFactory } from '@venizia/ignis';

@controller({ path: '/sale-orders' })
export class SaleOrderController extends ControllerFactory.defineCrudController({
  repository: { name: SaleOrderRepository.name },
  authenticate: { strategies: ['jwt', 'basic'] },
  controller: { name: 'SaleOrderController', basePath: '/sale-orders' },
  entity: () => SaleOrderEntity,
}) {
  constructor(
    @inject({ key: 'repositories.SaleOrderRepository' })
    repository: SaleOrderRepository,
  ) {
    super(repository);
  }
}

The factory generates the following endpoints:

MethodPathDescription
GET/sale-ordersList with filter, pagination, sorting
GET/sale-orders/:idFind by ID
GET/sale-orders/countCount matching filter
POST/sale-ordersCreate
PUT/sale-orders/:idUpdate by ID
DELETE/sale-orders/:idDelete by ID (soft delete)

6.2 Custom Controller Endpoints

Additional endpoints are defined alongside the factory-generated ones by adding methods to the controller class. The IGNIS Filter System is available on all list/count endpoints.

7. Repository and SoftDeletableRepository

7.1 Repository Declaration

Repositories are declared with the @repository decorator, linking a model to a datasource:

typescript
import { repository } from '@venizia/ignis';
import { PostgresCoreDataSource } from '@nx/core';

@repository({ dataSource: PostgresCoreDataSource, model: SaleOrderEntity })
export class SaleOrderRepository extends SoftDeletableRepository<
  TSaleOrderSchema,
  TSaleOrder,
  TSaleOrderPersist
> {}

7.2 SoftDeletableRepository

All BANA repositories extend SoftDeletableRepository instead of DefaultCRUDRepository. This ensures that DELETE operations set a deletedAt timestamp rather than physically removing rows.

MethodBehavior
deleteById(id)Sets deletedAt = NOW() instead of DELETE FROM
restoreById(id)Sets deletedAt = NULL to reverse a soft delete
find(filter)Automatically excludes rows where deletedAt IS NOT NULL

7.3 Database Transactions

Repositories expose the datasource for transaction support:

typescript
await this.repository.dataSource.withTransaction(async (tx) => {
  await this.saleOrderRepository.create({ data: orderData, options: { transaction: tx } });
  await this.saleOrderItemRepository.create({ data: itemData, options: { transaction: tx } });
});

7.4 PostgresCoreDataSource

The shared datasource uses node-postgres with Drizzle ORM. It auto-discovers models from @repository bindings via getSchema():

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

All backend packages re-export PostgresCoreDataSource from their local datasources/index.ts, so repositories always reference the same shared datasource.

8. Eventing

Cross-service application events run over Kafka (nx.bana.evt.* / nx.bana.cmd.*) and Debezium CDC (nx.bana.cdc.<schema>.<Table>) - the authoritative seam, fully documented in Event Architecture.

MechanismWhat it isScope
Kafka events/commandscross-service application eventsthe real inter-service seam
Debezium CDCrow changes -> read-models (Typesense, projections)cross-service replication
BullMQ (Redis)reliable async jobs with retrywithin a service
In-process EventEmitter (eventemitter3)intra-service decoupling (commerce, helpdesk only)within one service
WebSocket (Signal)real-time client push, Redis-backed cross-instance fan-outclient delivery

Legacy / unused

The IEventBus + RedisPubSubAdapter abstraction and the PaymentEventChannels / UserOnboardingEventChannels string constants in @nx/core (payment.order.success, commerce.initialized, ...) are dead code - never instantiated. Redis is not a cross-service event bus (it is cache + BullMQ store + WebSocket fan-out). The real PAYMENT_SUCCESS seam is the Kafka topic nx.bana.evt.payment.succeeded; merchant onboarding propagates via CDC nx.bana.cdc.public.Merchant.

8.1 WebSocket events

@nx/core provides utility classes for building WebSocket room and topic identifiers, used by the Signal service:

UtilityFormatExample
WebSocketRooms.build()wr:{prefix}/{paths}wr:observation/merchants/abc-123
WebSocketTopics.build()ws:{paths joined by .}ws:observation.sale.sale-order

9. Bootstrap Helpers

@nx/core provides two bootstrap functions that standardize entry points across all packages. See the IGNIS Bootstrapping reference for the underlying framework concepts.

9.1 Application Bootstrap

Used in each package's src/index.ts to start the HTTP server:

typescript
// packages/sale/src/index.ts
import { bootstrapApplication } from '@nx/core';
import { Application } from './application';
import { appConfig } from './common/app-config';
import { resolve } from 'node:path';

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

The bootstrap sequence is: new Application() --> init() --> boot() --> start().

9.2 Migration Bootstrap

Used in each package's src/migrate.ts to run database seeds and migrations:

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

bootstrapMigration({
  ApplicationClass: Application,
  getMigrationProcesses,
});

9.3 createMigrationProcessLoader

A factory function that creates a migration process getter from a list of seed file paths:

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

export const getMigrationProcesses = createMigrationProcessLoader({
  seedPaths: [
    'sale-0001-seed-initial-data',
    'sale-0002-seed-tracking-types',
  ],
  importFn: (path) => import(`../processes/${path}.js`),
});

Each migration process is an object with name, migrateFn, and optional cleanFn. The MigrationHelper class tracks execution status in the database to prevent duplicate runs.

9.4 createAppConfig

Centralizes application configuration so every package uses the same structure:

typescript
// packages/sale/src/common/app-config.ts
import { createAppConfig } from '@nx/core';

export const appConfig = createAppConfig();

The function reads from environment variables and returns an IApplicationConfigs object with host, port, base path, debug settings, and boot options (glob patterns for auto-discovering datasources and repositories from @nx/core).

10. Cross-Service Communication

10.1 IdentityNetworkService

The only cross-service network client in the system. It extends AxiosNetworkRequest from @venizia/ignis-helpers/axios and communicates with the Identity service for credential verification.

typescript
export class IdentityNetworkService extends AxiosNetworkRequest {
  constructor() {
    super({
      name: IdentityNetworkService.name,
      networkOptions: {
        baseUrl: applicationEnvironment.get<string>(
          EnvironmentKeys.APP_ENV_IDENTITY_SERVICE_BASE_URL,
        ),
      },
    });
  }

  async signIn(opts: {
    identifier: { scheme: string; value: string };
    credential: { scheme: string; value: string };
  }) {
    const networkService = this.getNetworkService();
    const response = await networkService.post({
      url: '/auth/sign-in',
      body: opts,
    });
    return response.data;
  }
}

This service is registered by default in BaseApplication.configureServices(), making it available to all downstream packages for Basic authentication credential verification.

10.2 Communication Topology

There is no API gateway. Each service handles its own authentication and exposes its HTTP API directly. The only inter-service HTTP call is for Basic auth credential verification.

All other cross-service communication happens through Redis Pub/Sub events or BullMQ queues (see Section 8).

11. Authentication Flow

BaseApplication.configureSecurity() delegates to the abstract configureAuthentication() and configureAuthorization() methods. Each subclass implements them, setting up the IGNIS AuthenticateComponent with two strategies (JWT + Basic).

11.1 JWT Strategy (JWKS)

JWTs are signed and verified through a JWKS trust chain rather than a shared symmetric secret:

RoleClassStrategyBehavior
IssuerIssuerApplication (@nx/identity)JWKSIssuerAuthenticationStrategySigns tokens with a PEM private key, publishes the public key set at /jw-certs
VerifierVerifierApplication (13 services)JWKSVerifierAuthenticationStrategyValidates tokens against identity's JWKS URL (APP_ENV_IDENTITY_SERVICE_BASE_URL + /jw-certs)
Environment VariableUsed byPurpose
APP_ENV_JWKS_ALGORITHMIssuerSigning algorithm (default ES256)
APP_ENV_JWKS_PRIVATE_KEY / APP_ENV_JWKS_PUBLIC_KEYIssuerPEM key pair
APP_ENV_JWKS_REST_PATHBothJWKS endpoint path (default /jw-certs)
APP_ENV_IDENTITY_SERVICE_BASE_URLVerifierBase URL of the issuer (identity) service
APP_ENV_JWT_EXPIRES_INIssuerToken expiration in seconds
APP_ENV_APPLICATION_SECRETBothApplication-level secret

11.2 Basic Strategy

Delegates credential verification to the Identity service via IdentityNetworkService.signIn():

11.3 Strategy Registration

Each subclass registers its two strategies in the AuthenticationStrategyRegistry singleton. A verifier service registers:

typescript
AuthenticationStrategyRegistry.getInstance().register({
  container: this,
  strategies: [
    { name: Authentication.STRATEGY_JWT, strategy: JWKSVerifierAuthenticationStrategy },
    { name: Authentication.STRATEGY_BASIC, strategy: BasicAuthenticationStrategy },
  ],
});

IssuerApplication registers JWKSIssuerAuthenticationStrategy for the JWT strategy instead. Controllers specify which strategies to use in their authenticate configuration:

typescript
authenticate: { strategies: ['jwt', 'basic'] }

12. Shared Utilities

@nx/core provides several singleton utilities used across all packages.

UtilityPurpose
IdGeneratorSnowflake ID generation singleton (wraps IGNIS SnowflakeUidHelper)
CryptoUtilityAES-256-GCM encryption and HMAC signing using APP_ENV_APPLICATION_SECRET
useRequestContext()Extracts authenticated user, roles, and provides response formatting helpers
@logged decoratorMethod-level performance measurement logging
RedisConnectionFactoryCreates single-mode or cluster-mode Redis connections

12.1 IdGenerator

typescript
import { IdGenerator } from '@nx/core';

const id = IdGenerator.getInstance().nextId();
// Returns: '7193487234817024' (Snowflake ID string)

Configured via environment variables:

  • APP_ENV_SNOWFLAKE_WORKER_ID (0-1023, unique per service instance)
  • APP_ENV_SNOWFLAKE_EPOCH_CHECKPOINT (custom epoch in milliseconds)

12.2 useRequestContext()

A wrapper around the IGNIS useRequestContext that adds BANA-specific fields:

typescript
const {
  context,        // Hono request context
  currentUser,    // JWT payload with userId, roles
  userId,         // Shortcut: currentUser.userId
  roles,          // Shortcut: currentUser.roles[].identifier
  isAlwaysAllowed, // true if user has SUPER_ADMIN or ADMIN role
  normalizeCountableData,  // Format list responses with count/range headers
  formatResponse,          // Format single-item responses
  formatArrayResponse,     // Format array responses
} = useRequestContext();

13. Package Dependency Chain

All backend packages depend on @nx/core. The following diagram shows the complete dependency tree:

PackageDirect Dependencies
@nx/coreNone (foundation)
@nx/asset@nx/core
@nx/search@nx/core
@nx/inventory@nx/core
@nx/identity@nx/core
@nx/finance@nx/core
@nx/signal@nx/core
@nx/payment@nx/core, @nx/mq-pay
@nx/sale@nx/core
@nx/commerce@nx/core, @nx/asset, @nx/search, @nx/inventory

14. Database Schemas

@nx/core defines all Drizzle ORM schemas centrally so that every service accesses the same database structure. Schemas are organized across 14 PostgreSQL schemas:

PostgreSQL SchemaModel CountDomain
helpdesk30Tickets, agents, SLAs, conversations
public26Users, roles, products, merchants, organizers, configurations
inventory17Stock, purchase orders, vendors, tracking
sale21Sale orders and items
invoice9E-invoices, tax info
pricing9Fares, pricing rules, costs, taxes
identity8Authentication, permissions, policies
ledger8Double-entry accounts and journal entries
finance6Wallets, transactions, categories
tax6Tax rules and configurations
licensing5Subscriptions and license plans
allocation4Event seating and venue layouts
outreach2Campaigns and messaging
payment0Reserved (no entities)

For detailed schema documentation, see Database ERD.

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