Skip to content

Repository files navigation

EHR Platform

Cloud-native Electronic Health Record platform MVP for hospitals, clinics, and telemedicine providers across Africa.

Architecture

The platform is split into independently deployable service hosts under src/Services, with src/ApiGateway providing client-facing routing and src/AppHost providing local Aspire orchestration.

Services

  • src/Services/TenantService/EHR.TenantService - hospitals, tenant IDs, branches, plans, settings.
  • src/Services/IdentityService/EHR.IdentityService - staff users, roles, permissions, authentication boundary.
  • src/Services/PatientService/EHR.PatientService - patient demographics, MRNs, contacts, insurance profile.
  • src/Services/AppointmentService/EHR.AppointmentService - appointment booking, queue/check-in workflow.
  • src/Services/EncounterService/EHR.EncounterService - visits, vitals, diagnosis, encounter completion.
  • src/Services/AuditService/EHR.AuditService - audit events and compliance history.
  • src/Services/AnalyticsService/EHR.AnalyticsService - OMOP-style analytics projections for population-health read models.
  • src/Integration/FhirApi/EHR.FhirApi - FHIR-facing integration facade for external apps and telemedicine clients.
  • src/Integration/Hl7Api/EHR.Hl7Api - HL7 v2 integration facade for hospital systems, starting with ADT messages.

Each service has its own ASP.NET Core host and service-owned persistence. EF Core-backed PostgreSQL repositories are used when a service connection string is configured; in-memory repositories remain available for lightweight local runs and unit tests.

Clean Architecture

Each microservice is split into class-library boundaries:

EHR.<Service>.Domain
EHR.<Service>.Application
EHR.<Service>.Infrastructure
EHR.<Service>

Dependency direction:

API -> Application
API -> Infrastructure
Application -> Domain
Infrastructure -> Application
Infrastructure -> Domain
Domain -> no service-layer dependency

Responsibilities:

  • Domain - entities, value objects, business behavior, business rules.
  • Application - commands, queries, CQRS handlers, repository abstractions, use cases.
  • Infrastructure - repository implementations, event/persistence adapters.
  • API - controllers, HTTP concerns, dependency injection composition.

Controllers are intentionally thin. They receive HTTP requests, dispatch commands/queries through ICqrsDispatcher, and translate results into HTTP responses.

Custom CQRS

The custom CQRS implementation is in src/BuildingBlocks/EHR.Cqrs.

It provides:

  • ICommand<TResponse>
  • IQuery<TResponse>
  • ICommandHandler<TCommand, TResponse>
  • IQueryHandler<TQuery, TResponse>
  • ICqrsDispatcher
  • CqrsDispatcher

Service endpoints do not call application logic directly. They send commands and queries through ICqrsDispatcher, and each service registers only the handlers it owns.

Workflow

  1. Register hospital
  2. Create staff user
  3. Register patient
  4. Book appointment
  5. Check patient in
  6. Start encounter
  7. Record vitals
  8. Add diagnosis
  9. Complete encounter
  10. Generate audit events

Projects

  • src/AppHost/EHR.AppHost - .NET Aspire AppHost for local orchestration.
  • src/ApiGateway/EHR.ApiGateway - YARP API Gateway for client-facing routing.
  • src/Services/* - real service hosts.
  • src/Integration/FhirApi/EHR.FhirApi - FHIR R4-style API facade over Patient, Practitioner, Appointment, Encounter, Observation, and Condition resources.
  • src/Integration/Hl7Api/EHR.Hl7Api - HL7 v2 ADT inbound/outbound message facade.
  • src/Services/*/*.Domain - service domain class libraries.
  • src/Services/*/*.Application - service application class libraries.
  • src/Services/*/*.Infrastructure - service infrastructure class libraries.
  • src/Services/AnalyticsService/EHR.AnalyticsService - OMOP-style projection host and analytics API.
  • src/BuildingBlocks/EHR.Cqrs - custom CQRS abstractions and dispatcher.
  • src/BuildingBlocks/EHR.SharedKernel - shared primitives such as entities, results, and tenant context.
  • src/BuildingBlocks/EHR.Messaging - integration-event contracts, in-memory publishing, Kafka publishing, and Kafka consumer dispatch.
  • src/BuildingBlocks/EHR.ServiceDefaults - Serilog, OpenTelemetry, JWT, role policies, health checks, Scalar, and infrastructure config loading.
  • tests/EHR.Infrastructure.Tests - infrastructure, messaging, outbox, and opt-in gateway workflow tests.

Infrastructure

Local infrastructure is defined in docker-compose.yml:

  • Kafka on localhost:9092 using confluentinc/cp-kafka:7.6.1
  • Mailpit SMTP on localhost:1025 and inbox UI on localhost:8025
  • Tenant PostgreSQL on localhost:5433
  • Identity PostgreSQL on localhost:5434
  • Patient PostgreSQL on localhost:5435
  • Appointment PostgreSQL on localhost:5436
  • Encounter PostgreSQL on localhost:5437
  • Audit PostgreSQL on localhost:5438
  • Analytics PostgreSQL on localhost:5439

