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' },
},
});
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' },
},
},
},
};
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
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' };
}
}
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
- Map your workflow as a state machine first — before writing any code, draw the states and transitions
- Identify your hard vs. soft constraints — this determines your matching/assignment algorithm structure
- Design your event schema early — event types are your domain language
- Build audit logging in from day one — healthcare workflows are heavily regulated; retrofitting is painful
- 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)