Skip to content

How a domain package is laid out

A domain package is one NestJS module, its business objects, and the providers that run against them.

domains/sales/
scenarios/ ← the domain's tests, one per business flow
src/
index.ts ← the runtime entry: exports SalesPlugin
plugin.ts ← the @Plugin: business objects, extensions, providers
factories.ts ← record factories, reached by path
business-partner-extensions.ts
migrations/index.ts ← the map plugin.ts registers
sales-order/ ← one folder per business object
schema.ts
create-invoice-from-order.action.ts
invoiced-quantity.rollup.ts
sales-invoice/
schema.ts
payment-terms.determinations.ts
cancel-invoice.action.ts
sales-price/
schema.ts
price.validators.ts
pricing.function.ts
document/ ← an area: rules with no single owner
tax.determinations.ts
currency-lock.validator.ts
statutory-notes.pipeline.ts
pdf/sales-invoice.template.tsx
agents/order-intake.ts ← an AgentDefinition, beside the workflow that starts it
workflows/order-intake.workflow.ts

Sales is the fullest of the six domains; the rest are the same shape with fewer parts. src/seeders/ is the one thing it does not have — admin, eu-vat, inventory and master each ship one, and logistics, like sales, seeds nothing.

The folder names the object, so the file inside does not repeat it: the schema is always schema.ts. sales/src/sales-order/schema.ts, master/src/product/schema.ts. Never sales-order.schema.ts, and never a business object sitting flat at src/. Consumers import the path that reads like the sentence — import { SalesOrderSchema } from "../sales-order/schema.js".

Everything about the object lives in that folder — the rules that fill and guard it, the actions that move it through its process, the operations that read it. You can read the whole order story without opening another directory.

A module that is not about one business object stays flat at src/: master/src/postal-address.ts is postalAddressFields, the fields map every address struct spreads in, inventory/src/db.ts is the hand-written Kysely typing for the stock ledger’s tables, eu-vat/src/errors.ts the domain’s error classes.

A determination or validator is a class, in a file beside the schema.ts of the object it binds to. inventory/src/goods-movement/movement.validators.ts holds a movement’s whole guard set, and the rule’s name is its identity everywhere else — in meta, on the data model screen, in an error.

A rule shared by several objects becomes a class factory, in an area folder. Orders and invoices share a header, so sales/src/document/ holds each shared rule as a function that takes the schema and returns the class, plus one decorated subclass per schema:

const linePriceLookup = (schema: SalesDocumentDef) => {
@Injectable()
class LinePriceLookup extends Determination.on(schema, { … }) { … }
return LinePriceLookup;
};
@Determination()
@Injectable()
export class OrderLinePriceLookup extends linePriceLookup(SalesOrderSchema) {}

Those files follow the suffix table below: tax.determinations.ts holds the line tax, the document tax totals and the tax-line summary — one topic, three steps of it — while line-price-lookup.determination.ts is alone because a price lookup has nothing to do with tax or with header defaults.

Plural names group; singular names don’t. <topic>.determinations.ts holds several; <name>.determination.ts holds one. Same for validators. The name says which before you open it.

Split along business topics, not size: eu-vat splits into what a line owes (vat-line.determinations.ts) and how the document is treated (vat-treatment.determinations.ts), with vat-treatment-settled.validator.ts singular because it holds one rule. eu-vat owns no sales business object, so its contribution lives in an area folder mirroring sales/src/document/ — its extensions and the rules it binds onto both schemas — not in a folder named for an object it does not own. What it extends on master’s objects stays flat at src/, as business-partner-extensions.ts does.

Kebab-case the export, add the dotted role suffix. CreateInvoiceFromOrder → create-invoice-from-order.action.ts. StockOverview → stock-overview.function.ts. A shared rule file names the factory: linePriceLookup → line-price-lookup.determination.ts. A file listing is then a table of contents, and grep -rl '\.action\.ts' finds every action in the repo.

