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/VerifierApplicationbase 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.
| Layer | Component | Description |
|---|---|---|
| Application | BaseApplication | preConfigure() lifecycle · CORS · BodyLimit 100 MB · Swagger UI · subclassed by IssuerApplication / VerifierApplication |
| Bootstrap Helpers | bootstrapApplication() · bootstrapMigration() · createAppConfig() · createMigrationProcessLoader() | |
| Built-in Components | HealthCheck GET /health · Swagger UI /explorer · Authenticate JWKS · Basic → Identity | |
| Package Components | Signal · Payment · Commerce · Finance · Inventory · Search · Asset | |
| Service | Business Services | Domain logic · Validation · Orchestration |
| IdentityNetworkService | signIn() HTTP → Identity service · Cross-service Basic auth delegation | |
| Event Handlers | Kafka consumers (cross-service) · in-process EventEmitter (commerce/helpdesk) · IEventBus/RedisPubSubAdapter legacy/unused | |
| Queue Consumers | BullMQ - 4 systems · 8 types · 3 partitions · Commerce · Finance · Inventory · Sale | |
| BaseSocketEventService | broadcast() · sendToRoom() · sendToClient() · Redis-backed cross-instance delivery | |
| Repository | SoftDeletableRepository | Soft delete · Restore · Default filter deletedAt IS NULL auto-applied |
| DefaultCRUDRepository | find · create · updateById · deleteById · count · withTransaction | |
| Infrastructure | PostgresCoreDataSource | Drizzle ORM · node-postgres Pool · 14 schemas · 151 entities |
| RedisConnectionFactory | Cache · Pub/Sub · WebSocket · Single mode · Cluster mode | |
| BullMQ | Scheduler · Confirmation · Processing · 12 queues with hash-based partition routing |
| Layer | IGNIS Base Class | Responsibility |
|---|---|---|
| Application | BaseApplication | Lifecycle management, middleware setup, DI container root |
| Component | BaseComponent | Feature module registration (repositories, services, controllers) |
| Service | BaseService | Business logic, transaction orchestration, external communication |
| Repository | DefaultCRUDRepository | Data access abstraction, CRUD operations, soft-delete |
| DataSource | BaseDataSource | Database 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 class | Extended by | Role |
|---|---|---|
BaseApplication (abstract) | - | Shared CORS, body-limit, Swagger UI, health check, IdentityNetworkService, the preConfigure() flow, and configureSecurity() orchestration |
IssuerApplication | @nx/identity only | Signs JWTs (JWKS issuer) and serves the public key set at /jw-certs |
VerifierApplication | the other 13 services | Verify JWTs against identity's JWKS endpoint |
DefaultApplication | nothing (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.
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.
| Order | Method | What It Does |
|---|---|---|
| 1 | configureDatasources() | Registers PostgreSQL connection pool (no-op by default, datasources are auto-discovered from glob) |
| 2 | configureRepositories() | Registers repository classes into the DI container (no-op by default, repositories are auto-discovered from glob) |
| 3 | configureServices() | Registers IdentityNetworkService for cross-service auth |
| 4 | configureComponents() | Binds HealthCheckComponent at /health and SwaggerComponent at /doc |
| 5 | configureSecurity() | Configures JWT and Basic authentication strategies via AuthenticateComponent |
| 6 | configureControllers() | Registers HTTP controllers (no-op by default, overridden per package) |
3.4 Middleware Setup
setupMiddlewares() configures two global Hono middlewares:
| Middleware | Configuration |
|---|---|
| CORS | Origin: *, all methods, 86400s max-age, credentials enabled |
| Body Limit | 100 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:
// 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
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}:
| Namespace | Example Key | Registered Via |
|---|---|---|
repositories | repositories.SaleOrderRepository | this.application.repository(SaleOrderRepository) |
services | services.IdentityNetworkService | this.application.service(IdentityNetworkService) |
controllers | controllers.SaleOrderController | this.application.controller(SaleOrderController) |
datasources | datasources.PostgresCoreDataSource | this.application.datasource(PostgresCoreDataSource) |
You can also build keys explicitly using the IGNIS BindingKeys utility:
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:
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.
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:
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.
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:
| Method | Path | Description |
|---|---|---|
GET | /sale-orders | List with filter, pagination, sorting |
GET | /sale-orders/:id | Find by ID |
GET | /sale-orders/count | Count matching filter |
POST | /sale-orders | Create |
PUT | /sale-orders/:id | Update by ID |
DELETE | /sale-orders/:id | Delete 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:
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.
| Method | Behavior |
|---|---|
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:
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():
@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.
| Mechanism | What it is | Scope |
|---|---|---|
| Kafka events/commands | cross-service application events | the real inter-service seam |
| Debezium CDC | row changes -> read-models (Typesense, projections) | cross-service replication |
| BullMQ (Redis) | reliable async jobs with retry | within 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-out | client 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:
| Utility | Format | Example |
|---|---|---|
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:
// 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:
// 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:
// 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:
// 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.
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:
| Role | Class | Strategy | Behavior |
|---|---|---|---|
| Issuer | IssuerApplication (@nx/identity) | JWKSIssuerAuthenticationStrategy | Signs tokens with a PEM private key, publishes the public key set at /jw-certs |
| Verifier | VerifierApplication (13 services) | JWKSVerifierAuthenticationStrategy | Validates tokens against identity's JWKS URL (APP_ENV_IDENTITY_SERVICE_BASE_URL + /jw-certs) |
| Environment Variable | Used by | Purpose |
|---|---|---|
APP_ENV_JWKS_ALGORITHM | Issuer | Signing algorithm (default ES256) |
APP_ENV_JWKS_PRIVATE_KEY / APP_ENV_JWKS_PUBLIC_KEY | Issuer | PEM key pair |
APP_ENV_JWKS_REST_PATH | Both | JWKS endpoint path (default /jw-certs) |
APP_ENV_IDENTITY_SERVICE_BASE_URL | Verifier | Base URL of the issuer (identity) service |
APP_ENV_JWT_EXPIRES_IN | Issuer | Token expiration in seconds |
APP_ENV_APPLICATION_SECRET | Both | Application-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:
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:
authenticate: { strategies: ['jwt', 'basic'] }12. Shared Utilities
@nx/core provides several singleton utilities used across all packages.
| Utility | Purpose |
|---|---|
IdGenerator | Snowflake ID generation singleton (wraps IGNIS SnowflakeUidHelper) |
CryptoUtility | AES-256-GCM encryption and HMAC signing using APP_ENV_APPLICATION_SECRET |
useRequestContext() | Extracts authenticated user, roles, and provides response formatting helpers |
@logged decorator | Method-level performance measurement logging |
RedisConnectionFactory | Creates single-mode or cluster-mode Redis connections |
12.1 IdGenerator
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:
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:
| Package | Direct Dependencies |
|---|---|
@nx/core | None (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 Schema | Model Count | Domain |
|---|---|---|
helpdesk | 30 | Tickets, agents, SLAs, conversations |
public | 26 | Users, roles, products, merchants, organizers, configurations |
inventory | 17 | Stock, purchase orders, vendors, tracking |
sale | 21 | Sale orders and items |
invoice | 9 | E-invoices, tax info |
pricing | 9 | Fares, pricing rules, costs, taxes |
identity | 8 | Authentication, permissions, policies |
ledger | 8 | Double-entry accounts and journal entries |
finance | 6 | Wallets, transactions, categories |
tax | 6 | Tax rules and configurations |
licensing | 5 | Subscriptions and license plans |
allocation | 4 | Event seating and venue layouts |
outreach | 2 | Campaigns and messaging |
payment | 0 | Reserved (no entities) |
For detailed schema documentation, see Database ERD.
15. Related Documentation
- Core Package Overview -- Package introduction and project structure
- Database ERD -- Entity-relationship diagrams
- Components -- Base classes and reusable components
- Utilities -- Helper functions and utility classes
- Configuration -- Environment and middleware configuration
- IGNIS Framework Reference -- Underlying framework documentation