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 —
setNextStep()iterates through all steps from the beginning, stopping at the first step with invalid data -
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
INIT / RESUME
│
├─ Person selection phase
│ └─ Add/remove persons (candidates)
│
▼
RUNNING
│
├─ 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 — Map submitted data from
FormDTOtoFormPersonobjects -
Validate submitted data —
FormField.validateSubmittedData()checks the answer string -
Save form data —
FormField.saveFormData()creates/updatesProcessDatarecords -
Advance —
setNextStep()re-validates from the beginning, advancing past completed steps -
Return — Next incomplete step as
FormDTO, or DONE status
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 |
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 |
Registration by Controller
FormField types are registered in the formFieldCandidates map during construction:
-
FormController (base) — TXT, SEL, RBO, ONE, BCB, ITC, CLV, CON, EMC, PHN, CSI, CSM, CSL
-
EventFormController — adds ECA, ERT
-
MembershipFormController — adds MFM, MFA, MFC, MCS
-
TestFormController — registers all types (union of all controllers)
REST API
The form system is exposed through FormResource at /api/forms:
| Method | Endpoint | Description |
|---|---|---|
GET |
|
Start or resume a membership form session |
POST |
|
Submit current step answers, receive next question or completion |
PUT |
|
Reset a process — delete all data and restart |
PUT |
|
Resume a paused process — allow person modifications |
GET |
|
Read-only: get all form field definitions without creating a session |
GET |
|
Read-only: get form fields for a membership period |
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
EventFormController
The event controller handles event participant registration. It is simpler than the membership controller, focusing on event-specific data capture.
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 |