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

Plugging into core through extension points

A module that only has its own pages is an island. Real modules change what happens when an application is submitted, add values to a permit, work out a fee or show a figure on the dashboard. They do all of this through extension points, without editing a single core file. In this lesson you learn how points are called, how to contribute safely, and how to propose a new one.

Why extension points

Core never requires a module (npm run check:modules fails if it does). Instead, core calls a point by name:

const points = require('../lib/modules/points');
const quote = await points.first('payment.quote', { kind, case: caseRow, service, params, user, transaction });

Every module that is on for the caller and contributes to payment.quote gets a chance to answer. The points are declared in one list, POINTS in zcm-backend/lib/modules/points.js, and documented in section 9 of docs/modules/README.md. The platform has close to seventy of them, covering applications, payments, documents, citizen views, the back office, personal data, maps and the laboratory.

Contributing

A module lists its contributions in contributions.js:

// modules/fisheries/contributions.js
module.exports = {
  'dashboard.widgets': async ({ mdaId }) => {
    const { FisheriesRecord } = require('../../models');
    return [{ key: 'fisheries.records', label: 'Fisheries records',
              data: { count: await FisheriesRecord.count({ where: mdaId ? { mdaId } : {} }) } }];
  },

  'documents.values': async ({ case: c, service }) => {
    if (!isOurService(service)) return undefined;             // not ours: add nothing
    return { CatchZone: await zoneLabelFor(c) };              // a new name on the output document
  },

  'case.onSubmit': async ({ case: c, service, transaction }) => {
    if (!isOurService(service)) return;
    await reserveLicenceNumber(c, { transaction });           // inside the submission's transaction
  },
};

The manifest points at it with contributes: () => require('./contributions'). A contribution to a point name that does not exist fails manifest validation.

The calling modes

ModeCallWhat you return
eachpoints.run(name, ctx)Anything; every contribution runs in dependency order.
each, isolatedpoints.runIsolated(name, ctx)Anything; each contribution runs in its own savepoint. A failure is rolled back alone and logged.
firstpoints.first(name, ctx)undefined when it is not yours; the first other answer wins.
collectpoints.collect(name, ctx)An array (or a function returning one); all arrays are concatenated.
collect (sync)points.collectSync(name, ctx)A value or a synchronous function. Returning a promise throws.

The first mode makes a simple contract: answer undefined for anything that is not yours. If your payment.quote contribution answers for every service, it will price other ministries' fees.

Transactions

Every point runs inside the caller's transaction. Use ctx.transaction for every query, and never open or commit a transaction of your own. If your contribution throws, the exception reaches the caller and its transaction rolls back. That is exactly how atomicity worked when the code was inline: a failing payment.onFinalised leaves the payment unpaid, and a failing case.onSubmit leaves no half-made case.

Two points are isolated: case.onDecision and subject.completed. A module must never be able to stop a refusal or undo what core records, so each contribution runs in its own savepoint and a failure is logged, not thrown. The consequence: a module that must not miss a decision keeps an idempotent sweep job as its safety net, because a failed contribution, a module switched off at the time, or a bulk update without model hooks is not seen by the hook.

case.statusChanged and signature.completed run after the change committed; a failure there is logged and the change stands.

Only enabled modules take part, except on bypass points

A module takes part only when it is on for ctx.mdaId, else for the ministry of ctx.user, else for the installation. With no module on, every point is a no-op.

The exception is the bypass points: payment.onFinalised, payment.finaliseExternalInvoice, billing.onAllocated and pos.offlineSale. They also reach a bundled module that is switched off. This is the money rule: switching a module off must never lose money, so a payment confirmation that arrives after the switch-off is still finalised by that module. When a switched-off module acts on a bypass point, core writes an audit row module.bypass. That is why a bypass contribution must return undefined when the payment is not its business: an ordinary case fee then writes no audit row.

Separately, the personal-data registries (softDelete.kinds, pii.subjects, pii.paths, anonymise.columns, dsr.exportSections) and registers.packs are read for every bundled module, on or off. Masking, deletion and a person's data export never depend on a switch.

Choosing the right point

You want to...Point
Start a case in a special statuscase.initialStatus
Act at submission (reserve a number, check a register)case.onSubmit
Release a reservation when an application is refused or withdrawncase.onDecision (plus a sweep job)
Price a fee on the serverpayment.quote
Update your records when money arrivespayment.onFinalised
Add a field to an issued documentdocuments.values
Set a permit's validity the designer cannot expresspermit.issueOptions
Block a step until a check has passedcase.stepGate
Check answers against each otherform.validators
Add a rule to the map fieldgeo.validators
Show lines in the citizen's My recordscitizen.records
Add a figure to the dashboarddashboard.widgets

Proposing a new point

If no point fits, add a generic extension point to core and document it. Never add module logic to caseController, caseWorkflow or other core files.

  1. Name it after the core concept, not the ministry (permit.issueOptions, not fisheries.licenceValidity).
  2. Give it a context that any module could use, with no ministry names.
  3. Choose the mode deliberately: first for one owner, collect for many contributors, each for "this happened",

    isolated if no module may stop core, bypass only for money.

  4. Declare it in POINTS with a comment describing the context and answer.
  5. Call it from core by name, inside the caller's transaction, so that with no module on nothing changes.
  6. Add its row to section 9 of docs/modules/README.md.
  7. Test it with a test-only module (setup({ modules: [manifest] })) for the off, on and failure cases.

Check your understanding

  • What should a first contribution return when the request is not about its module, and why?
  • Why must a contribution use ctx.transaction and never commit?
  • Which points are isolated, and what safety net does a module need because of it?
  • Name the bypass points and explain the rule they implement.
  • When a switched-off module acts on a bypass point, what does core record?
  • Which point would you use to add a value to an issued permit?

Sign in to record your progress.

Previous: Policy, routes, data, jobs and settings Next: Switching modules on and off, and deploying them