Skip to content

Backend conventions

Source: g2labs-in/billuu-docs@9f1dcd7 · content/backend/conventions.md · updated 2026-09-16

The rules at a glance

Each rule below names the file that enforces it or shows it being done. Follow them in this order when you add a feature.

RuleEnforced or shown by
Reach data through databaseExecutor, never a bare modelsrc/core/database/databaseExecutor.js, src/controllers/tenant/inventoryController.js:183
Do not import Sequelize models in a controllersrc/controllers/ contains one model import in total, at src/controllers/tenantProvisionController.js:274
Answer with the { success, message, data } envelopesrc/utils/standardResponse.js
Register every new model in src/utils/constants.jsThe TENANT_MODELS and CONTROL_MODELS arrays, frozen at the bottom of the file
Declare associations in centralAssociations.js, not in the modelsrc/architecture/centralAssociations.js, applyAssociations
Register every new model in the entity scope registrysrc/tests/utils/entityScopeRegistry.test.js
Scope queries with getScopedWheresrc/utils/tenantScope.js
Name migrations NNN_description.js or NNN_description.sqlsrc/architecture/migrationRunner.js:103
Put tests under src/tests/The jest.testMatch block in package.json

Reach the database through databaseExecutor

Controllers and services do not query models directly. They call the executor, and the executor handles the transaction, the schema binding, and the outlet filter.

const databaseExecutor = require('../core/database/databaseExecutor');
const result = await databaseExecutor.executeWithTenant(businessId, async (context) => {
const { transaction, transactionModels: models } = context;
const { InventoryItem } = models;
return InventoryItem.findAll({ where: { outletId }, transaction });
});

The callback context contains transaction, models, transactionModels, schemaName, and sequelize. models and transactionModels are the same object. Always pass transaction to your queries, or the work leaves the transaction.

MethodUse
executeWithTenant(tenantId, fn, options)Write work in a tenant schema, inside a transaction
readWithTenant(tenantId, fn, options)Read work in a tenant schema, no transaction
readWithCache(tenantId, key, fn, options)Read work through Redis, with in-flight deduplication
executeInPublic(fn, options)Write work in the public schema
readInPublic(fn)Read work in the public schema

For the control plane, either call executeInPublic or pass CONTROL_PLANE from src/utils/constants.js as the tenant id. executeWithTenant throws on 'public', 'health_check', and tenant_public*, so there is no quiet way to write to public by accident.

Import path rules. New code requires src/core/database/databaseExecutor.js. src/services/databaseExecutor.js is a deprecated re-export and logs nothing about being deprecated, so it is easy to keep using by mistake. Across src/, 37 of the 65 requires that name a databaseExecutor path still point at a services/databaseExecutor file. Do not add to that count.

For request-scoped code, use the helpers the tenant middleware attaches. They are defined in src/core/tenant/unifiedTenantMiddleware.js:451-482.

HelperEquivalent
req.executeWithTenant(fn)databaseExecutor.executeWithTenant(req.context.organizationId, fn)
req.readWithTenant(fn)databaseExecutor.readWithTenant(...)
req.readWithCache(tenantId, key, fn, options)databaseExecutor.readWithCache(...)

req.models also exists, but the header of src/routes/tenantRoutes.js marks it deprecated and removed in Phase 10. Use the callbacks.

Keep model imports out of controllers

The rule is a convention, not a lint rule, so check for it by hand. A search across src/controllers/ for model requires returns exactly one hit, src/controllers/tenantProvisionController.js:274, which requires the control plane index to read migration logs. Everything else goes through the executor.

Direct model imports break in two ways. A base tenant model is blocked from querying at all, because src/architecture/modelFactory.js:168-173 installs a guard that throws on findAll, create, update, destroy, and the other query methods for any model listed in TENANT_MODELS until it is bound with .schema(). And an unbound model resolves against whatever schema the pooled connection last used, which is the cross-tenant leak this architecture exists to prevent.

