Building Dynamic Question Systems
Dynamically collect data, without the mess.
Ever built a form that needed to show different questions based on previous answers? What starts as a few if-statements quickly becomes a mess. Here’s how we built a question system for insurance applications using directed graphs.
The problem
Insurance applications need conditional questionnaires. Consider these requirements:
- “Do you have employees?” → If yes, ask about workers’ compensation
- “How many employees?” → If > 25, ask about 401(k) plans
- Questions must appear in logical order (company name before employee count)
- Some questions depend on multiple previous answers
- Question text should reference previous answers: “How many people work at {{company_name}}?”
Nested conditionals don’t scale here. We modeled the question system as a directed acyclic graph (DAG): nodes are questions, edges are dependencies, and topological ordering determines the sequence.
public struct Question {
let id: String
let template: String // "How many employees at {{company_name}}?"
let dependsOn: [String] // Other question IDs
let condition: ((Context) -> Bool)? // Should this question appear?
let weight: Int // Ordering priority
}
How it works
Composite pattern: hierarchical question groups
The Node type can contain either a single question or an entire subgraph:
public enum NodeData {
case question(Question)
case graph(QuestionGraph) // Nested graph!
}
All workers’ compensation questions can be grouped in a subgraph that only appears when relevant.
Modified topological sort
Standard topological sort assumes you process all nodes. Ours respects both dependencies and conditions:
public func walk(context: Context, handler: (Node) throws -> Void) rethrows {
var queue: [Node] = [head]
var visited: Set<Node> = []
while !queue.isEmpty {
let node = queue.removeFirst()
guard !visited.contains(node) else { continue }
let unvisitedDeps = dependenciesOf(node).subtracting(visited)
if unvisitedDeps.isEmpty {
// All dependencies satisfied — check condition
if let condition = node.question.condition, !condition(context) {
// Condition not met — skip this node but still mark visited
// so dependents can evaluate their own conditions
visited.insert(node)
let dependents = edges
.filter { $0.source == node }
.map { $0.destination }
queue.append(contentsOf: dependents)
continue
}
try handler(node)
visited.insert(node)
let dependents = edges
.filter { $0.source == node }
.map { $0.destination }
queue.append(contentsOf: dependents)
} else {
// Dependencies not ready - defer this node
queue.append(node)
// Prioritize unvisited dependencies
queue.insert(contentsOf: Array(unvisitedDeps), at: 0)
}
}
}
The interesting part is what happens when a condition fails: we still mark the node as visited and enqueue its dependents. A skipped node doesn’t block the rest of the graph. “Do you have employees?” can be skipped without orphaning the entire workers’ comp subgraph; those questions evaluate their own conditions against the current context.
The queue reorders dynamically so dependencies are always processed first, while conditions control which questions actually appear.
Validation
Each question can have its own validation rules:
public enum ValidationRule {
case none
case number(min: Int? = nil, max: Int? = nil)
case currency(min: Decimal? = nil, max: Decimal? = nil)
case email
case phone
case custom((String) -> Result<String, String>)
}
The enum with associated values gives us type-safe validation without needing a protocol hierarchy.
Template rendering
Questions use Mustache templates:
public func render(context: Context) throws -> String {
// 1. Convert context to template variables
var variables: [String: Any] = [:]
for (question, answer) in context.answered {
variables[question.id] = answer
}
// 2. Render template
let template = try Template(string: self.template)
return try template.render(variables)
}
Questions can reference previous answers:
"Does {{company_name}} have a 401(k) plan?"
Transitive reduction
As teams add questions over time, redundant edges creep in. Say a question dependsOn both “Company Name” and “Industry”, but “Industry” already depends on “Company Name”. That direct edge to “Company Name” is redundant; the dependency is already guaranteed by the path through “Industry”.
Why does this matter? Every redundant edge means a node gets added to the walk queue unnecessarily. The visited guard catches duplicates, but with hundreds of questions across multiple systems, you’re burning cycles re-enqueuing and re-checking nodes that were already processed. Transitive reduction eliminates these at graph construction time.
Redundant edges also clutter the GraphViz exports we use for debugging, making it harder to trace why a question appears where it does.
The algorithm:
public func transitiveReduction() {
var reachability: [Node: Set<Node>] = [:]
// Build reachability map
for node in nodes {
reachability[node] = reachableFrom(node)
}
// Find redundant edges
for edge in edges {
let otherPaths = edges
.filter { $0.source == edge.source && $0 != edge }
.map { $0.destination }
for intermediate in otherPaths {
if reachability[intermediate]?.contains(edge.destination) == true {
// Found alternate path - this edge is redundant
edges.remove(edge)
break
}
}
}
}
The result is the smallest graph that preserves every dependency relationship. Same question ordering, same unlocking behavior, fewer edges to traverse.
Press "Find Next Redundant" to step through each redundant edge one at a time.
Weight-based ordering
Questions have weights to control macro-ordering:
- Marketing questions: weight = -1
- Product questions: weight = 0
- Review questions: weight = 10
Within each weight class, topological order is preserved:
let weightGroups = Dictionary(grouping: questions) { $0.weight }
for weight in weightGroups.keys.sorted() {
let subgraph = createSubgraph(questions: weightGroups[weight])
masterGraph.connect(previousGroup, to: subgraph)
}
In practice
Adding a new question with dependencies:
let question = Question(
id: "needs-cyber-insurance",
template: "Does {{company_name}} store customer data?",
dependsOn: ["company-name", "industry-type"],
condition: { ctx in
ctx.answered["industry-type"] == "Technology"
}
)
The graph structure makes testing question flows straightforward:
@Test func questionsAppearInCorrectOrder() {
let graph = QuestionGraph()
// Add questions...
let order = graph.walkOrder()
#expect(order.firstIndex(of: companyName) < order.firstIndex(of: employeeCount))
}
We export to GraphViz for debugging (questionGraph.toDot()), which has saved us more than once when tracking down why a question appears in the wrong spot.
Multi-system extensibility
Because questions are just nodes in a graph, different systems can contribute their own questions independently. The graph merges them, resolves cross-system dependencies, and produces the correct ordering:
From graph to screen: JSON Schema
The graph handles ordering and dependencies, and the ValidationRule enum knows the answer types. To bridge to the UI, each question node generates a JSON Schema fragment:
extension ValidationRule {
func toJSONSchema() -> [String: Any] {
switch self {
case .none:
return ["type": "string"]
case .number(let min, let max):
var schema: [String: Any] = ["type": "integer"]
if let min = min { schema["minimum"] = min }
if let max = max { schema["maximum"] = max }
return schema
case .currency(let min, _):
var schema: [String: Any] = ["type": "number"]
if let min = min { schema["minimum"] = min }
return schema
case .email:
return ["type": "string", "format": "email"]
case .phone:
return ["type": "string", "format": "phone"]
case .custom:
return ["type": "string"]
}
}
}
The walk algorithm emits questions in dependency order, skipping any whose condition fails. We collect the available questions — those whose dependencies are satisfied and whose conditions pass — into a batch:
func availableQuestions(context: Context) -> [Question] {
var available: [Question] = []
walk(context: context) { node in
// walk() already checked dependencies and conditions —
// if we get here, this question should be shown
if context.answered[node.question.id] == nil {
available.append(node.question)
}
}
return available
}
For each batch, we generate a combined JSON Schema and hand it to the frontend:
func schemaForAvailableQuestions(context: Context) -> [String: Any] {
let available = availableQuestions(context: context)
var properties: [String: Any] = [:]
var required: [String] = []
for question in available {
let rendered = try? question.render(context: context)
properties[question.id] = question.validation.toJSONSchema()
.merging(["title": rendered ?? question.template]) { $1 }
required.append(question.id)
}
return [
"type": "object",
"properties": properties,
"required": required
]
}
The backend emits JSON Schema, a standard format. The frontend receives a schema and renders a form using react-jsonschema-form. Each answer submission triggers a re-walk of the graph, producing a new schema with the next batch of questions. Validation runs on both sides: the schema validates in the browser, and the ValidationRule validates again on the server.
The graph handles what to ask and when. JSON Schema handles how to render it.
The same approach works for anything with conditional, dependent operations: CI/CD pipelines, multi-step wizards, rule engines. If your business logic forms a dependency graph, model it as one.