Each service has an appsettings.Infrastructure.json file with:

  • Kafka:BootstrapServers
  • Jwt issuer/audience/signing key
  • service-owned PostgreSQL connection string

EHR.Messaging now contains both:

  • InMemoryEventBus for tests/local runs without Kafka config
  • KafkaEventBus for Kafka-backed publishing when Kafka:BootstrapServers is configured
  • KafkaConsumerWorker and IIntegrationEventHandler for service-level Kafka consumers

EHR.ServiceDefaults adds:

  • Serilog request logging and console logs
  • OpenTelemetry ASP.NET Core and HTTP tracing with console exporter
  • OTLP exporting when OpenTelemetry:OtlpEndpoint is configured
  • /health
  • JWT bearer validation
  • role policies: ClinicalWrite, Admin

EF Core-backed PostgreSQL repositories are available for:

  • Tenant hospitals
  • Identity staff users, refresh tokens, staff invitations, and password reset tokens
  • Patients and tenant-registration read models
  • Appointments and known-patient read models
  • Encounters, including JSONB vitals and diagnoses
  • Audit records

Each service Infrastructure project owns an EF Core DbContext for its PostgreSQL tables. Repositories map between EF row models and Domain entities so Domain remains persistence-ignorant.

Each service runs EF Core migrations on startup when its service-owned connection string is configured. Applied versions are recorded by EF Core in the service database's __EFMigrationsHistory table. Without the connection string, the service falls back to in-memory repositories.

EF Core migration files live under each service Infrastructure project:

src/Services/<Service>/EHR.<Service>.Infrastructure/Migrations/<version>_InitialEfCore.cs

Run Services

dotnet run --project src/ApiGateway/EHR.ApiGateway --urls http://localhost:5190
dotnet run --project src/Services/TenantService/EHR.TenantService --urls http://localhost:5191
dotnet run --project src/Services/IdentityService/EHR.IdentityService --urls http://localhost:5192
dotnet run --project src/Services/PatientService/EHR.PatientService --urls http://localhost:5193
dotnet run --project src/Services/AppointmentService/EHR.AppointmentService --urls http://localhost:5194
dotnet run --project src/Services/EncounterService/EHR.EncounterService --urls http://localhost:5195
dotnet run --project src/Services/AuditService/EHR.AuditService --urls http://localhost:5196
dotnet run --project src/Integration/FhirApi/EHR.FhirApi --urls http://localhost:5197
dotnet run --project src/Integration/Hl7Api/EHR.Hl7Api --urls http://localhost:5198
dotnet run --project src/Services/AnalyticsService/EHR.AnalyticsService --urls http://localhost:5199

When running with appsettings.Infrastructure.json, start the infrastructure first:

docker compose up -d kafka tenant-db identity-db patient-db appointment-db encounter-db audit-db jaeger

Set the JWT signing key through environment variables or user secrets, not checked-in appsettings:

$env:Jwt__SigningKey = "development-signing-key-change-before-production-32chars"

Run the full containerized stack:

docker compose up --build

Run the Aspire AppHost:

dotnet run --project src/AppHost/EHR.AppHost

The AppHost starts the same local platform graph as Compose:

  • Confluent Kafka
  • PostgreSQL databases for every service
  • Mailpit
  • Jaeger
  • Tenant, Identity, Patient, Appointment, Encounter, Audit services
  • FHIR API facade
  • HL7 v2 API facade
  • Analytics service with OMOP-style projections
  • API Gateway

Example command:

$body = @{
  name = "Lagos Care Hospital"
  country = "Nigeria"
  city = "Lagos"
  plan = "Growth"
} | ConvertTo-Json

Invoke-RestMethod -Uri http://localhost:5191/api/hospitals -Method Post -Body $body -ContentType application/json

Gateway route example:

Invoke-RestMethod -Uri http://localhost:5190/tenant/api/hospitals -Method Post -Body $body -ContentType application/json

FHIR route examples:

Invoke-RestMethod -Uri http://localhost:5190/fhir/metadata
Invoke-RestMethod -Uri http://localhost:5190/fhir/Patient?name=Ada -Headers @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod -Uri http://localhost:5190/fhir/Appointment?status=Booked -Headers @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod -Uri http://localhost:5190/fhir/Observation?patient=$patientId -Headers @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod -Uri http://localhost:5190/fhir/Condition?encounter=$encounterId -Headers @{ Authorization = "Bearer $accessToken" }

HL7 v2 route examples:

Invoke-RestMethod -Uri http://localhost:5190/hl7/api/hl7/adt/capabilities

