Anatomy of a module and the generator
A module spans up to three apps: the API (zcm-backend), the back office (back-office-ui) and, only for generic blocks, the public portal (public-portal-ui). In this lesson you learn where each part lives, what the manifest says, and how to generate a working module in about ten minutes.
Where a module lives
zcm-backend/modules/<id>/ module.js (manifest), policy.js, contributions.js, routes/, controllers/,
lib/, models/, jobs/, test/ (unit), test/integration/, README.md
zcm-backend/migrations/<ts>-<id>-<what>.js its migrations, in the ONE migrations folder
back-office-ui/src/modules/<id>/ index.js, api.js, pages/, tests
public-portal-ui/src/modules/<id>/ index.js (generic, data-driven contributions only), tests
A new module needs no edit outside its own folders, except one line in each registry: modules/index.js in the backend and src/modules/registry.js in each front end. The generators add that line for you. Being listed in a registry makes a module bundled. Bundled does not mean on.
The manifest
modules/<id>/module.js exports a plain object. lib/modules/manifest.js validates it at boot, and an invalid manifest stops the process. Functions in it are called lazily, so nothing loads until it is needed.
| Key | Meaning |
|---|---|
id | Required. ^[a-z][a-z0-9-]{1,30}$. The folder name, and the prefix of every policy action, menu key and portal key. |
name, version, kind | Required. version is semver; kind is ministry or platform. |
description | Shown to administrators before they switch the module on (at least 10 characters). |
dependsOn | Other module ids. They must be on first and cannot be switched off while this one is on. |
defaultEnabled | Default false. Keep it false. |
tables | The tables the module owns, each with a model in models/. |
routes | [{ mount, router }], mounted at boot behind the module gate, by convention at /api/modules/<id>. |
policy | The module's policy rules; every action starts with <id>.. |
jobs | [{ name, cron, tz, catchUp, run }], scheduled only while the module is on. |
serviceCapabilities | Capabilities the module owns; the service designer offers them only while it is on. |
contributes | () => require('./contributions'): the extension point contributions. |
menu | Back-office entries: key starts with <id>., path under /m/<id>/, action must exist. |
portal | Keep it []. Every portal slot is an exception the product owner decides. |
pos | { capabilities: [] } unless the module has POS screens. |
settings | A settings schema, per installation and overridable per MDA. |
impact | The module's own figures in the switch-off impact preview, for example "active leases". |
health | Read-only state shown under System administration โ Modules. |
A real manifest from the labour module shows the shape (trimmed):
module.exports = {
id: 'labour',
name: 'Labour',
version: '1.0.0',
kind: 'ministry',
dependsOn: [],
defaultEnabled: false,
tables: ['labour_records', 'labour_establishments', 'labour_agreements', 'labour_complaints'],
routes: [{ mount: '/api/modules/labour', router: () => require('./routes') }],
policy: () => require('./policy').RULES,
jobs: [{ name: 'labour-daily', cron: '45 5 * * *', tz: 'Africa/Lusaka', catchUp: false,
run: () => require('./jobs/daily').run() }],
contributes: () => require('./contributions'),
menu: [{ group: 'Labour', key: 'labour.establishments', label: 'Register',
path: '/m/labour/establishments', action: 'labour.read', order: 1 }],
portal: [],
pos: { capabilities: [] },
};
Generate the backend part
The generator writes a module that passes every check at once.
cd zcm-backend
node scripts/newModule.js fisheries --name "Fisheries licensing" \
--description "Fishing licences and catch returns for the Department of Fisheries."
It writes:
modules/fisheries/module.js: off by default, one route, one job, one menu entry, no portal slot;policy.jswithfisheries.read(staff) andfisheries.write(officers);routes/index.jswithGET /api/modules/fisheries/recordsbehindcan('fisheries.read');- a controller, a model, a daily job, an empty
contributions.jsand a README; - a unit test and an integration test that switches the module on with
h.enableModule; migrations/<timestamp>-fisheries-init.jscreating the module's table.
It adds require('./fisheries/module') to modules/index.js and refreshes docs/modules/module-keys.json.
The generator refuses an id that does not match the pattern or is reserved (me, admin, core and others), is already bundled or already a folder, is the prefix of a core policy action (case, payment and so on), has a core route at /api/<id>, or clashes with an existing migration, model, table or job.
Useful options: --depends estate (needs another module), --dry-run (list the files without writing) and --no-register (skip the registry line).
Generate the front-end parts
Use the same id in each app:
cd ../back-office-ui && node scripts/newModule.js fisheries --name "Fisheries licensing"
cd ../public-portal-ui && node scripts/newModule.js fisheries --name "Fisheries licensing" # only for generic blocks
After any later change to the backend manifest's menu or portal entries, sync the keys in each front end:
node scripts/newModule.js --sync-keys ../zcm-backend
Each app's moduleContract.test.js compares its registry with module-keys.json, so a menu key or path that does not match the backend fails the build.
The back-office part
back-office-ui/src/modules/<id>/index.js exports the module's routes, menu entries and optional contributions such as dashboardWidgets, caseTabs, binTypes, feeTypes and stepTypes. Core screens ask useModules() for contributions and never import a module. A module renders only when GET /api/modules/me lists it, and a menu entry shows only when the server lists its key: the server's policy decides, not the front end. Pages use the shared UI kit in src/components/ui (PageHeader, Card, Table, Button, Banner, EmptyState, Dialog). Entries default to the Ministry functions section of the menu.
Run the checks
cd zcm-backend
npm test
npm run test:integration -- modules/fisheries/test/integration/fisheries.test.js
TEST_SCHEMA=migrations npm run test:integration -- modules/fisheries/test/integration/fisheries.test.js
npm run db:schema-diff && npm run check:modules && npm run check:mda
Point DATABASE_URL and TEST_DATABASE_ADMIN_URL at your own local PostgreSQL with PostGIS first. Never use a shared server.
Check your understanding
- Which files outside a module's own folders does a new module touch, and who edits them?
- What happens at boot when a manifest is invalid?
- Name four reasons the generator refuses a module id.
- Why should
portalstay an empty list? - Which test catches a back-office menu key that does not match the backend manifest?
Sign in to record your progress.
Previous: When to build a module Next: Policy, routes, data, jobs and settings