Skip to content

Ninaku Application Architecture

Status: architectural baseline for ninaku-app-api.

This document defines the target application architecture for Ninaku after the migration from the previous ASP.NET implementation to NestJS. It preserves the domain boundaries and database contracts already validated in the canonical PostgreSQL baseline while adapting the application structure to TypeScript/NestJS.

The goal is not to split Ninaku into microservices. The goal is to build one deployable application with strong internal boundaries.

1. Architectural style

Ninaku is a modular monolith with pragmatic DDD and Clean Architecture boundaries per module.

  • One backend application.
  • One deployment unit by default.
  • One PostgreSQL platform database by default.
  • Explicit business modules with clear ownership.
  • No HTTP calls between modules inside the same process.
  • No shared "god service" or generic repository spanning all domains.
  • Modules collaborate through explicit in-process contracts.
  • Vertical products compose reusable platform capabilities instead of duplicating them.

The dependency direction is conceptual: verticals consume reusable capabilities; reusable capabilities consume the platform core and cross-cutting infrastructure. A vertical must not become a backdoor for mutating arbitrary data owned by another module.

2. Product model

Ninaku must distinguish these concepts:

  • Organization: tenant / customer boundary.
  • Business Unit: operating subdivision inside an organization.
  • Legal Entity: legal/fiscal ownership boundary.
  • Brand: commercial identity.
  • Site: physical or logical location group.
  • Outlet: operating point where business activity occurs.
  • Vertical: product capability family such as restaurant, retail or hospitality.

A vertical is not a tenant, Business Unit, Legal Entity or Outlet.

An organization may activate different verticals for different Business Units. This is what allows a hotel group, for example, to run hospitality and food & beverage capabilities in the same Ninaku platform without creating unrelated systems.

3. Core principle: one business fact, one owner

Every important business fact has one owning module.

Examples:

  • sales owns sales, sale lines, sale lifecycle and returns.
  • payments owns payment intent, attempts, results, reversals and refunds.
  • cash owns cash custody, shifts, counts and differences.
  • treasury owns bank/wallet accounts, settlements, FX and reconciliation.
  • inventory owns stock custody, lots, reservations, movements and counts.
  • accounting owns ledgers, posting requests and journal entries.

This means:

  • Sales is not Payments.
  • Payments is not Cash.
  • Payments is not Treasury.
  • Catalog is not Inventory.
  • Catalog is not Production.
  • Tax is not Fiscal.
  • Fiscal is not Invoicing.
  • Restaurant service is not Hospitality reservations.

A user action may trigger several modules, but that does not merge their ownership.

4. Target source layout

The application should evolve toward the following shape:

text
src/
├── app/
│   └── app.module.ts

├── bootstrap/
│   ├── configure-http-application.ts
│   ├── create-application.ts
│   └── graceful-shutdown.ts

├── foundation/
│   ├── config/
│   ├── correlation/
│   ├── database/
│   ├── errors/
│   ├── events/
│   ├── http/
│   ├── idempotency/
│   ├── logging/
│   ├── tenancy/
│   ├── transactions/
│   └── validation/

├── modules/
│   ├── reference/
│   ├── organization/
│   ├── party/
│   ├── identity/
│   ├── platform/
│   ├── assets/
│   ├── catalog/
│   ├── pricing/
│   ├── commerce/
│   ├── sales/
│   ├── settlement/
│   ├── payments/
│   ├── inventory/
│   ├── production/
│   ├── procurement/
│   ├── crm/
│   ├── loyalty/
│   ├── receivables/
│   ├── payables/
│   ├── cash/
│   ├── treasury/
│   ├── tax/
│   ├── fiscal/
│   ├── invoicing/
│   ├── accounting/
│   ├── workforce/
│   ├── documents/
│   ├── printing/
│   ├── notifications/
│   ├── communications/
│   ├── integrations/
│   ├── events/
│   ├── sync/
│   ├── audit/
│   └── analytics/

