Architecture
Overview
Nompd API is a data transformation and distribution layer. It fetches FHIR-structured healthcare data from upstream APIs, converts it into simplified REST models, and exposes it to consumers with versioned diff support.
┌─────────────────────────────────┐
│ Upstream FHIR APIs │
│ (legemidler-api-test) │
│ - PlanDefinition │
│ - ActivityDefinition │
│ - RegulatedAuthorization │
└──────────────┬──────────────────┘
│ Sync triggered by K8s CronJob (`--sync`)
│ or manually via POST /api/internal/sync
▼
┌──────────────────────────────────┐
│ SyncController │
│ └─ SyncService │
│ ├─ UpstreamApiClient │ Fetch raw FHIR data
│ ├─ VersionService │ Increment global version
│ ├─ AppDbContext │ Upsert raw FHIR (versioned)
│ ├─ ConversionService │ Map FHIR → Output models
│ └─ AppDbContext │ Upsert output + write history
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Postgres (EF Core) │
│ Raw FHIR: │
│ - PlanDefinitions │
│ - ActivityDefinitions │
│ - RegulatedAuthorizations │
│ Converted output: │
│ - TreatmentGroups │
│ - ReimbursementGroups │
│ Internal: │
│ - SyncMetadata (global counter) │
│ - SyncHistory (one per sync run) │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ Output Controllers │
│ - TreatmentGroupController (v1) │
│ - ReimbursementGroupController (v1) │
│ - TreatmentGroupV2Controller (v2) │
│ - ReimbursementGroupV2Controller (v2) │
│ │
│ GET /api/v1/treatment-group (X-API-KEY) │
│ GET /api/v1/reimbursement-group (X-API-KEY) │
│ GET /api/v2/treatment-group (HelseID/DPoP) │
│ GET /api/v2/reimbursement-group (HelseID/DPoP) │
│ (with optional ?since-version=N and filters) │
└──────────────────────────────────────────────────┘
│
▼
API consumers
Repository Structure
.
├── .opencode/
│ └── agents/
├── Api/ Empty (only .idea) - leftover after move to backend/
├── backend/
│ ├── src/
│ │ └── Api/ API project (see Directory Structure below)
│ └── tests/
│ └── Api.Tests/ API test project
├── docs/
│ ├── data-model/
│ ├── developer/
│ ├── protokoll/
│ └── system/
├── frontend/
│ ├── public/
│ └── src/
├── manifests/
│ ├── appOfApps/
│ ├── apps/
│ └── scripts/
└── scripts/
Directory structure API
backend/
├── src/
│ └── Api/
│ ├── Controllers/ API endpoints
│ │ ├── SyncController POST /api/internal/sync
│ │ ├── TreatmentGroupController GET /api/v1/treatment-group
│ │ ├── ReimbursementGroupController GET /api/v1/reimbursement-group
│ │ ├── TreatmentGroupV2Controller GET /api/v2/treatment-group (HelseID)
│ │ ├── ReimbursementGroupV2Controller GET /api/v2/reimbursement-group (HelseID)
│ │ └── *ControllerBase Shared query logic for v1/v2
│ ├── HelseId/
│ │ ├── HelseIdExtensions DPoP (RFC 9449) JwtBearer scheme + scope policy
│ │ ├── Constants Audience (nhn:nompd), scope (nhn:nompd/api)
│ │ └── Common/ApiDPoPValidation DPoP proof validation (signature, ath, replay)
│ ├── Data/
│ │ └── AppDbContext Postgres table accessors + indexes
│ ├── Middleware/
│ │ └── ApiKeyAuthorizationFilter X-API-KEY header validation
│ ├── Models/
│ │ ├── Fhir/ Upstream FHIR models (deserialized from source APIs)
│ │ ├── Output/ Converted models (served to consumers)
│ │ └── Postgres/ Entity types (versioning, sync metadata)
│ ├── Services/
│ │ ├── ConversionService FHIR → Output mapping (pure, no I/O)
│ │ ├── SyncService Orchestrates fetch → convert → store
│ │ ├── UpstreamApiClient HTTP client for upstream FHIR APIs
│ │ └── VersionService Atomic version counter (Postgres)
│ ├── Program.cs DI registration and startup
│ ├── appsettings.json Base configuration
│ └── appsettings.Development.json Dev overrides (API keys)
└── tests/
└── Api.Tests/
├── IntegrationTests/ Full-stack tests against a running app
│ ├── Auth/
│ ├── Controllers/
│ ├── Middleware/
│ └── Services/
├── TestData/ Upstream FHIR fixture resources
│ └── 01-shi/
├── TestHelpers/ Shared test utilities (loaders, mocks)
├── UnitTests/ Isolated unit tests
│ ├── Controllers/
│ └── Services/
├── ConversionServiceRoundTripTests.cs
├── SmokeTest.cs
└── global.json
Separation of Concerns
| Layer | Responsibility | I/O |
|---|---|---|
UpstreamApiClient |
Fetch raw FHIR data from external APIs | HTTP |
ConversionService |
Transform FHIR models to output models | None (pure) |
VersionService |
Manage global version counter | Postgres |
SyncService |
Orchestrate the full sync pipeline | All (coordinates above) |
AppDbContext |
Provide typed Postgres table access | Postgres |
ApiKeyAuthorizationFilter |
Validate API keys on incoming v1 requests | Configuration |
HelseIdExtensions + DPoPProofValidator |
Validate HelseID access tokens and DPoP proofs on v2 requests | Configuration, JWKS (HelseID), replay cache |
*ControllerBase (treatment/reimbursement group) |
Shared v1/v2 query logic: filtering, versioned diff, single-item lookup | Postgres (read-only) |
| Output Controllers | Bind route + auth scheme, delegate to the controller base | Postgres (read-only) |
Key Design Decisions
Stateless versioning — The server does not track per-client state. Clients are responsible for storing the
currentVersionfrom responses and sending it back via?since-version=N.Soft deletes — When upstream data disappears, records are marked
IsDeleted = truewith the current version number rather than physically removed. This ensures diff consumers see the deletion.Sync via
--sync/ HTTP endpoint — Instead of aBackgroundService, the Kubernetes CronJob (sync-job) runs the API image with--sync, executing the sync in-process. ThePOST /api/internal/syncendpoint is retained for manual triggering for debugging or recovery. Both paths provide job history, logging, and alerting via K8s.ConversionService is pure — No injected dependencies, no database access. This makes it trivially testable and ensures the mapping logic is isolated from infrastructure concerns.
Generic VersionedDocument
— A single wrapper type handles versioning for any model (raw or converted), avoiding duplication of version/delete tracking logic. Content-hash-based change detection — Each
VersionedDocument<T>stores a SHA256 hash of its JSON representation. On sync, only records whose hash actually changed (or whose deletion state flipped) get a new version. This is what makes the version-based diff API meaningful: clients calling?since-version=Nreceive only the records that genuinely changed, not every record after every sync.Raw FHIR persistence — Upstream FHIR resources are stored alongside their converted output, sharing the same version number per sync. This enables re-conversion if mapping logic changes, debugging by comparing raw vs converted, and audit traceability back to the original source.
Sync history — Every sync run produces a
SyncHistoryEntrywith timestamps, durations, per-collection counts, and the business keys of each affected record. This provides durable audit trail and answers "what changed in sync N?" without inspecting the data collections directly.Two authentication schemes, one data layer — v1 endpoints are protected by the
X-API-KEYheader (ApiKeyAuthorizationFilter); v2 endpoints are protected by HelseID using DPoP (RFC 9449). The v2 scheme is a named JwtBearer scheme (dpop_token_authentication_scheme) that:- Rejects
Bearertokens — only theDPoPauthorization scheme is accepted (OnMessageReceived). - Validates the access token against the HelseID authority (
HelseId:Authority) with audiencenhn:nompd, and requires thenhn:nompd/apiscope via thecan_access_api_policypolicy (missing scope →403). - Validates the per-request DPoP proof (
DPoPProofValidator):typ: dpop+jwt, public-keyjwkwhose thumbprint matches the token'scnf.jkt, signature over a supported RSA/ECDSA algorithm,athbound to the access token,htm/htubound to the request (path without query string), a freshiat, andjtireplay detection viaIReplayCache(in-memoryIDistributedCache).
- Rejects
Controller bases for v1/v2 — Query logic (filters,
?since-versiondiff, single-item lookup) lives inTreatmentGroupControllerBaseandReimbursementGroupControllerBase. Each version-specific controller only binds the route, the auth scheme/policy, and rate limiting, then delegates to the base. Adding a new version is a thin controller, not a copy of the query code.