Appearance
Ninaku Module Contracts
Status: normative architecture contract for ninaku-app-api.
This document defines how business modules collaborate inside the Ninaku modular monolith. It complements ARCHITECTURE.md and replaces the need to infer current NestJS rules from the historical API_AND_MODULE_CONTRACTS.md.
The objective is to preserve strong module ownership while keeping collaboration explicit, typed, testable and understandable. Ninaku remains one deployable application by default; module boundaries do not imply internal HTTP or one database per module.
1. Core rule
A module may expose business capabilities through explicit public contracts. Another module must not reach into its repositories, SQL, infrastructure services or internal application handlers.
Every important business fact has one owner. Collaboration does not transfer ownership.
Bad:
ts
class ConfirmSaleUseCase {
constructor(
private readonly inventoryRepository: InventoryRepository,
private readonly paymentRepository: PaymentRepository,
) {}
}Preferred:
ts
class ConfirmSaleUseCase {
constructor(
private readonly inventory: InventoryContract,
private readonly payments: PaymentsContract,
) {}
}Sales asks Inventory to perform an Inventory capability. Sales does not write Inventory state itself.
2. Module layout
A business module evolves toward this shape when the responsibilities actually exist:
text
modules/
└── inventory/
├── domain/
├── application/
├── infrastructure/
├── presentation/
├── contracts/
└── inventory.module.tsDo not create empty folders to satisfy the diagram. Create a folder when the responsibility exists.
contracts/ is the module's public in-process surface. Everything else is internal unless explicitly documented otherwise.
A small module may keep a flat contract directory:
text
contracts/
├── inventory.contract.ts
└── inventory.contract.types.tsA larger module may split real categories:
text
contracts/
├── commands/
├── queries/
├── results/
├── events/
└── inventory.contract.tsDo not introduce layers or folders without a concrete use case.
3. Contract vs port
These terms are not interchangeable.
Public module contract
A contract describes what a module offers to other Ninaku modules.
text
Sales
↓
PaymentsContract
↓
Payments ApplicationExample:
ts
export abstract class PaymentsContract {
abstract authorize(input: AuthorizePaymentInput): Promise<AuthorizePaymentResult>;
}Application/domain port
A port describes a dependency the module itself requires from infrastructure or an external capability.
text
Payments Application
↓
PaymentGatewayPort
↓
DatafastAdapterExample:
ts
export interface PaymentGatewayPort {
authorize(input: GatewayAuthorizationInput): Promise<GatewayAuthorizationResult>;
}Rule of thumb:
text
Contract = what the module offers.
Port = what the module needs.Ports are internal implementation boundaries unless another explicit architecture decision makes them public.
4. Allowed collaboration modes
Ninaku uses three primary collaboration modes. They solve different problems and must not be collapsed into one generic bus.
4.1 Synchronous in-process contract
Use when the caller needs the result to complete the current use case.
text
Confirm Sale
↓
InventoryContract.reserveStock(...)
↓
Inventory ApplicationThe call stays inside the NestJS process. There is no internal HTTP call.
A synchronous contract may participate in the caller-owned PostgreSQL transaction when the business invariant requires atomicity across owners. In that case, participating modules share an opaque Foundation transaction/session context; they do not exchange pg.PoolClient, raw SQL handles or repository implementations through public contracts.
Each module remains responsible for its own writes and invariants.
4.2 Read contract
Use when another module or a query composer needs information owned by the module.
text
POS Workspace Query
├── CatalogReadContract
├── PricingReadContract
├── InventoryReadContract
└── CustomerReadContractRead contracts return task-oriented projections/results. They do not expose ORM entities, IQueryable, raw table rows, SQL builders or unrestricted repository access.
A query composer may combine multiple read contracts for a screen. This does not grant write ownership.
4.3 Durable event / outbox
Use for consequences that do not need to complete synchronously with the initiating use case or that cross an external/recoverable boundary.
text
Sales transaction
├── update Sales-owned state
└── append SaleConfirmed integration event to outbox
↓ COMMIT
dispatcher
↓
interested consumersExamples include notifications, analytics, printing, integrations and other consequences whose failure must not corrupt the initiating business transaction.
External network calls are never made transactionally with PostgreSQL. Use outbox/inbox or another explicitly recoverable pattern.
Do not promise exactly-once network delivery. Consumers that can receive duplicates must be idempotent or inbox-deduplicated.
5. Public contract design
A public module contract must:
- use business language, not table names;
- expose a small set of meaningful operations;
- use explicit command/query/result types;
- make tenant/business scope explicit where required;
- carry idempotency/concurrency information when the use case requires it;
- avoid leaking persistence technology;
- avoid leaking external-provider SDK types;
- remain owned by the providing module.
Good:
ts
inventory.reserveStock(...)
payments.authorize(...)
pricing.resolvePrice(...)Avoid:
ts
inventory.save(entity)
payments.execute(actionName, payload)
repository.findAll(table, filters)
contractBus.execute('inventory.reserve', payload)Ninaku does not use a string-based universal contract bus for normal in-process collaboration.
6. What contracts must not expose
The following are module internals and must not become public collaboration APIs:
- PostgreSQL pools or
pg.PoolClient; - SQL query builders or raw SQL fragments;
- repositories belonging to the provider module;
- ORM entities/models;
- internal domain aggregates solely for persistence convenience;
- NestJS service implementations;
- provider SDK request/response types;
- generic CRUD abstractions that let another module mutate arbitrary owned state.
A shared PostgreSQL database and cross-schema foreign keys do not authorize application-level cross-module writes.
7. NestJS composition convention
TypeScript interfaces do not exist at runtime, so an injectable public contract needs an explicit runtime token.
For simple contracts, Ninaku may use an abstract class as both the public type and DI token:
ts
export abstract class InventoryContract {
abstract reserveStock(input: ReserveStockInput): Promise<ReserveStockResult>;
}The owning module binds that public contract to an internal implementation:
ts
@Module({
providers: [
InventoryApplicationService,
{
provide: InventoryContract,
useExisting: InventoryApplicationService,
},
],
exports: [InventoryContract],
})
export class InventoryModule {}Consumers import the owning Nest module and inject only the public contract.
@Global() is reserved for ambient Foundation modules — configuration, logging and correlation — where every module needs the capability and an import would add no information. A Foundation module that provides a capability a consumer either uses or does not, including DatabaseModule, CollectionsModule and TelemetryModule, is imported explicitly by each consumer. This matters most for the database: when the first business module appears, "which modules touch the database?" must be answerable by reading import lists, and @Global() hides that answer. See ARCHITECTURE.md for the current grouping and the tests that enforce it.
Do not export repositories or infrastructure providers merely to make another module's implementation convenient.
If an abstract class stops fitting a concrete case, an explicit InjectionToken plus interface is acceptable. Do not introduce a generic token framework in advance.
8. Cross-module transaction rule
The use case owns the transaction boundary.
When atomicity requires multiple module owners to participate:
text
Application use case
↓
Foundation tenant-safe transaction/session
├── Sales contract/application writes Sales-owned state
├── Inventory contract/application writes Inventory-owned state
└── Outbox writes integration event
↓
COMMIT / ROLLBACKRules:
- the transaction/session is created by Foundation;
- tenant/RLS context is established before business work;
- participating modules receive only the opaque transaction/session abstraction required to join that unit of work;
- repositories do not create hidden nested commits;
- a module never performs another module's writes directly;
- external HTTP/provider calls are outside the database atomic boundary and use recoverable orchestration.
Do not create a giant transaction for every consequence of a command. Use synchronous participation only when the business invariant genuinely requires atomicity.
The concrete Foundation type for "the opaque transaction/session abstraction" above is TransactionContext; see ARCHITECTURE.md §10 for the implementation and the reasoning behind its shape.
9. Domain events vs integration events
A domain event may remain internal to the owning module and express something meaningful that happened inside its domain model.
An integration event is a public durable message intended for other modules/processes and therefore has a stronger compatibility contract.
Do not expose internal domain event classes automatically as integration events.
Integration events should be:
- immutable;
- explicitly named;
- serializable;
- versioned when compatibility requires it;
- minimal: include the data consumers need, not a dump of the aggregate;
- free of provider-specific or persistence-specific types.
10. Import boundary
When business modules exist, another module may import the provider module's public contract surface but not its internals.
Allowed conceptually:
text
modules/sales/**
→ modules/inventory/contracts/**Forbidden conceptually:
text
modules/sales/**
→ modules/inventory/domain/**
→ modules/inventory/application/**
→ modules/inventory/infrastructure/**
→ modules/inventory/presentation/**The owning module itself may of course use its internal layers according to the normal dependency direction.
These rules should become architecture checks/lint tests once the first real business modules exist. Do not rely only on documentation after there is code to enforce.
11. Compatibility
In-process contracts live in the same deployable application, so a breaking change may be made atomically when every consumer is updated in the same reviewed change.
Even so, public module contracts are treated as stable internal APIs:
- do not rename or reshape them casually;
- identify consumers before a breaking change;
- update all consumers and tests together;
- do not make internal implementation details part of the contract just to avoid writing an adapter.
Durable integration events require stronger compatibility discipline because producers and consumers may be temporally decoupled by outbox/inbox processing, retries or later service extraction.
12. Verticals
Verticals consume shared module contracts exactly like other consumers. They do not bypass module ownership.
Example:
text
Restaurant
├── CatalogContract
├── SalesContract
├── PaymentsContract
├── InventoryContract
└── PrintingContractRestaurant-specific code must not mutate Catalog, Sales, Payments or Inventory tables directly.
The same rule applies to Retail and Hospitality.
13. Anti-patterns explicitly rejected
Ninaku rejects these collaboration patterns unless a later architectural decision documents a concrete exception:
- internal HTTP between modules of the same monolith;
- importing another module's repository;
- exporting raw database clients through business contracts;
- generic repositories spanning multiple bounded contexts;
- a universal
execute(module, action, payload)service; - event-driven communication for everything merely to look distributed;
- a synchronous call for every consequence merely to avoid outbox/event infrastructure;
- vertical-specific
if (vertical === ...)branches inside shared modules; - duplicating a shared business capability inside each vertical.
14. Definition of done for a module collaboration
Before a cross-module dependency is accepted, verify:
- Which module owns the fact being read or changed?
- Why does the consumer need the capability?
- Is the interaction a synchronous command/query, a read contract or a durable event?
- Does the public type expose only business meaning?
- Does it avoid repositories, SQL, ORM/driver and provider SDK leakage?
- If atomicity crosses owners, is the shared transaction/session explicit and tenant-safe?
- If asynchronous, are outbox/inbox, retry and deduplication rules explicit?
- Are authorization, idempotency, concurrency and offline behavior addressed where applicable?
- Are all module consumers covered by tests?
- Can an architecture check enforce the import boundary now that code exists?
A collaboration is not complete merely because two Nest services can inject each other.
15. Practical mental model
text
What I offer to other modules → contracts/
What my module needs externally → application/domain ports
How I implement it → infrastructure/
How HTTP reaches me → presentation/
How I protect business rules → domain/ + application/The desired outcome is a modular monolith whose boundaries are strong enough to support future extraction if a real operational need appears, without paying the cost of distributed systems before that need exists.