├── verticals/
│   ├── restaurant/
│   │   ├── service/
│   │   └── kitchen/
│   ├── retail/
│   └── hospitality/

└── main.ts

This structure is a target, not a requirement to create empty folders upfront. A folder should exist because a responsibility exists, not to satisfy a diagram.

bootstrap/ owns the startup path: building the Nest application, applying HTTP policy to it, and orchestrating its shutdown. Grouping those three files keeps the root of src/ readable as a list of responsibilities rather than a mix of directories and loose startup files. main.ts deliberately stays at the root instead of moving inside bootstrap/: it is the process entry point named by package.json (start:prod) and by .railway/railway.ts, so the file the runtime actually executes is the first one a reader sees, and the path the deployment depends on does not change.

5. Foundation vs business modules

foundation/ contains technical capabilities shared by the application. It must not own restaurant, sales, hotel, inventory or accounting concepts.

Typical foundation abstractions include:

  • transaction manager;
  • request context;
  • tenant context;
  • clock;
  • ID generation;
  • error translation;
  • event/outbox infrastructure;
  • logging and tracing;
  • HTTP conventions;
  • validation infrastructure;
  • database connection lifecycle.

Business rules live in modules/ or verticals/, not in foundation/.

Module scope

Foundation modules fall into two groups, and the group decides whether the module is @Global().

Ambient modules are @Global(). Every module needs them, and an explicit import would only restate what the reader already assumes:

  • FoundationConfigModule — configuration.
  • LoggingModule — structured logging and redaction.
  • ObservabilityModule — request and trace correlation.

Capability modules are imported explicitly by each consumer. A module either uses the capability or it does not, and its import list is where that answer belongs:

  • DatabaseModule — transaction facades and database health.
  • CollectionsModule — cursor encoding for paginated collections.
  • TelemetryModule — HTTP metrics.

The reason is ownership, not style. Once business modules exist, "which modules touch the database?" must be answerable by reading import lists. @Global() erases that evidence: a module that opens transactions looks exactly like one that never reaches PostgreSQL. The cost of the explicit form is one import line per consumer, which is the point rather than the price.

This is enforced, not merely documented. database.module.spec.ts, collections.module.spec.ts and telemetry.module.spec.ts each compile a consumer that omits the import while the providing module is present elsewhere in the graph, and assert that Nest refuses to resolve the dependency. A capability module that regained @Global() would make those tests fail, because the forgotten import would silently start working again.

6. Module internal structure

A business module may use the following internal layout:

text
sales/
├── domain/
├── application/
├── infrastructure/
├── presentation/
├── contracts/
└── sales.module.ts

Responsibilities:

Domain

Contains business rules, entities, value objects, policies and domain events.

Domain must not depend on:

  • NestJS controllers;
  • HTTP;
  • PostgreSQL drivers;
  • an ORM;
  • external SDKs.

Application

Contains use cases, commands, queries and orchestration.

Application coordinates business behavior but does not turn itself into a generic CRUD layer.

Infrastructure

Contains adapters for PostgreSQL, external providers, queues, storage and other technical concerns.

Infrastructure implements ports owned by the application/domain side.

Presentation

Contains HTTP controllers, request/response DTOs and protocol translation.

Presentation must not expose persistence entities directly.

Contracts

Contains the public in-process contracts that another module is allowed to consume.

Internal repositories, SQL builders, ORM models and implementation-specific services are not public contracts.

7. Module collaboration

A module writes its own business facts.

Another module must not reach into its repositories simply because the code is in the same process.

Bad:

ts
class ConfirmSaleHandler {
  constructor(
    private readonly inventoryRepository: InventoryRepository,
    private readonly paymentRepository: PaymentRepository,
  ) {}
}

Preferred:

ts
class ConfirmSaleHandler {
  constructor(
    private readonly inventory: InventoryContract,
    private readonly payments: PaymentsContract,
  ) {}
}

