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 };

// ValidationState is optional - omit for simple scenarios
var result = schema.Validate(element, settings);

// Or provide your own state for advanced scenarios (tracking, caching, etc.)
// var state = new ValidationState();
// var result = schema.Validate(element, settings, state);

if (!result.IsValid)
{
foreach (var issue in result.Issues)
{
Console.WriteLine($"{issue.Severity}: {issue.Message}");
}
}

Validation State

The ValidationState parameter is optional and can be omitted for simple scenarios. A default state will be created automatically.

When to Provide ValidationState

Provide your own ValidationState when you need:

  1. Shared cache across validations - Reuse expensive computations (e.g., compiled FHIRPath expressions)
  2. Resource tracking - Track which resources have been validated in a batch operation
  3. Context information - Pass resource type, ID, and location context through nested validations
// Create state with shared cache for multiple validations
var state = new ValidationState();

// Validate multiple resources with shared state
foreach (var resource in resources)
{
var element = resource.ToElement(schemaProvider);
var result = schema.Validate(element, settings, state);

// Expensive FHIRPath expressions are cached in state.Global.Cache
// state.Global.ResourcesValidated is automatically incremented
}

Console.WriteLine($"Validated {state.Global.ResourcesValidated} resources");

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