Personal project · Open source · Interoperability
Onco
Bridge
Oncology interoperability with evidence you can trace.
A FHIR R4 workbench that preserves what arrived, normalizes selected oncology concepts into a FHIR-independent domain, evaluates deterministic quality checks, and traces every derived value back to its source.
Synthetic data only · Runs locally with Docker Compose
- Role
- Architecture · Full-stack engineering
- Model
- FHIR-independent canonical domain
- Interop
- FHIR R4 · mCODE STU4 subset
- Stack
- .NET · Angular · PostgreSQL
- Delivery
- Docker Compose · GitHub Actions
- Testing
- xUnit · Vitest · Playwright · real PostgreSQL
FHIR is the input.
It should not become the architecture.
Incoming data is shaped around FHIR resources and references. The application needs a model it owns, with its own invariants and its own cardinalities. So the two shapes are genuinely different, and the mapping between them is the product.
PatientSYN-0001
subject
Conditioncondition-001
focus
Stage group Observationstaging-group-001
hasMember · 3
T Observationstaging-t-001
N Observationstaging-n-001
M Observationstaging-m-001
A graph held together by references. Four resources carry one staging statement.
Patient SYN-0001
PrimaryCancerDiagnosis
CancerStaging
- Stage IIA
- T2
- N1
- M0
One aggregate with a stage group and an ordered T/N/M list. Names diverge where the concepts diverge.
OncoBridge.Domain contains no FHIR types.
Zero NuGet packages, zero project references. DomainBoundaryTests reads the real project files and the compiled assembly reference graph, so a leak fails the build rather than a review.
Four resources in.
One staging aggregate out.
Nothing becomes untraceable. One import screen carries the source, the derived value, the quality statement and the evidence behind each.


01 · Source
Four contributing Observations.
Marked A–D, out of the seven resources in the bundle.
02 · Normalized
One CancerStaging aggregate.
Stage IIA · T2 from B · N1 from C · M0 from D.
03 · Quality
Deterministic findings.
Three raised, each with the statement it was derived from.
04 · Provenance
Four lineage records.
Whole-entity record first, then field-level records for T/N/M.
Bytes first,
meaning second.
Eight stages, one direction. The evidence path runs beneath the transformation path and stays reachable from the far end.
- 01FHIR R4 bundleposted bytes
- 02Immutable payloadbytea · SHA-256
- 03Source resourcesjsonb · entry order
- 04FHIR normalizationInterop.Fhir
- 05Canonical modelDomain · no FHIR
- 06Quality assessmentsix cited checks
- 07REST / OpenAPIsix routes
- 08Angular workbenchgenerated client
Provenance — every normalized field stays reachable from the API
Raw payloadByte-exact
ImportBatch.RawPayload · bytea
ImportBatch.ContentHash · SHA-256
The exact uploaded bytes, and a digest computed over exactly those bytes inside the factory that creates the batch — so a caller cannot supply one that does not match.
Resource JSONNot byte-identical
SourceResource.ResourceJson · jsonb
A parsed, queryable copy of one entry. It may reorder keys, drop insignificant whitespace and rewrite escapes, so it preserves meaning but cannot reproduce the digest. The inspector says so on screen.
Meaning and evidence are separate obligations.
Normalize the graph.
Keep the evidence.
Collapsing a graph is only safe if the collapse is reversible on paper. Every displayed derived field names its source; the lineage table records the whole-entity transformation plus field-level T/N/M provenance.
Each source resource is marked A to D. The same marker appears on the canonical field it produced and on its lineage record. Pointing at or focusing a source highlights its path; the same information is written out in the lineage table below.
- AObservation · staging-group-001Whole entity · entry 2
- BObservation · staging-t-001Category T · entry 3
- CObservation · staging-n-001Category N · entry 4
- DObservation · staging-m-001Category M · entry 5
CancerStaging1 canonical entity
- Stage group
- Stage IIAfrom A
- Category T
- T2from B
- Category N
- N1from C
- Category M
- M0from D
- Effective
- 2019-04-02 Dayfrom A
| Scope · field path | Source resource | Transformation |
|---|---|---|
| Whole entity · fieldPath: null | staging-group-001 · entry 2 | FhirCancerStagingNormalization 1.0.0 |
| Category T · fieldPath: PrimaryTumour | staging-t-001 · entry 3 | FhirCancerStagingNormalization 1.0.0 |
| Category N · fieldPath: RegionalNodes | staging-n-001 · entry 4 | FhirCancerStagingNormalization 1.0.0 |
| Category M · fieldPath: DistantMetastases | staging-m-001 · entry 5 | FhirCancerStagingNormalization 1.0.0 |
A defective record
remains data.
Six checks, each carrying a citation, an expectation and what was actually found. The import still succeeds — reporting on imperfect data is the point of the system.
- Structural
OB-STR-001
A bundle entry could not be parsed as a known FHIR R4 resource.
Error
- Conformance
OB-CONF-001OB-CONF-002
A primary cancer condition does not state a mandatory category, or a TNM stage group does not state the staging method mCODE STU4 states as 1..1.
Error · Error
- Referential integrity
OB-REF-001OB-REF-002
A reference does not resolve within the same import batch, or a stage group member names a different subject. No external lookup is attempted.
Error · Error
- Domain consistency
OB-DOM-001
A staging effective time is definitely before the onset it stages. Evaluated on the canonical model, with no FHIR involved.
Warning
Finding
A stated expectation was checked and not met.
Carries target, expected, actual and the specification statement it was derived from, so a reviewer can check the claim rather than trust the tool.
Coverage note
OncoBridge did not establish this.
Not a claim that the data is wrong. OB-DOM-001 fires only on Before; an Indeterminate comparison records a coverage note instead of guessing.
OncoBridge conformance checks — a subset of mCODE STU4. Not full mCODE profile validation.
2019-03 is not 2019-03-01.
FHIR dates are variable precision. Collapsing them into a DateTime would invent information the source never stated, so precision travels with the value.
- Year
- 2019
- Month
- 2019-03
- Day
- 2019-03-14
- Instant
- 2019-03-14T10:00:00+02:00
Comparison is a partial order · four outcomes
- Before
- After
- Same
- Indeterminate
2019 versus 2019-03 is Indeterminate: March falls inside 2019, and nothing in the record settles the order. That is an outcome, not an error — which is why partial dates cannot simply be sorted.

