Publisert - 28.09.2026

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 successful FestSyncLog row. 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

  1. Stateless versioning — The server does not track per-client state. Clients are responsible for storing the currentVersion from responses and sending it back via ?since-version=N.

  2. Soft deletes — When upstream data disappears, records are marked IsDeleted = true with the current version number rather than physically removed. This ensures diff consumers see the deletion. The FEST sync uses an analogous Active flag on Fest* rows instead (see decision 12 below).

  3. Sync via --sync/--festsync / HTTP endpoints — Instead of a BackgroundService, Kubernetes CronJobs run the API image with --sync (FHIR) or --festsync (FEST/M30), executing the sync in-process. POST /api/internal/sync and POST /api/internal/fest/sync are retained for manual triggering for debugging or recovery. All paths provide job history, logging, and alerting via K8s.

  4. ConversionService is pure — No injected dependencies, no database access. This makes it trivially testable and ensures the mapping logic is isolated from infrastructure concerns.

  5. Generic VersionedDocument — A single wrapper type handles versioning for any model (raw or converted), avoiding duplication of version/delete tracking logic.

  6. 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=N receive only the records that genuinely changed, not every record after every sync.

  7. 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.

  8. Sync history — Every sync run produces a SyncHistoryEntry with 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.

  9. Two authentication schemes, one data layer — v1 endpoints are protected by the X-API-KEY header (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 Bearer tokens — only the DPoP authorization scheme is accepted (OnMessageReceived).
    • Validates the access token against the HelseID authority (HelseId:Authority) with audience nhn:legemiddelgrunndata, and requires the nhn:legemiddelgrunndata/api scope via the can_access_api_policy policy (missing scope → 403).
    • Validates the per-request DPoP proof (DPoPProofValidator): typ: dpop+jwt, public-key jwk whose thumbprint matches the token's cnf.jkt, signature over a supported RSA/ECDSA algorithm, ath bound to the access token, htm/htu bound to the request (path without query string), a fresh iat, and jti replay detection via IReplayCache (in-memory IDistributedCache).
  10. Controller bases for v1/v2 — Query logic (filters, ?since-version diff, single-item lookup) lives in TreatmentGroupControllerBase and ReimbursementGroupControllerBase. 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.

  11. 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 of SyncService: different upstream source (FEST SOAP/WCF vs. FHIR REST), different CronJob/CLI flag (--festsync vs. --sync), and their own history table (FestSyncLog vs. SyncHistory). The one integration point is one-directional: a FEST sync that adds/updates a Legemiddelpakning (or a linked LegemiddelMerkevare/VirkestoffMedStyrke) refreshes the FEST identifiers already persisted on TreatmentAlternativeProduct/ReimbursementGroupProduct rows via IProductIdentifierRefreshService, without waiting for the next FHIR sync.

  12. FEST full vs. delta sync, decided from history, not the caller — FestSyncService.ExecuteAsync(force, ct) looks at the last row in FestSyncLog with Success = true to decide its own mode: a full fetch (incrementalDate: null) if force = true or 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) existing Fest* 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-supplied incrementalDate query parameter on POST /api/internal/fest/sync (see docs/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.

  13. 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 by MedialProductLookupService/ProductIdentifierService are persisted (see Fest data model conventions). Every Fest* root entity shares FestEntityBase (ExternalId, Active, status fields, SourceUpdatedAt), keeping the per-category upsert logic (FestSyncService.{Category}.cs) small and consistent.

Søk i Utviklerportalen

Søket er fullført!