This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Pyro is a production-grade .NET 9.0 FHIR R4 server built on clean architecture with CQRS, multi-tenancy, hybrid caching, FHIR profile validation, and Subscriptions. This file sits at the repository root; all .NET source and the solution (Abm.Pyro.sln) live under the src/ folder.
All dotnet commands target the solution under src/. Run them from the repository root with the src/-relative paths below, or cd src first and drop the prefix.
# Build (use the CI solution filter — the full .sln includes CodeGeneration which targets .NET Framework 4.8.1)
dotnet build src/Abm.Pyro.CI.slnf
# Run tests (all — unit + integration)
# Integration tests require Docker running locally (Testcontainers pulls mcr.microsoft.com/mssql/server:2022-latest)
dotnet test src/Abm.Pyro.CI.slnf
# Run a single test project
dotnet test src/Abm.Pyro.Domain.Test/Abm.Pyro.Domain.Test.csproj
dotnet test src/Abm.Pyro.Application.Test/Abm.Pyro.Application.Test.csproj
dotnet test src/Abm.Pyro.Api.Test/Abm.Pyro.Api.Test.csproj
# Run the API
dotnet run --project src/Abm.Pyro.Api/Abm.Pyro.Api.csproj
# EF Core migrations (run from src/)
# Abm.Pyro.Repository is the self-contained EF startup project: it holds the
# IDesignTimeDbContextFactory, its own appsettings.json, and the Design/Tools packages.
cd src
dotnet ef database update --project Abm.Pyro.Repository --startup-project Abm.Pyro.Repository
dotnet ef migrations add <Name> --project Abm.Pyro.Repository --startup-project Abm.Pyro.Repository| Project | Purpose |
|---|---|
Abm.Pyro.Api |
ASP.NET Core 9 entry point — controllers, middleware, content formatters, DI wiring |
Abm.Pyro.Application |
CQRS handlers, pipeline behaviors, caching, indexing, validation, subscriptions |
Abm.Pyro.Domain |
Request/response records, domain entities, enums, exceptions, interfaces |
Abm.Pyro.Repository |
EF Core DbContext, entity config, migrations, query implementations |
Abm.Pyro.CodeGeneration |
.NET Framework 4.8.1 T4 templates → FhirResourceType.cs + search parameter seed data |
Abm.Pyro.Domain.Test |
xUnit unit tests for domain logic |
Abm.Pyro.Application.Test |
xUnit unit tests for application handlers |
Abm.Pyro.Api.Test |
Full-stack integration tests using WebApplicationFactory + Testcontainers |
FhirController(one controller, tenant-scoped route/{tenant}/fhir/{resourceName}/...) receives the HTTP request.- The controller dispatches a Mediator behavioral design pattern request (e.g.
FhirCreateRequest,FhirSearchRequest). - Three pipeline behaviors execute in order:
CorrelationBehavior→LoggingBehavior→DatabaseTransactionBehavior. - The relevant handler in
Abm.Pyro.Application/FhirHandler/processes the request using repository and support services. - The handler returns a typed response record from
Abm.Pyro.Domain/FhirResponse/, which the controller maps to an HTTP response.
All FHIR operations are modelled as immutable record types in Abm.Pyro.Domain/FhirRequest/. Every request implements IRequest<TResponse> and IValidatable. Handlers live in Abm.Pyro.Application/FhirHandler/.
IValidator orchestrates a set of IValidatorBase<TRequest> implementations keyed per request type. Validation runs inside each handler before business logic, not as a MediatR behavior.
ITenantService extracts the tenant identifier from the {tenant} route segment. IPyroDbContextFactory uses the resolved tenant to select the correct SQL Server connection string. All caches are tenant-aware.
FusionCache (v2.0.0) with an optional Redis backplane. Cache services in Abm.Pyro.Application/Cache/:
SearchParameterHybridCacheActiveSubscriptionHybridCacheMetaDataHybridCacheServiceBaseUrlHybridCacheServiceSettingsCache
Local TTL: 10 min. Distributed TTL: 30 min (configurable in appsettings.json → RedisCacheSettings).
When a FHIR resource is stored, index setters (IReferenceSetter, IStringSetter, IDateTimeSetter, IQuantitySetter, ITokenSetter, INumberSetter, IUriSetter) populate typed index tables (IndexString, IndexReference, IndexDateTime, etc.) that back FHIR search queries.
PyroDbContext has six DbSets: ResourceStore, IndexString, IndexReference, SearchParameterStore, ServiceBaseUrl, ServiceSetting. ResourceStore and SearchParameterStore compress their JSON columns via custom value converters in Abm.Pyro.Repository/Conversion/.
Three IHostedService implementations run at startup before the server accepts traffic:
DatabaseVersionValidationOnStartupServiceFhirServiceBaseUrlManagementOnStartupServiceValidateAndPrimeResourceEndpointPoliciesOnStartupService
NotificationManager is a recurring hosted service (1-second interval) that drives FHIR Subscriptions.
Domain exceptions extend FhirException with severity levels (Fatal, Error, Warning, Info). ErrorHandlingMiddleware converts unhandled exceptions to FHIR OperationOutcome responses. In non-Production environments (IHostEnvironment.IsProduction() == false) the full exception detail is included in the response body; in Production an opaque message with a log correlation ID is returned instead.
ResourceEndpointPolicies in appsettings.json defines a permission matrix (allowed HTTP operations per resource type per tenant). ValidateAndPrimeResourceEndpointPoliciesOnStartupService validates and primes this matrix on startup.
Pyro supports the FHIR R4 FHIRPath Patch interaction (PATCH /{tenant}/{ResourceType}/{id} and the conditional form PATCH /{tenant}/{ResourceType}?{criteria}). The request body must be a FHIR Parameters resource (Content-Type: application/fhir+json) containing one or more operation entries with type, path, and (where required) name, value, index, source, or destination parts.
Key invariant — PATCH never creates. Unlike PUT (which upserts), PATCH returns 404 when the target resource does not exist or has been deleted. Use PUT to create or replace.
Implementation files:
| File | Role |
|---|---|
Abm.Pyro.Domain/FhirRequest/FhirPatchRequest.cs |
Mediator request record (HttpVerbId.Patch) |
Abm.Pyro.Domain/FhirRequest/FhirConditionalPatchRequest.cs |
Mediator request record for conditional form |
Abm.Pyro.Application/FhirPatch/IFhirPathPatchService.cs |
Patch service interface |
Abm.Pyro.Application/FhirPatch/FhirPathPatchService.cs |
Applies FHIRPath patch operations using the Firely SDK's mutable ElementNode tree (ElementNode.FromElement(new ScopedNode(...)) → mutate → ToPoco<Resource>) |
Abm.Pyro.Application/FhirHandler/FhirPatchHandler.cs |
Loads the current resource, applies the patch, re-indexes, and stores a new ResourceStore row |
Abm.Pyro.Application/FhirHandler/FhirConditionalPatchHandler.cs |
Resolves a single matching resource via search then delegates to FhirPatchHandler |
Abm.Pyro.Application/Validation/PatchRequestValidator.cs |
Validates AllowPatch policy and that the body is a Parameters resource |
Abm.Pyro.Application/Validation/ConditionalPatchRequestValidator.cs |
Same for the conditional form, checks AllowConditionalPatch |
Supported operations: add, insert, delete, replace, move. An empty Parameters resource (no operation entries) is rejected with 400.
Endpoint policy flags: AllowPatch and AllowConditionalPatch must be true in the relevant policy in appsettings.json (ResourceEndpointPolicies). Both are true in the built-in AllowAll policy and false in ReadAndSearch.
HttpVerbId.Patch = 5 is stored in the ResourceStore row written by a successful patch and has a corresponding EF Core migration (20260701141423_PatchOperationHttpVerbAdd).
PATCH inside transaction Bundles. A PATCH entry (direct or conditional) inside a FHIR transaction Bundle is supported, handled by FhirTransactionPutAndPatchService (Abm.Pyro.Application/FhirBundleService/) — the PUT/PATCH-processing service in step 3 of the transaction commit sequence (FhirTransactionService.cs), reusing IFhirPatchHandler exactly as it reuses IFhirUpdateHandler for PUT. Pre-process resolves the PATCH target's identity (direct: load by id; conditional: run the search pipeline with PATCH match semantics) before commit, the same way conditional-PUT resolution works. Conditional PATCH with zero matches fails the whole transaction (PATCH never creates, even inside a transaction). Two entries (PUT or PATCH) resolving to the same ResourceType/id within one transaction also fails the transaction, per the FHIR trules overlapping-identity rule. PATCHing a resource whose identity is created by a POST/PUT in the same bundle is not supported (the target must already exist in the database) — but a patch value (e.g. a valueReference) pointing at a same-bundle POST/PUT is resolved correctly, since the generic reference-rewriting machinery in FhirTransactionService.UpdateResourceReferences walks a PATCH entry's Parameters body the same way it walks any other resource.
Hl7.Fhir.R4v5.11.4 — official FHIR R4 SDKEntity Framework Corev9.0.1 (SQL Server)Firely.Fhir.Validation.R4v2.6.3 — FHIR profile validationZiggyCreatures.FusionCachev2.0.0Serilogv9.0.0 with Splunk and rolling-file sinksSteeltoe ConfigServerv3.2.8 — Spring Cloud Config support (disabled by default)Pollyv7.x — HTTP resilience (12 retries with jitter on the FHIR HTTP client)
- Connection strings and secrets go in
appsettings.Development.json(gitignored for secrets). appsettings.jsoncontains the full settings schema with[Secret]placeholders.- All settings sections use the Options pattern with FluentValidation (
ValidateOnStart). - Redis and ConfigServer are disabled by default; enable in appsettings.
The server is deployed to Azure App Service (Linux, container-based) as a single Docker image built from src/Abm.Pyro.Api/Dockerfile. The runtime base image is mcr.microsoft.com/dotnet/aspnet:9.0-noble-chiseled-extra (shell-less, non-root, smaller/faster cold start; the -extra variant keeps ICU + tzdata so globalization and timezone behaviour are unchanged). Two GitHub Actions workflows live at the repository root under .github/workflows/ (one level above src/).
- Triggers: every push to
mainordevelopment, and every PR targetingmain. - Does: restore → build → run all three test suites (Domain.Test, Application.Test, Api.Test) in a single
dotnet teststep. Docker is pre-installed onubuntu-latest; Testcontainers pulls the SQL Server image on first run (~30–60 s cold, cached thereafter). No Azure interaction. - Solution filter: CI uses
src/Abm.Pyro.CI.slnf, notAbm.Pyro.sln. The filter excludesAbm.Pyro.CodeGeneration(targets .NET Framework 4.8.1, unavailable onubuntu-latest) andAbm.Pyro.Domain.Benchmark. If you add a new .NET 9 project that CI should build, add it to the.slnf.
- Trigger: pushing a Git tag matching
v*.*.*(semver). Nothing else deploys — CD is a deliberate release act, never an automatic merge/push deploy. - Three sequential jobs (order is load-bearing):
build-push→migrate→deploy. Migrations run before the new container is served, so the schema is always at least as new as the running app.build-push: builds & pushesanguscontainerregistry.azurecr.io/pyro-api:<tag>and:latest.migrate: reads the migration connection string from Key Vault, installsdotnet-ef, runsdotnet ef database updateagainstpyro-databasewithAbm.Pyro.Repositoryas both--projectand--startup-project.deploy: points the App Service at the new image tag and restarts it.
git tag v1.2.3
git push origin v1.2.3The CD pipeline then builds → migrates → deploys automatically. The deploy target is the GitHub Actions repo variable APP_SERVICE_NAME (currently pyroserver). Production base URL: https://pyroserver.azurewebsites.net, FHIR endpoint https://pyroserver.azurewebsites.net/pyro/... (the tenant UrlCode is pyro; the metadata route is /{tenant}/metadata).
- GitHub → Azure: OIDC federated credential on the
pyroserver-github-actionsapp registration. Each CD job declaresenvironment: production(the OIDC subject isrepo:angusmillar/PyroServer:environment:production). No Azure client secret is stored as a GitHub secret. A GitHub Environment namedproductionmust exist for this to work. - App → SQL: the App Service's system-assigned Managed Identity (DB user with
db_datareader/db_datawriteronly — the app cannot alter its own schema). The connection string usesAuthentication=Active Directory Managed Identitywith no credentials. - App → ACR: image pulls use the same Managed Identity (
acr-use-identity true), not registry admin creds. - Migrations → SQL: a separate
pyro_migration_user(SQL auth,db_owner), used only by the CDmigratejob; its connection string lives in Key Vault (pyro-key-vault, secretpyro-db-migration-connection-string).
- Connection string must be named
PyroDb(maps to the tenant'sSqlConnectionStringCode). WEBSITES_PORT=8080,ASPNETCORE_ENVIRONMENT=Production,ServiceBaseUrl__Url=https://pyroserver.azurewebsites.net.
- EF migration snapshots must be byte-identical across Windows and Linux. Snapshots are authored on Windows but verified on the Linux CI/CD runner; EF Core 9 fails
database updatewithPendingModelChangesWarningif they differ. The seed data is GZip-compressed via value converters inAbm.Pyro.Repository/Conversion/, and the GZip header OS byte is normalized so Windows-authored snapshots validate on Linux. After adding a migration, verify withdotnet ef migrations has-pending-model-changes(ideally also in a Linuxmcr.microsoft.com/dotnet/sdk:9.0container). Keep any new seed data deterministic (constant literals, not runtime-serialized objects). - Pushing changes to
.github/workflows/*requires the git credential to carry theworkflowOAuth scope (gh auth setup-gitwith a token scopedrepo,workflow,read:org). - GitHub Actions are pinned to Node 24 majors (
actions/checkout@v6,azure/login@v3,actions/setup-dotnet@v5). - App Service has no rename. Reclaiming a hostname means delete + recreate, which yields a new Managed Identity principal — every role assignment (SQL user, AcrPull, Key Vault Secrets User) and the GitHub Actions SP's Website Contributor must be re-provisioned for the new principal.
Full-stack tests that spin up the entire Pyro server using WebApplicationFactory<Program> backed by a real SQL Server instance managed by Testcontainers. Docker must be running locally to execute them.
- One SQL Server container per test run, shared across all tests via xUnit
ICollectionFixture<IntegrationTestFixture>. - EF Core migrations are applied programmatically against the container before the factory starts (schema must exist before startup services run).
- Respawn resets only FHIR resource and index tables between tests (
ResourceStore,IndexString,IndexReference,IndexDateTime,IndexQuantity,IndexToken,IndexUri).SearchParameterStore,ServiceBaseUrl, andServiceSettingare intentionally excluded: the first two are seeded by startup services, andServiceSettingis seeded by the EF migration that creates the table (a defaultFhirValidationrow). ClearingServiceSettingcausesServiceConfigurationGetCurrentByType.SingleAsyncto throw on the next request. PyroWebApplicationFactoryoverrides thePyroDbconnection string, setsServiceBaseUrl:Urltohttps://localhost, suppresses the Steeltoe ConfigServer, runs in theDevelopmentenvironment (soErrorHandlingMiddlewarereturns full exception detail on 500s), and removesDatabaseVersionValidationOnStartupServicefrom DI.Hl7.Fhir.Rest.FhirClientis used as the test HTTP client. Its base address is set tonew Uri(httpClient.BaseAddress!, "pyro/")— the trailing slash is required for correct URI resolution to/pyro/{ResourceType}routes. Error responses (4xx/5xx) throwFhirOperationException { Status }.
- Success → assert the returned resource is not null.
- 4xx errors →
Assert.ThrowsAsync<FhirOperationException>and checkex.Status. - DELETE on a non-existent resource returns 204 No Content (idempotent), not 404 — Pyro's
FhirDeleteHandleralways returnsNoContentwhen no current resource is found. - PATCH on a non-existent or deleted resource returns 404 Not Found — PATCH never creates.
- Direct PATCH:
FhirClient.PatchAsync(new Uri($"Patient/{id}", UriKind.Relative), patchParameters). - Conditional PATCH:
FhirClient.ConditionalPatchAsync<TResource>(SearchParams condition, patchParameters)— note this is a generic method keyed on the resource type; passing a query-string URI toPatchAsyncis rejected by the client withArgumentExceptionbecausePatchAsync(Uri, ...)validates that the URI contains a resource ID. - If-Match (optimistic concurrency):
FhirClient.PatchAsynchas noifMatchparameter. Create a dedicatedFhirClientfromFixture.Factory.CreateClient()and add the header viahttpClient.DefaultRequestHeaders.TryAddWithoutValidation("If-Match", "W/\"1\"")before constructing the client. - Patch body: build a
Parametersresource withparameter[].name = "operation"and nestedpartentries (type,path, and operation-specific parts). An emptyParameters(no operations) returns 400.
A PATCH entry inside a transaction Bundle is a Bundle.EntryComponent with Request.Method = Bundle.HTTPVerb.PATCH, Request.Url set to either ResourceType/id (direct) or ResourceType?criteria (conditional), and Resource set to a Parameters patch document (see Transactions/TransactionTests.cs's PatchEntry(...) helper and Patch/PatchTests.cs's MakeOp(...) builder, reused for building the Parameters body). Post the bundle via FhirClient.TransactionAsync(bundle); a successful PATCH entry's Bundle.Entry[x].Response.Status is "200 OK" and Entry[x].Resource is the patched resource. A failing entry anywhere in the bundle rolls back the whole transaction — assert FhirOperationException { Status == HttpStatusCode.BadRequest } and, where relevant, confirm a sibling entry was not committed by reading it back afterwards.
When a new FHIR index table is added via EF migration, add it to the TablesToInclude list in IntegrationTestFixture.cs so Respawn clears it between tests.
- When a file needs the FHIR model namespace and/or the
Tasktype, prefer these using statements:Theusing Hl7.Fhir.Model; using Task = System.Threading.Tasks.Task;
Taskalias avoids ambiguity withHl7.Fhir.Model.Task(the FHIRTaskresource type), which otherwise collides withSystem.Threading.Tasks.Task.
Abm.Pyro.CodeGeneration targets .NET Framework 4.8.1. Its T4 templates generate FhirResourceType.cs and the search parameter seed files from FHIR definition ZIPs. Re-run the templates when upgrading the FHIR version; the generated output is committed.