November 21st, 2025
0 reactions

Announcing OData .NET (ODL) 9 Preview 3 Release

John Gathogo
Senior Software Engineer
We’re happy to announce that OData .NET (ODL) 9 Preview 3 has been officially released and is available on NuGet:

What’s New in OData .NET (ODL) 9 Preview 3

The OData .NET (ODL) 9.0.0 Preview 3 introduces a set of important changes and cleanup efforts to modernize the library, align with .NET’s evolving runtime, and simplify the OData developer experience. Below, we’ll go through the notable changes included in this release.

Use SingleOrDefault() for Action Queries Returning a Single Item

Summary We updated DataServiceActionQuerySingle<T> to avoid throwing an exception when an action returns no result. The GetValue() and EndGetValue() methods now choose between SingleOrDefault() and Single() based on whether T can accept null. This preserves fail‑fast behavior for non‑nullable types, while returning null for nullable types.

Details Previously, GetValue()/EndGetValue() always used LINQ’s Single(). When an action returned an empty sequence, this led to an InvalidOperationException, even for scenarios where clients expected a “no result” to map to null. Community feedback flagged this as a long‑standing pain point.

To address this safely, we added a type check that evaluates the generic type parameter T:

  • Nullable or reference types: use SingleOrDefault() – returns null when no element is found.
  • Non‑nullable value types: keep Single() – still throws if no element is found, preserving fail‑fast behavior.

This strikes a balance between runtime safety and predictable semantics across different T. It also aligns action‑query behavior with prior feedback on GetValue* consistency raised in earlier issues.

Migration guidance Most apps won’t need code changes, but review the following:

  1. Non‑nullable assignments remain fail‑fast If you previously relied on InvalidOperationException to detect missing results for int, Guid, or other non‑nullable types, behavior is unchanged – an empty sequence still throws. Keep your existing exception handling.
  2. Nullable/reference targets may now receive null If you assign to int?, string, or other nullable/reference types, GetValue()/EndGetValue() can now return null on “no result.” Ensure your code checks for null rather than expecting an exception. This is the primary behavior change for those type categories.
  3. Asynchronous patterns If you wrap BeginGetValue/EndGetValue or use GetValueAsync() pathways, confirm your null checks and exception handling are consistent, particularly in areas where earlier versions showed inconsistency between sync/async flows.

Example

Previous (non‑nullable, expected exception on empty result – unchanged):

// Throws if no element is returned (fail-fast preserved)
var count = new DataServiceActionQuerySingle(context, actionUri).GetValue();

New (nullable, returns null on empty result – handle gracefully):

int? count = new DataServiceActionQuerySingle<int?>(context, actionUri).GetValue();
if (count is null)
{
    // handle no-result case
}

Why this matters The change removes surprising crashes for clients that expect “no result” to mean null (especially for reference/nullable types), while avoiding a silent regression for non‑nullable targets by keeping the fail‑fast safeguard.


Addressed .NET 8 Obsoletion Warnings

Summary ODL 9 resolves obsoletion warnings in .NET 8 related to legacy serialization APIs. These warnings (SYSLIB0051) occur when using constructors and methods tied to ISerializable.

Details Removed:

  • Serialization constructors following .ctor(SerializationInfo, StreamingContext).
  • Implicit implementations of ISerializable.GetObjectData(SerializationInfo, StreamingContext).
  • Implicit implementations of IObjectReference.GetRealObject(StreamingContext).

Why this matters This cleanup ensures compatibility with .NET 8+ and eliminates compile-time warnings for consumers building on modern frameworks.

Migration guidance

  • No action required for most consumers.
  • If you previously relied on these constructors or methods for custom serialization, review your code and adopt recommended .NET patterns for serialization.

IEdmOperation now uses IEdmOperationReturn

Summary We’ve modernized the EDM API by replacing the ReturnType property on IEdmOperation with an IEdmOperationReturn instance. This change aligns the interface with the design introduced in OData .NET 7.x and completes the transition planned for 9.x.

