DEV Community

Scott Coristine
Scott Coristine

Posted on • Originally published at signaturecare.ca

Building a Home Care Intake System: What Developers Can Learn From Healthcare Workflows

Originally published at signaturecare.ca


When I started mapping out the onboarding flow for a home care management system, I quickly realized that healthcare intake workflows are some of the most complex state machines you'll ever model in software. They involve asynchronous human decisions, dynamic form logic, and real-world constraints that don't fit neatly into a standard CRUD app.

This post breaks down the architecture behind a home care intake system — using Montreal-based home care as a real-world domain — and walks through practical implementation patterns you can apply to any multi-step, human-in-the-loop workflow.


The Domain Problem

Home care intake isn't just a form submission. It's a multi-stage pipeline involving:

  • Dynamic needs assessment
  • Care plan generation
  • Caregiver matching (with constraint satisfaction)
  • Scheduling and availability logic
  • Ongoing monitoring and plan adjustment

Each stage has dependencies, conditional branches, and external actors (clients, caregivers, care managers). Sound familiar? It should — this is essentially a workflow orchestration problem.


Modeling the State Machine

The intake process maps cleanly to a finite state machine. Here's a simplified implementation using XState:

import { createMachine, assign } from 'xstate';

const homeCareMachine = createMachine({
  id: 'homeCareIntake',
  initial: 'assessment',
  context: {
    clientProfile: null,
    carePlan: null,
    assignedCaregiver: null,
    assessmentScore: 0,
  },
  states: {
    assessment: {
      on: {
        SUBMIT_ASSESSMENT: {
          target: 'carePlanDevelopment',
          actions: assign({
            clientProfile: (_, event) => event.data,
            assessmentScore: (_, event) => calculateNeedsScore(event.data),
          }),
        },
      },
    },
    carePlanDevelopment: {
      invoke: {
        src: 'generateCarePlan',
        onDone: {
          target: 'caregiverMatching',
          actions: assign({
            carePlan: (_, event) => event.data,
          }),
        },
        onError: 'assessmentError',
      },
    },
    caregiverMatching: {
      invoke: {
        src: 'matchCaregiver',
        onDone: {
          target: 'ongoingSupport',
          actions: assign({
            assignedCaregiver: (_, event) => event.data,
          }),
        },
        onError: 'matchingFailed',
      },
    },
    ongoingSupport: {
      type: 'parallel',
      states: {
        monitoring: { /* periodic check-in logic */ },
        planAdjustment: { /* reactive updates */ },
      },
    },
    assessmentError: { type: 'final' },
    matchingFailed: { type: 'final' },
  },
});
Enter fullscreen mode Exit fullscreen mode

Why XState here? Because human-in-the-loop workflows have side effects at every transition. XState makes those side effects explicit and testable, rather than hiding them in imperative if/else chains.


The Assessment Layer: Dynamic Forms with Conditional Logic

The initial needs assessment isn't a static form — it branches based on answers. A senior requiring medical care (medication management, wound care) needs entirely different follow-up questions than someone seeking companion care (social interaction, supervision).

Here's a schema-driven approach using JSON Schema + a renderer like react-jsonschema-form:

interface AssessmentSchema {
  careTypes: CareType[];
  conditionalFields: ConditionalField[];
}

type CareType = 
  | 'personal'      // bathing, dressing, grooming
  | 'companion'     // social interaction, supervision
  | 'medical'       // medication, wound care
  | 'household';    // cooking, cleaning, laundry

interface ConditionalField {
  showIf: (answers: Record<string, unknown>) => boolean;
  fieldSchema: JSONSchema7;
}