The response envelope

Every JSON response is { success, message, data }.

HelperSignature
sendSuccesssendSuccess(res, data, message = "OK", statusCode = 200)
sendErrorsendError(res, message = "Error occurred", data = null, statusCode = 400)

standardResponseMiddleware attaches these as res.sendSuccess and res.sendError. sendSuccess throws when data is null or undefined, so failures must go through sendError.

responseValidationMiddleware rewrites res.json so the envelope holds even when a handler does not build it. It sets success from the payload or from the status code, defaults message, and moves the remaining keys into data. Arrays passed straight to res.json become data. Model instances are converted with toJSON().

In practice controllers write the envelope by hand. src/controllers/ contains 417 res.json(...) calls and zero calls to sendSuccess or sendError. Both styles reach the client in the same shape, so match the file you are editing.

Three responses do not follow the envelope. The 404 catch-all at app.js:390 returns { error, message, availableRoutes, multiTenant }. The timeout middleware at app.js:213 returns { success: false, error, message }. The global error handler at app.js:411 returns { success: false, message } with no data key. Do not copy those shapes into new handlers.

Add a model

Seven steps, in order.

  1. Create the file in src/models/ for tenant data, or src/control_plane_models/ for public data.
  2. Export a factory function that receives sequelize and DataTypes. src/models/inventoryBatchModel.js is the pattern.
  3. Name the file from the model name. ModelFactory derives it by lowercasing the first character and appending Model, so InventoryBatch maps to inventoryBatchModel.js. src/architecture/modelFactory.js:513-523 holds the rule, and TenantAuditLog to tenantAuditLogModel is the one special case.
  4. Add the model name to the right array in src/utils/constants.js. Tenant models go in TENANT_MODELS. Control plane models go in CONTROL_MODELS.
  5. For a control plane model, also add a controlMapping entry in ModelFactory.setupModelDefinitions(). That mapping is explicit and lives at src/architecture/modelFactory.js:474-492. A model missing from it is skipped without an error.
  6. Use underscored: true and set field on every attribute whose column name differs, as the existing models do.
  7. Register the entity scope, covered below.

Two guards to know. setupModelDefinitions throws ARCHITECTURAL VIOLATION when a name appears in both arrays, at src/architecture/modelFactory.js:540-543. And both arrays are frozen with Object.freeze at the bottom of src/utils/constants.js, so you edit the literal, not push to it at runtime.

Failure mode to expect. The tenant loop catches a missing file and only logs TENANT model <name> not found in models directory. A typo in the file name gives you a model that is absent at runtime with no crash. The control plane loop rethrows instead.

Declare associations in centralAssociations.js

Associations are not set up in the model file. They live in src/architecture/centralAssociations.js and are applied per schema by applyAssociations(models, schemaName).

if (models.Order && !hasAssociation(models.Order, 'items', schemaName)) {
models.Order.hasMany(models.OrderItem, { foreignKey: 'orderId', as: 'items' });
applied.push('Order->items');
}

Keep the three parts of that pattern. Guard with hasAssociation(model, alias, schemaName), keep the applied list push, and wrap the block in the existing try and catch for that model. Use forceAssociationOverwrite(model, alias) when you re-declare an association that Sequelize may already hold on a subclass.

Do not add Model.associate = (models) => {} and expect it to run. src/middlewares/associationInitializer.js states that associations are handled by centralAssociations.js through tenantModelLoader and that it no longer calls model.associate(). Several model files still define an associate function, including src/models/inventoryBatchModel.js, and nothing in src/architecture/ invokes it.

centralAssociations.js also keeps a schema fingerprint per model set, so associations are applied once per schema and reused for later requests.

Register the entity scope

src/utils/entityScopeRegistry.js opens with the rule that every model must be registered there. Each entry is one of three values.