The contract may execute in the same NestJS process and the same PostgreSQL transaction, but the owning module remains responsible for its own state transitions and invariants.

There are no internal HTTP calls between modules of the same monolith.

8. API design

Ninaku APIs model business tasks, not database tables.

Preferred examples:

text
POST /api/v1/sales/orders
POST /api/v1/sales/orders/{id}/confirm
POST /api/v1/inventory/transfers
POST /api/v1/inventory/transfers/{id}/dispatch
POST /api/v1/payments/intents
POST /api/v1/payments/{id}/capture

Avoid creating a public CRUD endpoint for every table.

Queries may compose read contracts from several modules when a screen needs an operational workspace. Writes remain explicit use cases.

Public response contracts should remain stable and independent from persistence implementation.

9. Database strategy

The canonical PostgreSQL database under database/ is an existing platform contract and must not be casually regenerated from application models.

The application persistence layer must adapt to the current schema, constraints, RLS, functions and migration ledger.

Rules:

  • PostgreSQL 18 remains the source of truth for the canonical schema.
  • ninaku_migrator owns installation/migrations.
  • ninaku_runtime is the application runtime role.
  • RLS and tenant security remain active runtime guarantees.
  • The application must not run as an owner/superuser or bypass RLS.
  • Application migrations evolve the canonical database forward; an ORM must not silently rewrite or own the schema.
  • The persistence technology for NestJS is a separate implementation decision and must respect these constraints.

Sharing PostgreSQL does not imply shared application ownership. Cross-schema foreign keys may exist where justified while module boundaries remain enforced at the application level.

10. Transactions across modules

Some operations require atomic behavior across multiple owners.

Example: confirming a sale may require Sales, Inventory and Outbox to commit atomically.

The application may share a connection, transaction and tenant context across participating module contracts.

Important rules:

  • Each module still owns its writes.
  • Repositories must not hide independent commits.
  • A transaction boundary belongs to the use case, not to every repository call.
  • External network calls are not made transactionally with PostgreSQL.
  • External side effects use outbox/inbox or another recoverable pattern.

Implemented in Foundation: src/foundation/transactions/transaction-runner.service.ts is the one transaction boundary: it owns BEGIN/COMMIT/ROLLBACK and client release, and repositories/use cases receive a TransactionContext (src/foundation/database/transaction-context.ts) instead of opening their own transaction. MODULE_CONTRACTS.md §4.1 is explicit that this shared surface must not exchange pg.PoolClient, raw SQL handles, or repository implementations — so TransactionContext declares no members at all: a use case that receives one can do nothing with it except pass it along to a repository or another contract call. DatabaseSession (same file) is the queryable type a repository turns a TransactionContext into, in one step, via asDatabaseSession(context); PooledDatabaseSession (src/foundation/database/database-session.ts) is the one concrete implementation, wrapping a checked-out pg.PoolClient, and it satisfies DatabaseSession today because that is the only shape Foundation ever constructs. asDatabaseSession is a plain structural type guard (checks for a callable query property) — no branding, WeakMap or registry — so a repository's own module owns the one line where opacity ends and querying begins, exactly where MODULE_CONTRACTS.md §8 says a participating module "turns that context into something that can query" for its own tables. This is a compile-time discipline, not a runtime seal: the object asDatabaseSession returns is the same object the whole way through, so TypeScript stops code that respects TransactionContext's declared shape from calling .query() without going through a repository, but it cannot stop code that deliberately reaches past its own static type. Enforcing that a module's own repository only ever touches its own schema remains the import-boundary discipline MODULE_CONTRACTS.md §10 describes — code review today, a static check once real business modules exist. src/foundation/tenancy/tenant-context-runner.service.ts applies the tenant GUCs (app.actor_identity_id, app.identity_id, app.membership_id, app.organization_id, app.impersonation_session_id) inside that same transaction, before any domain work, using SELECT set_config($1, $2, true) rather than string-built SET LOCAL app.x = '...'. SET LOCAL cannot take bound parameters, so building it from a string would require interpolating identity-controlled UUID values into SQL text; set_config(name, value, is_local) is a normal function call and accepts the value as a query parameter while still being transaction-local (is_local = true, the parameterized equivalent of SET LOCAL), so no tenant identifier is ever concatenated into a SQL string. The impersonation GUC is always set this way, to the given value or to NULL when the context carries none — set_config with a NULL value resets the setting transaction-locally, the same as if it had never been set — so a value applied for one context can never survive into a later apply call inside the same transaction. It then calls identity.assert_tenant_context(); a PostgreSQL 42501 rejection is translated into a typed TenantContextRejectedError, never surfaced as a raw pg error.

