On this page
Building modules
The Pamodzi e-Services platform is one product deployed for many ministries, departments and agencies (MDAs). Its core is always on and MDA-neutral: cases and workflow, the service designer, payments, documents, users and policy, audit, notifications, jobs, reporting and the partner API. Everything that belongs to one ministry lives in an optional module that an administrator switches on.
This guide explains when a module is the right tool, what a module is made of, and how to build, switch on, test and release one. The full reference is zcm-backend/docs/modules/README.md in the platform repository; the worked example is zcm-backend/modules/example.
Configuration, capability or module?
Choose the smallest thing that does the job. The platform's rule is "services are configuration, not code".
| You need | Build | Example |
|---|---|---|
| A new e-Service with a form, a process, fees, required documents and output templates | Configuration in the back office service designer. No code. | A new permit whose steps are review, inspection and decision |
| Behaviour that more than one MDA could use | A reusable capability: generic code, switched on per service through a service capability or setting | The shop (goods-sale), the allocation ledger, name reservation, inspections, enforcement cases, the geometry field |
| Behaviour, records or screens that belong to one ministry's mandate | A module | Rentals and leases, forestry concessions, co-operatives, labour registers |
Ask these questions in order:
- Can the service designer already express it? Then configure it.
- Could another ministry need the same thing tomorrow? Then build it generically and activate it per service.
- Only if both answers are no, build a ministry module.
The shop is the model of a reusable capability. A shop is any service with the goods-sale capability, in any ministry. Products, stock and orders are scoped to the service's ministry on the server, and the portal shows one generic, data-driven page whose title is the service's name. No ministry or kind of goods is named in code.
Anatomy of a module
A module owns its own folders. It needs no edit outside them, except one registry line per app, which the generators add for you.
zcm-backend/modules/<id>/ module.js (manifest), policy.js, contributions.js, routes/, controllers/,
lib/, models/, jobs/, test/, 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>/ generic, data-driven contributions only (no pages)
| Part | Where | Rule |
|---|---|---|
| Manifest | module.js | A plain object validated at boot; an invalid manifest stops the process. id, name, version (semver) and kind (ministry or platform) are required. |
| Tables | tables in the manifest, models in models/, migrations in the shared folder | Every table the module owns is listed. Migrations always run, whether the module is on or off. |
| Routes | routes in the manifest | Mounted under /api/modules/<id> behind the module gate. Every route names one of the module's policy actions. |
| Policy rules | policy.js | Every action starts with <id>. and has a label. |
| Jobs | jobs in the manifest | Scheduled only while the module is on. Must be idempotent. |
| Menu entries | menu in the manifest | Back-office entries under Ministry functions, each tied to a policy action. |
| Contributions | contributions.js | What the module adds to core screens and engines, through extension points. |
| Settings | settings in the manifest | Per installation, overridable per MDA, validated on the server. Never secrets. |
| Back-office screens | back-office-ui/src/modules/<id>/ | Pages, routes and menu entries; keys must match the backend manifest. |
A trimmed manifest, based on the generated example:
module.exports = {
id: 'fisheries',
name: 'Fisheries licensing',
version: '0.1.0',
kind: 'ministry',
description: 'Fishing licences and catch returns.',
dependsOn: [],
defaultEnabled: false, // keep it false: modules are off by default
tables: ['fisheries_records'],
routes: [{ mount: '/api/modules/fisheries', router: () => require('./routes') }],
policy: () => require('./policy').RULES,
jobs: [{ name: 'fisheries-daily', cron: '30 5 * * *', tz: 'Africa/Lusaka', catchUp: false,
run: () => require('./jobs/daily').run() }],
serviceCapabilities: [],
contributes: () => require('./contributions'),
menu: [{ group: 'Fisheries licensing', key: 'fisheries.records', label: 'Records',
path: '/m/fisheries/records', action: 'fisheries.read' }],
portal: [], // the public portal is ministry-neutral
pos: { capabilities: [] },
settings: { pageTitle: { type: 'string', label: 'Title of the records page', maxLength: 80 } },
};
Generate a module
The generators write a module that passes every check straight away.
cd zcm-backend
node scripts/newModule.js fisheries --name "Fisheries licensing" --description "Fishing licences and catch returns."
cd ../back-office-ui && node scripts/newModule.js fisheries --name "Fisheries licensing"
# only if the module adds generic blocks to the portal:
cd ../public-portal-ui && node scripts/newModule.js fisheries --name "Fisheries licensing"
The backend generator writes modules/fisheries/, a migration migrations/<timestamp>-fisheries-init.js, adds the module to modules/index.js and refreshes docs/modules/module-keys.json. It refuses an id that is not ^[a-z][a-z0-9-]{1,30}$, is reserved, is already used, is the prefix of a core policy action, has a core route at /api/<id>, or clashes with an existing migration, model, table or job. Useful options: --depends <ids>, --dry-run and --no-register. After a later menu or portal change in the backend manifest, run node scripts/newModule.js --sync-keys ../zcm-backend in each front-end app.
Then make it yours: change the model and add migrations, add routes and policy actions, contribute to extension points, build back-office pages with the UI kit in src/components/ui, and extend the tests.
Switching a module on and off
A module is on for a caller only when every level says yes:
- Deployment ceiling.
MODULES_AVAILABLE(a comma list; unset means every bundled module except samples inproduction) and the kill switch
MODULES_FORCE_OFF.MODULES_BY_MDAlimits which ministries may have which module. - Installation. An administrator switches it on under System administration → Modules, with a reason of at
least 8 characters. A second administrator approves the first switch-on and every switch-off (maker-checker).
- Ministry assignment. Scope
all, or assigned to chosen MDAs.
A module whose dependency is off is off too. Switching needs no restart: the state is cached for 30 seconds and refreshed across replicas.
Off means absent. While a module is off, for the installation or for the caller's ministry:
- every route answers 404 with
{ "code": "MODULE_DISABLED", "module": "<id>" }, for every method and everycaller, anonymous included;
- its policy actions are refused at step 0 of the policy decision point;
- its jobs are skipped (recorded as
skipped); - its menu entries, dashboard widgets and other contributions disappear;
- a service with a capability the module owns is hidden from the catalogue and refuses new applications with
409
SERVICE_MODULE_DISABLED.
Data stays. Tables and rows are kept; switching the module on again restores everything. A switch-off shows an impact preview (the manifest's impact function supplies the module's own figures) and asks for the module id to be typed in.
Money is never lost. A payment confirmation that arrives after a module was switched off is still finalised by that module, through the bypass points payment.onFinalised and payment.finaliseExternalInvoice. Such a run is audited as module.bypass. Personal-data registries (masking, deletion, a person's data export) also read every bundled module, on or off.
Rules that the checks enforce
- Core never names a ministry.
npm run check:mdafails on ministry names, ministry hosts and historicalministry service ids in core code. Ministry values belong in module folders, settings or data.
- Core never requires a module, and modules never require each other.
npm run check:modules(
scripts/check-module-boundaries.js) fails on such arequire. Modules talk to core and to each other only through extension points. - Do not add module logic to core files such as
caseControllerorcaseWorkflow. If you need a new hook, add ageneric extension point (see Extension points).
- The portal stays generic. A module adds no page, route, menu entry or My Workplace section to the public portal.
It may add only generic, data-driven blocks such as status words, fee texts or My records lines. A guard test (
portalNeutrality.test.js) with an allow-list enforces this; do not add to the allow-list. - Authorise on the server. Every route names a policy action with
can('<id>.<action>'), and every route thatchanges data writes a named audit record. Hiding a button is not authorisation.
Testing
cd zcm-backend
npm test # unit tests and test/modules.contract.test.js
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
Integration tests must pass in both schema modes: the schema built from the models, and the schema built from the migrations (TEST_SCHEMA=migrations). The integration harness switches modules for a test:
const { setup, teardown } = require('../../../../test/integration/harness');
const h = await setup();
await h.enableModule('fisheries', { mdaId }); // on, assigned to one ministry
await h.disableModule('fisheries'); // off, rows kept
Test at least: off means 404; on serves the assigned ministry's officer while another ministry's officer gets 404; citizens are refused. Anything that touches money, a status or a permission needs an integration test that proves the abuse case is refused and the legitimate path works, including the money rule (a late payment after switch-off is still finalised). Run a local PostgreSQL with PostGIS; never point tests at a shared server.
A service that a module adds or changes also needs its Playwright end-to-end journey in e2e/, on both seed profiles (E2E_SEED_PROFILE=mihud and neutral), and in the federated topology when the service can live on a node.
Release checklist
- Personal data:
pii.*,dsr.exportSections,anonymise.columnsandsoftDelete.kindscontributions where themodule holds personal data, and a data protection impact note.
- Retention class for every table.
- Every state change audited; every route names a policy action.
- Money only through payment points, inside the caller's transaction, with tests for atomicity and the money rule.
- Portal texts (generic blocks only) in English, with ny, bem and toi translations requested.
- Contract tests green in all three apps;
module-keys.jsonsynced. - A switch-on plan: which MDAs, which settings, who approves.
Take the Module developer course to practise all of this and earn the certificate.