Lodos Forms: building it around the failure modes
Three design moves — submit-before-charge, schema-on-every-submission, stable per-element IDs — that closed the failure modes Lodos Forms could have shipped with.
Lodos Forms ships with an awkward constraint: some events make every attendee fill out a form before they can buy a ticket. That sounds simple until you draw the failure cases. What if the payment goes through and then the form submission fails? You're left with a ticket that has no data behind it — and an organizer who paid the processor fee for a record they can't use.
The whole feature is built around making that case impossible. Not handling it. Making it impossible.
The gate
The fix is an ordering rule, not a recovery plan. When an attendee tries to buy a ticket for an event with a form attached, the checkout doesn't open. The form does. Each attendee on the order gets their own tab. Only once every tab is filled and validated does the gate open, fire the submissions in parallel, and — only if every one of them lands — call into the existing checkout flow.
Written out, the sequence is:
- Attendee clicks Buy Ticket on an event with a
formID. EventDetailPage'sproceedToCheckoutis intercepted.EventTicketFormStepmounts.- Each attendee fills their form.
useEventFormGatevalidates them in isolation. - On submit,
submitAll()POSTs onecreate-lodos-forms-resultper attendee, in parallel. - If every submission resolves successfully,
proceedToCheckoutis finally called. If any one fails, the gate stays closed — no ticket, no charge.
The path we didn't take was to create the ticket first and attach the form data to it afterwards. Cheaper to build — checkout already worked, forms could be a side effect. But "ticket exists, form data missing" then becomes a state the database can be in, and you owe the system a recovery story: a retry queue, a backfill UI, a "your form failed, please fill it again" toast that may or may not survive the user closing the tab. By inverting the order, that state is structurally unreachable. The invariant — if a ticket exists, the form data exists too — holds without anyone enforcing it.
The schema, attached
The second class of failure is slower. An organizer creates a form, ships an event, collects 200 submissions. A month later they tweak the form — renaming "Sertifika için tam adınız" to "Tam adınız", reordering two questions, adding a new one. Then they open the results dashboard.
If submissions stored only the values, every old answer would be either re-attached to the wrong question (because positional indices shifted) or quietly hidden (because the field's label changed). Both are silent corruption — no error, no warning, just a dashboard that lies about what the attendees actually said. The recovery story here is even worse than the orphan-ticket one, because by the time anyone notices, the original data is gone.
So every submission carries its own schema:
data: {
values: Record<elementId, primitive | primitive[]>,
schema: FormElement[], // a snapshot, frozen at submit time
attendeeIndex?: number,
ticketID?: string,
eventID?: string,
}
The values are indexed by elementId (more on that in a second), and the schema field is a full copy of the formElements array as it existed when the user hit submit. Editing the form later writes a new shape; old submissions still carry the old one. The values can never drift away from the questions they were answers to, because the questions are stored next to them.
The dashboard doesn't yet read each submission's snapshot — today it renders against the current form schema, which means a brand-new field added last week shows up as empty for last month's submissions. That's a deliberate trade-off, not a bug: the snapshot is the insurance policy, and we can change how the dashboard reads it without losing any data. The expensive thing to get wrong was the write side. The read side stays cheap.
Stable IDs
The third move is the one that makes the second one work. Inside each FormElement, the id field isn't the position in the array and isn't derived from the label — it's a crypto.randomUUID() generated when the element is first created, and it never changes for the lifetime of that element. Move a question from position two to position five, rename it, mark it optional: the ID is the same. The values in old submissions still point at it.
A small consequence: when an organizer picks a template — Basic Attendee Info, Networking Event, one of five — instantiateTemplate() doesn't hand back the template's element list. It hands back a copy with freshly minted IDs. Two forms built from the same template share no IDs. So a submission to one of them can never be misread as a submission to the other, even if they look identical on screen.
The pattern
Three moves: submit-before-charge, schema-on-every-submission, stable per-element IDs. None of them are error handling. There's no retry queue for a half-charged ticket; there is no migration script for a form whose labels changed; there is no garbage-collection pass for orphaned answers. Each failure case was made structurally unreachable instead.
That's the principle, and it's worth being honest about its limit. Not every feature can be built this way. The events team has plenty of code that catches a thrown error, shows a toast, and moves on — because the failure is recoverable and the cost of preventing it is higher than the cost of handling it. Forms was the inverse: the failures were the kind that compound silently, and the design moves that close them off are all small. Submit first, snapshot the schema, give every element a UUID. Three lines in a design doc. A feature that ships robust because the shape of the data refuses to be wrong.