- Order is server-owned
- The projection carries the policy sentence it was built under, and the view renders it verbatim so the screen cannot quietly disagree with the server.
- No date arithmetic in the view
- No client-side sorting, no widening, no inferred precision. Events whose anchors compare Same share a group with no order asserted inside it.
- Periods anchor on their start
- The end bound never anchors an event, and the response names which bound the anchor came from rather than leaving the client to guess.
FHIR stops at the interoperability boundary.
A modular monolith whose dependency direction points one way, and a stack a clean machine can run with one command.
- Api Application · Infrastructure · Interop.Fhir
- Interop.Fhir Application · Domain
- Infrastructure Application · Domain
- Application Domain
- Domain nothing
Interop.Fhir is the only non-test project permitted to reference Hl7.Fhir.*. PublicSurfaceTests fails the run if the normalizer's entry point stops speaking domain types only.
- Domain
- Oncology model · temporal model · quality vocabulary
- Application
- Use cases · read projections
- Interop.Fhir
- FHIR parsing · normalization · lineage · source-facing checks
- Infrastructure
- EF Core · PostgreSQL · migrations
- Api
- HTTP contracts · minimal APIs · OpenAPI
- Web
- Angular · generated OpenAPI client, never hand-edited
Browser
nginx · only published port · 8080
Angular
/api
ASP.NET Core
PostgreSQL 18.6
migrate · one-shot
Migrations run as their own Compose service that must complete successfully before the API starts — never as an API startup side effect.
- Build
- .NET 10 · Angular 22
- Verify
- xUnit · CsCheck · Vitest
- Persist
- Testcontainers · real PostgreSQL
- Accept
- 1 real-stack Playwright journey
- Contract
- api:check · client vs snapshot
- Package
- Docker Compose · nginx
- CI
- GitHub Actions · 4 jobs
The Compose smoke test imports through the reverse proxy and asserts the returned contentHash equals the SHA-256 of the bytes it posted — so the proxy is proven not to have altered the body.
Source in.
Evidence intact.
What I optimized for, and the repository evidence behind each decision.
- Traceability
- Every derived value leads back to evidence. Four lineage records for the four source resources behind one staging aggregate.
- Boundaries
- FHIR does not leak into the canonical domain. Asserted by DomainBoundaryTests against the real project files, not by convention.
- Honesty
- Unknown ordering remains unknown. Indeterminate is a result the model carries, not an error it hides.
- Reproducibility
- A clean machine runs it with
docker compose up --build. No local SDK, database or manual migration step.