ADR-030: Decision Matrix as Custom Metadata
Status
Accepted (2026-10-22). Note added 2026-10-22 on whose weights these are.
Context
The Nonprofit Solutions page (Pillar B) tells the story of an adult literacy nonprofit choosing a platform, in two parts: an unpaid pre-sales cycle of discovery, a demo and this comparison, then a paid build that I led. Its centre is a decision matrix the visitor can reweight. I chose a three-way matrix (my decision, 2026-10-22): NPSP with the Program Management Module, Education Cloud, and Nonprofit Cloud. The client’s own decision was two-way, NPSP and PMM against Education Cloud, and it chose NPSP and PMM. That decision is the dated baseline, and Nonprofit Cloud is the option a reviewer would ask about today. The scores and weights are mine to set. I reviewed every score and weight on 2026-10-22, and every placeholder flag is cleared.
Note, 2026-10-22. This record first called the starting weights the client’s. No client weighting exists in any source. The starting weights are mine, set from what the client said mattered most, and the reset button now reads “Reset to the starting weights”.
The matrix must be source controlled, deploy to production and to CI’s scratch orgs with no managed package, use no data storage, and never ship with an empty cell.
Decision
- Three Custom Metadata Types in the
nonprofit-literacypackage:Decision_Option__mdt:Summary__c,Sort_Order__c. Three records.Decision_Criterion__mdt:Description__c,Default_Weight__c(my starting weight, 0 to 10),Sort_Order__c. Ten records.Decision_Score__mdt:Option__candCriterion__cas metadata relationships,Score__c(1 to 5),Rationale__c,Evidence__c,Is_Placeholder__c. Thirty records, one per option and criterion pair.
- A placeholder flag. Every score carries
Is_Placeholder__cuntil I review it, and its rationale startsPLACEHOLDER:. While any score is flagged the page shows the banner “Scores and weights are provisional placeholders pending the owner’s review.” - One cacheable read.
DecisionMatrixController.getMatrix()(with sharing,@AuraEnabled(cacheable=true)) returns the options, the criteria, every score andhasPlaceholdersin one payload. It reads the types with SOQL, becausegetAll()returns only the first 255 characters of a long text field, and maps each score to its option and criterion through the related record Ids it has already read. - Totals in the browser.
c/decisionMatrixcomputes every weighted total, the ranking and the sensitivity line (the single weight change that would first put the runner-up ahead), so moving a weight makes no server call. “Reset to the starting weights” restores each criterion’sDefault_Weight__c. - No holes.
DecisionMatrixControllerTest.shouldScoreEveryOptionAndCriterionPairExactlyOncefails when any option and criterion pair lacks a score or has two.
Rationale
- Custom Metadata deploys with the code through CI, needs no data load and uses no data storage.
- Metadata relationships make each score name a real option and criterion, so a renamed row cannot leave an orphan.
- The site guest reads the three types through the controller with no custom metadata grant. Run as the real site guest (
DecisionMatrixControllerTest.shouldServeTheSameMatrixToTheSiteGuest), it gets the same 3 options, 10 criteria and 30 scores as an administrator, cell for cell (established 2026-10-22). - The placeholder flag keeps my judgement visible. The banner disappears on its own once the last flag is cleared.
Alternatives considered
| Option | Rejected because |
|---|---|
| A static JSON resource read by the LWC | Simpler and needs no class access, but it hides the metadata pattern the pillar is meant to show. |
| Custom objects with records | They use data storage and need a data load in every org, including CI’s scratch orgs. |
| A server call for each reweighting | It costs a call per slider move for arithmetic the browser can do. |
Implications
- The Apex class and both components (
c/decisionMatrix,c/pillarBStory) live inportfolio-site, because Jest only runs there. The package holds the types, their 43 records and theNL_Assetsstatic resource. - Changing a score or weight is a metadata deploy. I review the scores and weights, clears each
Is_Placeholder__cand commits the records. Done 2026-10-22: every score reviewed, every flag cleared, starting weights 2 to 10. - The cost and effort table on the page is built from public licence facts only, with a source per row (my decision). No client rates or totals appear anywhere.