Details

  • Before (7.x): Introduced IEdmOperationReturn but kept IEdmOperation.ReturnType for compatibility.
  • 8.x: Marked ReturnType as [Obsolete] and recommended using GetReturn() extension method.
  • Now (9.x): Removed ReturnType and GetReturn(). IEdmOperation directly exposes IEdmOperationReturn via its Return property.

The IEdmOperationReturn interface provides:

public interface IEdmOperationReturn : IEdmElement, IEdmVocabularyAnnotatable
{
    IEdmTypeReference Type { get; }
    IEdmOperation DeclaringOperation { get; }
}

Migration guidance

  • Replace any usage of ReturnType with Return.Type.
  • Remove calls to GetReturn(); use the new Return property instead.
  • If you previously annotated return types via GetReturn(), apply annotations directly on IEdmOperationReturn.

Example

// Previous:
var returnType = operation.ReturnType;
var returnInfo = operation.GetReturn();

// New:
var returnType = operation.Return.Type;

Support for Key Aliases in Entity Types

Summary OData .NET now supports key aliases in entity type definitions, aligning with the OData CSDL JSON specification. This enables scenarios where key properties reference complex properties or related entity types, improving interoperability with services that use aliases for composite keys.

Details Previously, ODL only supported simple keys defined directly on the entity type. The OData spec (see https://docs.oasis-open.org/odata/odata-csdl-json/v4.01/odata-csdl-json-v4.01.html#sec_KeyOData Spec – Key) requires that when a key property is a path (e.g., a property of a complex type), the key must specify an alias. This alias must be:

  • A simple identifier.
  • Unique within the declaring entity type and its base types.
  • Not defined for primitive properties directly on the entity type.

Example: Key alias in CSDL XML

<EntityType Name="Category">
  <Key>
    <PropertyRef Name="Info/ID" Alias="EntityInfoID" />
  </Key>
  <Property Name="Info" Type="Sales.EntityInfo" Nullable="false" />
  <Property Name="Name" Type="Edm.String" />
</EntityType>
<ComplexType Name="EntityInfo">
  <Property Name="ID" Type="Edm.Int32" Nullable="false" />
  <Property Name="Created" Type="Edm.DateTimeOffset" />
</ComplexType>

Why this matters Key aliases are essential for modeling composite keys that involve nested properties. Without alias support, services using complex keys could not be represented accurately in ODL.

Migration guidance

  • No breaking changes for existing models using simple keys.
  • For models with complex keys, you can now define aliases in CSDL and consume them via ODL.
  • Ensure aliases are unique and follow spec rules.

Removed CsdlTarget Support

Summary We have removed support for CsdlTarget in OData .NET 9.x. This property was originally introduced to differentiate between OData CSDL and Entity Framework (EF) CSDL formats. With ODL now targeting .NET Core and EF Core, this distinction is no longer relevant.

Details In earlier versions, ODL supported both OData CSDL and EF6 CSDL (used in EDMX files). CsdlTarget allowed consumers to specify which format to generate. EF Core does not use EDMX or CSDL XML files; it relies on internal model graphs. Maintaining CsdlTarget would introduce inconsistency and unnecessary complexity. In summary:

  • EF CSDL generation is no longer supported.
  • ODL focuses exclusively on OData CSDL for .NET Core scenarios.

Migration guidance

  • Remove any references to CsdlTarget in your code.
  • If you previously generated EF CSDL, note that EF Core does not support this format. Use EF Core’s built‑in model APIs instead.

IEdmModel.Find* Overloads for ReadOnlySpan<char> — faster, allocation‑free lookups

Summary We added ReadOnlySpan<char> overloads to common IEdmModel lookup methods (e.g., FindEntitySet, FindDeclaredType, FindDeclaredOperations). This lets callers pass slices of a path without allocating substrings, improving throughput and reducing GC pressure in routing, URI parsing, and CSDL/annotation processing.

Details Historically, model lookups accepted only string parameters, so callers had to split a path like "Sales/Orders/ByDate" into multiple substrings to look up individual segments. By introducing span‑based overloads — e.g., IEdmModel.FindEntitySet(ReadOnlySpan<char> entitySetName) — callers can slice and probe the original buffer directly (no intermediate string allocations). This aligns with prior internal proposals to adopt ReadOnlySpan<char> across ODL parsing code paths to avoid new string(...) churn.

Example

// Before: substring allocation
var entitySetName = path.Substring(start, length);
var entitySet = model.FindDeclaredEntitySet(entitySetName);

// Now: allocation-free slice
var entitySetName = segmentText.AsSpan(start, length);
var entitySet = model.FindDeclaredEntitySet(entitySetName);

This pattern generalizes to FindDeclaredType, FindDeclaredOperations, and related utilities. See existing string‑only methods in the public API surface for context. https://learn.microsoft.com/dotnet/api/microsoft.odata.edm.iedmmodel?view=odata-edm-7.0

Why this matters ReadOnlySpan<char> provides a stack‑allocated view over memory, ideal for parsers and routers that frequently slice text. Using spans avoids temporary strings, reduces GC pressure, and typically yields noticeable performance wins in hot paths.

Migration guidance

  • No breaking changes: existing string overloads remain.
  • Optional optimization: where you already compute indices into larger buffers (e.g., OData path segments), switch to AsSpan(start, length) and call the new overloads to reduce allocations.
  • Framework note: span APIs are first‑class in modern .NET; if you still target legacy frameworks, span support is limited and may require compatibility packages. ODL 9 targets modern .NET, so most consumers can adopt spans directly.

Untyped Numeric Deserialization

Summary We improved deserialization for untyped values so that common primitive numerics (e.g., int, long, double, float) and collections are handled correctly. A compatibility flag — ReadUntypedNumericAsDecimal — lets you choose between preserving legacy behaviour (numbers as decimal, e.g., for precision) or inferring more specific numeric CLR types.

Details Historically, ODL’s untyped value handling only recognized bool and string; all other numerics defaulted to decimal. That led to surprising results such as 42 materializing as System.Decimal rather than System.Int32 for open/dynamic properties. The new implementation expands support to infer numeric types when appropriate and adds a feature flag to control the behavior.

  • Default (type inference): ReadUntypedNumericAsDecimal = false Untyped numerics are inferred and converted to specific CLR types (int, long, double, float) based on the runtime value, aligning deserialization with caller expectations for well‑known numeric values.
  • Alternative (precision‑preserving): ReadUntypedNumericAsDecimal = true Untyped numerics are preserved as decimal to avoid precision loss, keeping wire compatibility with existing services that depend on decimal materialization.

Example

var messageReaderSettings = new ODataMessageReaderSettings
{
    // Toggle compatibility flag to preserve untyped numerics as decimal
    LibraryCompatibility = ODataLibraryCompatibility.ReadUntypedNumericAsDecimal
};

Note: In ambiguous cases without type information (no declared property and no @odata.type), the OData JSON spec describes heuristics for interpreting values. Historical behavior in ODL favored decimal; adopting double would be more spec‑aligned in some scenarios, but could be a silent breaking change. The new flag provides a controlled opt‑in path.

Why this matters

  • Fewer surprises for dynamic/open types – well‑known numerics deserialize to expected CLR types.
  • Precision‑sensitive workflows can toggle legacy decimal behavior, minimizing compatibility risk.
  • The feature‑flag approach avoids wire semantics regressions while enabling forward‑compatible adoption.

Migration guidance

  • Evaluate your dynamic/untyped usage: For scenarios expecting int/long/double, this will be the default behaviour. Verify downstream type checks and JSON serialization remain consistent.
  • Opt‑in to legacy decimal behaviour if necessary: If you’d like your code to assume decimal for untyped numerics (e.g., financial calculations), opt‑in to the behaviour by use of ReadUntypedNumericAsDecimal compatibility flag.

.NET 10 Compatibility: Avoid System.MemoryExtensions.Contains in LINQ Expression Trees

Summary We addressed a breaking change introduced in .NET 10 where LINQ expressions involving Contains on arrays now compile to use System.MemoryExtensions.Contains with ReadOnlySpan<T>. This method cannot be translated into OData query expressions because ReadOnlySpan<T> is a ref struct and its implicit conversion introduces stack pointers, causing NotSupportedException during expression tree processing.

Details

  • Behavior in .NET 8/9 LINQ Contains calls compiled to System.Linq.Enumerable.Contains on an array:

    .Call System.Linq.Enumerable.Contains(
        .NewArray System.String[] { "Milk" },
        $p.Name)
  • Behavior in .NET 10 The same query now compiles to:

    .Call System.MemoryExtensions.Contains(
        .Call System.ReadOnlySpan`1[System.String].op_Implicit(.NewArray System.String[] { "Milk" }),
        $p.Name)

    This introduces a ReadOnlySpan<T> conversion, which is unsupported in reflection‑based invocation because the declaring type contains stack pointers. The runtime throws:

    System.NotSupportedException: Specified method is not supported.

Fix ODL now detects and avoids translating MemoryExtensions.Contains in expression trees. Instead, it falls back to the safe pattern using Enumerable.Contains for query translation, ensuring compatibility across .NET versions.

Why this matters Without this fix, OData LINQ queries using Contains would fail under .NET 10 with NotSupportedException. This change restores predictable behavior and prevents runtime crashes when upgrading to .NET 10.

Migration guidance

  • No code changes required for most consumers.
  • If you implement custom expression visitors or rely on MemoryExtensions.Contains in query translation, ensure you handle or avoid ReadOnlySpan<T> conversions.
  • For performance‑critical in‑memory filtering, you can still use MemoryExtensions.Contains outside of expression trees.

Removal of ReadUntypedAsString and Alignment with Standard Untyped Handling

Summary ODL 9 removes the ReadUntypedAsString setting from ODataMessageReaderSettings. This setting allowed untyped values to be read as raw JSON strings (ODataUntypedValue), enabling payload patterns that deviate from the OData specification. Starting with ODL 9, untyped values are always materialized as structured types (ODataResource, ODataCollectionValue, ODataPrimitiveValue) based on their actual content. This change enforces spec‑compliant payload structures and eliminates ambiguity in annotation placement.

Why this matters This is not just an API‑breaking change – it can also break payloads that relied on the old behavior. Specifically:

  • Previously, annotations for untyped structured properties could appear as property‑level annotations (e.g., undeclaredComplex1@my.Annotation1).
  • Under the new logic, annotations for structured values must appear inside the structured object (e.g., "undeclaredComplex1": { "@my.Annotation1": "..." }).
  • Old payloads using property‑level annotations for structured values will fail to deserialize under ODL 9.

Implications

  • Roundtrip changes: Reading and writing untyped structured properties now produces payloads with annotations embedded inside the object, not alongside the property name.
  • Validation tightening: Invalid patterns (e.g., @odata.type as a property annotation) will throw exceptions unless corrected.
  • Removed configuration: ReadUntypedAsString is gone; untyped collections and complex values are always parsed into structured representations.

Example

Previous (ODL ≤ 8.x, ReadUntypedAsString=true):

{
  "undeclaredComplex1@my.Annotation1": "custom annotation value",
  "undeclaredComplex1": {
    "MyProp1": "aaaaaaaaa"
  }
}

New (ODL 9):

{
  "undeclaredComplex1": {
    "@my.Annotation1": "custom annotation value",
    "MyProp1": "aaaaaaaaa"
  }
}

Migration guidance

  1. Audit payloads If your clients or services emit annotations for structured properties as property‑level annotations, update them to embed annotations inside the structured object.
  2. Plan for coordination This change affects wire format. Services upgrading to ODL 9 must coordinate with clients to ensure payloads conform to the new structure.
  3. Versioning strategy Consider endpoint versioning or phased rollout if you cannot synchronize all clients immediately.

Why we made this change

  • The old behavior was non‑compliant with the OData spec and introduced inconsistencies.
  • ReadUntypedAsString was rarely used and primarily supported ill‑formed payloads.
  • Removing this feature simplifies the reader/writer pipeline and ensures predictable, spec‑aligned behavior.

Refactored Transcoding Logic — Use Encoding.CreateTranscodingStream

Summary ODL 9 replaces the custom TranscodingWriteStream implementation with the built‑in Encoding.CreateTranscodingStream API introduced in .NET 8. This change simplifies the codebase, reduces maintenance overhead, and leverages the optimized framework implementation for encoding conversion.

Details

  • Previous behavior ODL shipped its own TranscodingWriteStream to handle scenarios where payloads needed to be written in a different encoding than the underlying stream.
  • New behavior The library now uses:

    var transcodingStream = Encoding.CreateTranscodingStream(outputStream, sourceEncoding, targetEncoding);

    This API is part of the .NET standard library and provides a robust, well‑tested solution for transcoding streams.

Why this matters

  • Removes custom code that duplicated framework functionality.
  • Improves performance and reliability by using the optimized .NET implementation.
  • Reduces complexity for future maintenance.

Migration guidance

  • No breaking changes for consumers — behavior remains consistent.

Modernized Date and Time Handling — Use System.DateOnly and System.TimeOnly

Summary ODL 9 replaces legacy EDM primitives Microsoft.OData.Edm.Date and Microsoft.OData.Edm.TimeOfDay with .NET’s built‑in System.DateOnly and System.TimeOnly. This aligns OData .NET with modern .NET standards and improves consistency with framework APIs.

Details

  • Internal refactor All usages of Edm.Date and Edm.TimeOfDay have been replaced with DateOnly and TimeOnly.
  • Function and property names Updated to match .NET naming conventions:

    • hourshour
    • minutesminute
    • secondssecond
    • millisecondsmillisecond
  • Error handling Invalid time values now throw ArgumentOutOfRangeException (from TimeOnly) instead of FormatException. Error messages originate from .NET APIs for consistency.
  • String formatting Previous: TimeSpan.ToString(@\"hh\\:mm\\:ss\\.fffffff\", CultureInfo.InvariantCulture) Now: TimeOnly.ToString(@\"HH\\:mm\\:ss\\.fffffff\", CultureInfo.InvariantCulture)

Example

int hour = 20, minute = 30, second = 59, millisecond = 1;
var timeOnly = new TimeOnly(hour, minute, second, millisecond);
Console.WriteLine(timeOnly.ToString(@"HH:mm:ss.fffffff", CultureInfo.InvariantCulture));
// Output: 20:30:59.0010000

Spec alignment The OData spec requires Date and TimeOfDay values to conform to dateValue and timeOfDayValue rules. DateOnly and TimeOnly follow these rules, ensuring protocol compliance:

  • https://docs.oasis-open.org/odata/odata-csdl-json/v4.01/odata-csdl-json-v4.01.html#sec_DateOData Spec – Date
  • https://docs.oasis-open.org/odata/odata-csdl-json/v4.01/odata-csdl-json-v4.01.html#sec_TimeofDayOData Spec – TimeOfDay

Migration guidance

  • If you reference Edm.Date or Edm.TimeOfDay directly, update your code to use DateOnly and TimeOnly.
  • Validate any custom serialization logic to ensure it uses the correct format strings.
  • Exception handling may need updates if you previously caught FormatException.

Summary

ODL 9 Preview 3 focuses on safety, compatibility, and modern .NET alignment with a small set of high‑impact changes:

  • Safer defaults: Action queries now return null for nullable/reference types instead of throwing, while preserving fail‑fast behavior for non‑nullable value types.
  • Spec‑aligned untyped handling: Removed ReadUntypedAsString; untyped values are always materialized as structured types, and annotations for structured values are embedded inside the object.
  • Performance & ergonomics: Added ReadOnlySpan<char> overloads for model lookups and replaced custom transcoding with Encoding.CreateTranscodingStream.
  • Modern primitives: Switched to System.DateOnly/System.TimeOnly for date/time, and fixed .NET 10 LINQ expression translation to avoid span‑related runtime issues.
  • Compatibility cleanup: Addressed .NET 8 obsoletion warnings and removed legacy EF CSDL target support; the library now targets modern .NET runtimes.

These updates make ODL 9 more predictable and easier to maintain. Try the preview packages on NuGet and share feedback on the https://github.com/OData/odata.net/issuesGitHub repo.

Category
OData

Author

John Gathogo
Senior Software Engineer

0 comments