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.tsSales 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.
One folder per business object
Section titled “One folder per business object”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.
Rules live beside the schema
Section titled “Rules live beside the schema”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.
A file is named for what it exports
Section titled “A file is named for what it exports”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.
Area folders
Section titled “Area folders”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.
Seeders
Section titled “Seeders”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.
What the plugin declares
Section titled “What the plugin declares”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.
Which domain may know which
Section titled “Which domain may know which”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:
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.
Files reached by path
Section titled “Files reached by path”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— theRecord<string, Migration>map keyed by prefixed migration id, imported byplugin.tsalone.
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.
Translations
Section titled “Translations”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.
Descriptions
Section titled “Descriptions”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.
Naming inside a file
Section titled “Naming inside a file”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.