Suffix Holds Example
schema.ts defineBusinessObject, its instance type, and the line type of each hasMany (SalesOrderLine = SalesOrder["items"][number]) domains/master/src/product/schema.ts
.determination.ts / .determinations.ts @Determination classes, one or a topic domains/sales/src/document/tax.determinations.ts
.validator.ts / .validators.ts @Validator classes, one or a topic domains/sales/src/document/currency-lock.validator.ts
.action.ts one @Action class domains/sales/src/sales-invoice/cancel-invoice.action.ts
.function.ts one @Function class domains/inventory/src/views/stock-ledger.function.ts
.template.tsx one @PdfTemplate class — JSX, so .tsx domains/sales/src/pdf/sales-order.template.tsx
.workflow.ts one @Workflow class domains/sales/src/workflows/order-intake.workflow.ts
.step.ts one @Step class domains/eu-vat/src/e-invoice/factur-x.step.ts
.pipeline.ts a definePipeline seam a @Step binds to packages/core/src/pdf/render-pdf.pipeline.ts
.service.ts an injectable service domains/inventory/src/stock/stock.service.ts
.listener.ts an event listener domains/inventory/src/stock/goods-movement.listener.ts
.rollup.ts one Rollup.on class domains/sales/src/sales-order/invoiced-quantity.rollup.ts
.logic.ts pure functions another domain may import none yet — see below
.fixtures.ts shared literals for pure-unit tests beside them domains/eu-vat/src/e-invoice/e-invoice.fixtures.ts

One operation per file. An action or a function is a class that declares itself — its name, its subject, its parameters, its return shape — so a file holding two of them holds two unrelated declarations. That is the opposite of the rule-file rule above, and for a reason: two determinations about tax are one topic, two operations are two APIs.

A definePipeline export is a seam, not an implementation, so it lives with the work it opens up: sales/src/document/statutory-notes.pipeline.ts sits next to the determinations that produce a document’s exemption reason, and eu-vat’s vat-statutory-notes.step.ts wraps it. See Registration.

.logic.ts is the exception worth knowing: pure functions extracted from an operation so another domain can import them without importing a NestJS provider. Extract one when a second in-process consumer exists — not on principle.

Where the pure part turns out to belong to nobody’s domain, it leaves the domain instead — convert lives in @onerp/catalogs beside the unit catalog it is arithmetic over, which lets inventory’s line rule convert and keeps master/src/unit-conversion.function.ts a thin operation over it.

An area folder is for rules and operations that serve several business objects with no single owner. sales/src/document/ holds what orders and invoices share — the amounts, the tax, the header defaults, each bound onto both schemas; inventory/src/views/ holds the read models over the stock ledger, which is not a business object at all. An area holds the same kinds of file, named the same way. Reach for one only when the thing genuinely has no single owner; “it felt like a category” is how queries/ folders happen.

A domain’s base seed data — what every tenant of it starts with — is one exported async function in src/seeders/base.ts, over the business object manager:

export async function seedMasterBase(
bom: BusinessObjectManager
): Promise<void> { … }

plugin.ts names it where it declares everything else: @Plugin({ name: "master", seed: seedMasterBase, migrations: masterMigrations }).

The order comes from imports. A plugin’s imports seed first, so a domain whose seeder reads what another minted declares that import and gets the order with it — admin’s profile finds master’s payment terms because @Plugin({ imports: [MasterPlugin] }) says so. Each seeder runs in its own unit of work under the actor seeder:<plugin>, which is what lets the next one list what the one before it committed.

plugin.ts is the whole registration. @Plugin names the domain’s businessObjects and extensions, lists every provider — determinations, validators, actions, functions, services, listeners, PDF templates, workflows and steps — and names the migrations and the base seed function. See Registration.

src/index.ts is the package’s main, exporting the plugin class for the API to install — and whatever another domain injects from this one (InventoryPlugin and StockService, for inventory).

The browser never value-imports a domain package. It reaches a schema through useMeta() and an operation by name, and names an operation’s shapes through a type-only import of the class — Params<StockOverview>, Returns<StockOverview> — erased before any import graph exists. See the meta pipeline.

Domains form a directed acyclic graph, and every edge is permanent.

graph BT
  sales --> master
  sales --> admin
  inventory --> master
  eu-vat --> sales
  logistics --> sales
  logistics --> inventory

The arrow points the way knowledge flows: eu-vat imports sales, logistics imports both sales and inventory, and nothing points back. Along an edge a plain TypeScript import is fine — logistics imports ReservationMovementSchema and AllocationStrategy from @onerp/inventory the same way any package imports any other.