ScopeMeaning
outletData varies per outlet. Queries must carry an outlet filter
businessData belongs to the business as a whole
organizationData belongs to an organization in the hierarchy

This is the one rule in this document with a test behind it. src/tests/utils/entityScopeRegistry.test.js walks src/models, strips Model.js from each file name, and fails unless ENTITY_SCOPES has the matching entry. Its second test rejects any value outside the three.

getScope throws ENTITY_SCOPE_MISSING for anything unregistered, and src/utils/tenantScope.js calls it during query scoping, so an unregistered model fails at request time before it fails in the test.

Scope every query

getScopedWhere(req, modelName) from src/utils/tenantScope.js builds the where clause for a request. src/controllers/tenant/orderController.js:8 imports it.

What it returns depends on the scope registered for that model.

ScopeReturned filter
organization{}, because the schema itself is the boundary
business{ businessId }, and never an outlet filter
outletAn outlet filter built from req.outletId, or from the first of req.allowedOutletIds

Pass the model name or it throws SCOPE_MISSING_MODEL_NAME. Pass an unregistered name and getScope throws ENTITY_SCOPE_MISSING.

Multi-outlet reads have their own gate. getMultiOutletWhere(req, baseWhere, options) throws UnauthorizedMultiOutletError when the user’s role is not in MULTI_OUTLET_ALLOWED_ROLES and does not match requireRole. The allowed list is SUPER_ADMIN, ADMIN, OWNER, and HEAD_OFFICE, and the minimum role defaults to ADMIN. Both come from ENFORCEMENT_CONFIG at the top of the file.

Be careful about the quieter paths. When no outlet is resolved, getScopedWhere logs outletScope is 'outlet_specific' but no outlet resolved and returns a filter without an outlet key. When a business-scoped model has no businessId in the request, it logs and returns {}. Both are warnings, not throws. Read the log when a query looks wider than it should.

executeWithTenant adds a second layer. When you pass options.outletId, it wraps the model set in a Proxy that injects outletId into where for every model with an outlet column, and it never touches create or bulkCreate. That injection is skipped for the control plane, and it is skipped entirely when you do not pass options.outletId. Passing it is the cheaper habit.

Write and run a migration

Migration files live in src/migrations/tenant/. They run against one tenant schema at a time.

The file name carries the version. src/architecture/migrationRunner.js:103 parses it with /^(v|0+)(\d+)/i, sorts the directory by file name, and applies a file when its number is greater than the schema’s current version.

A JavaScript migration exports one object.

module.exports = {
name: '033_fefo_batch_columns',
async up({ queryInterface, schema, sequelize }) { /* ... */ },
async down({ queryInterface, schema }) { /* ... */ },
};

src/migrations/tenant/033_fefo_batch_columns.js is a full example. It uses queryInterface.sequelize.query with ALTER TABLE ... ADD COLUMN IF NOT EXISTS, and it checks pg_enum before altering an enum type, because ALTER TYPE cannot run with IF NOT EXISTS inside a transaction. Its header states that every statement uses IF NOT EXISTS or safe DDL so a re-run is harmless. Follow that, because the runner applies a file whenever the recorded version is behind it and gives you no undo.

SQL files are supported alongside JavaScript. The runner strips line and block comments and splits the file on semicolons.

The current version of a schema is MAX(version) from <schema>.schema_versions, read by getCurrentVersion at src/architecture/migrationRunner.js:24. Recorded versions come from recordVersion just below it.

Number files so each version is unique. The runner skips a file when its version is less than or equal to the current one, so a second file with the same number never runs. Four pairs in the folder already collide.

VersionFiles
21021_product_availability_enhanced.js, 021_stock_alerts.js
22022_modifier_recipes.js, 022_product_inventory_orchestration.js
33033_fefo_batch_columns.js, 033_inventory_transaction_variant_id.js
34034_inventory_action_approvals.js, 034_nullable_inventory_transaction_id.js

