Cloud-native Electronic Health Record platform MVP for hospitals, clinics, and telemedicine providers across Africa.
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.
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.
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.
The custom CQRS implementation is in src/BuildingBlocks/EHR.Cqrs.
It provides:
ICommand<TResponse>IQuery<TResponse>ICommandHandler<TCommand, TResponse>IQueryHandler<TQuery, TResponse>ICqrsDispatcherCqrsDispatcher
Service endpoints do not call application logic directly. They send commands and queries through ICqrsDispatcher, and each service registers only the handlers it owns.
- Register hospital
- Create staff user
- Register patient
- Book appointment
- Check patient in
- Start encounter
- Record vitals
- Add diagnosis
- Complete encounter
- Generate audit events
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.
Local infrastructure is defined in docker-compose.yml:
- Kafka on
localhost:9092usingconfluentinc/cp-kafka:7.6.1 - Mailpit SMTP on
localhost:1025and inbox UI onlocalhost: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:BootstrapServersJwtissuer/audience/signing key- service-owned PostgreSQL connection string
EHR.Messaging now contains both:
InMemoryEventBusfor tests/local runs without Kafka configKafkaEventBusfor Kafka-backed publishing whenKafka:BootstrapServersis configuredKafkaConsumerWorkerandIIntegrationEventHandlerfor 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:OtlpEndpointis 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
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:5199When 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 jaegerSet 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 --buildRun the Aspire AppHost:
dotnet run --project src/AppHost/EHR.AppHostThe 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/jsonGateway route example:
Invoke-RestMethod -Uri http://localhost:5190/tenant/api/hospitals -Method Post -Body $body -ContentType application/jsonFHIR 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/jsonDevelopment 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:
ClinicalWriteprotects patient, appointment, and encounter clinical workflows.Adminprotects 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.registeredinto a tenant registration read model. - AppointmentService consumes
patient.createdinto a known-patient read model.
Normal test runs do not require Docker:
dotnet test EHR.Platform.slnxTo 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.TestsTo 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- 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.