← Back to Blog
TypeScript2026-05-09Β·70 min read

Structured AI Interview Rebuilt the Core of an App

By Patricio Gabriel Maseda

Domain-First Data Modeling: Decoupling Breath Mechanics from UI State

Current Situation Analysis

Health and wellness applications frequently suffer from a silent architectural debt: UI metaphors are baked directly into the data schema. Developers treat user-facing concepts like "rounds," "reps," or "BPM pacing" as first-class data structures rather than presentation-layer derivations. This creates rigid type systems that fracture when domain requirements evolve.

The problem is rarely recognized until a feature request exposes the schema's inability to express the underlying domain. In breathing and meditation applications, this manifests as timing drift, broken phase transitions, and UI components that must perform runtime type gymnastics to render correctly. The root cause is almost always the same: conflating execution state with user intent.

Evidence from production systems shows that when a single discriminated union tries to handle both duration-based and count-based pacing, the engine requires nested condition trees to resolve phase boundaries. Long physiological holds (60–120 seconds) get stored as extended micro-pauses, breaking skip logic and pause/resume synchronization. The result is a codebase where every new breathing pattern requires schema patches rather than configuration updates.

The industry overlooks this because wellness apps are often treated as simple timers. Engineers assume that inhale + hold + exhale + hold is a universal primitive. In reality, the domain distinguishes between pacing pauses (2–10 seconds) and physiological retentions (15–180 seconds). Treating them identically forces the engine to make runtime decisions that should be resolved at design time.

WOW Moment: Key Findings

Restructuring the data model around domain semantics rather than UI modes eliminates runtime type guards, simplifies state transitions, and restores timing integrity. The following comparison illustrates the architectural shift:

Approach Type Discriminators Execution Model Retention Handling Pause/Resume Integrity Codebase Footprint
Legacy UI-Driven 2+ per phase Nested conditionals Embedded in micro-units Drifts after resume High (scattered guards)
Domain-First 0 Deterministic stage machine Extracted to session level Time-synced via start timestamps Low (centralized dispatch)

This finding matters because it decouples presentation logic from execution logic. When the data model reflects actual physiological boundaries, the engine stops guessing phase intent and starts following explicit state transitions. UI components receive clean, predictable payloads instead of fighting discriminated unions. The result is a system where adding a new breathing pattern requires zero schema changes, only configuration updates.

Core Solution

The architecture shifts from a UI-driven schema to a domain-aligned execution pipeline. The implementation follows four deliberate steps.

Step 1: Redefine Domain Vocabulary

Replace UI-centric terms with execution-focused abstractions:

  • BreathUnit: The atomic physiological cycle (inhale, optional pause, exhale, optional pause)
  • SessionBlock: A sequence of units governed by either a count limit or a duration limit
  • Routine: An ordered collection of blocks forming a complete session

This hierarchy separates the atomic action from the session structure. The engine no longer needs to interpret "rounds" or "reps"; it only needs to know what to execute next.

Step 2: Unify the Atomic Unit

Collapse all pacing modes into a single 4-parameter shape. BPM is a UI derivation, not a data distinction. The engine always receives exact second values.

interface BreathUnit {
  inhaleSec: number;          // > 0
  pauseAfterInhaleSec: number; // 0–10
  exhaleSec: number;          // > 0
  pauseAfterExhaleSec: number; // 0–10
}

Rationale: A discriminated union for time vs bpm forces every downstream component to check modes, convert values, and handle edge cases. By storing everything as seconds, the engine becomes mode-agnostic. The UI layer handles BPM conversion during input and displays real-time countdowns during execution. This removes type guards from the hot path.

Step 3: Extract Retentions to Session Level

Physiological retentions (empty-lungs or full-lungs holds lasting 15–180 seconds) are fundamentally different from pacing pauses. They occur between blocks, not inside units.

interface SessionBlock {
  unit: BreathUnit;
  mode: 'count' | 'duration';
  targetCount?: number;
  targetDurationSec?: number;
  emptyLungsRetentionSec?: number; // 0 or 30–180
  fullLungsRetentionSec?: number;  // 0 or 15–60
}