Fix this by renumbering the later file to the next free number. Do not assume both ran.

Three ways a migration reaches a schema.

PathCommand or triggerRunner
StartupAutomatic at boot, skipped when SKIP_STARTUP_MIGRATIONS=truesrc/architecture/startupMigration.js calls migrationRunner.runPendingMigrations for each active tenant
CLInode src/scripts/runTenantMigrations.js with --tenant, --to, --dry-run, --status, or --repairsrc/migrations/tenantMigrationEngine.js, which uses Umzug and SequelizeStorage
Schema creationnpm run migrate:tenantsrc/scripts/initTenantSchema.js, which creates the schema and syncs models

The third row is the trap. npm run migrate:tenant does not run files in src/migrations/tenant/. It creates a schema and calls tenantModelLoader.initializeTenantSchema, which syncs models onto the schema. To apply a new migration file to existing tenants, rely on the startup run or call src/scripts/runTenantMigrations.js.

Note also that the two runners track different state. migrationRunner.js reads and writes <schema>.schema_versions. The Umzug engine writes through SequelizeStorage. A migration applied by one path is not recorded by the other.

The CLI header in src/scripts/runTenantMigrations.js documents its own usage as node scripts/runTenantMigrations.js. The file lives one directory deeper, at src/scripts/runTenantMigrations.js, so copy the path in the table above.

On an empty schema the runner does not migrate. startupMigration.js checks the version first, and a schema at version 0 gets tenantModelLoader.initializeTenantSchema instead, because a migration numbered above 1 assumes the base tables exist.

Tests

All Jest tests live under src/tests/. There is no tests/ directory at the repo root.

package.json drives Jest. testMatch is **/src/tests/**/*.test.js and **/src/scripts/**/*.test.js. setupFilesAfterEnv is ./src/tests/setup.js, which loads .env.test, sets NODE_ENV=test, mocks razorpay and @aws-sdk/client-s3, and sets a 30 second timeout.

DirectoryJest filesCovers
src/tests/integration/10Order payment, recipe and inventory, tenant flow, tenant provisioning, setup flow
src/tests/regression/3Orders, payments, auth
src/tests/unit/2Inventory variant reversal, tax calculator
src/tests/utils/2tenantScope, entityScopeRegistry
src/tests/security/1tenantIsolationSecurity.test.js. roleSegregationTests.js sits beside it and is not collected, because the name does not end in .test.js
src/tests/franchise/1Franchise behavior
src/tests/automation/1system-validation.test.js, plus runTests.js, which is a script and not a Jest test
src/tests/ root3system-validation.test.js, tenantIsolation.test.js, dataFirstCompliance.test.js

src/tests/e2e/ and src/tests/load/ hold plain scripts with no .test.js files, so Jest does not collect them.

Commands whose targets exist and that are safe to reach for:

CommandNotes
npm testjest --detectOpenHandles --forceExit. The testMatch patterns collect the files under src/tests/
npx jest src/tests/unitOne directory. Jest takes a positional argument as a path pattern
npx jest src/tests/utils/entityScopeRegistry.test.jsOne file, and a useful gate before committing a model
npx jest --coverageCoverage, written to coverage/
npm run check:syntaxParses every file under src/ and app.js without executing it, and exits 1 on a parse failure
npm run test:smoke, test:admin, test:inventory, test:accounting, test:staffLive API smoke scripts. docs/LOCAL_SETUP.md section 6 documents the local Postgres setup they expect, and they refuse to run against a non-local host

Do not reach for the test:unit, test:integration, test:security, test:regression, test:automation, test:onboarding, test:db, or lint scripts. They point at paths that do not exist. The next section has the evidence.

Commands that do not work

Each row was checked by looking for the target on disk.