$hl7 = "MSH|^~\&|HIS|GENERAL|EHR|EHR_PLATFORM|20260607120000||ADT^A04|MSG00001|P|2.5.1`rEVN|A04|20260607120000`rPID|1||MRN-12345^^^GENERAL||Okafor^Ada||19870513|F|||1 Marina Road^^Lagos||+2348000000000`rPV1|1|O|OPD^1||||1234^Okafor^Ada||||||||||||VISIT-123`r"

Invoke-RestMethod -Uri http://localhost:5190/hl7/api/hl7/adt/parse -Method Post -Body $hl7 -ContentType "application/hl7-v2" -Headers @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod -Uri http://localhost:5190/hl7/api/hl7/adt/inbound -Method Post -Body $hl7 -ContentType "application/hl7-v2" -Headers @{ Authorization = "Bearer $accessToken" }

OMOP analytics route examples:

Invoke-RestMethod -Uri http://localhost:5190/analytics/api/omop/persons -Headers @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod -Uri http://localhost:5190/analytics/api/omop/visit-occurrences?personId=$patientId -Headers @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod -Uri http://localhost:5190/analytics/api/omop/condition-occurrences?personId=$patientId -Headers @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod -Uri http://localhost:5190/analytics/api/omop/measurements?sourceValue=oxygen_saturation -Headers @{ Authorization = "Bearer $accessToken" }
Invoke-RestMethod -Uri http://localhost:5190/analytics/api/omop/concept-maps?domain=Measurement -Headers @{ Authorization = "Bearer $accessToken" }

The analytics service includes an omop_concept_map table for ICD-10, SNOMED, LOINC, and UCUM mappings. Condition and measurement projections populate OMOP concept ID columns from this table when mappings exist, and fall back to 0 for unmapped source codes. Admins can upsert mappings through PUT /analytics/api/omop/concept-maps.

Identity token flow:

$invite = @{
  tenantId = "tenant-demo"
  fullName = "Dr Ada Okafor"
  email = "ada@example.com"
  role = "Doctor"
  department = "General Medicine"
} | ConvertTo-Json

$invitation = Invoke-RestMethod -Uri http://localhost:5192/api/staff/invitations -Method Post -Body $invite -ContentType application/json

$accept = @{
  invitationToken = $invitation.invitationToken
  password = "Use-a-real-secret-123!"
  mfaCode = $null
} | ConvertTo-Json

Invoke-RestMethod -Uri http://localhost:5192/api/auth/invitations/accept -Method Post -Body $accept -ContentType application/json

$login = @{
  email = "ada@example.com"
  password = "Use-a-real-secret-123!"
  mfaCode = $null
} | ConvertTo-Json

Invoke-RestMethod -Uri http://localhost:5192/api/auth/login -Method Post -Body $login -ContentType application/json

Development OpenAPI is available at /openapi/v1.json; Scalar API reference is available at /scalar on each host in Development.

Identity security now includes:

  • PBKDF2 password hashing
  • Staff invitation tokens
  • SMTP staff invitation email delivery
  • Password reset tokens and reset flow
  • Invitation acceptance with password setup
  • Refresh-token rotation
  • Failed-login counters
  • 15-minute account lockout after 5 failed attempts
  • RFC 6238 TOTP MFA provider
  • Recovery codes protected with SHA-256 hashes

Configure SMTP through:

Email__From
Email__Smtp__Host
Email__Smtp__Port
Email__Smtp__Username
Email__Smtp__Password
Email__Smtp__EnableSsl

Compose uses Mailpit for local SMTP capture.

Authorization is applied to service endpoints:

  • ClinicalWrite protects patient, appointment, and encounter clinical workflows.
  • Admin protects staff management and audit event APIs.
  • Login, refresh, invitation acceptance, password reset, and tenant onboarding remain public bootstrap/auth endpoints.

Concrete Kafka consumers now include:

  • AuditService materializes integration events into audit records.
  • PatientService consumes tenant.hospital.registered into a tenant registration read model.
  • AppointmentService consumes patient.created into a known-patient read model.

Infrastructure Tests

Normal test runs do not require Docker:

dotnet test EHR.Platform.slnx

To run PostgreSQL and Kafka smoke tests against the Compose infrastructure:

docker compose up -d kafka tenant-db identity-db patient-db appointment-db encounter-db audit-db
$env:RUN_INFRA_TESTS = "true"
dotnet test tests/EHR.Infrastructure.Tests

To run the containerized API Gateway workflow test, start the full stack first:

docker compose up --build
$env:RUN_E2E_TESTS = "true"
$env:GATEWAY_BASE_URL = "http://localhost:5190"
dotnet test tests/EHR.Infrastructure.Tests

Next Infrastructure Steps

  • Swap SMTP settings from Mailpit to the production email provider.
  • Split each initial migration into smaller forward-only migration versions as schema changes grow.
  • Add tenant-scoped authorization requirements, not only role policies.
  • Promote read-model consumers into process managers for cross-service workflows such as referral, discharge, and billing.
  • Add CI secrets for production-like SMTP and observability exporters when those environments exist.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages