Skip to main content

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:

LevelPurposeExample Use
GlobalShared across entire validation runCache, resource counter
InstanceCurrent resource being validatedResource type, ID
LocationCurrent element pathFHIRPath 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​

SeverityDescriptionValid Resource?
FatalCannot process❌
ErrorFHIR violation❌
WarningBest practice✅
InformationAdvisory✅

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​

OptionDescription
--input <file>Path to JSON file to validate
--json <string>Inline JSON string to validate
--out <file>Output file for OperationOutcome JSON
--consoleDisplay 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 DepthCoding.system Absolute URITerminology BindingsFHIRPath InvariantsUse Case
MinimalNot checkedNot checkedNot checkedBulk ingestion, high throughput
CompatibilityNot enforced (accepts relative URIs)Required onlyNot checkedMicrosoft FHIR Server migration
SpecEnforced (absolute URI required)Required onlyNot checkedStandard API operations
FullEnforced (absolute URI required)All bindingsCheckedCompliance 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 Compatibility depth
  • ❌ Rejected at Spec and Full depths (due to "system": "internal-tags")

Migration Path​

Follow this path to gradually improve data quality:

  1. Start with Compatibility: Validate existing data with ValidationDepth.Compatibility

    var settings = new ValidationSettings { Depth = ValidationDepth.Compatibility };
  2. Identify Issues: Review resources that would fail at Spec depth

    // Test against Spec to find issues
    var specSettings = new ValidationSettings { Depth = ValidationDepth.Spec };
    var specResult = schema.Validate(element, specSettings);
    // Log issues for data cleanup
  3. 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
  4. Upgrade to Spec: Once data is compliant, switch to ValidationDepth.Spec

    var settings = new ValidationSettings { Depth = ValidationDepth.Spec };
  5. Full Validation: Eventually enable ValidationDepth.Full for complete FHIR compliance

    var settings = new ValidationSettings { Depth = ValidationDepth.Full };

Usage Guidelines​

DepthUse Case
MinimalBulk ingestion, high throughput scenarios
CompatibilityMicrosoft FHIR Server migration, E2E test compatibility
SpecStandard API operations, general-purpose validation
FullCompliance testing, IG validation, profile conformance

Optimization Tips​

  1. Use CachedValidationSchemaResolver - Caches compiled schemas to avoid rebuilding checks
  2. Choose appropriate depth - Use Minimal for bulk ingestion, Full only when needed
  3. Reuse ValidationSettings - Create once and reuse across validations
  4. Parallel validation - Validate multiple resources concurrently (schemas are thread-safe)
  5. Skip terminology when possible - Set SkipTerminologyValidation = true for performance