Skip to main content

Patient Chart Service — Application Logic

Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template · DOMAIN_MODEL · API_CONTRACTS

1. Layering

LayerResponsibility
presentation/Controllers, Guards (JWT, scope, licensing), DTO validation
application/Use cases, ports (interfaces), orchestration, saga orchestrators, outbox emission
domain/Aggregates, invariants, domain events, domain errors
infrastructure/Postgres adapters, NATS publisher, HTTP clients (terminology, ai-gateway, medication, lab, rad, imm, care-plan, registration, provider-dir, facility, document-service)

Import rule: domain imports nothing; application imports only domain; infrastructure and presentation import application + domain.

2. Use cases

2.1 Problem list

Use caseCommand / QueryEmits
AddProblemAddProblemCommand(patientId, code, clinicalStatus, verificationStatus, onset?, encounterId?)problem.added.v1
UpdateProblemUpdateProblemCommand(problemId, version, patch)problem.updated.v1
ResolveProblemResolveProblemCommand(problemId, version, abatementDate, reason?)problem.resolved.v1
InactivateProblemInactivateProblemCommand(problemId, version, reason?)problem.inactivated.v1
MarkProblemEnteredInErrorMarkProblemEnteredInErrorCommand(problemId, version, reason, actor)problem.entered_in_error.v1
LinkProblemLinkProblemCommand(problemId, target: encounter/note/goal/order)problem.updated.v1
GetProblem / ListProblems / SearchProblemsQueries

2.2 Allergies

Use caseCommand / QueryEmits
RecordAllergyRecordAllergyCommand(patientId, substance, categories, reactions?, verificationStatus, clinicalStatus)allergy.added.v1
UpdateAllergyUpdateAllergyCommand(allergyId, version, patch)allergy.updated.v1
InactivateAllergyInactivateAllergyCommand(allergyId, version, reason?)allergy.inactivated.v1
AssertNKA / AssertNKDAAssertNoKnownCommand(patientId, kind)allergy.added.v1
MarkAllergyEnteredInErrorallergy.entered_in_error.v1
CheckAllergyAdvisoryQuery: `(patientId, rxnormCode?substanceCode?){ matches: AllergyMatch[]; highestSeverity }`

2.3 Vitals

Use caseCommand / QueryEmits
RecordVitalsSetRecordVitalsSetCommand(patientId, recordedAt, measurements[], encounterId?, collectionLocationId?, method?, deviceId?)vitals.recorded.v1, zero-or-more vitals.abnormal_flagged.v1
CorrectVitalsSetCorrectVitalsSetCommand(vitalsSetId, version, patch, reason)vitals.updated.v1
GetVitalsTrendQuery: (patientId, code, range)

2.4 Clinical notes

Use caseCommand / QueryEmits
CreateNoteDraftCreateNoteDraftCommand(patientId, encounterId?, templateId, authorId)note.created.v1
UpdateNoteDraftUpdateNoteDraftCommand(noteId, version, sectionPatches)note.updated_draft.v1
SignNoteSignNoteCommand(noteId, version, signatureMethod)note.signed.v1 or routes to cosign
RequestCosignRequestCosignCommand(noteId, cosignerId)note.cosign_requested.v1
AttestCosignAttestCosignCommand(noteId, version, cosignerSignature)note.cosigned.v1, note.signed.v1
AppendAddendumAppendAddendumCommand(noteId, body, reason)note.addendum_created.v1
MarkNoteEnteredInErrorMarkNoteEnteredInErrorCommand(noteId, reason)note.entered_in_error.v1
AcceptAIChunkAcceptAIChunkCommand(noteId, sectionId, feature, model, actor, content)note.ai_accepted.v1
MarkNoteReadMarkNoteReadCommand(noteId, userId) (idempotent)

2.5 Chart composition

