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
| Mode | Call | What you return |
|---|---|---|
each | points.run(name, ctx) | Anything; every contribution runs in dependency order. |
each, isolated | points.runIsolated(name, ctx) | Anything; each contribution runs in its own savepoint. A failure is rolled back alone and logged. |
first | points.first(name, ctx) | undefined when it is not yours; the first other answer wins. |
collect | points.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 status | case.initialStatus |
| Act at submission (reserve a number, check a register) | case.onSubmit |
| Release a reservation when an application is refused or withdrawn | case.onDecision (plus a sweep job) |
| Price a fee on the server | payment.quote |
| Update your records when money arrives | payment.onFinalised |
| Add a field to an issued document | documents.values |
| Set a permit's validity the designer cannot express | permit.issueOptions |
| Block a step until a check has passed | case.stepGate |
| Check answers against each other | form.validators |
| Add a rule to the map field | geo.validators |
| Show lines in the citizen's My records | citizen.records |
| Add a figure to the dashboard | dashboard.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.
- Name it after the core concept, not the ministry (
permit.issueOptions, notfisheries.licenceValidity). - Give it a context that any module could use, with no ministry names.
- Choose the mode deliberately:
firstfor one owner,collectfor many contributors,eachfor "this happened",isolated if no module may stop core, bypass only for money.
- Declare it in
POINTSwith a comment describing the context and answer. - Call it from core by name, inside the caller's transaction, so that with no module on nothing changes.
- Add its row to section 9 of
docs/modules/README.md. - Test it with a test-only module (
setup({ modules: [manifest] })) for the off, on and failure cases.
Check your understanding
- What should a
firstcontribution return when the request is not about its module, and why? - Why must a contribution use
ctx.transactionand 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