amin.mallol
← All work

2025 to 2026

FlexiForm

Dynamic form platform with a usage-metered billing engine

Role: Lead engineer. Owned the Next.js app end to end; majority author of the Rails API.

lines of code
~40klines of code
question types
7question types
the whole budget
512MBthe whole budget
commits
771commits

Context

What this is for

Teams that run recurring processes (onboarding checks, compliance reviews, site inspections) mostly run them in spreadsheets, because every form product assumes the form is the end of the story. FlexiForm treats the form as the input to a monitoring table that a reviewer works in afterwards.

Problem

The thing that was actually hard to get right

The form schema is owned by the customer and changes under live data, so answers cannot be columns. Reviewers edit the same cells repeatedly and the history has to survive. Every read has to be authorized against an organization four joins away. And the whole thing had to run inside a 512MB dyno alongside a Selenium scraping pipeline.

What I built

The surface area

  • A question-by-question builder with seven server-defined types, per-question rank, required and editable flags, and a file limit, with drag-and-drop reordering persisted as rank.
  • A virtualised, spreadsheet-style monitoring grid where a reviewer edits answers in place over an append-only history.
  • A reviewer workflow with an explicit state machine: invited, submitted, reviewed, rejected, plus an orthogonal lock that can hand editing to the first reviewer only.
  • A template marketplace where a seller's form can be forked into your own organization, tracked through a self-referential template graph.
  • Stripe subscription tiers with a server-owned price catalogue, signature-verified webhooks, and live plan state pushed to the browser over ActionCable.
  • A nightly scraping pipeline over ten named brokerage sites with fuzzy duplicate detection, change notifications and a weekly digest.

The hard parts

Decisions worth defending

Each of these names its trade-off, because a decision without one is a preference.

01

The latest answer, without losing the previous ones

Answers are append-only: editing a cell writes a new row. Reading a survey therefore means the latest row per question, which is a DISTINCT ON over (user_survey_id, question_id) ordered by created_at descending. The subtle part is that the association has to stay correct when Rails batch-preloads it across many surveys at once, so the ordering has to include the partition key in the right position, and it is backed by a dedicated index. A second, deliberately skinny association selects only ids, so JSON:API relationship linkage never drags answer text into memory.

The trade-off

DISTINCT ON is Postgres-specific and the query is harder to read than a current_answer column would be. The history is the product, so denormalising a pointer to the latest row would be a second source of truth to keep honest.

02

512MB as a design input, not a complaint

Four forked Rails processes do not fit in 512MB alongside a headless Chrome, so the job system runs in async mode: worker, dispatcher and scheduler are threads inside one process of around 170MB. Selenium is lazily required because loading it costs about 11MB resident, and the scrape job is concurrency-limited to one so two browsers never coexist. A stale-run reconciler force-finishes orphaned runs using a single UPDATE ... RETURNING as an atomic flip-and-enqueue guard, and the notification window is clamped so a late reconcile cannot re-mail a day of alerts. Spreadsheet exports are written to a tempfile and streamed rather than held in memory, where the buffer would cost roughly three times the file size inside a web thread.

The trade-off

One process means one failure domain: a worker crash takes the scheduler with it. At this scale and cost the restart is cheaper than the memory, and the reconciler exists because that trade was made knowingly.

03

Whose plan is it?

A quota engine looks obvious until you ask whose plan applies. A member on the free tier acting inside an organization owned by a paying customer must get the paying limits, not their own, or the product breaks the moment anyone invites a colleague. Limits are therefore resolved from the organization owner's plan, bulk operations pre-check current plus requested against the limit rather than failing partway, and a quota failure returns 403 with a distinct code from an authorization failure, so the client can offer an upgrade instead of showing a permission error.

The trade-off

The owner becomes a single point of billing failure: if their subscription lapses, every member drops at once. That is the behaviour the business wanted, and it is why the distinction between "not allowed" and "not paid for" had to be visible in the API.

Ownership

Who wrote what

Commit counts from the repository history, both of my git identities summed. I would rather give you this than a claim you have to take on trust.

SurfaceCommitsMy share
Next.js app30998% of commits, owned end to end
Rails API27359% of commits, majority author

Stack

What it runs on

Frontend

  • Next.js 16
  • React 19
  • Redux Toolkit
  • TanStack Virtual
  • FormKit drag-and-drop
  • TipTap
  • Stripe
  • ActionCable

Backend

  • Rails 8.1
  • Ruby 4.0
  • PostgreSQL
  • Solid Queue (on Postgres, no Redis)
  • ActionCable (on Postgres)
  • Devise + JWT + TOTP
  • Pundit (18 policies)
  • Selenium + Nokogiri
  • Jaro-Winkler fuzzy matching

Infrastructure

  • Heroku (512MB dyno)
  • AWS S3
  • Resend
  • Stripe
  • Headless Chrome

Testing & quality

  • RSpec: the spec suite is larger than the application code (12.4k vs 10.1k lines)

Where it is now

Current status

In production with paying subscribers. The marketing domain and the API domain diverged during the build and have not been reconciled, which is a loose end rather than a bug.