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.
| Rule | Enforced or shown by |
|---|---|
Reach data through databaseExecutor, never a bare model | src/core/database/databaseExecutor.js, src/controllers/tenant/inventoryController.js:183 |
| Do not import Sequelize models in a controller | src/controllers/ contains one model import in total, at src/controllers/tenantProvisionController.js:274 |
Answer with the { success, message, data } envelope | src/utils/standardResponse.js |
Register every new model in src/utils/constants.js | The TENANT_MODELS and CONTROL_MODELS arrays, frozen at the bottom of the file |
Declare associations in centralAssociations.js, not in the model | src/architecture/centralAssociations.js, applyAssociations |
| Register every new model in the entity scope registry | src/tests/utils/entityScopeRegistry.test.js |
Scope queries with getScopedWhere | src/utils/tenantScope.js |
Name migrations NNN_description.js or NNN_description.sql | src/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.
| Method | Use |
|---|---|
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.
| Helper | Equivalent |
|---|---|
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 }.
| Helper | Signature |
|---|---|
sendSuccess | sendSuccess(res, data, message = "OK", statusCode = 200) |
sendError | sendError(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.
- Create the file in
src/models/for tenant data, orsrc/control_plane_models/forpublicdata. - Export a factory function that receives
sequelizeandDataTypes.src/models/inventoryBatchModel.jsis the pattern. - Name the file from the model name.
ModelFactoryderives it by lowercasing the first character and appendingModel, soInventoryBatchmaps toinventoryBatchModel.js.src/architecture/modelFactory.js:513-523holds the rule, andTenantAuditLogtotenantAuditLogModelis the one special case. - Add the model name to the right array in
src/utils/constants.js. Tenant models go inTENANT_MODELS. Control plane models go inCONTROL_MODELS. - For a control plane model, also add a
controlMappingentry inModelFactory.setupModelDefinitions(). That mapping is explicit and lives atsrc/architecture/modelFactory.js:474-492. A model missing from it is skipped without an error. - Use
underscored: trueand setfieldon every attribute whose column name differs, as the existing models do. - 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.
| Scope | Meaning |
|---|---|
outlet | Data varies per outlet. Queries must carry an outlet filter |
business | Data belongs to the business as a whole |
organization | Data 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.
| Scope | Returned filter |
|---|---|
organization | {}, because the schema itself is the boundary |
business | { businessId }, and never an outlet filter |
outlet | An 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.
| Version | Files |
|---|---|
| 21 | 021_product_availability_enhanced.js, 021_stock_alerts.js |
| 22 | 022_modifier_recipes.js, 022_product_inventory_orchestration.js |
| 33 | 033_fefo_batch_columns.js, 033_inventory_transaction_variant_id.js |
| 34 | 034_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.
| Path | Command or trigger | Runner |
|---|---|---|
| Startup | Automatic at boot, skipped when SKIP_STARTUP_MIGRATIONS=true | src/architecture/startupMigration.js calls migrationRunner.runPendingMigrations for each active tenant |
| CLI | node src/scripts/runTenantMigrations.js with --tenant, --to, --dry-run, --status, or --repair | src/migrations/tenantMigrationEngine.js, which uses Umzug and SequelizeStorage |
| Schema creation | npm run migrate:tenant | src/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.
| Directory | Jest files | Covers |
|---|---|---|
src/tests/integration/ | 10 | Order payment, recipe and inventory, tenant flow, tenant provisioning, setup flow |
src/tests/regression/ | 3 | Orders, payments, auth |
src/tests/unit/ | 2 | Inventory variant reversal, tax calculator |
src/tests/utils/ | 2 | tenantScope, entityScopeRegistry |
src/tests/security/ | 1 | tenantIsolationSecurity.test.js. roleSegregationTests.js sits beside it and is not collected, because the name does not end in .test.js |
src/tests/franchise/ | 1 | Franchise behavior |
src/tests/automation/ | 1 | system-validation.test.js, plus runTests.js, which is a script and not a Jest test |
src/tests/ root | 3 | system-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:
| Command | Notes |
|---|---|
npm test | jest --detectOpenHandles --forceExit. The testMatch patterns collect the files under src/tests/ |
npx jest src/tests/unit | One directory. Jest takes a positional argument as a path pattern |
npx jest src/tests/utils/entityScopeRegistry.test.js | One file, and a useful gate before committing a model |
npx jest --coverage | Coverage, written to coverage/ |
npm run check:syntax | Parses 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:staff | Live 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.
| Script | Command | Why it fails |
|---|---|---|
npm run test:unit | jest tests/unit --forceExit | No tests/ directory exists at the repo root. The real path is src/tests/unit |
npm run test:integration | jest tests/integration --forceExit | Same. The real path is src/tests/integration |
npm run test:security | jest tests/security --forceExit | Same. The real path is src/tests/security |
npm run test:regression | jest tests/regression --forceExit | Same. The real path is src/tests/regression |
npm run test:automation | node tests/automation/runTests.js | The file is at src/tests/automation/runTests.js |
npm run test:automation:fix | AUTO_FIX=true node tests/automation/runTests.js | Same missing target |
npm run test:onboarding | SKIP_RESET=true SKIP_API=true node tests/automation/runTests.js | Same missing target |
npm run test:db | SKIP_RESET=true SKIP_ONBOARDING=true SKIP_API=true node tests/automation/runTests.js | Same missing target |
npm run lint | eslint 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.mdbrewlabs-billu-backend/package.jsonbrewlabs-billu-backend/app.jsbrewlabs-billu-backend/src/core/database/databaseExecutor.jsbrewlabs-billu-backend/src/core/database/index.jsbrewlabs-billu-backend/src/services/databaseExecutor.jsbrewlabs-billu-backend/src/architecture/modelFactory.jsbrewlabs-billu-backend/src/architecture/centralAssociations.jsbrewlabs-billu-backend/src/architecture/migrationRunner.jsbrewlabs-billu-backend/src/architecture/startupMigration.jsbrewlabs-billu-backend/src/architecture/databaseMiddlewareChain.jsbrewlabs-billu-backend/src/migrations/tenant/033_fefo_batch_columns.jsbrewlabs-billu-backend/src/migrations/tenantMigrationEngine.jsbrewlabs-billu-backend/src/scripts/initTenantSchema.jsbrewlabs-billu-backend/src/scripts/runTenantMigrations.jsbrewlabs-billu-backend/src/scripts/start-parallel.jsbrewlabs-billu-backend/src/models/inventoryBatchModel.jsbrewlabs-billu-backend/src/utils/constants.jsbrewlabs-billu-backend/src/utils/standardResponse.jsbrewlabs-billu-backend/src/utils/entityScopeRegistry.jsbrewlabs-billu-backend/src/utils/tenantScope.jsbrewlabs-billu-backend/src/middlewares/associationInitializer.jsbrewlabs-billu-backend/src/middlewares/safeControllerWrapper.jsbrewlabs-billu-backend/src/core/tenant/unifiedTenantMiddleware.jsbrewlabs-billu-backend/src/controllers/tenantProvisionController.jsbrewlabs-billu-backend/src/controllers/menuPermissionController.jsbrewlabs-billu-backend/src/controllers/tenant/orderController.jsbrewlabs-billu-backend/src/controllers/tenant/inventoryController.jsbrewlabs-billu-backend/src/routes/tenantRoutes.jsbrewlabs-billu-backend/src/routes/permissionRoutes.jsbrewlabs-billu-backend/src/tests/setup.jsbrewlabs-billu-backend/src/tests/utils/entityScopeRegistry.test.jsbrewlabs-billu-backend/scripts/check-syntax.jsbrewlabs-billu-backend/docs/LOCAL_SETUP.md