Architecture Documentation (arc42)¶
This document describes the architecture of Open CIS following the arc42 template (version 8). It is the entry point for architecture documentation; details on individual decisions live in the ADRs and the PRDs.
Note
Open CIS is a personal learning project, not a certified medical product. Sections on quality and risks reflect that scope.
1. Introduction and Goals¶
1.1 Requirements Overview¶
Open CIS is a minimal Clinical Information System (CIS) built on openEHR and EHRBase. It is used to explore modern, standards-based clinical data architecture.
| Capability | Description | Reference |
|---|---|---|
| Patient management | Create, search, update and soft-delete patients; each patient is linked to one EHR | PRD-0002 |
| Encounters | Record and manage patient encounters (ambulatory, emergency, inpatient, ...) | PRD-0003 |
| Vital signs | Record vital signs as openEHR compositions and chart them | PRD-0004 |
| CAVE / adverse reactions | Allergy and adverse-reaction list, including "no known allergies" | PRD-0006 |
| Medications | Medication orders per patient | api/src/medications |
| Terminology | FHIR R4 terminology lookup, validation and enrichment of coded values | PRD-0012 |
| Authentication & roles | OIDC login, role-based access control, audit log | ADR-0010 |
| System info | Version and health overview of all components | PRD-0009 |
1.2 Quality Goals¶
| Priority | Goal | Motivation |
|---|---|---|
| 1 | Standards conformance | Clinical data must be stored as valid openEHR compositions, queryable via AQL, independent of the application |
| 2 | Learnability / simplicity | Small, readable code base with a consistent module structure; the project exists to learn |
| 3 | Separation of clinical and app data | Clinical data in the CDR, application data in a separate database, so either can evolve independently |
| 4 | Resilience to optional dependencies | An unavailable terminology server must not block clinical reads |
1.3 Stakeholders¶
| Role | Expectations |
|---|---|
| Maintainer / author | Fast iteration, clear structure, documented decisions |
| Contributors | Easy local setup (docker compose up), conventions in CLAUDE.md |
| Readers of the blog series / docs site | Understandable reference for an openEHR-based application |
| Clinicians (simulated users) | Usable UI in light and dark mode, reliable data entry |
2. Architecture Constraints¶
| Constraint | Background |
|---|---|
| openEHR as clinical data model | ADR-0001; clinical content is modelled through archetypes and templates, not custom tables |
| EHRBase 2.x as the CDR | Provides the openEHR REST API and AQL; run as Docker image ehrbase/ehrbase:2.0.0 |
| FLAT composition format | Compositions are built in EHRBase FLAT format; suffix rules depend on RM data type (see CLAUDE.md, ADR-0008, ADR-0009) |
| Python 3.11+, async only | All API functions are async, HTTP via httpx.AsyncClient, Prisma client with interface = "asyncio" |
| Strict typing | mypy for Python, TypeScript strict mode |
| Standard OIDC | No provider-specific code; Dex locally, swappable (ADR-0010) |
| Deployment on Railway | One railway.toml per service; Docker-based builds |
| Conventional Commits | Single monorepo version, releases automated from commit/PR titles (ADR-0006) |
| Light and dark UI | All UI must support both themes |
3. Context and Scope¶
3.1 Business Context¶
graph LR
User["Clinician / Nurse / Admin"]
CIS["Open CIS"]
IdP["OIDC Provider (Dex)"]
TX["FHIR Terminology Server (tx.fhir.ch)"]
User -->|uses via browser| CIS
CIS -->|authenticates users| IdP
CIS -->|code lookup, ValueSet expand/validate| TX
| Partner | Input / Output |
|---|---|
| Users (clinician, nurse, admin, read-only) | Enter and view patients, encounters, vitals, allergies, medications |
| OIDC provider | Issues ID tokens (Authorization Code + PKCE); publishes discovery document and JWKS |
| FHIR terminology server | Resolves display names for codes (e.g. SNOMED CT), expands and validates ValueSets. Default https://tx.fhir.ch/r4, configurable, can be disabled |
3.2 Technical Context¶
graph LR
Browser["Browser (Vue SPA)"]
API["FastAPI API :8000"]
EHR["EHRBase :8080"]
EHRDB[("EHRBase PostgreSQL :5433")]
AppDB[("App PostgreSQL :5454")]
Dex["Dex :5556"]
TX["tx.fhir.ch"]
Browser -->|"HTTPS/JSON, Bearer JWT"| API
Browser -->|"OIDC code + PKCE"| Dex
API -->|"JWKS / discovery"| Dex
API -->|"openEHR REST (FLAT, AQL)"| EHR
API -->|"Prisma"| AppDB
API -->|"FHIR R4 REST"| TX
EHR -->|JDBC| EHRDB
Dex -->|storage| AppDB
| Interface | Protocol / format | Notes |
|---|---|---|
| Browser → API | REST/JSON under /api/*, Authorization: Bearer <JWT> |
CORS restricted via CORS_ORIGINS |
| Browser → OIDC | Authorization Code flow with PKCE | Implemented in web/src/lib/oidc.ts, pkce.ts |
| API → OIDC | HTTPS, discovery + JWKS (cached 1 h) | OIDC_TOKEN_URL / OIDC_JWKS_URL override for Railway private networking |
| API → EHRBase | openEHR REST API, FLAT JSON, AQL, ADL 1.4 templates (OPT) | Basic auth optional (EHRBASE_USER/EHRBASE_PASSWORD); local setup runs with SECURITY_AUTHTYPE=NONE |
| API → app DB | PostgreSQL via Prisma | |
| API → terminology server | FHIR R4 REST, 5 s timeout, in-memory TTL cache | Fails soft |
4. Solution Strategy¶
| Goal | Approach |
|---|---|
| Standards conformance | Store all clinical data in EHRBase; model with openEHR archetypes/templates; read via AQL and composition retrieval |
| Separation of data | EHRBase holds observations, allergies, medications. App DB holds users, audit log, and the patient registry/encounters, linking MRN → ehrId |
| Simplicity | Modular monolith: one FastAPI service, one SPA. Feature-oriented modules with router.py / service.py / repository.py / schemas.py |
| EHRBase integration | Direct httpx integration instead of a heavy SDK (ADR-0004), with the oehrpy SDK for builders and web-template handling (ADR-0009) |
| Correct FLAT paths | Paths are resolved dynamically from the EHRBase web template instead of being hard-coded (ADR-0008, api/src/openehr/path_resolver.py) |
| Template lifecycle | Templates (api/templates/*.opt) are registered on startup; changes are detected (ADR-0003, ADR-0010-template-change-detection) |
| Security | Standard OIDC, JWT validation in the API, DB-managed roles, audit log for mutating operations |
| Delivery | Docker Compose for local dev, Railway for staging, GitHub Actions for CI, docs and releases |
5. Building Block View¶
5.1 Level 1 – Whitebox Overall System¶
graph TB
subgraph Web["web (Vue 3 SPA)"]
Pages["pages / components"]
Stores["Pinia stores"]
ApiLib["lib/api.ts + lib/oidc.ts"]
end
subgraph Api["api (FastAPI)"]
Modules["Domain modules"]
EHRInt["ehrbase / openehr"]
TermInt["terminology"]
AuthMod["auth + audit"]
Db["db (Prisma)"]
end
EHRBase["EHRBase (CDR)"]
AppDB[("App PostgreSQL")]
Dex["Dex (OIDC)"]
Pages --> Stores --> ApiLib
ApiLib -->|REST| Modules
Modules --> EHRInt --> EHRBase
Modules --> TermInt
Modules --> AuthMod
Modules --> Db --> AppDB
| Building block | Responsibility |
|---|---|
web/ |
Single-page app: routing, auth flow, forms, charts |
api/ |
REST API, business logic, orchestration of EHRBase, app DB and terminology server |
EHRBase (+ ehrbase-db) |
openEHR clinical data repository |
app-db |
Application PostgreSQL (Prisma); also used by Dex for storage |
dex/ |
OIDC provider configuration |
scripts/ |
Setup and synthetic-data seeding (ADR-0005) |
archetypes/, api/templates/ |
openEHR archetypes and operational templates (OPT) |
docs/ |
ADRs, PRDs, MkDocs site, brand kit |
5.2 Level 2 – API (api/src)¶
| Module | Responsibility | Stores data in |
|---|---|---|
patients |
Patient CRUD; creates an EHR in EHRBase and the registry entry (MRN → ehrId) | App DB + EHRBase |
encounters |
Encounter CRUD, soft delete | App DB |
observations |
Vital signs compositions; openEHR template/composition inspection endpoints | EHRBase |
cave |
Adverse reactions, "no known allergies" (NKA), per-patient summary | EHRBase |
medications |
Medication orders per patient | EHRBase |
terminology |
FHIR terminology proxy under /api/terminology and /api/fhir; enrichment of coded values |
– (in-memory cache) |
auth |
JWT validation, user upsert on first login, require_role dependency, /me, token exchange |
App DB |
audit |
log_action() writes audit entries; failures are logged, never raised |
App DB |
system |
Component versions and health | – |
ehrbase |
client.py (singleton REST client), compositions.py, templates.py, queries.py (AQL) |
– |
openehr |
FLAT composition helpers and dynamic path resolver | – |
db |
Prisma client singleton | – |
config, errors, dependencies |
Settings (pydantic-settings), domain exceptions, shared FastAPI dependencies |
– |
Inside a domain module the dependency direction is always router → service → repository / ehrbase client; routers never access Prisma or EHRBase directly.
5.3 Level 2 – Web (web/src)¶
| Folder | Responsibility |
|---|---|
pages/ |
Route components (dashboard, patients, encounters, system, login/callback) |
components/ |
Feature components (patients, encounters, vitals, cave, openehr, system, auth) |
stores/ |
Pinia stores (auth, patient, encounter, cave, system) |
composables/ |
Reusable logic, e.g. usePatients |
lib/ |
API client, OIDC/PKCE helpers, shadcn-vue utilities |
router/ |
Routes and the global auth guard (non-public routes redirect to /login) |
5.4 Data Model (App DB)¶
erDiagram
User ||--o{ AuditLog : writes
PatientRegistry ||--o{ Encounter : has
User {
string externalId "OIDC sub"
string email
Role role "ADMIN CLINICIAN NURSE READONLY"
}
PatientRegistry {
string mrn
string ehrId "link to EHRBase"
datetime deletedAt
}
Encounter {
string type
string status
datetime startTime
}
AuditLog {
string action
string resource
string ehrId
json metadata
}
Clinical content (vital signs, allergies, medications) has no table here; it lives only in EHRBase, referenced through ehrId.
6. Runtime View¶
6.1 Login¶
sequenceDiagram
actor U as User
participant W as Web SPA
participant D as Dex (OIDC)
participant A as API
participant DB as App DB
U->>W: open protected route
W->>D: redirect (code + PKCE challenge)
D-->>W: /auth/callback?code=...
W->>A: POST /api/auth/token (code, verifier)
A->>D: token exchange
D-->>A: ID token
A-->>W: token
W->>A: GET /api/auth/me (Bearer)
A->>D: JWKS (cached)
A->>DB: find or create User (by sub), default role CLINICIAN
A-->>W: user + role
6.2 Create a Patient¶
sequenceDiagram
participant W as Web SPA
participant A as API (patients)
participant E as EHRBase
participant DB as App DB
W->>A: POST /api/patients
A->>E: POST /ehr
E-->>A: ehr_id
A->>DB: insert PatientRegistry (mrn, ehrId, name, birthDate)
A->>DB: audit log entry
A-->>W: 201 patient
6.3 Record Vital Signs¶
sequenceDiagram
participant W as Web SPA
participant A as API (observations)
participant DB as App DB
participant E as EHRBase
W->>A: POST /api/observations/vital-signs
A->>DB: resolve patient -> ehrId
A->>E: web template (cached) for FLAT path verification
A->>A: build FLAT composition
A->>E: POST composition (template IDCR - Vital Signs Encounter.v1)
E-->>A: composition UID (or 422 validation error)
A-->>W: 201 / mapped error
Reads use AQL (ehrbase/queries.py) or composition retrieval by UID. Coded values may be enriched with display names from the terminology server; if it is down or slow, the unenriched data is returned.
6.4 Application Startup¶
- Connect to the app DB (failure is logged;
/healththen reportsdegradedwith 503). - Register missing openEHR templates in EHRBase (
ensure_templates_registered). - Warm the web-template cache used for FLAT path verification.
- On shutdown: disconnect Prisma, close EHRBase and terminology HTTP clients.
Each startup step tolerates failure so the API comes up even when EHRBase is still booting (30–60 s).
6.5 Error Handling¶
Domain exceptions are mapped centrally in main.py to a uniform body {"error": "...", "message": "..."}:
| Exception | HTTP status |
|---|---|
EHRBaseValidationError |
422 (with details) |
EHRBaseUnavailableError |
503 |
PatientNotFoundError, CompositionNotFoundError |
404 |
| anything else | 500 (generic message, details only in the log) |
7. Deployment View¶
7.1 Local Development¶
graph LR
subgraph Host
Vite["pnpm dev :5173"]
Uvicorn["uvicorn :8000"]
end
subgraph Docker["docker compose"]
EHRBase[":8080 ehrbase"]
EHRDB[(":5433 ehrbase-db")]
AppDB[(":5454 app-db")]
Dex[":5556 dex"]
end
Vite --> Uvicorn
Uvicorn --> EHRBase --> EHRDB
Uvicorn --> AppDB
Dex --> AppDB
Infrastructure runs in Docker Compose; API and web run on the host with hot reload.
7.2 Staging (Railway)¶
Each service is built from its own Dockerfile (api/Dockerfile, web/Dockerfile) with a railway.toml (health checks /health and /, restart on failure).
| Aspect | Details |
|---|---|
| API container | Runs as non-root user; on start runs prisma migrate deploy, seeds synthetic data when RAILWAY_ENVIRONMENT=staging and fewer than 3 patients exist, then starts uvicorn |
| Web container | Vite build; VITE_API_URL, VITE_OIDC_ISSUER, VITE_OIDC_CLIENT_ID, VITE_APP_MODE are build-time args |
| Internal networking | OIDC_TOKEN_URL, OIDC_JWKS_URL point to private network addresses because the public OIDC URL is not reachable from inside Railway |
| CI/CD | backend-ci.yml, frontend-ci.yml (lint, types, tests); release.yml (version, changelog, tag, GitHub Release); docs.yml (MkDocs → GitHub Pages) |
7.3 Configuration¶
| Variable | Purpose |
|---|---|
DATABASE_URL |
Prisma connection to the app DB |
EHRBASE_URL, EHRBASE_USER, EHRBASE_PASSWORD, EHRBASE_TIMEOUT, EHRBASE_VERIFY_SSL |
EHRBase access |
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_TOKEN_URL, OIDC_JWKS_URL |
Authentication |
TERMINOLOGY_SERVER_URL, TERMINOLOGY_ENABLED, TERMINOLOGY_CACHE_TTL_SECONDS, TERMINOLOGY_TIMEOUT_SECONDS |
Terminology integration |
CORS_ORIGINS |
Allowed browser origins (JSON array) |
RAILWAY_ENVIRONMENT |
staging enables synthetic data seeding |
8. Cross-cutting Concepts¶
8.1 Domain Model: openEHR¶
- One EHR per patient; the patient registry stores
ehrId. - Clinical documents are compositions based on templates composed of archetypes (
archetypes/,api/templates/). - Retrieval via AQL or by composition UID. See openEHR Concepts.
8.2 FLAT Format and Path Resolution¶
EHRBase rejects compositions whose FLAT paths or suffixes do not match the template ("Could not consume Parts"). Rules: bare path for DV_TEXT, DV_DATE_TIME, DV_BOOLEAN; |code/|value/|terminology for DV_CODED_TEXT; |magnitude/|unit for DV_QUANTITY. Paths are verified against the template's web template (ADR-0008, ADR-0009).
8.3 Security¶
- Authentication: OIDC Authorization Code + PKCE in the SPA; the API validates RS256 JWTs (audience = client id) against the provider's JWKS.
- Authorization: roles
ADMIN,CLINICIAN,NURSE,READONLYstored inUser; enforced withrequire_role(...). New users default toCLINICIAN; elevation is done in the DB. - Audit:
audit.log_action()records user, action, resource, andehrIdfor mutating operations. - Transport/CORS: origins whitelist; TLS terminated by the hosting platform.
- Secrets: via environment variables;
.envis not committed. The Dex users and local DB credentials indocker-compose.yml/dex/config.yamlare for local development only.
8.4 Error Handling and Resilience¶
Central exception handlers (see 6.5). Optional dependencies (terminology, template registration, cache warming) fail soft. Audit log failures never fail the business operation.
8.5 Caching¶
| Cache | Scope | TTL |
|---|---|---|
| OIDC JWKS | API process | 1 h |
| Terminology lookups / validations | API process (cachetools.TTLCache) |
TERMINOLOGY_CACHE_TTL_SECONDS (default 1 h) |
| Web templates | API process, warmed at startup | process lifetime |
All caches are per-process; there is no shared cache.
8.6 Persistence Conventions¶
Soft deletes (deletedAt) on patients and encounters; cuid primary keys; Prisma migrations for schema changes (prisma migrate dev / deploy).
8.7 UI Conventions¶
shadcn-vue + Tailwind, semantic colour tokens, dark: variants for hard-coded colours, responsive tables and loading indicators (ADR-0007).
8.8 Testing¶
pytest for the API, vitest for the web app, ruff + mypy and ESLint + vue-tsc as static checks, all run in CI.
8.9 Versioning and Releases¶
A single semantic version for the monorepo, derived from Conventional Commit PR titles; CHANGELOG.md is generated by git-cliff (ADR-0006).
9. Architecture Decisions¶
Decisions are recorded as ADRs in docs/adr.
| ADR | Decision |
|---|---|
| 0000 | Record architecture decisions |
| 0001 | Use openEHR for clinical data |
| 0002 | FastAPI as backend framework |
| 0003 | openEHR template management |
| 0004 | Direct httpx integration with EHRBase |
| 0005 | Synthetic data generation for staging |
| 0006 | Versioning and release management |
| 0007 | Responsive tables and loading indicators |
| 0008 | Dynamic FLAT path resolution |
| 0009 | oehrpy web-template integration |
| 0010 | Dex as OIDC provider |
| 0010 (b) | Template change detection |
(Two ADRs share the number 0010.)
10. Quality Requirements¶
10.1 Quality Tree¶
graph LR
Q["Quality"] --> Conf["Interoperability"]
Q --> Maint["Maintainability"]
Q --> Rel["Reliability"]
Q --> Sec["Security"]
Q --> Use["Usability"]
Conf --> C1["Valid openEHR compositions"]
Conf --> C2["Standard OIDC / FHIR"]
Maint --> M1["Uniform module layout"]
Maint --> M2["Typed code, CI checks"]
Rel --> R1["Soft-failing optional services"]
Sec --> S1["JWT validation, RBAC, audit"]
Use --> U1["Light/dark, responsive"]
10.2 Quality Scenarios¶
| ID | Scenario | Expected response |
|---|---|---|
| Q1 | EHRBase rejects a composition | API returns 422 with details; nothing is persisted |
| Q2 | EHRBase is down | API returns 503 with a clear message; the process stays up |
| Q3 | Terminology server is unreachable | Reads succeed without enrichment (5 s timeout) |
| Q4 | Token with invalid signature, audience or expiry | 401, request not processed |
| Q5 | READONLY user attempts a restricted action |
403 |
| Q6 | New contributor sets up locally | ./scripts/setup.sh, then running in minutes (EHRBase warm-up excluded) |
| Q7 | Template changes | Detected and re-registered at startup |
11. Risks and Technical Debt¶
| Risk / debt | Impact | Mitigation / idea |
|---|---|---|
| Patient creation spans EHRBase and the app DB without a distributed transaction | An EHR may exist without registry entry (or vice versa) after a partial failure | Reconciliation job, or create registry entry first and compensate |
| Template changes can break FLAT paths | Writes rejected by EHRBase | Dynamic path resolution and change detection (ADR-0008, ADR-0010) |
| Dex is a prototype-grade IdP; roles are edited directly in the DB | Not suitable for production | Swap to Keycloak via OIDC_ISSUER (ADR-0010); admin UI (PRD-0001) |
| Per-process caches | Inconsistent behaviour when scaling horizontally | Shared cache if multiple replicas are needed |
| Public terminology server as default | Availability and data-protection concerns for real data | Self-host a terminology server; disable via TERMINOLOGY_ENABLED |
| No end-to-end test suite spanning EHRBase | Integration regressions found late | Integration tests against Compose stack in CI |
| Not a certified medical device; no GDPR/HIPAA review | Must not hold real patient data | Use synthetic data only |
| Duplicate ADR number 0010 | Confusing references | Renumber one of them |
12. Glossary¶
| Term | Definition |
|---|---|
| AQL | Archetype Query Language, openEHR's query language |
| Archetype | Reusable, constraint-based model of a clinical concept (e.g. blood pressure) |
| CAVE | Clinical alert for allergies/adverse reactions (German-speaking usage) |
| CDR | Clinical Data Repository; here EHRBase |
| CIS | Clinical Information System |
| Composition | An openEHR clinical document (e.g. vital signs encounter) |
| EHR | Electronic Health Record; one per patient in EHRBase |
| EHRBase | Open-source openEHR CDR |
| FLAT format | Simplified path/value JSON representation of compositions |
| JWKS | JSON Web Key Set used to verify JWT signatures |
| MRN | Medical Record Number, the business identifier of a patient |
| OIDC / PKCE | OpenID Connect / Proof Key for Code Exchange |
| oehrpy | Python SDK for openEHR used by this project |
| OPT | Operational Template (ADL 1.4 XML) |
| Template | Combination of archetypes for a specific use case |
| Web template | JSON description of a template, used to derive FLAT paths |