Form System Architecture
Overview
The Form System provides a question-based data capture mechanism built on the process entity model. It drives multi-step registration workflows for memberships, events, and other enrolment processes.
The core abstraction is:
-
A Form is a
ProcessDefinitioninstantiated as aProcessInstance -
Each question in the form is a
ProcessStepmapped to aFormFieldclass -
A FormController orchestrates the runtime lifecycle: person selection, question iteration, validation, and completion
This architecture separates the what (process configuration in the database) from the how (FormController and FormField classes in code), enabling the same code to drive different forms with different questions.
Process Entity Model
The form system is built on five core entities:
ProcessDefinition (workflow template)
├── type: "E" (Event), "M" (Membership), "T" (Test)
└── steps: List<ProcessStep> (ordered by seq)
└── options: Set<ProcessStepOption> (choices)
ProcessInstance (runtime execution)
├── identifier: UUID (unique session key)
├── status: ProcessState (INIT → RUNNING → DONE)
├── definition: → ProcessDefinition
├── currentStep: → ProcessStep
├── startedBy: → OrgUser
├── people: Set<User> (persons in the process)
└── data: Set<ProcessData> (all answers)
├── key: String (field key, typically "DEFAULT")
├── value: String (answer)
├── step: → ProcessStep (optional)
└── person: → User (optional)
ProcessInstance organises its data in a four-tier nested map for efficient lookup:
| Step | Person | Scope |
|---|---|---|
null |
null |
General process data |
set |
null |
Step-specific data |
set |
set |
Step and person-specific data (most common) |
null |
set |
Person-specific process data |
FormController Architecture
FormController is the base class that wraps a ProcessInstance and manages the form lifecycle. It is instantiated fresh for each form interaction (not long-lived).
Class Hierarchy
FormController (abstract base)
├── MembershipFormController — membership enrolment with family logic
├── EventFormController — event registration
└── TestFormController — single-field testing (dev/test only)
Key Responsibilities
-
FormField registration — Each subclass registers its supported FormField types in a
formFieldCandidatesmap (step code → class) -
Person management — Manages the set of persons being processed, with add/remove capabilities
-
Step iteration — two mechanisms. On a submit,
advanceOneStep()moves to the next step in sequence that at least one person must answer, re-showing a step that is already valid so that after Back each later step is offered again. On INIT, on a resume with no current step, and at completion, the saved-answer walk (walkAndFindFirstInvalid()) validates every step from the first and stops at the first that fails — see Validation Flow -
Validation orchestration — Coordinates validation across all persons for the current step
-
Data persistence — Manages ProcessData records for form answers
Factory Method
FormService.createFormController() is the factory that creates the appropriate controller based on ProcessDefinition.type:
private FormController createFormController(ProcessInstance instance) {
String type = instance.getDefinition().getType();
if (ProcessDefinition.EVENT_TYPE.equals(type)) {
return new EventFormController(...);
} else if (ProcessDefinition.MEMBERSHIP_TYPE.equals(type)) {
return new MembershipFormController(...);
}
// Future: ProcessDefinition.TEST_TYPE for TestFormController
}
Lifecycle
The walk shown under RUNNING is the saved-answer walk: it positions the form after person selection, on a resume of an instance with no current step, and at completion, where it must pass every step before onDone() runs. A submit does not run it — it validates and saves the current step and moves one step forward, as described under Validation Flow.
INIT / RESUME
│
├─ Person selection phase
│ └─ Add/remove persons (candidates)
│
▼
RUNNING — saved-answer walk (INIT, resume without a step, completion)
│
├─ For each ProcessStep (in sequence order):
│ ├─ Instantiate FormField from step.type code
│ ├─ For each person:
│ │ ├─ validateSavedFormData() — check persisted data
│ │ └─ If invalid: STOP here, show this question
│ └─ If all persons valid: continue to next step
│
▼
DONE
└─ All steps validated → onDone() hook
Validation Flow
When the front-end submits an answer via POST /api/forms/next:
-
Apply answers — the submitted
FormDTOanswers are copied onto freshFormPersonobjects, one per person on the process. AFormPersonis the view of one person for one request: the options offered, the answer shown, a message, and the person’s selection, removal and skip state. -
Validate submitted data —
FormField.validateSubmittedData()checks each person’s answer for the current step. -
Save form data —
FormField.saveFormData()creates or updatesProcessDatarecords. -
Advance one step —
advanceOneStep()moves to the next step in sequence that at least one person must answer (mustProcessPerson()). A step every person may skip is passed over silently. A step that is already valid is still shown, so that after Back each later step is offered again on the way forward. -
Complete — when no later step needs anyone, every step is re-validated from the first (
walkAndFindFirstInvalid()): each step’svalidateSavedFormData()runs for each person it applies to, in sequence order. The first step that fails is shown again, with the message it set; when every step passes,onDone()runs and the process is DONE.
The same walk positions the form on INIT, and on a resume of an instance that has no current step yet.
Step view contract
One request serves several steps on the same FormPerson objects: the step just submitted, the step moved to, and — at completion — every step in the walk. Before a step renders (moveToField(), loadCurrentStep()) and before each step validates a person’s saved data in the walk, the engine clears that person’s view — options, answer and message — so that every step starts from what it would see if it were the only step (FormPerson.clearStepView()). Selection, removal and skip state are not cleared: they are the person’s standing on the process, not a step’s rendering.
A FormField must therefore:
-
Validate against options it computed itself. A field that asks "is the answer one of the options offered?" either computes them unconditionally in
validateData()(CsaMembershipCheckFormField,CsaLicenseCheckFormField) or loads them when the person carries none (EventCategorySelectFormField,EventRaceTypeSelectFormField). It must never take a non-null list to be its own. -
Set its own answer in
validateSavedFormData()from the savedProcessData. The answer the previous step showed is gone. -
Set its own message when it fails.
moveToField()clears messages before the next step renders.
Why this is a rule and not a convention: in 2.4.29 the category and race-type steps began loading their options during the completion walk, and the membership step — which only loaded its options when the list was absent — found category ids, never PROCEED, and rejected every rider’s saved answer. Riders were sent back to the membership step with no message, answered it, submitted indemnity, and were sent back again; for three days no entry for the live event completed. The licence step had had the same defect fixed a week earlier, without the lesson reaching its sibling.
Adding a step or a controller
The flow matrix RegistrationFlowMatrixIT drives an event registration through FormService, one request per step as the portal does, for every combination of step order, CSA checks required, CSA standing and navigation (straight through, back and forward again, leave and resume). Each flow must end in one of three states: the registration completes; the person is told why it cannot; or the person who cannot be verified is removed and the rest complete. A step rendered twice in a row is a failure.
When adding a FormField type, changing what a step validates, or adding a controller:
-
Add the step to the matrix: the definition shapes it belongs in, the answer the portal would send, and the trail of steps expected.
-
If the step validates against options, add a unit test that hands it a person carrying another step’s options (as
CsaMembershipCheckLeakedOptionsTestdoes). -
A new controller needs a matrix of its own, driven the same way.
TestFormControllerrenders one step and never runs the completion walk, so it proves nothing about this. -
Run the matrix against the current code before changing anything, so that the failure seen is the one being fixed and not the fixture.
Lifecycle Hooks
Subclasses can override these hooks to add domain-specific behaviour:
| Hook | Purpose |
|---|---|
|
Pre-processing before step iteration (e.g., assign membership criteria) |
|
Completion logic (e.g., create orders, memberships, event participants) |
|
Per-field hook called when a field passes validation |
|
Per-field/person hook to skip a person for a specific step |
|
Cross-field/person validation after individual validation passes |
|
Gather completion summary data for display |
FormField Types
FormField is the abstract base class that wraps a ProcessStep to provide validation and data management for a specific question type. All implementations follow the constructor signature (FormController, ProcessStep) and are instantiated via reflection.
Step Code Reference
| Code | Class | Description |
|---|---|---|
TXT |
FreeTextFormField |
Free text input with optional length and regex pattern validation |
TXM |
RememberedTextFormField |
Free text, as TXT, remembered per person and organisation in user meta and prefilled on later registrations — see Remembered Answers |
SEL |
DropDownOptionFormField |
Dropdown/select populated from ProcessStep options |
RBO |
RadioButtonOptionFormField |
Radio button selection with multiple options |
ONE |
RadioButtonPickOneFormField |
Single radio button — select one option across all persons |
BCB |
CheckBoxBooleanFormField |
Boolean checkbox (true/false) |
ITC |
IndemnityTermsConditionsFormField |
Terms and conditions acceptance checkbox |
CLV |
CustomListValueFormField |
Dropdown populated from pipe-delimited list in ProcessStep.listOptions |
CON |
CommunicationPreferenceFormField |
Three-way toggle (Email / SMS / WhatsApp) with conditional contact input |
EMC |
EmergencyContactFormField |
Composite field for emergency contact name and phone number |
PHN |
PhoneNumberFormField |
Phone number with country code dropdown |
CSI |
CsaIdentificationFormField |
CSA and UCI identification number capture |
CSM |
CsaMembershipCheckFormField |
CSA membership status check via external API |
CSL |
CsaLicenseCheckFormField |
CSA license status check via external API |
ECA |
EventCategorySelectFormField |
Event category selection filtered by age and gender eligibility |
ERT |
EventRaceTypeSelectFormField |
Event race type multi-select |
MFM |
MembershipMainSelectFormField |
Family membership main member designation |
MFA |
MembershipAdultSelectFormField |
Family membership adult selection |
MFC |
MembershipChildSelectFormField |
Family membership child selection |
MCS |
MembershipCrossSellFormField |
Cross-sell membership selection — one person — see Cross-Sell Selection |
MCM |
MembershipCrossSellMultiFormField |
Cross-sell membership selection — any number of people — see Cross-Sell Selection |
Registration by Controller
FormField types are registered in the formFieldCandidates map during construction:
-
FormController (base) — TXT, TXM, SEL, RBO, ONE, BCB, ITC, CLV, CON, EMC, PHN, CSI, CSM, CSL
-
EventFormController — adds ECA, ERT
-
MembershipFormController — adds MFM, MFA, MFC, MCS, MCM
-
TestFormController — registers all types (union of all controllers)
Process Instance Selection
GET /api/forms/event and GET /api/forms/membership are new-or-resume entry points: they either resume the caller’s in-flight process or start a fresh one. The selection rule is deliberately narrow.
Resumability
Only a non-terminal instance is resumable:
| State | Resumable | Meaning |
|---|---|---|
|
Yes |
Created, still in the person-selection phase |
|
Yes |
Re-entered for person modifications |
|
Yes |
Part-way through the question sequence |
|
No |
All steps validated and |
|
No |
Abandoned |
When no resumable instance exists a new ProcessInstance is created. A terminal instance is never reused — and in particular, a completed registration whose order is still unpaid is not a reason to reuse it.
That distinction matters because onDone() has already fired on a DONE instance: the EventParticipant or membership records and the order all exist. Reusing it cannot enrol anyone new, because next() refuses to advance a terminal instance and resume() rejects one outright. Handing a terminal instance to a screen that is about to start a registration therefore strands the user rather than registering them.
Unpaid orders are tracked against the person, not the process
An unpaid order left behind by a completed registration is surfaced independently of process state:
-
GET /api/forms/event/linked-statusreturnsorderId,orderStatusandpaidfor each linked person -
The caller renders a "Pending Payment" status with a Pay action routing to that order
Keeping payment recovery separate from process selection is what allows a user holding one unpaid registration to still start a second registration for another person.
Operation guards
| Operation | Guard |
|---|---|
|
Refuses to advance a |
|
Requires a non-terminal instance; rejects |
|
Requires a non-terminal instance, or a |
REST API
The form system is exposed through FormResource at /api/forms:
| Method | Endpoint | Description |
|---|---|---|
GET |
|
Start or resume an event registration form session |
GET |
|
Per-person event order status ( |
GET |
|
Start or resume a membership form session |
POST |
|
Submit current step answers, receive next question or completion |
PUT |
|
Move one step backward by |
PUT |
|
Reset a process — delete all data and restart |
PUT |
|
Re-enter an in-progress process and render its current step |
GET |
|
Start a single-field test session (dev/test only) |
MembershipFormController
The membership controller handles multi-person membership enrolment with family membership logic.
Key Behaviours
-
Person loading — Loads the principal user and all managed (linked) persons as candidates
-
Family membership detection —
onBeforeProcessing()checks person counts to determine if family membership options should be offered (requires 2+ adults AND 2+ children) -
Membership criteria assignment — Assigns base membership criteria to each person before step iteration
-
Dynamic question enablement — Family-specific questions (MFM, MFA, MFC) are conditionally enabled based on family eligibility
-
Order creation —
onDone()creates membership records, assigns membership numbers, and generates orders
Cross-Sell Selection
| MCM and the server-side MCS limit are planned for the HNR 2027 renewal season. MCS as a single selection is current behaviour. |
A membership type may name a cross-sell membership type (membership_type.cross_sell_membership_type_id) — at HNR, the "Early Riser" membership offered alongside the annual one. The form offers it to each adult or senior in the transaction. On completion, every selected person is enrolled in the cross-sell type’s latest period, with a line item on the same order.
The process designer chooses how many people may take it by choosing the step code:
| Code | UI code | Selection | Behaviour |
|---|---|---|---|
MCS |
ONE |
At most one person |
One radio group across all eligible people. With |
MCM |
BCB |
Any number, including none |
One checkbox per eligible person, labelled with the step’s option text ( |
Both codes store their answers the same way: one ProcessData row per eligible person, data key MCS, value 1 (selected) or 0. Completion reads those rows without knowing which code produced them, so moving a definition from single to multiple selection is a change to process_step.type alone — no data migration and no front-end change, because the BCB checkbox already renders per person.
Eligibility
Only adults and seniors are offered the cross-sell (mustProcessPerson()); children are hidden from the question in both modes.
Completion
Each selected person gets their own cross-sell Membership. Its baseMembership is that person’s membership in the main period, resolved before the number is assigned so that the cross-sell inherits the base membership’s number:
-
a membership created for the person earlier in the same completion (family or normal), otherwise
-
the person’s existing membership in the main period, for example an annual membership bought before this registration, otherwise
-
none — the cross-sell mints its own number, and a warning is logged.
One order can therefore carry several cross-sell memberships, each linked to a different base membership. baseMembership is many-to-one, so no schema change is needed.
Limitations
-
The cross-sell period is resolved at completion as the cross-sell type’s latest period, not fixed when the question is answered. A period that closes between the question and completion is not detected. This is unchanged by MCM.
-
The administrator’s add-membership screen offers a single cross-sell for a single person; it is not part of this change.
Remembered Answers
| Planned for the HNR 2027 renewal season. |
A remembered answer is a free-text answer captured once and prefilled on every later registration for the same person, in any flow of the same organisation. Example: "Medical aid number", asked on the membership form and again on each event entry, prefilled each time with the last answer given.
Step Code
TXM — RememberedTextFormField, a subclass of FreeTextFormField. It sends UI code TXT, so the front-end renders an ordinary text box and needs no change. Validation is exactly that of TXT: value_min, value_max and value_pattern.
TXT itself is unchanged: it reads person_data_key verbatim to prefill and never writes back. Choosing TXM is what opts a question into being remembered, because not every free-text question should outlive its registration (for example "Anything the organiser should know?").
Meta Key
The answer is stored in WordPress user meta (wp_usermeta) on the person being registered, under:
<organisationId>-<slug>
| Part | Rule |
|---|---|
|
Taken from the form’s context, not from the process definition — definitions carry no organisation and may be shared. |
|
|
Example: HNR (organisation 8) asks "Medical aid number" → slug medical-aid-number → key 8-medical-aid-number.
Consequences:
-
Steps share a value by sharing a slug. Two questions with the same name — or the same supplied key — in different definitions of one organisation read and write the same value. That is the intent: a new season’s definition with new step ids still prefills last season’s answers.
-
Renaming a question changes its derived key. To keep existing values across a rename, set
person_data_keyto the old slug. -
A supplied key is a slug, not a meta key.
person_data_keyon aTXMstep must match(-[a-z0-9])*and be at most 100 characters; otherwise the field fails to load withInvalidProcessStepException. The organisation prefix is always added, so a supplied key can reach neither a WordPress core key such aswp_capabilitiesnor another organisation’s values.
Read and Write
Prefill, in order of precedence:
-
the answer already saved in this process instance (resume, or going back a step);
-
the user meta value under the key above;
-
empty.
The value is written in saveFormData(), on each successful submission of the step, to the person answered for — not to the principal who is filling in the form — only when the principal has READ_WRITE access to that person’s data (including implicit access to their own data). For a READ link, answers may be saved in this process instance’s ProcessData, but user meta must not be inserted, updated or deleted, regardless of sensitive. A registration abandoned after an authorised write has still updated the remembered value; that is accepted.
A blank answer to an optional TXM question deletes the meta row, so a person can clear a value that no longer applies. For text fields, null and the empty string are the same answer: neither is ever stored as a meta value, and a missing meta row prefills as empty.
Sensitive Questions
process_step.sensitive (boolean, not null, default false) marks a question whose answer is sensitive, such as medical information.
In this release the flag is stored and exposed only. One rule applies now: the answer of a sensitive step is never written to a log, at any level.
Planned behaviour: when the principal’s link to a person has access_level READ — a coach or teacher linked to a minor, for example — sensitive questions are skipped for that person: not shown, not prefilled and not overwritten.
EventFormController
The event controller handles event participant registration. It is simpler than the membership controller, focusing on event-specific data capture.
Key Behaviours
-
Event validation — Ensures the event is open for registration
-
Participant creation — Creates
EventParticipantrecords on completion -
Category and race type selection — Provides event-specific question types for selecting categories and race types
Step order
The event steps depend on one another, and the dependency is carried by ProcessStep.seq:
-
The category (
ECA) and race-type (ERT) steps each narrow their options by whichever of the two was answered earlier. Either may come first;ERTis optional. -
The CSA block —
CSI,CSM,CSL— follows both, in that order.CSMreads the chosen category to decide whether a check is required.CSLreads theCSManswer, and a licence is only valid on a day the membership is valid, soCSLnever runs withoutCSMbefore it.CSLis optional, and the block is optional as a whole. -
Indemnity (
ITC) is the last step.
In short: <any> › [ECA, ERT] › (CSI › CSM › [CSL]) › <any> › ITC.
Nothing enforces this yet. A definition saved in another order — CSL before CSM, or ERT after the CSA block — runs, and its steps give wrong or empty answers. Planned: a definition is validated against the rule when it is saved through ProcessDefinitionResource and ProcessStepResource (on the resulting set of steps, since steps are saved one at a time), and FormService refuses to start a registration on a definition that breaks it, with a message that names the rule. Whether CSI must be present whenever CSM is remains open: the code falls back to the person’s identity number when there is no CSI step, which serves a South African rider and nobody else.
TestFormController
The test controller enables front-end developers to test individual FormField types in isolation, without configuring a full ProcessDefinition.
Purpose
During development of new form questions (Epic #34), front-end developers need to integrate with specific FormField types. Creating a full ProcessDefinition for each field type under development creates a dependency bottleneck. The TestFormController eliminates this by allowing any FormField type to be tested via a single API call.
API Usage
# Start a test session for a text field
GET /api/forms/test?stepCode=TXT&required=true&valueMin=2&valueMax=50
# Start a test session for a radio button field with options
GET /api/forms/test?stepCode=RBO&required=true&options=["Option A","Option B","Option C"]
# Submit the answer (uses the standard next endpoint)
POST /api/forms/next
Body: { "processId": "<uuid>", "people": [...] }
Behaviour
-
The
GETendpoint creates a freshProcessDefinition(type"T") with a singleProcessStepconfigured from the request parameters -
A
ProcessInstanceis created and persisted -
The front-end receives a standard
FormDTOwith the question -
Submissions go through the existing
POST /api/forms/next— the factory method detects type "T" and creates aTestFormController -
Since there is only one step, successful validation results in DONE status
Restrictions
-
Available in
devandtestSpring profiles only — not available in production -
Registers all FormField types (base + event + membership) to support testing any question
-
No domain-specific side effects (
onDoneperforms cleanup, not order/membership creation)
Request Parameters
| Parameter | Required | Description |
|---|---|---|
stepCode |
Yes |
FormField type code (see step code reference table above) |
required |
No |
Whether the field is required (default: true) |
valueMin |
No |
Minimum value or length |
valueMax |
No |
Maximum value or length |
valuePattern |
No |
Regex validation pattern |
options |
No |
JSON array of option values for choice-based fields |
personDataKey |
No |
Person data key for initial value resolution |