Patient Chart Service — Application Logic
Status: populated Owner: TBD Last updated: 2026-04-17 Companion: Service Template · DOMAIN_MODEL · API_CONTRACTS
1. Layering
| Layer | Responsibility |
|---|---|
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 case | Command / Query | Emits |
|---|---|---|
| AddProblem | AddProblemCommand(patientId, code, clinicalStatus, verificationStatus, onset?, encounterId?) | problem.added.v1 |
| UpdateProblem | UpdateProblemCommand(problemId, version, patch) | problem.updated.v1 |
| ResolveProblem | ResolveProblemCommand(problemId, version, abatementDate, reason?) | problem.resolved.v1 |
| InactivateProblem | InactivateProblemCommand(problemId, version, reason?) | problem.inactivated.v1 |
| MarkProblemEnteredInError | MarkProblemEnteredInErrorCommand(problemId, version, reason, actor) | problem.entered_in_error.v1 |
| LinkProblem | LinkProblemCommand(problemId, target: encounter/note/goal/order) | problem.updated.v1 |
| GetProblem / ListProblems / SearchProblems | Queries | — |
2.2 Allergies
| Use case | Command / Query | Emits |
|---|---|---|
| RecordAllergy | RecordAllergyCommand(patientId, substance, categories, reactions?, verificationStatus, clinicalStatus) | allergy.added.v1 |
| UpdateAllergy | UpdateAllergyCommand(allergyId, version, patch) | allergy.updated.v1 |
| InactivateAllergy | InactivateAllergyCommand(allergyId, version, reason?) | allergy.inactivated.v1 |
| AssertNKA / AssertNKDA | AssertNoKnownCommand(patientId, kind) | allergy.added.v1 |
| MarkAllergyEnteredInError | — | allergy.entered_in_error.v1 |
| CheckAllergyAdvisory | Query: `(patientId, rxnormCode? | substanceCode?)→{ matches: AllergyMatch[]; highestSeverity }` |
2.3 Vitals
| Use case | Command / Query | Emits |
|---|---|---|
| RecordVitalsSet | RecordVitalsSetCommand(patientId, recordedAt, measurements[], encounterId?, collectionLocationId?, method?, deviceId?) | vitals.recorded.v1, zero-or-more vitals.abnormal_flagged.v1 |
| CorrectVitalsSet | CorrectVitalsSetCommand(vitalsSetId, version, patch, reason) | vitals.updated.v1 |
| GetVitalsTrend | Query: (patientId, code, range) | — |
2.4 Clinical notes
| Use case | Command / Query | Emits |
|---|---|---|
| CreateNoteDraft | CreateNoteDraftCommand(patientId, encounterId?, templateId, authorId) | note.created.v1 |
| UpdateNoteDraft | UpdateNoteDraftCommand(noteId, version, sectionPatches) | note.updated_draft.v1 |
| SignNote | SignNoteCommand(noteId, version, signatureMethod) | note.signed.v1 or routes to cosign |
| RequestCosign | RequestCosignCommand(noteId, cosignerId) | note.cosign_requested.v1 |
| AttestCosign | AttestCosignCommand(noteId, version, cosignerSignature) | note.cosigned.v1, note.signed.v1 |
| AppendAddendum | AppendAddendumCommand(noteId, body, reason) | note.addendum_created.v1 |
| MarkNoteEnteredInError | MarkNoteEnteredInErrorCommand(noteId, reason) | note.entered_in_error.v1 |
| AcceptAIChunk | AcceptAIChunkCommand(noteId, sectionId, feature, model, actor, content) | note.ai_accepted.v1 |
| MarkNoteRead | MarkNoteReadCommand(noteId, userId) (idempotent) | — |
2.5 Chart composition
| Use case | Query | Notes |
|---|---|---|
| GetBanner | GetBannerQuery(patientId, encounterId?) | Fan-out: registration + owned + facility |
| GetSummary | GetSummaryQuery(patientId, widgets[]) | Fan-out: owned + med/lab/rad/imm/care-plan |
| GetTimeline | GetTimelineQuery(patientId, cursor?, types[]) | Merge-sort cursor pagination |
| ExportSnapshot | ExportSnapshotCommand(patientId, format) | Emits chart.snapshot_exported.v1; audit |
| InvokeBreakGlass | InvokeBreakGlassCommand(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-service → GET /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
outboxin the same DB transaction. A relay (outbox-relay) publishes to NATS JetStream and marks rowsdispatched. - Inbox pattern: consumption of
registration.patient.merged.v1writes 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 anonymizesrecordedBy/author display, keeps coded clinical content per retention policy, responds to the saga coordinator with participation receipt.
6. Error handling
| Error class | HTTP mapping | Notes |
|---|---|---|
| Domain invariant violation | 422 + code | e.g., CHART_NKA_CONFLICT |
| Not found | 404 | |
| Version mismatch | 409 + CHART_INVALID_VERSION | Optimistic concurrency |
| Licensing denied | 403 + LICENSE_DENIED | Handled by guard |
| Auth denied | 401/403 | |
| External service failure (non-critical) | 200 + partial | Summary widgets degrade gracefully |
| Terminology unavailable on write | 503 if required; else warn-log | See 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.