How to Design a Survey & Forms Builder Platform
A complete system design guide covering form building, response collection, analytics, branching logic, integrations, and compliance — like Google Forms and Typeform at scale.
1. Overview & Problem Statement
A survey and forms builder platform enables users to create, distribute, and analyze questionnaires — from simple contact forms to complex multi-branch surveys with conditional logic, quiz grading, and real-time analytics. Think Google Forms, Typeform, SurveyMonkey, JotForm, or Microsoft Forms.
Why This Is Hard
- Schema Flexibility: Forms are dynamic schemas defined at runtime — there is no fixed database table for "form responses" because each form has a different shape.
- Branching Logic: Skip logic, piping, and conditional blocks create a directed graph of question flow, not a linear sequence.
- Real-Time Analytics: Form owners expect live dashboards as responses arrive, requiring event streaming and incremental aggregation.
- File Uploads: Handling file uploads at scale (images, documents, PDFs) with virus scanning, storage, and access control.
- Global Scale: Forms must load fast worldwide, with CDNs, edge caching, and geo-distributed response collection.
Platform Capabilities at a Glance
| Capability | Details |
|---|---|
| Form Builder | Drag-and-drop, block-based, real-time preview |
| Question Types | 15+ types including text, choice, rating, NPS, matrix, file upload, date, dropdown |
| Branching Logic | Conditional skip, show/hide blocks, piping, calculated fields |
| Themes | Custom colors, fonts, logos, custom CSS injection |
| Response Collection | Anonymous & authenticated, partial save, multi-device |
| Analytics | Real-time charts, completion rates, drop-off analysis |
| Export | CSV, Excel, Google Sheets integration |
| Sharing | Public link, email campaigns, embed iframe, QR code |
| Quiz Mode | Auto-grading, points, answer keys, time limits |
| Integrations | Webhooks, Slack, Zapier, Google Sheets, Mailchimp |
| Spam Protection | CAPTCHA, rate limiting, link expiration |
| Compliance | GDPR, data encryption, right to deletion |
2. Functional & Non-Functional Requirements
Functional Requirements
| Requirement | Description |
|---|---|
| FR-1 | Users can create forms with drag-and-drop builder |
| FR-2 | Support 15+ question types with rich configuration |
| FR-3 | Conditional branching and skip logic per question |
| FR-4 | Respondents can submit responses, including file uploads |
| FR-5 | Form owners view real-time analytics dashboards |
| FR-6 | Export responses to CSV, Excel, Google Sheets |
| FR-7 | Share forms via link, email, embed, QR code |
| FR-8 | Quiz mode with auto-grading and point allocation |
| FR-9 | Form scheduling (open/close dates) and response limits |
| FR-10 | Partial responses with save-and-continue via email link |
| FR-11 | Email notifications on each submission |
| FR-12 | Integrations with Slack, Zapier, webhooks, Google Sheets |
| FR-13 | Themes and branding (colors, fonts, logos) |
| FR-14 | Multi-language form support |
| FR-15 | Anonymous and authenticated response collection |
| FR-16 | Spam protection via CAPTCHA and rate limiting |
Non-Functional Requirements
| Requirement | Target |
|---|---|
| Availability | 99.99% uptime |
| Latency (form load) | < 200ms p99 via CDN |
| Latency (submission) | < 500ms p99 |
| Scale (forms) | 100M+ forms created |
| Scale (responses) | 10B+ responses stored |
| Scale (concurrent) | 1M+ concurrent respondents |
| Analytics freshness | < 5 seconds from submission |
| Data retention | Configurable, default 2 years |
| Security | Encryption at rest and in transit, RBAC |
| Compliance | GDPR, CCPA, SOC 2 |
3. Question Types
A forms builder must support a rich set of question types, each with its own configuration schema, rendering component, validation rules, and analytics aggregation strategy.
Complete Question Type Catalog
| Type | Config Options | Validation | Analytics |
|---|---|---|---|
| Short Text | placeholder, maxLength, pattern | Required, regex, max length | Word cloud, text length distribution |
| Long Text (Paragraph) | placeholder, maxLength, rows | Required, max length | Word cloud, sentiment |
| Multiple Choice | options[], allowOther, multiSelect, shuffle | Required, min/max selections | Bar chart, pie chart, option distribution |
| Checkboxes | options[], allowOther, minSelect, maxSelect | Required, min/max count | Bar chart, co-occurrence matrix |
| Dropdown | options[], searchable, multiSelect | Required, valid option | Bar chart, option distribution |
| Linear Scale (Rating) | min, max, minLabel, maxLabel | Required | Average, histogram, distribution |
| Star Rating | maxStars (default 5) | Required | Average, distribution |
| NPS (Net Promoter Score) | lowLabel, highLabel | Required | NPS score (-100 to 100), distribution |
| Date | includeTime, minDate, maxDate | Required, date range | Timeline histogram |
| Time | format (12h/24h) | Required | Distribution |
| placeholder | Required, email regex | Count, uniqueness | |
| Number | min, max, step, prefix, suffix | Required, range | Average, median, histogram |
| File Upload | maxSize, allowedTypes, maxFiles | Required, type, size | Count, type distribution |
| Matrix (Grid) | rows[], columns[], type (single/multi) | Required per row | Heat map, per-row distribution |
| Phone Number | countryCode, format | Required, phone regex | Count |
| Address | fields[] (street, city, state, zip, country) | Required per field | Geographic aggregation |
| Signature | penColor, backgroundColor | Required (non-empty canvas) | Count |
| Section Header | title, description | N/A | N/A |
| Image Choice | images[], allowMultiSelect | Required | Option distribution |
Question Type Configuration Schema
Each question type has a discriminated union configuration stored as JSON in the database. This allows the builder UI to render the correct editor component and the renderer to produce the correct respondent view.
C#
public abstract class QuestionConfig
{
public string QuestionId { get; set; }
public string Type { get; set; } // discriminator
public string Title { get; set; }
public string Description { get; set; }
public bool IsRequired { get; set; }
public int OrderIndex { get; set; }
public string ValidationRuleJson { get; set; } // optional custom validation
}
public class MultipleChoiceConfig : QuestionConfig
{
public List<OptionItem> Options { get; set; }
public bool AllowOther { get; set; }
public bool MultiSelect { get; set; }
public int? MinSelections { get; set; }
public int? MaxSelections { get; set; }
public bool ShuffleOptions { get; set; }
}
public class RatingConfig : QuestionConfig
{
public int Min { get; set; } = 1;
public int Max { get; set; } = 5;
public string MinLabel { get; set; }
public string MaxLabel { get; set; }
}
public class NpsConfig : QuestionConfig
{
public string LowLabel { get; set; } = "Not at all likely";
public string HighLabel { get; set; } = "Extremely likely";
}
public class FileUploadConfig : QuestionConfig
{
public long MaxFileSizeBytes { get; set; } = 10 * 1024 * 1024; // 10MB
public List<string> AllowedMimeTypes { get; set; }
public int MaxFiles { get; set; } = 1;
}
public class MatrixConfig : QuestionConfig
{
public List<string> Rows { get; set; }
public List<string> Columns { get; set; }
public MatrixType MatrixType { get; set; }
}
public enum MatrixType { Single, Multi }
public class OptionItem
{
public string Id { get; set; }
public string Label { get; set; }
public string ImageUrl { get; set; } // for image choice
}
4. Form Builder UI — Drag-and-Drop & Block-Based Editor
The form builder is the heart of the product. It must feel instant, intuitive, and powerful — supporting real-time preview, undo/redo, keyboard shortcuts, and rich configuration panels.
Builder Architecture
Drag-and-Drop Implementation
The builder uses a virtual list for question blocks to handle forms with 100+ questions without performance degradation. Each block is a self-contained component with its own state, validation, and configuration panel.
C# — Form Builder State Model
public class FormBuilderState
{
public string FormId { get; set; }
public string Title { get; set; }
public string Description { get; set; }
public List<QuestionBlock> Blocks { get; set; } = new();
public FormTheme Theme { get; set; }
public List<LogicRule> LogicRules { get; set; } = new();
public FormSettings Settings { get; set; }
public int Version { get; set; }
public Stack<FormBuilderState> UndoStack { get; set; } = new();
public Stack<FormBuilderState> RedoStack { get; set; } = new();
public void MoveBlock(int fromIndex, int toIndex)
{
if (fromIndex == toIndex) return;
SaveSnapshot();
var block = Blocks[fromIndex];
Blocks.RemoveAt(fromIndex);
Blocks.Insert(toIndex, block);
ReindexBlocks();
}
public void AddBlock(QuestionBlock block, int? insertAt = null)
{
SaveSnapshot();
var index = insertAt ?? Blocks.Count;
Blocks.Insert(index, block);
ReindexBlocks();
}
public void RemoveBlock(string blockId)
{
SaveSnapshot();
Blocks.RemoveAll(b => b.Id == blockId);
ReindexBlocks();
}
private void SaveSnapshot()
{
UndoStack.Push(DeepClone(this));
RedoStack.Clear();
}
private void ReindexBlocks()
{
for (int i = 0; i < Blocks.Count; i++)
Blocks[i].OrderIndex = i;
}
private FormBuilderState DeepClone(FormBuilderState source)
{
return JsonSerializer.Deserialize<FormBuilderState>(
JsonSerializer.Serialize(source));
}
}
public class QuestionBlock
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Type { get; set; }
public QuestionConfig Config { get; set; }
public int OrderIndex { get; set; }
public bool IsVisible { get; set; } = true;
}
Auto-Save Strategy
The builder implements debounced auto-save with optimistic versioning. Every change triggers a debounced save (500ms after last keystroke) that sends a JSON patch to the server. The server maintains version history so form owners can roll back to any previous version.
Preview System
The preview panel renders the form exactly as respondents will see it, including conditional logic simulation. The builder can toggle between desktop, tablet, and mobile preview widths. In preview mode, branching logic is active so the builder can test skip patterns end-to-end.
Block Configuration Panel
When a user clicks a block, the right panel slides in with type-specific configuration. For a multiple choice block, this includes: options list (with drag reorder), "allow other" toggle, shuffle toggle, selection limits, and validation rules. Changes are reflected in real-time on the canvas.
5. Data Model & Schema Design
The data model must handle dynamic schemas — each form defines its own structure, and responses are shapeless key-value maps validated against the form schema at write time.
Entity Relationship Diagram
Response Schema — The Dynamic Answer Store
Since each form has a unique schema, responses are stored as JSON documents. Each answer maps a question ID to its typed value. This is the most critical design decision — we trade strict schema validation for flexibility, enforcing correctness at the API layer.
C# — Response & Answer Models
public class Response
{
public Guid Id { get; set; }
public Guid FormId { get; set; }
public Guid? UserId { get; set; } // null for anonymous
public string SessionId { get; set; }
public ResponseStatus Status { get; set; }
public Dictionary<string, object> Metadata { get; set; }
public DateTime StartedAt { get; set; }
public DateTime? SubmittedAt { get; set; }
public double? Score { get; set; } // for quiz mode
public List<Answer> Answers { get; set; } = new();
}
public class Answer
{
public Guid Id { get; set; }
public Guid QuestionId { get; set; }
public AnswerValue Value { get; set; }
public int TimeSpentMs { get; set; }
}
[JsonConverter(typeof(JsonSubtypeConverter))]
[JsonSubtypeFallbackType(typeof(AnswerValue))]
public abstract class AnswerValue { }
public class TextAnswer : AnswerValue
{
public string Text { get; set; }
}
public class ChoiceAnswer : AnswerValue
{
public List<string> SelectedOptionIds { get; set; }
public string OtherText { get; set; }
}
public class RatingAnswer : AnswerValue
{
public int Rating { get; set; }
}
public class NumberAnswer : AnswerValue
{
public decimal Number { get; set; }
}
public class DateAnswer : AnswerValue
{
public DateTime Date { get; set; }
}
public class FileAnswer : AnswerValue
{
public List<FileAttachment> Files { get; set; }
}
public class MatrixAnswer : AnswerValue
{
public Dictionary<string, string> RowSelections { get; set; }
// rowId -> selectedColumnId
}
public class AddressAnswer : AnswerValue
{
public string Street { get; set; }
public string City { get; set; }
public string State { get; set; }
public string ZipCode { get; set; }
public string Country { get; set; }
}
public enum ResponseStatus
{
InProgress,
Completed,
PartiallyCompleted
}
Storage Strategy
| Entity | Primary Store | Reason |
|---|---|---|
| Forms | PostgreSQL | Relational, ACID for schema edits |
| Questions/Options | PostgreSQL | Part of form schema, needs transactions |
| Responses | PostgreSQL (partitioned) + S3 (archive) | Queryable, partitioned by form_id and date |
| Answers | PostgreSQL (JSONB column) | Flexible schema, indexed for analytics |
| File Attachments | S3 / Azure Blob | Binary objects, CDN-cacheable |
| Form Schema Snapshots | PostgreSQL | Version history |
| Analytics Aggregates | Redis + ClickHouse | Real-time counters + OLAP queries |
| Form Content (rendered) | CDN Edge Cache | Low-latency delivery worldwide |
Response Partitioning
At scale, the responses table must be partitioned. The optimal strategy is composite partitioning: first by form_id hash (for query locality), then by submission date (for time-range queries and archival).
SQL — Partitioned Response Table
CREATE TABLE responses (
id UUID PRIMARY KEY,
form_id UUID NOT NULL,
user_id UUID,
session_id VARCHAR(128),
status VARCHAR(20),
metadata JSONB,
started_at TIMESTAMPTZ NOT NULL,
submitted_at TIMESTAMPTZ,
score DOUBLE PRECISION,
created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY HASH (form_id);
CREATE TABLE responses_p0 PARTITION OF responses
FOR VALUES WITH (MODULUS 64, REMAINDER 0);
CREATE TABLE responses_p1 PARTITION OF responses
FOR VALUES WITH (MODULUS 64, REMAINDER 1);
-- ... through p63
CREATE INDEX idx_responses_form_submitted
ON responses (form_id, submitted_at);
CREATE INDEX idx_responses_user
ON responses (user_id) WHERE user_id IS NOT NULL;
6. High-Level Architecture
Service Responsibilities
| Service | Responsibility | Scaling Strategy |
|---|---|---|
| Form Service | CRUD for forms, schema management, versioning, sharing | Horizontal, read replicas for form reads |
| Response Service | Accept submissions, validate against schema, partial saves | Sharded by form_id, write-optimized |
| Analytics Service | Real-time aggregation, dashboard data, completion stats | ClickHouse for OLAP, Redis for counters |
| User Service | Authentication, profiles, team management, billing | Standard horizontal scaling |
| Notification Service | Email on submission, form alerts, digest emails | Async via message queue |
| Integration Service | Webhook dispatch, Slack/Zapier connectors, Google Sheets sync | Worker pool with rate limiting |
| Upload Service | Presigned URL generation, file validation, virus scanning | Stateless, S3-native |
| Logic Engine | Evaluate conditional branching rules per response | Stateless, embeddable in response service |
7. API Design
The API follows RESTful conventions with some extensions for complex operations like conditional logic evaluation and bulk analytics queries.
Form Management APIs
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/forms | Create a new form |
| GET | /api/v1/forms | List user's forms (paginated) |
| GET | /api/v1/forms/{id} | Get form schema (builder view) |
| PATCH | /api/v1/forms/{id} | Update form metadata |
| PUT | /api/v1/forms/{id}/schema | Replace full form schema (auto-versioned) |
| PATCH | /api/v1/forms/{id}/schema | Partial schema update (JSON patch) |
| DELETE | /api/v1/forms/{id} | Soft delete form |
| POST | /api/v1/forms/{id}/publish | Publish form (makes it live) |
| POST | /api/v1/forms/{id}/close | Close form (stops accepting responses) |
| GET | /api/v1/forms/{id}/versions | List schema versions |
| GET | /api/v1/forms/{id}/versions/{v} | Get specific version snapshot |
| POST | /api/v1/forms/{id}/duplicate | Clone form |
Response Collection APIs
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/forms/{id}/responses | Submit complete response |
| PUT | /api/v1/forms/{id}/responses/{rid}/partial | Save partial response |
| GET | /api/v1/forms/{id}/responses/{rid} | Get single response (auth required) |
| GET | /api/v1/forms/{id}/responses | List responses (paginated, filtered) |
| DELETE | /api/v1/forms/{id}/responses/{rid} | Delete a response |
| POST | /api/v1/forms/{id}/responses/export | Trigger export (returns job ID) |
Public (Respondent) APIs
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/public/forms/{token} | Get rendered form (no auth, CDN-cached) |
| POST | /api/v1/public/forms/{token}/responses | Submit response (anonymous or with auth) |
| PUT | /api/v1/public/forms/{token}/responses/{sid}/partial | Save partial progress |
| GET | /api/v1/public/forms/{token}/responses/{sid} | Resume partial response |
Analytics APIs
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/v1/forms/{id}/analytics/summary | Response count, completion rate, avg time |
| GET | /api/v1/forms/{id}/analytics/questions | Per-question analytics |
| GET | /api/v1/forms/{id}/analytics/dropoff | Question-by-question drop-off funnel |
| GET | /api/v1/forms/{id}/analytics/timeline | Responses over time (time-series) |
| WS | /ws/forms/{id}/analytics/live | WebSocket for real-time dashboard updates |
Example — Submit Response Endpoint
C# — Response Submission
[ApiController]
[Route("api/v1/public/forms")]
public class PublicResponseController : ControllerBase
{
private readonly IResponseService _responseService;
private readonly IFormSchemaValidator _schemaValidator;
private readonly ILogicEngine _logicEngine;
private readonly IEventBus _eventBus;
[HttpPost("{formToken}/responses")]
public async Task<ActionResult<SubmitResponseResult>> SubmitResponse(
string formToken,
[FromBody] SubmitResponseRequest request)
{
var form = await _responseService.GetPublishedFormAsync(formToken);
if (form == null) return NotFound("Form not found or not published");
if (!IsFormOpen(form))
return BadRequest("Form is not currently accepting responses");
if (await _responseService.HasReachedLimitAsync(form.Id))
return Conflict("Response limit reached");
var validationErrors = _schemaValidator.Validate(form.Schema, request.Answers);
if (validationErrors.Any())
return BadRequest(new { Errors = validationErrors });
var processedAnswers = _logicEngine.ProcessAnswers(form, request.Answers);
var response = new Response
{
FormId = form.Id,
SessionId = request.SessionId,
Status = ResponseStatus.Completed,
StartedAt = request.StartedAt,
SubmittedAt = DateTime.UtcNow,
Metadata = request.Metadata,
Answers = processedAnswers
};
if (form.Settings.IsQuizMode)
response.Score = _logicEngine.CalculateScore(form.Schema, processedAnswers);
var responseId = await _responseService.SaveResponseAsync(response);
await _eventBus.PublishAsync(new ResponseSubmittedEvent
{
ResponseId = responseId,
FormId = form.Id,
SubmittedAt = response.SubmittedAt.Value,
Score = response.Score
});
return Ok(new SubmitResponseResult
{
ResponseId = responseId,
Score = response.Score,
ThankYouMessage = form.Settings.ThankYouMessage
});
}
}
8. Form Logic & Branching
Conditional logic transforms a linear form into a dynamic decision tree. Respondents see only relevant questions based on their previous answers, creating personalized experiences and improving completion rates.
Logic Rule Types
| Rule Type | Description | Example |
|---|---|---|
| Skip Logic | Skip to a specific question based on answer | If Q3 = "No", skip to Q7 |
| Conditional Visibility | Show/hide a question based on answer | Show Q5 only if Q4 = "Other" |
| Branching | Navigate to different sections | If satisfaction < 3, go to feedback section |
| End Form | End the form early based on answer | If "Not interested", end immediately |
| Piping | Insert previous answer into question text | "You said {Q2}. Why?" |
| Calculations | Computed fields from numeric answers | Total = Q1 + Q2 + Q3 |
| Quota Logic | Stop accepting based on quotas | Max 100 responses from age group 18-24 |
Logic Rule Schema
C# — Logic Rule Models
public class LogicRule
{
public string Id { get; set; }
public string FormId { get; set; }
public string SourceQuestionId { get; set; }
public LogicCondition Condition { get; set; }
public LogicAction Action { get; set; }
public int Priority { get; set; } // lower = evaluated first
}
public abstract class LogicCondition
{
public string QuestionId { get; set; }
}
public class EqualsCondition : LogicCondition
{
public string OptionId { get; set; }
public string OtherText { get; set; }
}
public class ContainsCondition : LogicCondition
{
public List<string> OptionIds { get; set; }
}
public class NumericCondition : LogicCondition
{
public NumericOperator Operator { get; set; }
public decimal Value { get; set; }
}
public class TextCondition : LogicCondition
{
public TextOperator Operator { get; set; }
public string Value { get; set; }
}
public enum NumericOperator { Lt, Gt, Eq, Lte, Gte, Ne }
public enum TextOperator { Contains, NotContains, StartsWith, EndsWith, Regex }
public abstract class LogicAction { }
public class SkipToAction : LogicAction
{
public string TargetQuestionId { get; set; }
}
public class ShowQuestionAction : LogicAction
{
public string TargetQuestionId { get; set; }
}
public class HideQuestionAction : LogicAction
{
public string TargetQuestionId { get; set; }
}
public class EndFormAction : LogicAction { }
public class JumpToSectionAction : LogicAction
{
public string TargetSectionId { get; set; }
}
Evaluating Logic at Submission Time
C# — Logic Engine
public class LogicEngine : ILogicEngine
{
public List<Answer> ProcessAnswers(FormSchema form, List<Answer> answers)
{
var answerMap = answers.ToDictionary(a => a.QuestionId);
var rules = form.LogicRules.OrderBy(r => r.Priority).ToList();
var hiddenQuestions = new HashSet<string>();
var processedAnswers = new List<Answer>();
foreach (var question in form.Questions.OrderBy(q => q.OrderIndex))
{
if (hiddenQuestions.Contains(question.Id))
continue;
if (!answerMap.TryGetValue(question.Id, out var answer))
continue;
var triggeredRules = rules.Where(r =>
r.SourceQuestionId == question.Id &&
EvaluateCondition(r.Condition, answerMap));
foreach (var rule in triggeredRules)
{
switch (rule.Action)
{
case SkipToAction skip:
HideQuestionsInRange(form, question.Id,
skip.TargetQuestionId, hiddenQuestions);
break;
case HideQuestionAction hide:
hiddenQuestions.Add(hide.TargetQuestionId);
break;
case ShowQuestionAction show:
hiddenQuestions.Remove(show.TargetQuestionId);
break;
case EndFormAction:
return processedAnswers;
}
}
processedAnswers.Add(answer);
}
return processedAnswers;
}
private bool EvaluateCondition(LogicCondition condition,
Dictionary<string, Answer> answers)
{
if (!answers.TryGetValue(condition.QuestionId, out var answer))
return false;
return condition switch
{
EqualsCondition eq => answer.Value is ChoiceAnswer c
&& c.SelectedOptionIds.Contains(eq.OptionId),
ContainsCondition cc => answer.Value is ChoiceAnswer c2
&& cc.OptionIds.All(id => c2.SelectedOptionIds.Contains(id)),
NumericComparison nc => answer.Value is NumberAnswer n
&& CompareNumeric(nc.Operator, n.Number, nc.Value),
TextCondition tc => answer.Value is TextAnswer t
&& EvaluateTextCondition(tc.Operator, t.Text, tc.Value),
_ => false
};
}
}
9. Themes & Branding
Themes allow form creators to match their brand identity. The system supports predefined themes, custom color/font overrides, background images, and optional CSS injection for advanced users.
Theme Configuration
C# — Theme Model
public class FormTheme
{
public string PrimaryColor { get; set; } = "#4285f4";
public string SecondaryColor { get; set; } = "#34a853";
public string BackgroundColor { get; set; } = "#ffffff";
public string TextColor { get; set; } = "#202124";
public string FontFamily { get; set; } = "Google Sans, Roboto, sans-serif";
public string HeaderFontFamily { get; set; }
public string BackgroundImageUrl { get; set; }
public BackgroundPosition BackgroundPosition { get; set; }
public string LogoUrl { get; set; }
public int? LogoMaxWidth { get; set; }
public string CustomCss { get; set; }
public bool CoverPage { get; set; }
public string ProgressBarColor { get; set; }
public ButtonStyle ButtonStyle { get; set; }
public string HeaderAlignment { get; set; }
}
public enum BackgroundPosition { Cover, Tile, Center, Stretch }
public enum ButtonStyle { Rounded, Square, Pill }
public class PredefinedTheme
{
public string Id { get; set; }
public string Name { get; set; }
public string PreviewImageUrl { get; set; }
public FormTheme Theme { get; set; }
}
Theme Application Pipeline
Themes are applied during form rendering. The renderer combines the form schema with the theme configuration to produce the final HTML/CSS. Custom CSS is sanitized (CSS injection prevention) using a whitelist of safe CSS properties before inclusion.
10. Response Collection & Storage
Response collection is the most write-intensive part of the system. Every submission must be validated, stored, and trigger downstream events (analytics, notifications, integrations) within strict latency requirements.
Submission Flow
Write Path Optimization
- Batched Writes: For high-volume forms, responses are batched before database writes (micro-batching within 100ms windows).
- Write-Ahead for Analytics: Analytics counters in Redis are updated synchronously before the database write, ensuring the dashboard is always at least as up-to-date as the database.
- Async Downstream: Notifications, integrations, and export triggers are fully asynchronous via message queue.
- Connection Pooling: PgBouncer or similar connection pooler in front of PostgreSQL to handle connection spikes.
Concurrent Submission Handling
C# — Response Service
public class ResponseService : IResponseService
{
private readonly AppDbContext _db;
private readonly IRedisCache _cache;
private readonly IEventBus _eventBus;
public async Task<Guid> SaveResponseAsync(Response response)
{
var countKey = $"form:{response.FormId}:response_count";
await _cache.IncrementAsync(countKey);
await using var transaction = await _db.Database.BeginTransactionAsync();
try
{
_db.Responses.Add(response);
await _db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch (DbUpdateException)
{
await transaction.RollbackAsync();
throw new ResponseConflictException("Duplicate submission detected");
}
await _eventBus.PublishAsync(new ResponseSubmittedEvent
{
ResponseId = response.Id,
FormId = response.FormId,
SubmittedAt = response.SubmittedAt.Value,
Score = response.Score
});
return response.Id;
}
}
11. Real-Time Response Analytics
Form owners expect live dashboards showing response counts, completion rates, and per-question breakdowns as they happen. This requires a streaming analytics architecture.
Analytics Architecture
Real-Time Counter Model
C# — Analytics Aggregator
public class AnalyticsWorker : IEventHandler<ResponseSubmittedEvent>
{
private readonly IRedisCache _redis;
public async Task HandleAsync(ResponseSubmittedEvent evt)
{
var formId = evt.FormId.ToString();
await _redis.IncrementAsync($"analytics:{formId}:total_responses");
await _redis.IncrementAsync($"analytics:{formId}:completed_responses");
var hourBucket = evt.SubmittedAt.ToString("yyyyMMddHH");
await _redis.IncrementAsync($"analytics:{formId}:timeline:{hourBucket}");
foreach (var answer in evt.Answers)
{
var qKey = $"analytics:{formId}:q:{answer.QuestionId}";
if (answer.Value is ChoiceAnswer choice)
{
foreach (var optionId in choice.SelectedOptionIds)
await _redis.HashIncrementAsync($"{qKey}:options", optionId);
}
else if (answer.Value is RatingAnswer rating)
{
await _redis.ListRightPushAsync($"{qKey}:ratings", rating.Rating);
await _redis.IncrementAsync($"{qKey}:sum");
}
}
await _redis.PublishAsync($"form:{formId}:live",
JsonSerializer.Serialize(evt));
}
}
Analytics Dashboard Components
| Component | Data Source | Refresh Rate |
|---|---|---|
| Total Responses | Redis counter | Real-time (WebSocket push) |
| Completion Rate | Redis (completed / started) | Real-time |
| Average Completion Time | ClickHouse aggregate | 30-second polling |
| Response Timeline | ClickHouse time-series | 1-minute refresh |
| Per-Question Distribution | Redis hash + ClickHouse | Real-time for recent, batch for historical |
| NPS Score | ClickHouse aggregate | 1-minute refresh |
| Drop-off Funnel | ClickHouse | 5-minute refresh |
| Text Responses (Word Cloud) | ClickHouse + NLP pipeline | Hourly |
| Device/Browser Stats | ClickHouse | Hourly |
| Geographic Distribution | ClickHouse + GeoIP | Hourly |
Drop-Off Analysis
Drop-off analysis shows where respondents abandon the form. Each question tracks how many respondents reached it versus how many progressed to the next. This is computed by comparing the order of answers within each response.
12. Response Export — CSV, Excel, Google Sheets
Export is a resource-intensive operation for forms with millions of responses. It must be handled asynchronously with progress tracking and delivery via download link or direct cloud integration.
Export Architecture
Export Worker Implementation
C# — Export Worker
public class ExportWorker : IJobHandler<ExportJob>
{
private readonly AppDbContext _db;
private readonly IBlobStorage _storage;
public async Task HandleAsync(ExportJob job)
{
var form = await _db.Forms
.Include(f => f.Questions)
.FirstOrDefaultAsync(f => f.Id == job.FormId);
var fileName = $"{form.Title}_responses_{DateTime.UtcNow:yyyyMMdd}.{job.Format}";
var tempPath = Path.GetTempFileName();
await using var stream = File.Create(tempPath);
await using var writer = new StreamWriter(stream);
var headers = form.Questions
.OrderBy(q => q.OrderIndex)
.Select(q => q.Title)
.ToList();
headers.Insert(0, "Response ID");
headers.Insert(1, "Submitted At");
headers.Insert(2, "Status");
await writer.WriteLineAsync(string.Join(",", headers.Select(EscapeCsv)));
var batchSize = 1000;
long offset = 0;
long totalResponses = await _db.Responses
.CountAsync(r => r.FormId == job.FormId
&& r.Status == ResponseStatus.Completed);
while (true)
{
var batch = await _db.Responses
.Where(r => r.FormId == job.FormId
&& r.Status == ResponseStatus.Completed)
.OrderBy(r => r.SubmittedAt)
.Skip((int)offset)
.Take(batchSize)
.ToListAsync();
if (!batch.Any()) break;
foreach (var response in batch)
{
var row = new List<string>
{
response.Id.ToString(),
response.SubmittedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "",
response.Status.ToString()
};
foreach (var question in form.Questions.OrderBy(q => q.OrderIndex))
{
var answer = response.Answers
.FirstOrDefault(a => a.QuestionId == question.Id);
row.Add(answer != null ? FormatAnswerValue(answer.Value) : "");
}
await writer.WriteLineAsync(string.Join(",", row.Select(EscapeCsv)));
}
offset += batch.Count;
await UpdateJobProgressAsync(job.Id, (double)offset / totalResponses);
}
var s3Key = $"exports/{job.Id}/{fileName}";
await _storage.UploadAsync(s3Key, tempPath);
File.Delete(tempPath);
var downloadUrl = await _storage.GetPresignedUrlAsync(s3Key,
TimeSpan.FromHours(24));
await NotifyExportCompleteAsync(job, downloadUrl);
}
}
Google Sheets Integration
For Google Sheets sync, the export worker uses the Google Sheets API v4 to create or update a spreadsheet. A background job keeps the sheet in sync by polling for new responses every 5 minutes and appending rows incrementally.
14. Form Validation
Validation happens at two layers: client-side (immediate UX feedback) and server-side (authoritative enforcement). Both layers share the same validation rules to ensure consistency.
Validation Rule Types
| Rule | Applies To | Example |
|---|---|---|
| Required | All types | Question must be answered |
| Min Length | Text | At least 10 characters |
| Max Length | Text | No more than 500 characters |
| Regex Pattern | Text, Email, Phone | Phone: ^\+?[1-9]\d{1,14}$ |
| Min Selections | Checkboxes | Select at least 2 options |
| Max Selections | Checkboxes, Multi-select | Select no more than 5 |
| Numeric Range | Number | Between 1 and 100 |
| Date Range | Date | Must be in the past |
| File Type | File Upload | Only PDF, JPG, PNG |
| File Size | File Upload | Max 10MB per file |
| Custom JavaScript | All types | Custom validation function |
Server-Side Validation
C# — Schema Validator
public class FormSchemaValidator : IFormSchemaValidator
{
public List<ValidationError> Validate(
FormSchema schema, List<Answer> answers)
{
var errors = new List<ValidationError>();
var answerMap = answers.ToDictionary(a => a.QuestionId);
foreach (var question in schema.Questions)
{
if (question.IsHiddenByLogic) continue;
answerMap.TryGetValue(question.Id, out var answer);
if (question.Config.IsRequired
&& (answer == null || IsEmpty(answer.Value)))
{
errors.Add(new ValidationError
{
QuestionId = question.Id,
Message = $"Question '{question.Title}' is required",
ErrorCode = "REQUIRED"
});
continue;
}
if (answer == null) continue;
var typeErrors = question.Config switch
{
TextConfig tc => ValidateText(answer, tc),
MultipleChoiceConfig mc => ValidateChoice(answer, mc),
NumberConfig nc => ValidateNumber(answer, nc),
FileUploadConfig fc => ValidateFile(answer, fc),
DateConfig dc => ValidateDate(answer, dc),
EmailConfig ec => ValidateEmail(answer, ec),
_ => Enumerable.Empty<ValidationError>()
};
errors.AddRange(typeErrors);
if (!string.IsNullOrEmpty(question.Config.ValidationRuleJson))
{
var customErrors = EvaluateCustomValidation(
answer, question.Config.ValidationRuleJson);
errors.AddRange(customErrors);
}
}
return errors;
}
private IEnumerable<ValidationError> ValidateText(
Answer answer, TextConfig config)
{
if (answer.Value is not TextAnswer text) yield break;
if (config.MaxLength.HasValue
&& text.Text.Length > config.MaxLength)
yield return new ValidationError
{
QuestionId = answer.QuestionId,
Message = $"Text must be no longer than {config.MaxLength} characters",
ErrorCode = "MAX_LENGTH"
};
if (!string.IsNullOrEmpty(config.Pattern)
&& !Regex.IsMatch(text.Text, config.Pattern))
yield return new ValidationError
{
QuestionId = answer.QuestionId,
Message = config.PatternErrorMessage ?? "Invalid format",
ErrorCode = "REGEX"
};
}
private IEnumerable<ValidationError> ValidateChoice(
Answer answer, MultipleChoiceConfig config)
{
if (answer.Value is not ChoiceAnswer choice) yield break;
if (config.MinSelections.HasValue
&& choice.SelectedOptionIds.Count < config.MinSelections)
yield return new ValidationError
{
QuestionId = answer.QuestionId,
Message = $"Select at least {config.MinSelections} option(s)",
ErrorCode = "MIN_SELECTIONS"
};
if (config.MaxSelections.HasValue
&& choice.SelectedOptionIds.Count > config.MaxSelections)
yield return new ValidationError
{
QuestionId = answer.QuestionId,
Message = $"Select no more than {config.MaxSelections} option(s)",
ErrorCode = "MAX_SELECTIONS"
};
}
private bool IsEmpty(AnswerValue value) => value switch
{
TextAnswer t => string.IsNullOrWhiteSpace(t.Text),
ChoiceAnswer c => !c.SelectedOptionIds.Any(),
NumberAnswer n => false,
RatingAnswer r => false,
FileAnswer f => !f.Files.Any(),
_ => true
};
}
15. File Upload Handling
File uploads in forms must handle images, documents, spreadsheets, and other file types securely. The system must scan for viruses, enforce size limits, and store files durably.
Upload Flow
Presigned URL Upload
C# — Upload Service
public class UploadService : IUploadService
{
private readonly IBlobStorage _storage;
private readonly IVirusScanner _virusScanner;
private readonly AppDbContext _db;
public async Task<PresignedUploadResponse> GenerateUploadUrlAsync(
Guid formId, string fileName, string contentType, long fileSize)
{
var question = await _db.Questions
.OfType<FileUploadConfig>()
.FirstOrDefaultAsync(q => q.FormId == formId);
if (question == null)
throw new InvalidOperationException(
"Form does not accept file uploads");
if (!question.AllowedMimeTypes.Contains(contentType))
throw new ValidationException(
$"File type '{contentType}' is not allowed");
if (fileSize > question.MaxFileSizeBytes)
throw new ValidationException(
$"File size exceeds maximum of {question.MaxFileSizeBytes / 1024 / 1024}MB");
var extension = Path.GetExtension(fileName);
var storageKey = $"form-uploads/{formId}/{Guid.NewGuid()}{extension}";
var uploadUrl = await _storage.GeneratePresignedUrlAsync(
storageKey, HttpMethod.Put, TimeSpan.FromMinutes(15),
new Dictionary<string, string>
{
["Content-Type"] = contentType,
["Content-Length"] = fileSize.ToString()
});
var attachment = new FileAttachment
{
FileName = fileName,
StorageKey = storageKey,
FileSize = fileSize,
MimeType = contentType,
Status = FileStatus.Uploaded
};
_db.FileAttachments.Add(attachment);
await _db.SaveChangesAsync();
return new PresignedUploadResponse
{
FileId = attachment.Id,
UploadUrl = uploadUrl,
StorageKey = storageKey,
ExpiresAt = DateTime.UtcNow.AddMinutes(15)
};
}
public async Task ConfirmUploadAsync(Guid fileId)
{
var file = await _db.FileAttachments.FindAsync(fileId);
file.Status = FileStatus.PendingScan;
await _db.SaveChangesAsync();
await _virusScanner.EnqueueScanAsync(file.StorageKey, fileId);
}
}
16. Anonymous vs Authenticated Responses
Forms can collect responses from anonymous users or require authentication. This is a per-form setting that affects data collection, analytics, and response deduplication.
Authentication Modes
| Mode | Data Collected | Deduplication | Use Case |
|---|---|---|---|
| Fully Anonymous | None (no login required) | Cookie-based session | Public surveys, feedback forms |
| Email Only | Email address (self-reported) | Email uniqueness check | Surveys requiring follow-up |
| Verified Email | Verified email via magic link | Verified email uniqueness | Employee surveys, customer feedback |
| SSO Required | Authenticated user profile | User ID uniqueness | Internal surveys, compliance forms |
| Invite Only | Pre-registered email list | Invite token uniqueness | Panel surveys, paid research |
Session Management for Anonymous Responses
C# — Anonymous Response Handler
public class AnonymousResponseHandler
{
public string GetOrCreateSessionId(
HttpRequest request, HttpResponse response)
{
if (request.Cookies.TryGetValue("form_session", out var existing))
return existing;
var sessionId = Convert.ToBase64String(
RandomNumberGenerator.GetBytes(16))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
response.Cookies.Append("form_session", sessionId,
new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSitePolicy.Lax,
Expires = DateTimeOffset.UtcNow.AddYears(1),
Path = "/"
});
return sessionId;
}
public async Task<bool> IsDuplicateAsync(
Guid formId, string sessionId, string? email = null)
{
var bySession = await _db.Responses.AnyAsync(r =>
r.FormId == formId
&& r.SessionId == sessionId
&& r.Status == ResponseStatus.Completed);
if (bySession) return true;
if (!string.IsNullOrEmpty(email))
{
var byEmail = await _db.Responses.AnyAsync(r =>
r.FormId == formId
&& r.Metadata["email"].ToString() == email
&& r.Status == ResponseStatus.Completed);
if (byEmail) return true;
}
return false;
}
}
17. Multi-Language Forms
Multi-language forms allow a single form to serve respondents in different languages. Translations are stored as locale-specific overrides on top of the base form schema.
Translation Model
C# — Translation Schema
public class FormTranslation
{
public string Locale { get; set; } // "en", "es", "fr", "de", "ja"
public string FormTitle { get; set; }
public string FormDescription { get; set; }
public Dictionary<string, QuestionTranslation> Questions { get; set; }
public Dictionary<string, string> UIStrings { get; set; }
// "submit_button": "Submit" -> "Enviar"
// "required_field": "This field is required"
// -> "Este campo es obligatorio"
// "thank_you": "Thank you!" -> "!Gracias!"
}
public class QuestionTranslation
{
public string Title { get; set; }
public string Description { get; set; }
public Dictionary<string, string> Options { get; set; }
public string Placeholder { get; set; }
public string ValidationMessage { get; set; }
}
Language Selection Strategy
- URL parameter:
?lang=es(highest priority) - Browser's
Accept-Languageheader - Saved preference in cookie
- Form's default language
18. Quiz Mode & Auto-Grading
Quiz mode transforms a form into a scored assessment with correct answers, point values, time limits, and automatic grading. It is essential for educational platforms, training assessments, and certification tests.
Quiz Configuration
C# — Quiz Settings
public class QuizSettings
{
public bool IsEnabled { get; set; }
public int? TimeLimitMinutes { get; set; }
public bool ShowCorrectAnswers { get; set; }
public bool ShowScore { get; set; }
public bool AllowRetakes { get; set; }
public int? MaxRetakes { get; set; }
public int? PassingScorePercentage { get; set; }
public string PassingMessage { get; set; }
public string FailingMessage { get; set; }
public bool ShuffleQuestions { get; set; }
public bool ShuffleOptions { get; set; }
public int? QuestionsPerPage { get; set; }
}
public class QuestionQuizConfig
{
public int Points { get; set; } = 1;
public List<string> CorrectOptionIds { get; set; }
public string CorrectTextAnswer { get; set; }
public decimal? CorrectNumericAnswer { get; set; }
public string Explanation { get; set; }
public bool CaseSensitive { get; set; }
public decimal? NumericTolerance { get; set; }
}
Grading Algorithm
C# — Auto-Grading Engine
public class GradingEngine : IGradingEngine
{
public QuizResult GradeQuiz(
FormSchema form, List<Answer> answers)
{
var result = new QuizResult();
var answerMap = answers.ToDictionary(a => a.QuestionId);
int totalPoints = 0;
int earnedPoints = 0;
foreach (var question in form.Questions
.Where(q => q.QuizConfig != null))
{
totalPoints += question.QuizConfig.Points;
if (!answerMap.TryGetValue(question.Id, out var answer))
{
result.Details.Add(new QuestionGrade
{
QuestionId = question.Id,
IsCorrect = false,
PointsEarned = 0,
PointsPossible = question.QuizConfig.Points
});
continue;
}
var isCorrect = EvaluateAnswer(question, answer);
var points = isCorrect ? question.QuizConfig.Points : 0;
earnedPoints += points;
result.Details.Add(new QuestionGrade
{
QuestionId = question.Id,
IsCorrect = isCorrect,
PointsEarned = points,
PointsPossible = question.QuizConfig.Points,
Explanation = question.QuizConfig.Explanation,
CorrectAnswer = GetCorrectAnswerDisplay(question)
});
}
result.TotalPoints = totalPoints;
result.EarnedPoints = earnedPoints;
result.Percentage = totalPoints > 0
? (double)earnedPoints / totalPoints * 100 : 0;
result.Passed = form.QuizSettings.PassingScorePercentage.HasValue
? result.Percentage >= form.QuizSettings.PassingScorePercentage
: true;
return result;
}
private bool EvaluateAnswer(Question question, Answer answer)
{
var config = question.QuizConfig;
return question.Config switch
{
MultipleChoiceConfig mc => answer.Value is ChoiceAnswer c
&& AreEqual(c.SelectedOptionIds, config.CorrectOptionIds),
NumberConfig _ => answer.Value is NumberAnswer n
&& Math.Abs(n.Number - config.CorrectNumericAnswer.Value)
<= (config.NumericTolerance ?? 0.001m),
TextConfig _ => answer.Value is TextAnswer t
&& string.Equals(t.Text.Trim(),
config.CorrectTextAnswer.Trim(),
config.CaseSensitive
? StringComparison.Ordinal
: StringComparison.OrdinalIgnoreCase),
_ => false
};
}
}
Timer Implementation
For timed quizzes, the timer runs client-side (for UX) but the deadline is enforced server-side. The server records the quiz start time when the form is first opened and calculates whether the submission arrived within the time limit. A clock-skew tolerance of 30 seconds prevents gaming.
19. Form Scheduling & Response Limits
Forms can be configured with availability windows and response caps to control when they accept submissions and how many responses they collect.
Scheduling Configuration
C# — Form Scheduling
public class FormSchedule
{
public DateTime? OpensAt { get; set; }
public DateTime? ClosesAt { get; set; }
public int? MaxResponses { get; set; }
public bool AllowPartialAfterClose { get; set; }
public string ClosedMessage { get; set; }
public string FullMessage { get; set; }
public List<AvailabilityWindow> RecurringWindows { get; set; }
}
public class AvailabilityWindow
{
public DayOfWeek Day { get; set; }
public TimeOnly OpensAt { get; set; }
public TimeOnly ClosesAt { get; set; }
}
public bool IsFormOpen(Form form, DateTime utcNow)
{
var schedule = form.Settings.Schedule;
if (schedule.OpensAt.HasValue && utcNow < schedule.OpensAt)
return false;
if (schedule.ClosesAt.HasValue && utcNow > schedule.ClosesAt)
return false;
if (schedule.RecurringWindows.Any())
{
var today = utcNow.DayOfWeek;
var time = TimeOnly.FromDateTime(utcNow);
var window = schedule.RecurringWindows
.FirstOrDefault(w => w.Day == today);
if (window == null) return false;
if (time < window.OpensAt || time > window.ClosesAt)
return false;
}
return true;
}
20. Partial Responses & Save-and-Continue
Long forms benefit from save-and-continue functionality. Respondents can save partial progress and resume later via a unique link sent to their email.
Partial Save Flow
C# — Partial Response Service
public class PartialResponseService
{
private readonly AppDbContext _db;
public async Task SavePartialAsync(
string formToken, string sessionId, List<Answer> answers)
{
var form = await _db.Forms.FirstOrDefaultAsync(
f => f.ShareToken == formToken);
var response = await _db.Responses
.FirstOrDefaultAsync(r =>
r.FormId == form.Id
&& r.SessionId == sessionId);
if (response == null)
{
response = new Response
{
FormId = form.Id,
SessionId = sessionId,
Status = ResponseStatus.InProgress,
StartedAt = DateTime.UtcNow
};
_db.Responses.Add(response);
}
response.Answers = answers;
response.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
public async Task<string> GenerateResumeLinkAsync(
Guid responseId, string? email)
{
var resumeToken = Convert.ToBase64String(
RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
var response = await _db.Responses.FindAsync(responseId);
response.ResumeToken = resumeToken;
response.ResumeEmail = email;
await _db.SaveChangesAsync();
return $"https://forms.yourapp.com/resume/{resumeToken}";
}
}
21. Notifications on Submission
Form owners need immediate notification when responses arrive. The system supports email alerts, Slack notifications, webhook triggers, and in-app notifications.
Notification Configuration
C# — Notification Settings
public class FormNotificationSettings
{
public bool EmailOnResponse { get; set; } = true;
public List<string> NotificationEmails { get; set; }
public bool DailyDigest { get; set; }
public bool WeeklyDigest { get; set; }
public int DigestHour { get; set; } = 9;
public SlackConfig Slack { get; set; }
public WebhookConfig Webhook { get; set; }
public bool InAppNotification { get; set; } = true;
}
public class SlackConfig
{
public bool Enabled { get; set; }
public string WebhookUrl { get; set; }
public string Channel { get; set; }
public string MessageTemplate { get; set; }
}
public class WebhookConfig
{
public bool Enabled { get; set; }
public string Url { get; set; }
public string Secret { get; set; } // HMAC signing
public List<string> Events { get; set; }
}
public class NotificationWorker : IEventHandler<ResponseSubmittedEvent>
{
public async Task HandleAsync(ResponseSubmittedEvent evt)
{
var settings = await _settingsService.GetForFormAsync(evt.FormId);
if (settings.EmailOnResponse)
await _emailService.SendResponseAlertAsync(
settings.NotificationEmails, evt);
if (settings.Slack?.Enabled)
await _slackService.SendAsync(settings.Slack.WebhookUrl, evt);
if (settings.Webhook?.Enabled)
await _webhookService.DispatchAsync(settings.Webhook, evt);
}
}
22. Integrations — Slack, Zapier, Webhooks
Integrations connect the forms platform to the broader ecosystem, enabling automated workflows triggered by form submissions.
Integration Architecture
Webhook Implementation
C# — Webhook Dispatcher
public class WebhookDispatcher
{
private readonly HttpClient _httpClient;
private readonly AppDbContext _db;
public async Task DispatchAsync(
WebhookConfig config, ResponseSubmittedEvent evt)
{
var payload = new WebhookPayload
{
Event = "response.submitted",
Timestamp = DateTime.UtcNow,
FormId = evt.FormId,
ResponseId = evt.ResponseId,
Data = evt.Answers
};
var json = JsonSerializer.Serialize(payload);
var content = new StringContent(json, Encoding.UTF8, "application/json");
var signature = ComputeHmacSha256(config.Secret, json);
content.Headers.Add("X-Webhook-Signature", signature);
content.Headers.Add("X-Webhook-Event", "response.submitted");
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
var response = await _httpClient.PostAsync(config.Url, content);
if (response.IsSuccessStatusCode) return;
await LogWebhookAttemptAsync(config.Id, evt, attempt,
response.StatusCode);
}
catch (HttpRequestException ex)
{
await LogWebhookAttemptAsync(config.Id, evt, attempt, ex);
}
await Task.Delay(TimeSpan.FromSeconds(Math.Pow(5, attempt)));
}
await MarkWebhookUnhealthyAsync(config.Id);
}
private string ComputeHmacSha256(string secret, string payload)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
Google Sheets Integration
The Google Sheets integration uses OAuth 2.0 to connect to the user's Google account. Once authorized, a background worker syncs new responses to the spreadsheet every 5 minutes, appending rows incrementally. The sheet can be auto-created on first connection with headers matching the form questions.
Zapier Integration
Zapier integration uses the REST hook pattern: users create a Zap, the platform registers a webhook URL, and new responses trigger the webhook with a standardized payload. The platform also provides a Zapier action for creating responses programmatically.
23. Form Performance Analytics
Beyond response-level analytics, form owners need to understand form performance — how many people view, start, and complete their forms.
Key Metrics
| Metric | Formula | Target Insight |
|---|---|---|
| View Count | Unique page loads | How many people see the form |
| Start Rate | Started / Viewed | First impression effectiveness |
| Completion Rate | Completed / Started | Form usability and length impact |
| Average Completion Time | Avg(submittedAt - startedAt) | Expected respondent effort |
| Median Completion Time | Median of above | Skew-resistant time estimate |
| Drop-Off Rate per Question | (N_q - N_q+1) / N_q | Which questions cause abandonment |
| Device Split | Desktop vs Mobile vs Tablet | Responsive design priorities |
| Geographic Distribution | GeoIP of submissions | Audience demographics |
| Referrer Analysis | HTTP Referer header | Which channels drive responses |
| Time-of-Day Distribution | Hourly histogram | Optimal send times for email campaigns |
Performance Tracking Implementation
C# — Performance Tracking Middleware
public class FormPerformanceMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context)
{
var path = context.Request.Path.Value;
if (Regex.IsMatch(path,
@"/api/v1/public/forms/([a-zA-Z0-9]+)$")
&& context.Request.Method == "GET")
{
var token = Regex.Match(path).Groups[1].Value;
await TrackFormViewAsync(token, context);
}
await _next(context);
}
private async Task TrackFormViewAsync(
string token, HttpContext context)
{
var formId = await _formService.GetFormIdByTokenAsync(token);
var viewKey = $"perf:{formId}:views";
await _redis.IncrementAsync(viewKey);
var viewEvent = new FormViewEvent
{
FormId = formId,
ViewedAt = DateTime.UtcNow,
UserAgent = context.Request.Headers.UserAgent.ToString(),
Referrer = context.Request.Headers.Referer.ToString(),
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
DeviceType = DetectDeviceType(
context.Request.Headers.UserAgent)
};
await _eventBus.PublishAsync(viewEvent);
}
}
24. Spam Protection
Forms are a common vector for spam and abuse. The platform implements multiple layers of protection without creating friction for legitimate respondents.
Protection Layers
| Layer | Mechanism | Threshold |
|---|---|---|
| CAPTCHA | Google reCAPTCHA v3 invisible | Score below 0.3 triggers challenge |
| Rate Limiting | Per-IP per-form submission limits | 10 submissions per hour per IP |
| Duplicate Detection | Session cookie and timing heuristics | Same session within 30 seconds |
| Honeypot Fields | Hidden fields that bots fill | If filled then reject |
| Token Expiration | Form link tokens with expiry | Configurable 7d 30d never |
| Bot Detection | Behavioral analysis | Suspicious patterns then CAPTCHA |
| IP Reputation | Check against known bot IPs | Known bot then block |
| Content Filtering | Spam keyword detection | Match then flag for review |
C# - Spam Protection Pipeline
public class SpamProtectionMiddleware
{
private readonly IRedisCache _redis;
public async Task<SpamCheckResult> CheckAsync(
HttpRequest request, Guid formId)
{
var ip = request.HttpContext.Connection.RemoteIpAddress?.ToString();
var rateLimitKey = $"ratelimit:{formId}:{ip}";
var count = await _redis.IncrementAsync(rateLimitKey);
if (count == 1)
await _redis.SetExAsync(rateLimitKey,
TimeSpan.FromHours(1), "1");
if (count > 10)
return new SpamCheckResult
{ Blocked = true, Reason = "Rate limit exceeded" };
if (request.Form.ContainsKey("website_url"))
return new SpamCheckResult
{ Blocked = true, Reason = "Honeypot triggered" };
var captchaToken = request.Headers["X-Recaptcha-Token"].FirstOrDefault();
if (!string.IsNullOrEmpty(captchaToken))
{
var score = await VerifyCaptchaAsync(captchaToken);
if (score < 0.3)
return new SpamCheckResult
{ Blocked = true, Reason = "CAPTCHA score too low" };
}
return new SpamCheckResult { Blocked = false };
}
}
25. Accessibility - WCAG Compliance
Form accessibility ensures all users, including those with disabilities, can interact with forms. WCAG 2.1 AA compliance is the target.
WCAG Requirements for Forms
| WCAG Criterion | Requirement | Implementation |
|---|---|---|
| 1.3.1 Info and Relationships | Form fields have proper labels | HTML label elements associated via for/id |
| 1.3.5 Identify Input Purpose | Input purpose declared | autocomplete attributes on all fields |
| 1.4.3 Contrast Minimum | 4.5:1 text contrast ratio | Theme validator checks contrast ratios |
| 1.4.11 Non-text Contrast | 3:1 for UI components | Buttons, borders, focus indicators |
| 2.1.1 Keyboard | All functionality via keyboard | Tab order, Enter to select, Arrow keys |
| 2.4.3 Focus Order | Logical focus sequence | Visual order matches DOM order |
| 2.4.6 Headings and Labels | Descriptive labels | Required field indicators, error descriptions |
| 2.4.7 Focus Visible | Visible focus indicator | Custom focus ring CSS |
| 3.3.1 Error Identification | Errors described in text | Inline errors with aria-describedby |
| 3.3.2 Labels or Instructions | Labels and help text | Every question has visible label and description |
| 3.3.3 Error Suggestion | Suggest corrections | Format hints, regex patterns, examples |
| 4.1.2 Name Role Value | ARIA attributes | Custom widgets use ARIA roles and states |
Accessibility Testing
- Automated: Axe-core integrated into CI pipeline, runs on every component render
- Manual: Keyboard-only navigation testing, screen reader testing (NVDA, VoiceOver)
- Contrast: Theme customizer warns when color combinations fail WCAG contrast ratios
- ARIA Live Regions: Dynamic content updates announced via aria-live=polite
26. Mobile Optimization
Over 60% of form responses come from mobile devices. The mobile experience must be fast, touch-friendly, and fully functional.
Mobile-Specific Optimizations
| Optimization | Details |
|---|---|
| Responsive Layout | Single-column on mobile, multi-column on desktop |
| Touch Targets | Minimum 44x44px for all interactive elements |
| Virtual Keyboard | Appropriate input types for email, tel, number |
| Lazy Loading | Questions load progressively not all at once |
| Offline Support | Service worker caches form, responses queue locally |
| Image Optimization | WebP/AVIF with fallbacks, responsive srcset |
| Font Loading | Font-display swap for fast first paint |
| Minimized JavaScript | Tree-shaken bundle, code-split by question type |
| Viewport Meta | width=device-width, initial-scale=1 |
| PWA Support | Installable as home screen app, offline capable |
27. Monitoring and Observability
Comprehensive monitoring ensures the platform reliability and provides insights into usage patterns and performance.
Monitoring Stack
Key Metrics and Alerts
| Metric | Threshold | Severity |
|---|---|---|
| Response submission latency p99 | above 500ms | Warning |
| Response submission latency p99 | above 2s | Critical |
| Form load time p95 | above 300ms | Warning |
| Error rate (5xx) | above 0.1% | Warning |
| Error rate (5xx) | above 1% | Critical |
| Database connection pool utilization | above 80% | Warning |
| Message queue lag | above 10000 messages | Warning |
| S3 upload failure rate | above 0.5% | Critical |
| Analytics dashboard update lag | above 30 seconds | Warning |
| CAPTCHA verification failure rate | above 5% | Warning |
| Virus scan queue depth | above 1000 | Warning |
| Export job duration p95 | above 10 minutes | Warning |
Distributed Tracing
Every request gets a trace ID that follows it through all services. This is critical for debugging latency issues in the response submission path, which touches the API Gateway, Response Service, PostgreSQL, Redis, and message queue.
28. Security
Forms often collect sensitive data - personal information, employee feedback, medical questionnaires. Security is paramount.
Security Measures
| Layer | Measure | Details |
|---|---|---|
| Transport | TLS 1.3 | All traffic encrypted, HSTS enforced |
| Storage | AES-256 encryption at rest | Database encryption, S3 SSE-KMS |
| Authentication | JWT + refresh tokens | Short-lived access tokens (15 min) |
| Authorization | RBAC | Owner, Editor, Viewer roles per form |
| API Security | Rate limiting, input validation | OWASP Top 10 mitigation |
| File Security | Virus scanning, type validation | ClamAV, MIME type verification |
| XSS Prevention | CSP, output encoding | Form titles and descriptions sanitized |
| CSRF Protection | Double-submit cookie pattern | All mutation endpoints protected |
| SQL Injection | Parameterized queries (EF Core) | No raw SQL concatenation |
| Data Masking | Response data masked in logs | PII never appears in application logs |
| Audit Logging | All admin actions logged | Form edits, response deletes, sharing changes |
| Vulnerability Scanning | Dependency scanning (Snyk) | Weekly automated scans |
| Penetration Testing | Annual third-party pentest | Scope includes form submission, file upload |
29. Compliance - GDPR for Survey Data
Survey data often contains personal data subject to GDPR, CCPA, and other privacy regulations. The platform must support data subject rights and compliance requirements.
GDPR Requirements Implementation
| GDPR Right | Implementation |
|---|---|
| Right to Information (Art. 13-14) | Form owners can add privacy notice and consent checkbox |
| Right of Access (Art. 15) | API endpoint to export all data for a user |
| Right to Erasure (Art. 17) | Response deletion cascade: delete answers, files, analytics |
| Right to Rectification (Art. 16) | Response editing by respondent if enabled by form owner |
| Data Portability (Art. 20) | Machine-readable export (JSON, CSV) |
| Consent Management | Consent checkbox with timestamp, IP, and version tracking |
| Data Processing Agreement | Platform acts as processor; form owners are controllers |
| Data Retention | Configurable retention periods, auto-deletion after expiry |
| Breach Notification | Automated detection and 72-hour notification pipeline |
C# - GDPR Data Deletion Service
public class GdprDeletionService
{
private readonly AppDbContext _db;
private readonly IBlobStorage _storage;
private readonly IAnalyticsStore _analytics;
public async Task DeleteAllUserDataAsync(Guid userId)
{
using var transaction = await _db.Database.BeginTransactionAsync();
var responses = await _db.Responses
.Where(r => r.UserId == userId)
.ToListAsync();
var responseIds = responses.Select(r => r.Id).ToList();
await _db.Answers
.Where(a => responseIds.Contains(a.ResponseId))
.ExecuteDeleteAsync();
var files = await _db.FileAttachments
.Where(f => f.Answers.Any(a => responseIds.Contains(a.ResponseId)))
.ToListAsync();
foreach (var file in files)
await _storage.DeleteAsync(file.StorageKey);
await _db.FileAttachments
.Where(f => f.Answers.Any(a => responseIds.Contains(a.ResponseId)))
.ExecuteDeleteAsync();
await _db.Responses
.Where(r => r.UserId == userId)
.ExecuteDeleteAsync();
await _analytics.AnonymizeUserDataAsync(userId);
await _db.Users.Where(u => u.Id == userId).ExecuteDeleteAsync();
await transaction.CommitAsync();
await _auditLog.LogAsync(new AuditEntry
{
Action = "GDPR_DELETION",
TargetUserId = userId,
Details = $"All data deleted. {responses.Count} responses, "
+ $"{files.Count} files removed.",
Timestamp = DateTime.UtcNow
});
}
}
30. Cost Estimation
Running a forms platform at scale involves predictable infrastructure costs. Here is an estimate for a platform handling 50M forms and 10B responses.
Monthly Cost Breakdown (AWS)
| Component | Specification | Monthly Cost (USD) |
|---|---|---|
| PostgreSQL (RDS Multi-AZ) | db.r6g.2xlarge x 3, 5TB storage | ,500 |
| Redis (ElastiCache) | r6g.xlarge cluster, 3 shards | ,400 |
| S3 Storage | 10TB file uploads + 2TB analytics exports | |
| S3 Requests | 100M PUT + 500M GET per month | |
| CloudFront CDN | 500TB transfer, 1B requests | ,000 |
| EKS (Kubernetes) | 20 x m6i.2xlarge nodes | ,500 |
| Application Load Balancer | 2 ALBs with high throughput | |
| Kafka (MSK) | m5.large x 3 brokers | ,200 |
| ClickHouse (self-managed) | r6i.2xlarge x 4 nodes, 4TB SSD | ,200 |
| OpenSearch | r6g.xlarge.search x 3 nodes | ,800 |
| Email (SES) | 10M emails/month | |
| DNS (Route 53) | 50 hosted zones, 500M queries | |
| Monitoring (CloudWatch + Grafana) | Custom metrics, dashboards | |
| WAF | Web ACL + managed rules | |
| Data Transfer | Inter-AZ, internet egress | ,000 |
| Total | ~,700/month |
31. Testing Strategy
A comprehensive testing strategy ensures correctness across the dynamic schema validation, branching logic, real-time analytics, and integration systems.
Testing Pyramid
| Level | Scope | Count Target | Key Areas |
|---|---|---|---|
| Unit Tests | Individual functions and classes | 2000+ | Schema validation, grading engine, logic engine, theme merging |
| Integration Tests | Service and database interactions | 500+ | API endpoints, response persistence, analytics aggregation |
| Contract Tests | API contracts between services | 200+ | Event schemas, webhook payloads, API request/response |
| E2E Tests | Full user workflows | 100+ | Form creation, submission, analytics, export |
| Performance Tests | Load and stress testing | 20+ | Concurrent submissions, large form rendering, export throughput |
| Security Tests | Vulnerability assessment | 50+ | SQL injection, XSS, CSRF, file upload abuse |
| Accessibility Tests | WCAG compliance | 30+ | Axe-core scans, keyboard navigation, screen reader |
Key Test Scenario: Branching Logic
C# - Branching Logic Unit Test
[Fact]
public async Task SkipLogic_ShouldSkipQuestions_WhenConditionMet()
{
var form = new FormSchema
{
Questions = new List<Question>
{
new MultipleChoiceConfig
{
Id = "q1",
Title = "Do you have a car?",
Options = new List<OptionItem>
{
new() { Id = "yes", Label = "Yes" },
new() { Id = "no", Label = "No" }
}
},
new TextConfig { Id = "q2", Title = "What brand?" },
new RatingConfig { Id = "q3", Title = "How satisfied?" },
new TextConfig { Id = "q4", Title = "Why not satisfied?" }
},
LogicRules = new List<LogicRule>
{
new()
{
SourceQuestionId = "q1",
Condition = new EqualsCondition
{ QuestionId = "q1", OptionId = "no" },
Action = new SkipToAction { TargetQuestionId = "q3" },
Priority = 1
}
}
};
var answers = new List<Answer>
{
new() { QuestionId = "q1", Value = new ChoiceAnswer
{ SelectedOptionIds = new() { "no" } } },
new() { QuestionId = "q3", Value = new RatingAnswer { Rating = 4 } },
new() { QuestionId = "q4", Value = new TextAnswer
{ Text = "Too expensive" } }
};
var engine = new LogicEngine();
var processedAnswers = engine.ProcessAnswers(form, answers);
Assert.Equal(2, processedAnswers.Count);
Assert.DoesNotContain(processedAnswers,
a => a.QuestionId == "q2");
Assert.Contains(processedAnswers,
a => a.QuestionId == "q3");
}
Load Testing
Use k6 or Locust to simulate 1M concurrent respondents filling out forms. Key scenarios: (1) form rendering under cache miss conditions, (2) response submission spike during a viral form campaign, (3) simultaneous export of large response sets, (4) real-time analytics update throughput under load.
32. Interview Q&A
Q1: How would you handle a form with 10,000 questions?
Q2: How do you prevent a form owner from seeing responses that contain PII of other users?
Q3: How would you design the branching logic evaluation to be O(n) regardless of rule count?
Q4: How do you handle schema evolution when a form is updated while responses are being collected?
Q5: How would you support real-time collaborative form editing?
Q6: How would you handle a form that goes viral and receives 100,000 responses per minute?
Q7: How do you ensure form rendering is fast globally?
Q8: How would you implement quiz auto-grading for essay-type (long text) questions?
Q9: How would you design the form preview to accurately simulate branching logic?
Q10: How would you handle file uploads when the respondent has a slow or unreliable connection?
33. Summary and Key Takeaways
Key Architectural Decisions
| Decision | Choice | Trade-off |
|---|---|---|
| Form Schema Storage | JSONB in PostgreSQL | Flexible but harder to query than relational |
| Analytics Engine | Redis (real-time) + ClickHouse (historical) | Two systems to maintain, but each optimized for its use case |
| Response Partitioning | Hash by form_id, then by date | Good query locality but rebalancing needed as forms grow |
| File Uploads | Presigned URLs to S3 | Direct upload reduces server load but requires client-side validation |
| Form Delivery | CDN-cached static HTML | Fast globally but requires version-based cache invalidation |
| Notification Processing | Async via message queue | Decoupled but introduces eventual consistency |
| Logic Evaluation | Precomputed skip graph | Fast at runtime but adds complexity to form publish pipeline |
| Collaborative Editing | OT/CRDT on block list | Complex implementation but enables real-time collaboration |
Scaling Milestones
| Milestone | Forms | Responses | Architecture Adjustment |
|---|---|---|---|
| Launch (0-1M) | 100K | 10M | Single PostgreSQL, single app server, Redis for caching |
| Growth (1M-100M) | 10M | 1B | Add read replicas, CDN, message queue, ClickHouse for analytics |
| Scale (100M+) | 100M+ | 10B+ | Partitioned databases, Kubernetes auto-scaling, global CDN, multi-region |
| Enterprise | 1B+ | 100B+ | Multi-region active-active, custom CDN nodes, dedicated analytics cluster |
Final Recommendations
- Start with a strong data model: The dynamic schema approach (JSONB for questions and answers) is the foundation. Get this right first.
- Separate read and write paths: Form rendering (read) and response submission (write) scale independently. Optimize each path separately.
- Build the analytics pipeline early: Real-time analytics is a key differentiator. Redis for counters, ClickHouse for OLAP queries, WebSockets for live updates.
- Design for offline-first: The builder should auto-save, and the respondent experience should survive network interruptions.
- Compliance from day one: GDPR and privacy compliance are hard to retrofit. Build data deletion, consent tracking, and audit logging into the core.
- Test branching logic thoroughly: Conditional logic is the most complex feature. Property-based testing and exhaustive scenario coverage are essential.
- Invest in the file upload pipeline: Presigned URLs, virus scanning, and resumable uploads are table stakes for an enterprise forms platform.
- Monitor everything: Form submission latency, analytics freshness, export job duration, webhook delivery success rates - all need alerts.