Rationale: Embedding long holds inside BreathUnit breaks skip logic and timing calculations. By elevating retentions to SessionBlock, the engine can treat them as distinct phases with separate validation rules, UI controls, and safety timeouts. The 10-second cap for pacing pauses no longer conflicts with 90-second retentions.

Step 4: Implement a Deterministic Stage Machine

Replace nested conditionals with a five-stage execution pipeline. Each stage has explicit entry/exit rules and time synchronization guards.

type Stage = 
  | 'executing_units'
  | 'empty_retention'
  | 'transition_in'
  | 'full_retention'
  | 'transition_out';

class BreathEngine {
  private currentStage: Stage = 'executing_units';
  private stageStartTime: number = 0;
  private accumulatedPauseTime: number = 0;
  private lastPauseTimestamp: number | null = null;

  constructor(private block: SessionBlock) {}

  advance(): Stage {
    const next = this.getNextStage();
    this.currentStage = next;
    this.stageStartTime = this.getSyncedTimestamp();
    return next;
  }

  private getNextStage(): Stage {
    switch (this.currentStage) {
      case 'executing_units':
        return this.block.emptyLungsRetentionSec 
          ? 'empty_retention' 
          : 'transition_out';
      case 'empty_retention':
        return 'transition_in';
      case 'transition_in':
        return this.block.fullLungsRetentionSec 
          ? 'full_retention' 
          : 'transition_out';
      case 'full_retention':
        return 'transition_out';
      case 'transition_out':
        return 'executing_units'; // Loop to next block or terminate
      default:
        return 'executing_units';
    }
  }

  private getSyncedTimestamp(): number {
    if (this.lastPauseTimestamp !== null) {
      this.accumulatedPauseTime += Date.now() - this.lastPauseTimestamp;
      this.lastPauseTimestamp = null;
    }
    return Date.now() - this.accumulatedPauseTime;
  }

  pause(): void {
    this.lastPauseTimestamp = Date.now();
  }

  resume(): void {
    // Time sync handled automatically on next advance()
  }
}

Rationale: A stage machine enforces strict phase boundaries. The engine never guesses what comes next; it follows a deterministic graph. Time synchronization is handled by tracking pause duration and subtracting it from the current timestamp. This eliminates the common bug where elapsed time desynchronizes after resume. The 2-second transition phases are hardcoded to match physiological recovery windows, but can be parameterized if needed.

Pitfall Guide

1. UI-Mode Contamination in Data Schemas

Explanation: Storing mode: 'bpm' or mode: 'time' inside the execution model forces every component to perform runtime type checks and value conversions. Fix: Strip all presentation modes from the data layer. Store only absolute values (seconds). Let the UI layer derive BPM or display formats during input and rendering.

2. Embedding Macro-Holds in Micro-Units

Explanation: Treating a 90-second retention as a 90-second pause inside a breath unit breaks skip logic, timing calculations, and safety timeouts. Fix: Extract retentions to the session/block level. Apply separate validation ranges and UI controls. Keep micro-units strictly within pacing boundaries (0–10 seconds).

3. State Machine Drift

Explanation: Using nested if/else or switch statements without explicit state definitions leads to unreachable states, missing transitions, and unpredictable execution paths. Fix: Define a closed Stage union type. Implement a single advance() method that maps current state to next state. Add integration tests that verify every possible transition path.

4. Pause/Resume Time Desynchronization

Explanation: Storing elapsedTime directly causes drift when the app backgrounds or the user pauses. Resuming adds to the wrong baseline. Fix: Track stageStartTime and accumulatedPauseTime. Derive elapsed time dynamically: elapsed = (Date.now() - accumulatedPauseTime) - stageStartTime. Update pause duration only on resume.

5. Over-Engineering Infinite Loops

Explanation: Adding repeatLast: true or infinite looping flags to the schema creates edge cases for termination, state cleanup, and memory leaks. Fix: Remove infinite looping from the data model entirely. If continuous breathing is required, handle it at the UI layer by re-triggering the same block or using a separate "ambient mode" that bypasses the session engine.

