Ignixa.Validation
Three-tier validation system supporting Minimal, Spec, and Full validation depths.
Installation
dotnet add package Ignixa.Validation
Quick Start
using Ignixa.Validation;
using Ignixa.Validation.Abstractions;
using Ignixa.Validation.Schema;
using Ignixa.Specification.Generated;
using Ignixa.Serialization.SourceNodes;
// Get schema provider for your FHIR version
var schemaProvider = new R4CoreSchemaProvider();
// Create schema resolver
var schemaResolver = new StructureDefinitionSchemaResolver(schemaProvider);
var cachedResolver = new CachedValidationSchemaResolver(schemaResolver);
// Get validation schema - accepts type name or canonical URL
var schema = cachedResolver.GetSchema("Patient");
// Or: cachedResolver.GetSchema("http://hl7.org/fhir/StructureDefinition/Patient");
// Convert resource to IElement
var sourceNode = JsonNodeSourceNode.Create(resourceJsonNode);
var element = sourceNode.ToElement(schemaProvider);
// Validate the resource
var settings = new ValidationSettings { Depth = ValidationDepth.Spec };
// Omit the state and the schema roots one at this element for you
var result = schema.Validate(element, settings);
if (!result.IsValid)
{
foreach (var issue in result.Issues)
{
Console.WriteLine($"{issue.Severity}: {issue.Message}");
}
}
Validation State
ValidationState carries the resource a validation is rooted at. That root is what %resource and
%rootResource resolve to, so every state must have one — there is no public constructor, and
ValidationState.ForRoot(element) is the only way to make one. The two-argument Validate overload
calls it for you, which is why most callers never name the type.
:::warning A state is rooted at one resource
Never share one ValidationState across several resources — ForRoot binds %resource to the element
you pass it, and nothing re-roots it for you. The two-argument overload avoids this by construction (it
roots a fresh state per call), so this only matters if you implement a custom IValidationCheck (below)
that builds or forwards a ValidationState itself. Get it wrong and invariants evaluate against the
wrong resource silently, and only at ValidationDepth.Full, where invariants actually run.
:::
When to Provide ValidationState
Never, from outside the SDK. ValidationSchema.Validate(element, settings, state) — the overload that
takes an explicit ValidationState — is internal. External callers only ever see the two-argument
overload shown above, which roots a fresh state at the element you pass it; there is no public way to
supply your own state, and so no public way to mis-root one.
The internal overload exists so validation of a nested element can carry its enclosing validation's scope
down, and different checks need different disciplines: ContainedResourceCheck re-roots — %resource
moves to the contained resource while %rootResource stays on the container — while
NestedComplexTypeCheck and ChoiceVariantNestedCheck deliberately do not re-root, because a nested
datatype is not a resource and %resource must keep pointing at the one enclosing it. An external caller
has no way to know which discipline a given case wants, which is why the overload isn't exposed rather
than documented with a warning.
To validate many resources, root a fresh state per resource by calling the two-argument overload once per resource — it does this for you:
foreach (var resource in resources)
{
var element = resource.ToElement(schemaProvider);
var result = schema.Validate(element, settings);
}
:::note No run-level caching today
ValidationState.Global exposes a Cache dictionary and a ResourcesValidated counter, but nothing
in the SDK currently reads or writes either — sharing a GlobalState across validations buys you
nothing at present. Each constraint parses its FHIRPath expression once per check instance and the
checks live on the schema, so reuse comes from caching the schema (see CachedSchemaResolver above),
not from reusing a state.
:::
State Levels
ValidationState has three context levels:
| Level | Purpose | Example Use |
|---|---|---|
Global | Shared across entire validation run | Cache, resource counter |
Instance | Current resource being validated | Resource type, ID |
Location | Current element path | FHIRPath location, definition path |
// Access state information in custom checks
public class CustomCheck : IValidationCheck
{
public ValidationResult Validate(IElement element, ValidationSettings settings, ValidationState state)
{
// Access global cache
if (!state.Global.Cache.TryGetValue("myKey", out var cached))
{
cached = ExpensiveComputation();
state.Global.Cache["myKey"] = cached;
}
// Access current resource info
var resourceType = state.Instance.ResourceType;
var resourceId = state.Instance.ResourceId;
// Access current location
var path = state.Location.InstancePath;
return ValidationResult.Success();
}
}
Validation Depths
Minimal
Structural validation only - fastest option:
var settings = new ValidationSettings { Depth = ValidationDepth.Minimal };
Checks:
- JSON structure validity
- ID format validation
- Narrative structure
- Basic resource type validation
Spec
FHIR specification compliance:
var settings = new ValidationSettings { Depth = ValidationDepth.Spec };
Checks (includes Minimal, plus):
- Cardinality constraints (min/max)
- Type checking
- Reference format validation
- Choice element validation
- Required terminology bindings
- Fixed value constraints
- Pattern constraints
- Coding.system must be absolute URI
Compatibility
Microsoft FHIR Server compatibility mode for migration scenarios:
var settings = new ValidationSettings { Depth = ValidationDepth.Compatibility };
Checks (similar to Spec, but more lenient):
- All Minimal checks
- Cardinality constraints (min/max)
- Type checking
- Reference format validation
- Choice element validation
- Required terminology bindings
- Fixed value constraints
- Pattern constraints
- Accepts relative URIs in Coding.system (e.g.,
"internal-tags") - relaxed from Spec
Use when:
- Migrating from Microsoft FHIR Server (Firely SDK validation)
- Running Microsoft's FHIR Server E2E test suite
- Gradually improving data quality over time
Full
Full profile-based validation:
var settings = new ValidationSettings { Depth = ValidationDepth.Full };
Checks (includes Spec, plus):
- FHIRPath invariants
- Slice matching
- Extension validation
- Extensible terminology bindings
- Display name validation
- Advanced profile constraints
Validation Settings
var settings = new ValidationSettings
{
// Validation depth (Minimal/Spec/Full)
Depth = ValidationDepth.Full,
// Skip terminology validation if needed
SkipTerminologyValidation = false,
// How to handle terminology service failures
TerminologyFailureMode = TerminologyFailureMode.Warning,
// Optional terminology service for code validation
TerminologyService = new InMemoryTerminologyService()
};
Validation Results
Validation returns a ValidationResult:
public sealed record ValidationResult
{
public bool IsValid { get; }
public IReadOnlyList<ValidationIssue> Issues { get; }
public bool HasErrors { get; }
public bool HasWarnings { get; }
// Convert to FHIR OperationOutcome
public OperationOutcomeJsonNode ToOperationOutcome();
}
public sealed record ValidationIssue
{
public IssueSeverity Severity { get; }
public string Code { get; }
public string Path { get; }
public string Message { get; }
public CodeableConceptJsonNode? Details { get; }
}
Severity Levels
| Severity | Description | Valid Resource? |
|---|---|---|
Fatal | Cannot process | ❌ |
Error | FHIR violation | ❌ |
Warning | Best practice | ✅ |
Information | Advisory | ✅ |
Profile Validation
Against Specific Profile
// Get schema for a specific profile
var profileUrl = "http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient";
var schema = cachedResolver.GetSchema(profileUrl);
// Validate against the profile
var settings = new ValidationSettings { Depth = ValidationDepth.Full };
var result = schema.Validate(element, settings);
Using Custom Schema Resolvers
// Create a custom resolver that combines multiple sources
public class CustomSchemaResolver : IValidationSchemaResolver
{
private readonly IValidationSchemaResolver _baseResolver;
private readonly Dictionary<string, ValidationSchema> _customSchemas = new();
public CustomSchemaResolver(IValidationSchemaResolver baseResolver)
{
_baseResolver = baseResolver;
}
public void AddCustomSchema(string canonicalUrl, ValidationSchema schema)
{
_customSchemas[canonicalUrl] = schema;
}
public ValidationSchema? GetSchema(string canonicalUrl)
{
return _customSchemas.TryGetValue(canonicalUrl, out var schema)
? schema
: _baseResolver.GetSchema(canonicalUrl);
}
}
Custom Validation Checks
Implementing Custom Checks
public class BusinessRuleCheck : IValidationCheck
{
public ValidationResult Validate(
IElement element,
ValidationSettings settings,
ValidationState state)
{
var issues = new List<ValidationIssue>();
// Example: Require Patient to have either name or identifier
if (element.Name == "Patient")
{
var hasName = element.Children("name").Any();
var hasIdentifier = element.Children("identifier").Any();
if (!hasName && !hasIdentifier)
{
issues.Add(new ValidationIssue(
IssueSeverity.Error,
"business-rule-1",
"Patient",
"Patient must have either name or identifier"));
}
}
return issues.Any()
? ValidationResult.Failure(issues)
: ValidationResult.Success();
}
}
Adding Checks to Schema
// Build custom schema with additional checks
var baseSchema = cachedResolver.GetSchema(canonicalUrl);
var customChecks = new List<IValidationCheck>
{
new BusinessRuleCheck(),
new OrganizationPolicyCheck()
};
// Combine base checks with custom checks
var allChecks = baseSchema.Checks.Concat(customChecks).ToList();
var customSchema = new ValidationSchema(
baseSchema.CanonicalUrl,
baseSchema.ResourceType,
universalChecks: allChecks.Where(c => /* categorize */).ToList(),
specChecks: new List<IValidationCheck>(),
profileChecks: customChecks);
Terminology Validation
Using InMemoryTerminologyService
using Ignixa.Validation.Services;
// Create an in-memory terminology service
var termService = new InMemoryTerminologyService();
// Configure validation settings
var settings = new ValidationSettings
{
Depth = ValidationDepth.Full,
SkipTerminologyValidation = false,
TerminologyService = termService,
TerminologyFailureMode = TerminologyFailureMode.Warning
};
Implementing Custom Terminology Service
public class CustomTerminologyService : ITerminologyService
{
public async Task<TerminologyValidationResult> ValidateCodeAsync(
string? system,
string? code,
string? display,
string? valueSetUrl,
CancellationToken cancellationToken)
{
// Implement code validation logic
// e.g., check against external terminology server
var isValid = await CheckCodeAgainstServer(system, code, valueSetUrl);
return new TerminologyValidationResult(
IsValid: isValid,
Severity: isValid ? IssueSeverity.Information : IssueSeverity.Error,
Message: isValid ? null : $"Code {system}#{code} not found in {valueSetUrl}");
}
public async Task<BindingValidationResult> ValidateBindingAsync(
string valueSetUrl,
BindingStrength strength,
string? system,
string? code,
string? display,
string? version,
CancellationToken cancellationToken)
{
// Validate based on binding strength
// Required → Error, Extensible → Warning, Preferred → Info
var validationResult = await ValidateCodeAsync(system, code, display, valueSetUrl, cancellationToken);
return new BindingValidationResult(
IsValid: validationResult.IsValid,
Strength: strength,
Severity: DetermineSeverity(validationResult.IsValid, strength),
Message: validationResult.Message,
SuggestedDisplay: null);
}
// Implement other ITerminologyService methods (LookupCodeAsync, ExpandValueSetAsync, etc.)...
}
Batch Validation
Multiple Resources
// Validate multiple resources in parallel
var schemaProvider = new R4CoreSchemaProvider();
var schemaResolver = new CachedValidationSchemaResolver(
new StructureDefinitionSchemaResolver(schemaProvider));
var results = new ConcurrentBag<ValidationResult>();
var settings = new ValidationSettings { Depth = ValidationDepth.Spec };
await Parallel.ForEachAsync(resources, async (resourceNode, ct) =>
{
var sourceNode = JsonNodeSourceNode.Create(resourceNode.MutableNode);
var element = sourceNode.ToElement(schemaProvider);
var resourceType = resourceNode.ResourceType;
var schema = schemaResolver.GetSchema(
$"http://hl7.org/fhir/StructureDefinition/{resourceType}");
if (schema != null)
{
var result = schema.Validate(element, settings);
results.Add(result);
}
});
Bundle Validation
// Validate entire bundle as a resource
var bundleSchema = schemaResolver.GetSchema(
"http://hl7.org/fhir/StructureDefinition/Bundle");
var bundleElement = JsonNodeSourceNode.Create(bundle.MutableNode)
.ToElement(schemaProvider);
var bundleResult = bundleSchema.Validate(bundleElement, settings);
// Validate each entry resource individually
if (bundle.Entry != null)
{
foreach (var entry in bundle.Entry)
{
if (entry.Resource != null)
{
var entryElement = JsonNodeSourceNode.Create(entry.Resource.MutableNode)
.ToElement(schemaProvider);
var entrySchema = schemaResolver.GetSchema(
$"http://hl7.org/fhir/StructureDefinition/{entry.Resource.ResourceType}");
var entryResult = entrySchema?.Validate(entryElement, settings);
}
}
}
CLI Tool
The ignixa-validator tool validates FHIR resources from the command line.
Installation
dotnet tool install --global Ignixa.Validation.Cli
Usage
# Validate a file
ignixa-validator r4 --input patient.json --console
# Validate inline JSON
ignixa-validator r4 --json '{"resourceType":"Patient","id":"123"}' --console
# Output OperationOutcome to file
ignixa-validator r4 --input patient.json --out result.json
# Use different FHIR versions
ignixa-validator r5 --input patient.json --console
ignixa-validator stu3 --input patient.json --console
Options
| Option | Description |
|---|---|
--input <file> | Path to JSON file to validate |
--json <string> | Inline JSON string to validate |
--out <file> | Output file for OperationOutcome JSON |
--console | Display formatted results in console |
Migration from Microsoft FHIR Server
The Compatibility validation depth is specifically designed for migration scenarios from Microsoft FHIR Server (which uses Firely SDK validation).
Validation Depth Comparison
| Validation Depth | Coding.system Absolute URI | Terminology Bindings | FHIRPath Invariants | Use Case |
|---|---|---|---|---|
Minimal | Not checked | Not checked | Not checked | Bulk ingestion, high throughput |
Compatibility | Not enforced (accepts relative URIs) | Required only | Not checked | Microsoft FHIR Server migration |
Spec | Enforced (absolute URI required) | Required only | Not checked | Standard API operations |
Full | Enforced (absolute URI required) | All bindings | Checked | Compliance testing, IG validation |
Key Differences: Compatibility vs Spec
The Compatibility depth is more lenient than Spec in these areas:
Coding.system URIs:
Compatibility: Accepts relative or local references (e.g.,"internal-tags","local-system")Spec: Requires absolute URIs (e.g.,"http://terminology.hl7.org/CodeSystem/v3-ActCode")
This is common in meta.tag fields used for internal categorization.
Example: Compatibility Mode
var settings = new ValidationSettings
{
Depth = ValidationDepth.Compatibility
};
var result = schema.Validate(element, settings);
// Accepts resources with relative URIs in Coding.system
Resource that passes at Compatibility but fails at Spec:
{
"resourceType": "Medication",
"meta": {
"tag": [{
"system": "internal-tags",
"code": "test-medication"
}]
},
"code": {
"coding": [{
"system": "http://www.nlm.nih.gov/research/umls/rxnorm",
"code": "1234",
"display": "Test Med"
}]
}
}
- ✅ Accepted at
Compatibilitydepth - ❌ Rejected at
SpecandFulldepths (due to"system": "internal-tags")
Migration Path
Follow this path to gradually improve data quality:
-
Start with Compatibility: Validate existing data with
ValidationDepth.Compatibilityvar settings = new ValidationSettings { Depth = ValidationDepth.Compatibility }; -
Identify Issues: Review resources that would fail at
Specdepth// Test against Spec to find issuesvar specSettings = new ValidationSettings { Depth = ValidationDepth.Spec };var specResult = schema.Validate(element, specSettings);// Log issues for data cleanup -
Fix Data Quality: Gradually update relative URIs to absolute URIs
- Replace
"internal-tags"with"http://your-organization.com/fhir/CodeSystem/internal-tags" - Document your internal code systems with CodeSystem resources
- Replace
-
Upgrade to Spec: Once data is compliant, switch to
ValidationDepth.Specvar settings = new ValidationSettings { Depth = ValidationDepth.Spec }; -
Full Validation: Eventually enable
ValidationDepth.Fullfor complete FHIR compliancevar settings = new ValidationSettings { Depth = ValidationDepth.Full };
Usage Guidelines
| Depth | Use Case |
|---|---|
| Minimal | Bulk ingestion, high throughput scenarios |
| Compatibility | Microsoft FHIR Server migration, E2E test compatibility |
| Spec | Standard API operations, general-purpose validation |
| Full | Compliance testing, IG validation, profile conformance |
Optimization Tips
- Use CachedValidationSchemaResolver - Caches compiled schemas to avoid rebuilding checks
- Choose appropriate depth - Use Minimal for bulk ingestion, Full only when needed
- Reuse ValidationSettings - Create once and reuse across validations
- Parallel validation - Validate multiple resources concurrently (schemas are thread-safe)
- Skip terminology when possible - Set
SkipTerminologyValidation = truefor performance