Use caseQueryNotes
GetBannerGetBannerQuery(patientId, encounterId?)Fan-out: registration + owned + facility
GetSummaryGetSummaryQuery(patientId, widgets[])Fan-out: owned + med/lab/rad/imm/care-plan
GetTimelineGetTimelineQuery(patientId, cursor?, types[])Merge-sort cursor pagination
ExportSnapshotExportSnapshotCommand(patientId, format)Emits chart.snapshot_exported.v1; audit
InvokeBreakGlassInvokeBreakGlassCommand(patientId, reason, durationMinutes)Emits breakglass.invoked.v1; audit

3. Ports

// application/ports/
interface ProblemRepository { save(p: Problem): Promise<void>; findById(tenantId, id): Promise<Problem | null>; list(...): Promise<...>; }
interface AllergyRepository { ... }
interface VitalsRepository { ... }
interface ClinicalNoteRepository { ... }
interface ChartAccessRepository { ... }

interface TerminologyClient {
lookup(system: string, code: string): Promise<Display>;
validate(system: string, code: string, valueSet?: string): Promise<boolean>;
expand(valueSet: string): Promise<Coding[]>;
}

interface MedicationSummaryClient { getActiveMedications(tenantId, patientId): Promise<MedicationSummary[]>; }
interface LabSummaryClient { getRecentResults(tenantId, patientId, days): Promise<LabSummary[]>; }
interface ImagingSummaryClient { getRecentStudies(tenantId, patientId, days): Promise<ImagingSummary[]>; }
interface ImmunizationSummaryClient { getHistory(tenantId, patientId): Promise<ImmunizationSummary[]>; }
interface CarePlanSummaryClient { getActivePlans(tenantId, patientId): Promise<CarePlanSummary[]>; }

interface ProviderDirectoryClient { resolvePractitioner(id): Promise<Practitioner>; }
interface FacilityClient { resolveLocation(id): Promise<Location>; }
interface RegistrationClient { resolvePatient(id): Promise<Patient>; resolveEncounter(id): Promise<Encounter>; }

interface DocumentServiceClient { createDocumentReference(req): Promise<DocumentReference>; uploadBinary(bytes, mime): Promise<BinaryRef>; }

interface AIGatewayClient { requestAssist(req: AIAssistRequest, subjectToken: string): Promise<AIAssistResponse>; }

interface AuditPublisher { publish(event: AuditEvent): Promise<void>; }
interface OutboxPublisher { publish(envelope: EventEnvelope): Promise<void>; }
interface EventPublisher { publish(subject: string, event: any): Promise<void>; } // called by relay
interface ConsentPolicyClient { evaluate(patientId, segment, actor): Promise<PolicyResult>; }

4. Orchestration flows

4.1 Add problem (happy path)

4.2 Sign note with cosign policy

4.3 Allergy-advisory call (synchronous)

orders-service or medication-serviceGET /v1/allergies/advisory?patientId=&rxnormCode= → returns { matches[], highestSeverity } within 150 ms p95. Fail-open is the caller's responsibility.

5. Saga / Outbox

  • Outbox pattern: every write transaction that would mutate an aggregate enqueues one or more envelopes into outbox in the same DB transaction. A relay (outbox-relay) publishes to NATS JetStream and marks rows dispatched.
  • Inbox pattern: consumption of registration.patient.merged.v1 writes an inbox row keyed by (subject, eventId); the handler is idempotent; dedupe prevents double-rehoming.
  • GDPR erasure saga: on gdpr.subject_request.received.v1, the service anonymizes recordedBy/author display, keeps coded clinical content per retention policy, responds to the saga coordinator with participation receipt.

6. Error handling

Error classHTTP mappingNotes
Domain invariant violation422 + codee.g., CHART_NKA_CONFLICT
Not found404
Version mismatch409 + CHART_INVALID_VERSIONOptimistic concurrency
Licensing denied403 + LICENSE_DENIEDHandled by guard
Auth denied401/403
External service failure (non-critical)200 + partialSummary widgets degrade gracefully
Terminology unavailable on write503 if required; else warn-logSee BR policy

7. Open Questions

  • Do we move vitals range configs into config-service or keep inline per-tenant?
  • Provenance as FHIR Provenance resource vs audit-only for chart mutations — depends on interop-service maturity.