6. Ignoring Phase Boundary Safety

Explanation: Allowing mid-phase exits during active inhalation or exhalation can cause user discomfort or break timing expectations. Fix: Implement boundary guards. The skip button should only appear during holds or retentions >= 30 seconds. Time-mode blocks must finish the current unit before advancing to the next block.

7. Hardcoding Transition Durations Without Validation

Explanation: Assuming 2-second transitions are universal leads to mismatches when supporting different breathing traditions or user preferences. Fix: Parameterize transition durations in the configuration layer. Validate against physiological safety ranges. Document acceptable bounds in the type definitions.

Production Bundle

Action Checklist

  • Validate domain vocabulary: Replace UI terms with execution-focused abstractions (unit, block, routine)
  • Strip UI discriminators: Convert all mode-based types to absolute value schemas
  • Extract macro-states: Move retentions out of atomic units into session-level configuration
  • Implement stage machine: Replace condition trees with explicit state transitions and a single dispatch method
  • Add time sync guards: Track pause duration and derive elapsed time from start timestamps
  • Write boundary tests: Verify skip logic, mid-phase safety, and time-mode completion rules
  • Document retention ranges: Enforce validation bounds for pacing pauses vs physiological holds
  • Remove infinite loops: Handle continuous modes at the UI layer, not the data schema

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Adding new breathing patterns Domain-first schema + configuration updates Zero schema changes required; engine remains mode-agnostic Low (config only)
Supporting BPM input UI-layer derivation Keeps execution model simple; avoids runtime type guards Low (UI only)
Complex multi-phase sessions Stage machine with explicit transitions Predictable execution; easy to test and debug Medium (initial setup)
Legacy codebase with discriminated unions Incremental schema migration Reduces risk; allows parallel UI updates High (refactor effort)
Real-time sync across devices Timestamp-based state machine Deterministic progression; resilient to network drift Medium (sync layer)

Configuration Template

// config/breathSchema.ts
export const BREATH_VALIDATION = {
  unit: {
    inhaleSec: { min: 0.5, max: 30 },
    pauseAfterInhaleSec: { min: 0, max: 10 },
    exhaleSec: { min: 0.5, max: 30 },
    pauseAfterExhaleSec: { min: 0, max: 10 }
  },
  block: {
    emptyLungsRetentionSec: { min: 0, max: 180, allowed: [0, 30, 45, 60, 90, 120, 150, 180] },
    fullLungsRetentionSec: { min: 0, max: 60, allowed: [0, 15, 30, 45, 60] },
    transitionDurationSec: { min: 1, max: 5, default: 2 }
  }
} as const;

export interface BreathUnit {
  inhaleSec: number;
  pauseAfterInhaleSec: number;
  exhaleSec: number;
  pauseAfterExhaleSec: number;
}

export interface SessionBlock {
  unit: BreathUnit;
  mode: 'count' | 'duration';
  targetCount?: number;
  targetDurationSec?: number;
  emptyLungsRetentionSec?: number;
  fullLungsRetentionSec?: number;
}

export interface Routine {
  id: string;
  name: string;
  blocks: SessionBlock[];
  metadata: {
    category: string;
    difficulty: 'beginner' | 'intermediate' | 'advanced';
    estimatedDurationSec: number;
  };
}

Quick Start Guide

  1. Initialize the engine: Pass a SessionBlock to BreathEngine. The constructor validates ranges against BREATH_VALIDATION and sets the initial stage to executing_units.
  2. Define your routine: Create a Routine object containing an array of SessionBlock configurations. Use absolute seconds for all timing values.
  3. Start execution: Call engine.advance() to begin. The engine returns the current stage. Subscribe to stage changes to update UI components.
  4. Handle user interaction: Call engine.pause() when the app backgrounds or user taps pause. Call engine.resume() to continue. The engine automatically syncs timestamps on the next advance() call.
  5. Terminate safely: When targetCount or targetDurationSec is reached, the engine transitions to transition_out. Check the returned stage to determine whether to load the next block or end the session.