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. It also ingests the FEST/M30 medicinal products dataset (Statens legemiddelverk) and uses it to enrich the FHIR-derived output with medicinal product identifiers.
┌─────────────────────────────────┐
│ 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
│ │ └─ IProductIdentifierService Resolve FEST identifiers by varenummer
│ └─ AppDbContext │ Upsert output + write history
└──────────────┬───────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Postgres (EF Core) │
│ Raw FHIR: │
│ - PlanDefinitions │
│ - ActivityDefinitions │
│ - RegulatedAuthorizations │
│ Converted output: │
│ - TreatmentGroups │
│ - ReimbursementGroups │
│ FEST/M30 (see below): │
│ - FestLegemiddelpakning, ... │
│ Internal: │
│ - SyncMetadata (global counter) │
│ - SyncHistory (one per FHIR sync) │
└──────────────┬───────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ 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
FEST/M30 sync pipeline
┌───────────────────────────────────┐
│ FEST/M30 SOAP service │
│ (Statens legemiddelverk) v │
└──────────────┬────────────────────┘
│ Sync triggered by K8s CronJob (`--festsync`)
│
▼
┌─────────────────────────────────────────────┐
│ FestController │
│ └─ FestSyncService │
│ ├─ IFestServiceClient │ FestClient WCF proxy: GetM30(filter, incrementalDate)
│ ├─ AppDbContext.FestSyncLogs │ Decide Full vs Delta mode from last successful run
│ ├─ AppDbContext (Fest*) │ Upsert per category (Legemiddelpakning,
│ │ │ LegemiddelMerkevare, Virkestoff); deactivates rows
│ │ │ missing from the fetch only on a Full run
│ └─ IProductIdentifierRefreshService │ Refresh FEST identifiers on already-persisted
│ │ TreatmentAlternativeProduct/ReimbursementGroupProduct
└──────────────┬──────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Postgres (EF Core) │
│ - FestLegemiddelpakning (+Pakningsinfo)
│ - FestLegemiddelMerkevare (+VirkestoffRefs)
│ - FestVirkestoffMedStyrke
│ - FestSyncLog (one per FEST sync run)
└───────────────────────────────────────┘
Mode selection (FestSyncService.ExecuteAsync(force, ct)):
- Full —
force=true, or no previous successfulFestSyncLogrow. Fetches the entire dataset (incrementalDate: null) and deactivates existing rows absent from the fetch. - Delta — otherwise. Fetches only changes since (last successful run's start time minus one hour) and leaves rows absent from that (small) response untouched.
Repository Structure
.
├── .opencode/
│ └── agents/
├── Api/ Empty (only .idea) - leftover after move to backend/
├── backend/
│ ├── src/
│ │ ├── Api/ API project (see Directory Structure below)
│ │ └── FestClient/ WCF client for the FEST medicinal products service (see docs/developer/fest-client.md), used by FestSyncService
│ └── 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 (FHIR)
│ │ │ ├── FestController POST /api/internal/fest/sync
│ │ │ ├── HealthController GET /api/internal/health
│ │ │ ├── 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:legemiddelgrunndata), scope (nhn:legemiddelgrunndata/api)
│ │ │ └── Common/ApiDPoPValidation DPoP proof validation (signature, ath, replay)
│ │ ├── Data/
│ │ │ ├── AppDbContext Postgres table accessors + indexes (FHIR/output/internal)
│ │ │ ├── AppDbContext.Fest.cs Fest* DbSets (Legemiddelpakning, LegemiddelMerkevare,
│ │ │ │ Virkestoff, FestSyncLog)
│ │ │ └── Configurations/Fest/ IEntityTypeConfiguration<T> per FEST category (indexes,
│ │ │ cascade deletes), auto-applied via ApplyConfigurationsFromAssembly
│ │ ├── Infrastructure/
│ │ │ ├── Constants/ API key + rate limiting constants
│ │ │ └── Swagger/ FestFilterEnumOperationFilter (Swagger UI enum filter for FEST)
│ │ ├── Middleware/
│ │ │ ├── ApiKeyAuthorizationFilter X-API-KEY header validation
│ │ │ ├── ExceptionHandlingMiddleware Uniform error responses
│ │ │ ├── ForwardedHeadersStartupFilter
│ │ │ ├── RequestLoggingMiddleware
│ │ │ └── SwaggerSecurityFixMiddleware
│ │ ├── Models/
│ │ │ ├── Fhir/ Upstream FHIR models (deserialized from source APIs)
│ │ │ ├── Output/ Converted models (served to consumers; Output/Internal/ holds
│ │ │ │ internal-endpoint response DTOs like FestSyncResponse,
│ │ │ │ SyncLogResponse)
│ │ │ ├── Query/ Controller-facing query filter models
│ │ │ ├── Postgres/ Entity types (versioning, sync metadata)
│ │ │ ├── Fest/ FEST/M30 entities (FestEntityBase + per-category rows,
│ │ │ │ FestSyncLog) — see docs/developer/fest-data-model-conventions.md
│ │ │ └── Internal/ Sync result/status summary DTOs (SyncResult, ...)
│ │ ├── Services/
│ │ │ ├── Shi/ FHIR sync services (named after upstream "Shi" API)
│ │ │ │ ├── ConversionService FHIR → Output mapping (pure, no I/O)
│ │ │ │ ├── SyncService Orchestrates fetch → convert → store (FHIR)
│ │ │ │ ├── UpstreamApiClient HTTP client for upstream FHIR APIs
│ │ │ │ ├── VersionService Atomic version counter (Postgres)
│ │ │ │ └── ContentHasher SHA256 hashing for change detection
│ │ │ ├── Fest/
│ │ │ │ └── FestSyncService Fetches FEST/M30 (full or delta) and upserts Fest* tables
│ │ │ └── MedicalProducts/
│ │ │ ├── MedialProductLookupService Varenummer → Fest* lookup (used by ProductIdentifierService)
│ │ │ ├── ProductIdentifierService Builds FEST identifiers for a varenummer
│ │ │ └── ProductIdentifierRefreshService Refreshes persisted product identifiers after a FEST sync
│ │ ├── Program.cs DI registration and startup; `--sync`/`--festsync`/`--migrate` CLI modes
│ │ ├── appsettings.json Base configuration
│ │ └── appsettings.Development.json Dev overrides (API keys)
│ └── FestClient/ WCF client for the FEST medicinal products SOAP service
│ (see docs/developer/fest-client.md)
└── 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 FHIR sync pipeline | All (coordinates above) |
IFestServiceClient (FestClient) |
Fetch raw FEST/M30 data (full or delta) from the SOAP service | SOAP/WCF |
FestSyncService |
Decide full vs. delta mode from FestSyncLog, upsert Fest* tables, trigger identifier refresh |
Postgres, SOAP (via IFestServiceClient) |
IProductIdentifierService / IProductIdentifierRefreshService |
Resolve/refresh FEST identifiers (Legemiddelpakning/Merkevare/Virkestoff ids) for a varenummer | Postgres (read-only for resolve; read/write for refresh) |
IMedialProductLookupService |
Look up a Legemiddelpakning by varenummer against Fest* tables (used by ProductIdentifierService) |
Postgres (read-only) |
AppDbContext |
Provide typed Postgres table access (FHIR, output, internal, and Fest*) |
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. The FEST sync uses an analogousActiveflag onFest*rows instead (see decision 12 below).Sync via
--sync/--festsync/ HTTP endpoints — Instead of aBackgroundService, Kubernetes CronJobs run the API image with--sync(FHIR) or--festsync(FEST/M30), executing the sync in-process.POST /api/internal/syncandPOST /api/internal/fest/syncare retained for manual triggering for debugging or recovery. All 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:legemiddelgrunndata, and requires thenhn:legemiddelgrunndata/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.FEST sync is a separate, independently-scheduled pipeline —
FestSyncService/FestController/Fest*tables mirror the FHIR sync's shape (a service orchestrating fetch → upsert → history) but are entirely independent ofSyncService: different upstream source (FEST SOAP/WCF vs. FHIR REST), different CronJob/CLI flag (--festsyncvs.--sync), and their own history table (FestSyncLogvs.SyncHistory). The one integration point is one-directional: a FEST sync that adds/updates aLegemiddelpakning(or a linkedLegemiddelMerkevare/VirkestoffMedStyrke) refreshes the FEST identifiers already persisted onTreatmentAlternativeProduct/ReimbursementGroupProductrows viaIProductIdentifierRefreshService, without waiting for the next FHIR sync.FEST full vs. delta sync, decided from history, not the caller —
FestSyncService.ExecuteAsync(force, ct)looks at the last row inFestSyncLogwithSuccess = trueto decide its own mode: a full fetch (incrementalDate: null) ifforce = trueor there is no successful prior run, otherwise a delta fetch since (that run's start time minus one hour). Only a full fetch deactivates (Active = false) existingFest*rows absent from the response — a delta response is inherently partial, so absence there means "unchanged", not "deleted". A failed run is still logged (Success = false), so it is never mistaken for a successful baseline on the next call. This intentionally replaced an earlier, caller-suppliedincrementalDatequery parameter onPOST /api/internal/fest/sync(seedocs/developer/changelog.md), which had no way to safely distinguish "first run" from "resync" and could not know when it was safe to reconcile deletions.Trimmed FEST data model — Of the FEST/M30 dataset's ~130 entity classes across 15 categories, only 3 categories (
Legemiddelpakning,LegemiddelMerkevare,Virkestoff) and only the columns actually read byMedialProductLookupService/ProductIdentifierServiceare persisted (see Fest data model conventions). EveryFest*root entity sharesFestEntityBase(ExternalId,Active, status fields,SourceUpdatedAt), keeping the per-category upsert logic (FestSyncService.{Category}.cs) small and consistent.