// Example: only show medication fields if 'medical' care is selected
const medicationField: ConditionalField = {
  showIf: (answers) => answers.careTypes?.includes('medical'),
  fieldSchema: {
    title: 'Current Medications',
    type: 'array',
    items: {
      type: 'object',
      properties: {
        name: { type: 'string' },
        dosage: { type: 'string' },
        frequency: { type: 'string' },
      },
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

This pattern keeps your form logic declarative and testable — each condition is a pure function, not embedded in JSX.


Caregiver Matching: A Constraint Satisfaction Problem

This is where it gets interesting. Matching a caregiver isn't a simple lookup — it's a constraint satisfaction problem (CSP) with hard and soft constraints:

from dataclasses import dataclass
from typing import List, Optional

@dataclass
class CaregiverConstraints:
    # Hard constraints (must satisfy)
    required_certifications: List[str]
    language_requirements: List[str]   # e.g., ['fr', 'en'] for Montreal
    availability_windows: List[TimeWindow]
    location_radius_km: float

    # Soft constraints (prefer, but negotiable)
    preferred_gender: Optional[str]
    experience_years_min: int
    specialized_skills: List[str]      # dementia care, mobility assistance, etc.


def score_caregiver_match(
    caregiver: Caregiver, 
    constraints: CaregiverConstraints
) -> float:
    """
    Returns a match score from 0.0 to 1.0.
    Hard constraint failures return 0.0 immediately.
    """
    # Hard constraint checks
    if not meets_certifications(caregiver, constraints.required_certifications):
        return 0.0

    if not is_available(caregiver, constraints.availability_windows):
        return 0.0

    if not speaks_required_languages(caregiver, constraints.language_requirements):
        return 0.0

    # Soft constraint scoring
    score = 1.0
    score *= experience_weight(caregiver, constraints.experience_years_min)
    score *= skills_overlap_ratio(caregiver, constraints.specialized_skills)
    score *= proximity_score(caregiver, constraints.location_radius_km)

    return score
Enter fullscreen mode Exit fullscreen mode

Montreal-specific note: Bilingual matching is a hard constraint here, not a soft one. Many families in Montreal require caregivers fluent in both French and English — the same complexity Signature Care navigates operationally gets encoded as a non-negotiable filter in the matching algorithm.


The Ongoing Support Loop: Event-Driven Architecture

Once care is initiated, the system shifts from intake pipeline to event-driven monitoring. Care plans need adjustment triggers — think of it like a feedback control system:

// Event types that can trigger a care plan review
type CarePlanEvent =
  | { type: 'HEALTH_CHANGE'; severity: 'minor' | 'major' }
  | { type: 'CAREGIVER_UNAVAILABLE'; durationDays: number }
  | { type: 'CLIENT_REQUEST'; requestType: string }
  | { type: 'SCHEDULED_REVIEW'; intervalWeeks: number }
  | { type: 'INCIDENT_REPORTED'; incidentId: string };

// Event handler with priority routing
function handleCarePlanEvent(
  event: CarePlanEvent, 
  currentPlan: CarePlan
): CarePlanAction {
  switch (event.type) {
    case 'HEALTH_CHANGE':
      return event.severity === 'major'
        ? { action: 'IMMEDIATE_REASSESSMENT', priority: 'urgent' }
        : { action: 'SCHEDULE_REVIEW', priority: 'normal' };

    case 'CAREGIVER_UNAVAILABLE':
      return event.durationDays > 3
        ? { action: 'REMATCH_CAREGIVER', priority: 'high' }
        : { action: 'FIND_TEMPORARY_COVER', priority: 'high' };

    case 'SCHEDULED_REVIEW':
      return { action: 'GENERATE_REVIEW_REPORT', priority: 'low' };

    default:
      return { action: 'LOG_AND_NOTIFY', priority: 'normal' };
  }
}
Enter fullscreen mode Exit fullscreen mode

This event-driven approach mirrors how real care coordination works — changes don't happen in isolation, they trigger cascading updates to schedules, notifications, and documentation.


Key Architectural Takeaways

If you're building any human-in-the-loop workflow system — healthcare, legal, HR onboarding — these patterns translate directly:

Pattern Use Case Tool/Approach
State machines Multi-step intake flows XState, Temporal
Schema-driven forms Dynamic conditional logic JSON Schema, react-jsonschema-form
CSP scoring Matching/assignment problems Custom scoring, OR-Tools
Event sourcing Audit trails + plan history EventStoreDB, Kafka
Priority queues Urgency-based task routing Redis sorted sets, BullMQ

The Human Layer You Can't Automate Away

Here's the honest engineering take: the hardest part of this system isn't the code. It's designing the handoff points between software and human judgment.

A care manager reviewing an assessment brings contextual knowledge no algorithm fully captures. The matching score is a starting point, not a final answer. The event system surfaces issues — a human decides the response.

Good healthcare software augments the human workflow, it doesn't replace it. If you're curious how these workflows look from the operational side — the actual care coordination, assessment processes, and caregiver matching that inspired this architecture — Signature Care's complete guide to starting home care in Montreal is worth reading for the domain context.


Practical Next Steps

  1. Map your workflow as a state machine first — before writing any code, draw the states and transitions
  2. Identify your hard vs. soft constraints — this determines your matching/assignment algorithm structure
  3. Design your event schema early — event types are your domain language
  4. Build audit logging in from day one — healthcare workflows are heavily regulated; retrofitting is painful
  5. Test your conditional form logic with property-based tests — edge cases in dynamic forms bite hard

Signature Care is a Montreal-based bilingual home care agency providing personal care, companion care, and medical support services. If you're exploring home care options rather than building the software, you can learn more and get a free consultation at signaturecare.ca.


Tags: #workflow #architecture #healthtech #statemachines #typescript

Top comments (0)