Modules use TenantTransactionRunnerService, never the two primitives above. src/foundation/transactions/tenant-transaction-runner.service.ts composes them behind one signature, run<T>(context: TenantContext, work: TransactionalWork<T>): Promise<T>, so the only way to run tenant-scoped work is with the tenant context already applied and asserted before work starts. It delegates the transaction itself to TransactionRunnerService.run, wrapping work in a callback that first calls TenantContextRunnerService.apply and then invokes work with the same TransactionContext; it does not reimplement BEGIN/COMMIT/ROLLBACK or the release-with-rollback-error behavior, it inherits them, so a rollback that itself fails still destroys the client with the rollback error exactly as it always did. TransactionRunnerService and TenantContextRunnerService are Foundation internals: DatabaseModule provides TransactionRunnerService but does not export it, TenancyModule provides and exports TenantContextRunnerService but is not @Global(), so only DatabaseModule (which imports it) can reach it. Nest's own dependency injection refuses to compile any other module that tries to inject either one directly, so running without an applied tenant context, or applying it after domain work has already started, is not an option the API offers — this is enforced at module-boot time, not left as a convention. src/foundation/database/database.module.spec.ts proves both refusals. DatabaseModule itself is not @Global() either, so a module that needs TenantTransactionRunnerService or SystemTransactionRunnerService imports DatabaseModule and declares that dependency in its own import list; see Module scope. A caller that genuinely has no tenant to apply, such as the reference-data read in test/fixtures/collections/collections-fixture.controller.ts, uses SystemTransactionRunnerService instead: an explicitly named, exported facade that opens a plain TransactionRunnerService transaction with no tenant context, so reaching for "no tenant" is a deliberate, visible choice rather than the path of least resistance TransactionRunnerService itself would otherwise be. The runner's guarantee stops at the database transaction and tenant-context lifecycle: it does not track, cancel, or clean up any other resource — a second connection, a timer, a lock, an external call — that work opens on its own; releasing those remains the caller's responsibility.

11. Idempotency and concurrency

Idempotency is required for commands where duplicate execution would produce duplicate effects, especially:

  • sale confirmation;
  • payment/capture;
  • receiving goods;
  • stock dispatch;
  • posting;
  • offline replay.

A retry with the same business intention must not create another operation.

Concurrency control is selected by invariant:

  • optimistic version / ETag for editing drafts and configuration;
  • atomic update or row locking for stock consumption/reservation;
  • conditional state transitions for confirmation/posting;
  • database uniqueness for one-time effects;
  • leases/tokens for background workers.

Do not use an in-memory mutex as a distributed guarantee and do not default the entire system to SERIALIZABLE without a specific invariant requiring it.

12. Offline-first behavior

Offline support is not a second business implementation.

Online and offline execution converge on the same application use case.

A device persists a stable command identity locally and retries the same command after reconnecting.

sync is responsible for transport, ordering metadata, conflict evidence and replay. It must not duplicate Sales, Inventory, Payments or other business rules.

The backend must distinguish states such as pending, confirmed, rejected and reconciliation-required where a disconnected operation cannot be globally guaranteed.

