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.

Interactive Question DAG
Click an available node to answer it. 0 / 7 answered.
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.

Composite Pattern: Nested Subgraphs
Company InfoWorkers' Comp3 questionsReviewCompany InfoReviewWorkers' Comp SubgraphHas employees?State filed in?Coverage amount
The Workers' Comp node is actually a subgraph containing 3 questions

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.

Walk Algorithm Stepper
[ ]
{ }
Step 0 / 0 — Press "Next Step" to begin

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.

Validation Strategy in Action
Click a validation rule to try it

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?"

Live Template Rendering
Edit the answers on the left to see templates re-render in real time

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.

Transitive Reduction
For each edge A→Z, check if there's an alternate path from A to Z through other nodes. If so, the direct edge is redundant — the dependency is already implied.

Press "Find Next Redundant" to step through each redundant edge one at a time.
Necessary Redundant Alternate path
10 edges — click "Find Next Redundant" to begin reduction

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:

Weight-Based Ordering
Change weights to reorder question groups
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"
    }
)
Adding a Question is Trivial
5 questions, 4 edges

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:

Multi-System Question Graph
Toggle systems on to add their questions to the graph

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.

Graph → JSON Schema → Form
Step 1 — answer the current questions to proceed

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.

Read more

    © Copyright 2026 Conor Mongey