ScriptCommandWhy it fails
npm run test:unitjest tests/unit --forceExitNo tests/ directory exists at the repo root. The real path is src/tests/unit
npm run test:integrationjest tests/integration --forceExitSame. The real path is src/tests/integration
npm run test:securityjest tests/security --forceExitSame. The real path is src/tests/security
npm run test:regressionjest tests/regression --forceExitSame. The real path is src/tests/regression
npm run test:automationnode tests/automation/runTests.jsThe file is at src/tests/automation/runTests.js
npm run test:automation:fixAUTO_FIX=true node tests/automation/runTests.jsSame missing target
npm run test:onboardingSKIP_RESET=true SKIP_API=true node tests/automation/runTests.jsSame missing target
npm run test:dbSKIP_RESET=true SKIP_ONBOARDING=true SKIP_API=true node tests/automation/runTests.jsSame missing target
npm run linteslint src/ tests/eslint is not in devDependencies and is not installed in node_modules, and the repo has no .eslintrc or eslint.config.js

The Jest rows fail as “no tests found”, because Jest treats the argument as a path pattern and nothing matches. The automation rows fail as a missing module on node. The lint row fails before it starts, because the eslint binary is absent.

Two smaller documentation drifts, worth knowing so you do not chase them.

AGENTS.md in the backend says domain context lives in CONTEXT.md and docs/adr/ at the repo root. Neither exists. docs/agents/ does exist and holds domain.md, issue-tracker.md, and triage-labels.md.

npm run migrate:tenant is named as though it runs migrations. It does not. See the migration section above.

Sources

  • brewlabs-billu-backend/AGENTS.md
  • brewlabs-billu-backend/package.json
  • brewlabs-billu-backend/app.js
  • brewlabs-billu-backend/src/core/database/databaseExecutor.js
  • brewlabs-billu-backend/src/core/database/index.js
  • brewlabs-billu-backend/src/services/databaseExecutor.js
  • brewlabs-billu-backend/src/architecture/modelFactory.js
  • brewlabs-billu-backend/src/architecture/centralAssociations.js
  • brewlabs-billu-backend/src/architecture/migrationRunner.js
  • brewlabs-billu-backend/src/architecture/startupMigration.js
  • brewlabs-billu-backend/src/architecture/databaseMiddlewareChain.js
  • brewlabs-billu-backend/src/migrations/tenant/033_fefo_batch_columns.js
  • brewlabs-billu-backend/src/migrations/tenantMigrationEngine.js
  • brewlabs-billu-backend/src/scripts/initTenantSchema.js
  • brewlabs-billu-backend/src/scripts/runTenantMigrations.js
  • brewlabs-billu-backend/src/scripts/start-parallel.js
  • brewlabs-billu-backend/src/models/inventoryBatchModel.js
  • brewlabs-billu-backend/src/utils/constants.js
  • brewlabs-billu-backend/src/utils/standardResponse.js
  • brewlabs-billu-backend/src/utils/entityScopeRegistry.js
  • brewlabs-billu-backend/src/utils/tenantScope.js
  • brewlabs-billu-backend/src/middlewares/associationInitializer.js
  • brewlabs-billu-backend/src/middlewares/safeControllerWrapper.js
  • brewlabs-billu-backend/src/core/tenant/unifiedTenantMiddleware.js
  • brewlabs-billu-backend/src/controllers/tenantProvisionController.js
  • brewlabs-billu-backend/src/controllers/menuPermissionController.js
  • brewlabs-billu-backend/src/controllers/tenant/orderController.js
  • brewlabs-billu-backend/src/controllers/tenant/inventoryController.js
  • brewlabs-billu-backend/src/routes/tenantRoutes.js
  • brewlabs-billu-backend/src/routes/permissionRoutes.js
  • brewlabs-billu-backend/src/tests/setup.js
  • brewlabs-billu-backend/src/tests/utils/entityScopeRegistry.test.js
  • brewlabs-billu-backend/scripts/check-syntax.js
  • brewlabs-billu-backend/docs/LOCAL_SETUP.md