This document contains technical details, assumptions, and implementation details for maintaining the Firely CQL SDK.
The SDK targets both .NET 8 (LTS) and .NET 10 (LTS) to provide performance benefits from .NET 10 while maintaining compatibility with .NET 8.
- Base configuration:
cql-base.propssets<TargetFrameworks>net8.0;net10.0</TargetFrameworks> - SDK projects: All library projects (Hl7.Cql.*) target both frameworks
- Tool projects: Executable projects like PackagerCLI target both frameworks
- Example projects: May target a single framework for simplicity
.NET 10 introduced a breaking change in System.Text.Json where the type discriminator property name cannot conflict with actual property names in polymorphic type hierarchies. The ELM type system uses "type" as both:
- A discriminator for polymorphic serialization (e.g., distinguishing
ChoiceTypeSpecifierfromNamedTypeSpecifier) - An actual property in some types (e.g.,
ChoiceTypeSpecifier.typeis an array ofTypeSpecifier[],TupleElementDefinition.typeis aTypeSpecifier)
This causes System.InvalidOperationException in .NET 10 when setting up polymorphism.
We implemented a code generation solution that automatically adds [System.Text.Json.Serialization.JsonIgnore] attributes to properties that would conflict with the JSON type discriminator:
XSD to C# Code Generator Enhancement:
- The
XsdCodeGeneratorintools/XsdToCSharpConverternow automatically detects properties named "type" with complex types (objects/arrays) - It adds
[JsonIgnore]attributes to these properties during code generation - This prevents JSON serialization of conflicting properties while preserving XML serialization
- The generated
Elm.g.csincludes these attributes automatically
Example Generated Code:
[System.Xml.Serialization.XmlElementAttribute("type")]
[System.Text.Json.Serialization.JsonIgnore] // Auto-generated
public TypeSpecifier[] type { get; set; }Benefits:
- ✅ Clean solution using standard .NET attributes
- ✅ No manual modifications to generated code needed
- ✅ No conditional compilation required
- ✅ No runtime preprocessing or postprocessing
- ✅ Works identically on both .NET 8 and .NET 10
- ✅ Better performance (no JSON tree manipulation)
- ✅ Type-safe at compile time
Custom JSON Converters: The solution also includes custom JSON converters for handling polymorphic ELM types:
PolymorphicObjectJsonConverter<T>- Handles polymorphic single objectsPolymorphicArrayJsonConverter<T>- Handles polymorphic arrays with ELM-specific wrappingTopLevelDefinitionConverterFactory- Creates converters for top-level definition arraysPolymorphicTypeResolver- Discovers type hierarchies via[XmlInclude]attributes
See Cql/Elm/Serialization/README.md for complete architecture documentation.
The LibraryJsonSerializer includes preprocessing logic (CorrectLegacyConstructs) to handle legacy ELM JSON formats:
- Converts legacy "type" arrays to "choice" property for
ChoiceTypeSpecifier - Removes object-valued "type" properties (handled by
resultTypeSpecifier) - Supports old-style type discriminators on wrapper objects
- Ensures backward compatibility with ELM generated by older CQL-to-ELM translators
-
tools/XsdToCSharpConverter/XsdCodeGenerator.cs:- Detects properties named "type" with complex types
- Automatically adds
[JsonIgnore]attributes during code generation
-
Cql/Elm/Serialization/LibraryJsonSerializer.cs:- Central coordinator for JSON serialization/deserialization
- Configures custom converters and modifiers
- Handles legacy format conversion
-
Cql/Elm/Serialization/PolymorphicObjectJsonConverter.cs:- Custom converter for polymorphic objects using "type" discriminators
- Removes discriminator before deserialization to avoid conflicts
-
Cql/Elm/Serialization/PolymorphicTypeResolver.cs:- Discovers type hierarchies via
[XmlInclude]attributes - Configures polymorphism options with "type" as discriminator
- Discovers type hierarchies via
Both .NET 8 and .NET 10 now exhibit identical behavior:
- Same "type" discriminator used for polymorphism
- Same JSON format for serialization/deserialization
- No conditional compilation needed in serialization code
- Test results are identical across frameworks
Custom MSBuild targets (e.g., ElmTooling.Targets.xml, CqlTooling.Targets.xml) that generate code or artifacts are configured to run only once when multi-targeting:
<!-- Use ToolTargetFramework property to specify the latest framework -->
<PropertyGroup>
<ToolTargetFramework>net10.0</ToolTargetFramework>
</PropertyGroup>
<!-- Add condition to only run on the tool framework -->
<Target Name="GenerateCSharp"
Condition="'$(TargetFramework)' == '$(ToolTargetFramework)' Or '$(TargetFramework)' == ''"
BeforeTargets="PreBuildEvent">This prevents generating the same artifacts multiple times and ensures tools use the latest framework.
System.Text.Json:
- .NET 8: Requires explicit PackageReference
- .NET 10: Included in the framework, explicit reference causes NU1510 error
- Solution: Removed explicit PackageReference as it's transitively included from dependencies
Microsoft.CodeAnalysis.CSharp:
- Used for runtime C# compilation in CodeGeneration.NET
- Version 4.12.0 provides cross-platform Roslyn compiler APIs
GeneratorToolVersion is a separate scale from the SDK package version, which uses EffVer — see versioning.md. The generator version does follow SemVer.
When modifying C# code generation logic in CodeGeneration.NET, always update LibrarySetCSharpCodeGenerator.GeneratorToolVersion:
- The version is in
CodeGeneration.NET/_CODE GENERATOR VERSION_.cs - Follow semantic versioning (major.minor.patch.build) — this applies to the generator version only, not to the SDK package version
- Ensure
LibraryInstanceInvokersupports the new version range - Regenerate libraries after version changes
- Any change here makes the next SDK release at least MESO, because consumers regenerate
# Build all SDK projects (recommended - excludes submodules)
dotnet build Cql-Sdk.slnf -c Debug
# Build for specific framework
dotnet build Cql-Sdk.slnf -c Debug --framework net10.0# Run tests for both frameworks
dotnet test Cql/CoreTests/CoreTests.csproj -c Debug
# Run tests for specific framework
dotnet test Cql/CoreTests/CoreTests.csproj -c Debug --framework net8.0
dotnet test Cql/CoreTests/CoreTests.csproj -c Debug --framework net10.0The repository includes build/build-test-sign.yml, a dedicated Azure Pipelines template that builds, tests, and signs assemblies for both .NET 8 and .NET 10 in parallel. This template implements three parallel jobs to maximize efficiency while following Microsoft's best practices for multi-targeting validation.
Configuration: The multi-framework testing is fully integrated and runs automatically on every build in a dedicated stage after the main build stage.
Test Categories:
-
Multi-Target Tests (tested on both .NET 8 and .NET 10):
Cql/CoreTests/CoreTests.csproj- Core SDK testsCql/CqlToElmTests/CqlToElmTests.csproj- CQL to ELM conversion tests
-
.NET 10 Only Tests:
submodules/Firely.Cql.Sdk.Integration.Runner/IntegrationRunner/IntegrationRunner.csprojDemo/Test.Measures.Demo/Test.Measures.Demo.csproj
-
Excluded Tests:
tools/XsdToCSharpConverterTests/XsdToCSharpConverterTests.csproj- Internal tool testssubmodules/Ncqa.DQIC/Ncqa.HT.DeckTests/Ncqa.HT.DeckTests.csproj- Commented outsubmodules/Ncqa.DQIC/Ncqa.HT.MeasuresTests/Ncqa.HT.MeasuresTests.csproj- Commented out
Benefits:
- ✅ Tests run against both frameworks simultaneously (parallel execution)
- ✅ Framework-specific test results and code coverage
- ✅ Automatic comparison report to identify framework-specific issues
- ✅ Early detection of framework behavioral differences
Usage: See build/README.md for complete documentation and configuration details.
Local Testing: Use the provided scripts for local validation:
# Windows - Test all multi-target projects
.\test-multiframework.ps1
# Windows - Test specific project
.\test-multiframework.ps1 -TestProject CoreTests# Linux/macOS - Test all multi-target projects
./test-multiframework.sh
# Linux/macOS - Test specific project
./test-multiframework.sh CoreTests- Always maintain both PowerShell (.ps1) and Bash (.sh) script variants
- Use OS-conditional logic in MSBuild:
Condition="'$(OS)' == 'Windows_NT'"for Windows - Use correct case for directory paths - Unix filesystems are case-sensitive
- Don't hardcode path separators - use MSBuild properties
- PowerShell commands via bash should use
-NonInteractiveflag
The SDK minimizes the use of conditional compilation. Standard .NET conditional compilation symbols are available if needed:
NET8_0: Code specific to .NET 8NET10_0: Code specific to .NET 10NET10_0_OR_GREATER: Code for .NET 10 and later
Example (rarely needed with current architecture):
#if NET10_0_OR_GREATER
// .NET 10+ specific code
#else
// .NET 8 specific code
#endifNote: The current serialization architecture avoids conditional compilation entirely by using automatic [JsonIgnore] attribute generation and framework-agnostic custom converters.
When adding support for future .NET versions (e.g., .NET 12 LTS):
- Update
cql-base.propswith new target framework - Test System.Text.Json behavior - the
[JsonIgnore]solution should continue to work - Review if any new conditional compilation is needed (should be minimal/none)
- Update this documentation
- Update main README.md
- Test all custom build targets run only once
- Update CI/CD pipelines (build variables, SDK versions)
Solution: Ensure custom targets have Condition="'$(TargetFramework)' == 'net10.0' Or '$(TargetFramework)' == ''"
Solution: Regenerate Elm.g.cs using the updated XsdCodeGenerator which automatically adds [JsonIgnore] attributes to conflicting properties.
Solution: Verify both frameworks use identical serialization logic. With current architecture, test results should be identical across frameworks. If not, investigate custom converters or legacy format handling.
- .NET 10 Breaking Changes
- System.Text.Json Polymorphism
- Multi-Targeting in .NET
- ELM JSON Serialization Architecture - Complete technical documentation of the serialization system