An official developer portal of the Government of the Republic of Zambia · Test environmentVerify a certificate
Pamodzi Developer HubBuild, integrate and learn the e-Services framework
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 needBuildExample
A new e-Service with a form, a process, fees, required documents and output templatesConfiguration 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 useA reusable capability: generic code, switched on per service through a service capability or settingThe shop (goods-sale), the allocation ledger, name reservation, inspections, enforcement cases, the geometry field
Behaviour, records or screens that belong to one ministry's mandateA moduleRentals and leases, forestry concessions, co-operatives, labour registers

Ask these questions in order:

  1. Can the service designer already express it? Then configure it.
  2. Could another ministry need the same thing tomorrow? Then build it generically and activate it per service.
  3. 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)
PartWhereRule
Manifestmodule.jsA plain object validated at boot; an invalid manifest stops the process. id, name, version (semver) and kind (ministry or platform) are required.
Tablestables in the manifest, models in models/, migrations in the shared folderEvery table the module owns is listed. Migrations always run, whether the module is on or off.
Routesroutes in the manifestMounted under /api/modules/<id> behind the module gate. Every route names one of the module's policy actions.
Policy rulespolicy.jsEvery action starts with <id>. and has a label.
Jobsjobs in the manifestScheduled only while the module is on. Must be idempotent.
Menu entriesmenu in the manifestBack-office entries under Ministry functions, each tied to a policy action.
Contributionscontributions.jsWhat the module adds to core screens and engines, through extension points.
Settingssettings in the manifestPer installation, overridable per MDA, validated on the server. Never secrets.
Back-office screensback-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:

  1. Deployment ceiling. MODULES_AVAILABLE (a comma list; unset means every bundled module except samples in

    production) and the kill switch MODULES_FORCE_OFF. MODULES_BY_MDA limits which ministries may have which module.

  2. 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).

  3. 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 every

    caller, 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:mda fails on ministry names, ministry hosts and historical

    ministry 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 a require. Modules talk to core and to each other only through extension points.

  • Do not add module logic to core files such as caseController or caseWorkflow. If you need a new hook, add a

    generic 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 that

    changes 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.columns and softDelete.kinds contributions where the

    module 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.json synced.
  • A switch-on plan: which MDAs, which settings, who approves.

Take the Module developer course to practise all of this and earn the certificate.