The general domain never learns the specific one. sales does not know deliveries exist; inventory does not know a reservation might belong to a fulfillment order. That is not politeness, it is the substitution rule: a deployment that installs sales and inventory without logistics must still boot, and it cannot if either of them names a type that ships in the package it left out.

So a field one domain needs on another domain’s business object is declared by the domain that needs it, through defineExtension:

domains/eu-vat/src/product-extensions.ts
export const euVatProductExtension = defineExtension(ProductSchema, {
taxClassification: field.referenceOne("eu-vat/TaxClassification", { … }),
});

The field exists when eu-vat is installed and does not when it is not. The same applies to rules: a determination or validator may bind to a business object the package does not own, and a listener on the other domain’s event is how a downstream domain reacts to an upstream fact without the upstream knowing.

Reaching against an arrow is always the same three-step answer: an extension field, an event, and the dependent domain owning both.

Two things sit at src/ and are deliberately not in index.ts, so a consumer names their file. The package’s exports has a ./* wildcard, so @onerp/master/factories and @onerp/master/product/schema both resolve with no entry of their own.

  • factories.ts — the record factories scenarios and other domains stage through.
  • migrations/index.ts — the Record<string, Migration> map keyed by prefixed migration id, imported by plugin.ts alone.

A business-object folder may have its own barrel

Section titled “A business-object folder may have its own barrel”

Add index.ts to a folder when another domain reaches into it for more than a schema — it is the one place to state what the folder offers outward. No folder needs one today. Without one, reach the file directly (@onerp/master/product/schema), which is what every folder does.

A def carries its own translations. The source-locale string stays on label, description and optionLabels; every other locale goes under translations, and the whole thing rides meta like any other def property. A field inside a business object always has a source label — defineBusinessObject and defineExtension complete a missing one from the field name.

city: field.string({ label: "City", translations: { de: { label: "Stadt" } } }),
export const SalesOrderSchema = defineBusinessObject("sales/SalesOrder", fields, {
label: { singular: "Sales Order", plural: "Sales Orders" },
translations: { de: { singular: "Kundenauftrag", plural: "Kundenaufträge" } },
});

A fields map spread into several slots — postalAddressFields into eight address structs — carries its translations once.

Reading is by def and locale. fieldLabel(def, locale), fieldDescription, optionLabel, businessObjectLabel and codeListLabel(catalog, code, locale) from @onerp/meta fall back from the locale to the source string; a catalog has no source string and throws. In React, useFieldLabel(def), useFieldDescription(def), useEnumOptionLabel(def, value), useBusinessObjectLabel(ref) and useCodeListLabel(catalog, code) from @onerp/react/meta read the active locale, and <FieldLabel field={def} /> / <OptionLabel field={def} value={v} /> render it.

Completeness is a test: apps/api/src/meta-completeness.test.ts boots every plugin and asserts untranslated(businessObjects, "de") and untranslatedOperations(operations, "de") from @onerp/meta/testing are empty over the composed meta, extensions merged.

The description on a business object, field, action, function and determination is the knowledge base: the AI agent reads it on demand, the forms show a field’s as the tooltip on its label, and the docs quote it. Write it for the person who has to decide what to put in the field — what it means, what fills it, what it locks. Completeness is the same test: it asserts undescribed(businessObjects) from @onerp/meta/testing is empty and every action and function carries one, and a determination cannot be declared without one.

A domain’s tests are domains/<domain>/scenarios/*.scenario.test.ts, named for a business flow, and they are the main tier. A domain owns zero HTTP tests — those live once, in packages/core/e2e/. Pure-unit tests sit beside what they test — eu-vat/src/e-invoice/mapping.test.ts — with shared literals in a .fixtures.ts next to them, which tsconfig.build.json excludes from the published output. Full rules: the repo’s TESTING.md.

An operation’s name is the wire identity and follows its own rule — two segments when unbound, a bare name when bound to a subject (see Actions) — while the class is named for what it does. Field maps shared between operations are named for the group of fields, not for any one operation that spreads them: stockScopeFields, stockPageFields and stockPositionFields are spread by all three stock reads.