13. Verticals

Verticals contain capabilities that are genuinely specific to a product family.

Restaurant

Examples:

  • dining areas and tables;
  • restaurant reservations;
  • service sessions;
  • courses;
  • kitchen stations;
  • kitchen work/tickets.

Restaurant consumes shared modules such as Catalog, Pricing, Sales, Payments, Inventory, Printing and Notifications.

Kitchen is not universal platform infrastructure. It is an optional food-service capability that may also be enabled inside a hotel with food & beverage operations.

Hospitality

Hospitality should own concepts such as:

  • properties/accommodation structure;
  • room inventory and room types;
  • hotel reservations;
  • stays;
  • housekeeping;
  • folio-specific hospitality behavior;
  • OTA/channel connectivity contracts.

Hospitality may consume Party, Pricing, Payments, Accounting, Documents and Notifications.

A hotel reservation is not the same aggregate as a restaurant table reservation.

Retail

Retail composes the shared commerce, catalog, sales, payment and inventory capabilities without requiring restaurant/kitchen concepts.

14. Multi-business composition example

A hotel organization may operate multiple business models in the same platform:

text
Organization
├── Business Unit: Hospitality
│   └── Hotel operations
└── Business Unit: Food & Beverage
    ├── Restaurant
    ├── Bar
    └── Room service

The same Organization can share selected platform capabilities while each Business Unit activates the vertical capabilities it actually needs.

This is preferred over adding if (vertical === ...) branches throughout generic core modules.

15. Cross-cutting modules

Capabilities such as events, sync, audit, analytics, printing, documents, notifications and integrations must remain composable.

They should not make every possible business source a mandatory dependency.

Prefer typed optional bridges/adapters when two modules need to collaborate, for example a restaurant-printing adapter connecting kitchen output with printing infrastructure.

Avoid universal polymorphic tables or services that can mutate arbitrary domain records.

16. Initial implementation order

Before implementing Login, Sales or Restaurant features, establish the application foundation and module boundaries.

Recommended sequence:

  1. Bootstrap/application configuration.
  2. PostgreSQL runtime connection using ninaku_runtime.
  3. Request + tenant context compatible with current RLS contracts.
  4. Transaction boundary abstraction.
  5. Error/problem contract.
  6. Idempotency foundation.
  7. Event/outbox foundation.
  8. Module public-contract convention.
  9. Organization + Platform topology.
  10. Identity/authentication/authorization.
  11. Onboarding.
  12. Shared operational modules such as Catalog/Sales/Inventory.
  13. Vertical modules.

Do not generate all modules as empty scaffolding. Build the architectural mechanisms first, then add modules as real use cases are implemented.

17. Non-goals

This architecture does not require:

  • one microservice per module;
  • one database per module;
  • one Nest application per vertical;
  • HTTP communication between internal modules;
  • an ORM-generated schema;
  • a generic CRUD API;
  • duplicating core capabilities inside each vertical.

Modules may be extracted into services in the future only when there is a concrete scaling, ownership or deployment reason. Extraction should be easier because the modular boundaries already exist, not because the system was distributed prematurely.

18. Architectural rule of thumb

When deciding where code belongs, ask:

  1. Who owns this business fact?
  2. Is this reusable across verticals or genuinely vertical-specific?
  3. Does this module need a public contract, or am I reaching into another module's internals?
  4. Does this rule belong to Domain, orchestration to Application, protocol translation to Presentation, or an adapter to Infrastructure?
  5. Can this operation preserve tenant isolation, idempotency and transaction guarantees?

If ownership is unclear, do not hide the ambiguity in shared/ or common/. Resolve the domain boundary first.


This document is the application-architecture baseline for the NestJS implementation. Historical ASP.NET architecture documents remain useful as design evidence, but new backend decisions should be documented in this repository and expressed using NestJS/TypeScript terminology.

Application Foundation in progress. Tracked in issue #13.