October 7th, 2025
heartlike2 reactions

Announcing a new OData.NET serializer

Clément Habinshuti
Senior Software Engineer

One of the major, recurring complaints of the OData.NET libraries is the performance overhead of the serialization stack. We have done a lot of work to improve the serialization performance, but the existing architecture limits how far we can go. For this reason, we have started work on a new serialization stack for OData.NET libraries that addresses major performance concerns and also makes general improvements to usability. We plan to ship this new serializer as part of Microsoft.OData.Core 9.x library (the next major release) under a new namespace as an alternative to the existing ODataMessageWriter and ODataMessageReader.

But you don’t have to wait for Microsoft.OData.Core 9.x release, we have shipped the preview release to NuGet of the serializer as standalone package Microsoft.OData.Serializer so you can start testing it and sharing feedback with us. Let’s write a quick demo to show how it works.

  • To get started, create a new .NET Console application with .NET 8 or later.
  • AddMicrosoft.OData.Core package from NuGet. You can use either versions 8.x or 9.x preview.
  • Depending on your version of Microsoft.OData.Core, you may also need to add the Microsoft.Extensions.DependencyInjection.Abstractions package from NuGet.
  • Add Microsoft.OData.Serializer package from NuGet. The latest version is 0.1.0-preview.1 at the time of this writing. If using Visual Studio, make sure to **Include prerelease** versions in your package search.

Then write the following code in your Program.cs file:

// Setup EDM model that describes the OData service
var csdlSchema =
"""
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
  <edmx:DataServices>
    <Schema Namespace="ODataDemo" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityType Name="Product">
        <Key>
          <PropertyRef Name="ID"/>
        </Key>
        <Property Name="ID" Type="Edm.Int32" Nullable="false"/>
        <Property Name="Name" Type="Edm.String"/>
        <Property Name="Description" Type="Edm.String"/>
        <Property Name="InStock" Type="Edm.Boolean"/>
        <Property Name="Price" Type="Edm.Decimal"/>
      </EntityType>
      <EntityContainer Name="DemoService">
        <EntitySet Name="Products" EntityType="ODataDemo.Product"/>
      </EntityContainer>
    </Schema>
 </edmx:DataServices>
</edmx:Edmx>
""";
var xmlReader = XmlReader.Create(new StringReader(csdlSchema));
IEdmModel model = CsdlReader.Parse(xmlReader);

// OData URI determines the kind of payload being requested
var odataUri = new ODataUriParser(
    model,
    new Uri("http://localhost/odata"),
    new Uri("Products", UriKind.Relative))
  .ParseUri();


// Prepare payload to write
List<Product> products = [
    new Product
    {
        ID = 1,
        Name = "Laptop",
        Description = "A high-performance laptop.",
        IsAvailable = false,
        Price = 999.99m
    },
    new Product
    {
        ID = 2,
        Name = "Smartphone",
        Description = "A latest model smartphone.",
        IsAvailable = true,
        Price = 699.99m
    }
];

// Initialize serializer options
var serializerOptions = new ODataSerializerOptions();

// Write the output to the console
using var outputStream = Console.OpenStandardOutput();
await ODataSerializer.WriteAsync(products, outputStream, odataUri, model, serializerOptions);

Console.ReadKey();

[ODataType("ODataDemo.Product")]
class Product
{
    public int ID { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }

    [ODataPropertyName("InStock")]
    public bool IsAvailable { get; set; }
    public decimal Price { get; set; }

    public int Version { get; set; } // Skipped because it's not in the schema
}

When you run the application, it should print output similar to the following (pretty-printed for clarity):

{
  "@odata.context": "http://localhost/odata/$metadata#Products",
  "value": [
    {
      "ID": 1,
      "Name": "Laptop",
      "Description": "A high-performance laptop.",
      "InStock": false,
      "Price": 999.99
    },
    {
      "ID": 2,
      "Name": "Smartphone",
      "Description": "A latest model smartphone.",
      "InStock": true,
      "Price": 699.99
    }
  ]
}

Understanding the sample code

The above sample application creates an OData schema containing a single entity type Product and a single entity set Products that returns a collection of Product entity. The class also defines a .NET class called Product that maps to the entity type in the service schema. This demonstrates built-in support for serializing plain CLR types (POCO classes). We create a list of products and use ODataSerializer.WriteAsync() to serialize the product list and write to the output stream, which is the console standard output in this case. The WriteAsync method receives the payload, the output stream, the IEdmModel, the ODataUri and ODataSerializerOptions instance. The ODataUri helps the serializer know the kind of payload and @odata.context URL to write.

The ODataSerializerOptions class is used to customize various settings of the serializer, such as the buffer size or how to handle specific types. It’s intended for use a singleton per OData service, shared by all requests of the same OData service. In this example we create a new instance without modifying any setting. Instead of customizing the ODataSerializerOptions directly, we used the new OData-specific attribute [ODataType] to tell the serializer which entity type in the IEdmModel the .NET class maps to. We also used [ODataPropertyName] attribute to tell serialize which OData property a CLR property maps to when they don’t have the same names.

How it works

The new ODataSerializer works very differently from the existing ODataMessageWriter. ODataSerializer works by creating metadata that maps each input type like List<Product> and the Product class to specialized writer that knows how to serialize that type. In the example above, the metadata was generated automatically by the serializer using reflection, relying on the [ODataType] attribute to find the right entity type to map it to. But we could also define this mapping manually using the ODataSerializerOptions.AddTypeInfo() method. Here’s how the code would look like if we defined the mapping manually:

// Initialize serializer options
var serializerOptions = new ODataSerializerOptions();
serializerOptions.AddTypeInfo<Product>(new ODataTypeInfo<Product>()
{
    EdmTypeName = "ODataDemo.Product",
    Properties = [
        new ODataPropertyInfo<Product, int, DefaultState>()
        {
            Name = "ID",
            GetValue = (product, state) => product.ID
        },
        new ODataPropertyInfo<Product, string, DefaultState>()
        {
            Name = "Name",
            GetValue = (product, state) => product.Name
        },
        new ODataPropertyInfo<Product, string, DefaultState>()
        {
            Name = "Description",
            GetValue = (product, state) => product.Description
        },
        new ODataPropertyInfo<Product, bool, DefaultState>()
        {
            Name = "InStock",
            GetValue = (product, state) => product.IsAvailable
        },
        new ODataPropertyInfo<Product, decimal, DefaultState>()
        {
            Name = "Price",
            GetValue = (product, state) => product.Price
        }
    ]
});

// Write the output to the console
using var outputStream = Console.OpenStandardOutput();
await ODataSerializer.WriteAsync(products, outputStream, odataUri, model, serializerOptions);

The  ODataTypeInfo<T> type holds metadata that tells the serializer how to handle a certain CLR type. There many settings that we can configure for a specific type, in this case we use the Properties setting to tell which properties the type has and how to extract the value from each property. The ODataPropertyInfo type holds metadata that tells the serializer how to handle a specific property. In this case, it defines a GetValue getter function for each property. For now, you may ignore the state parameter and DefaultState type. The important thing to note here is that we define a single ODataTypeInfo per type, all instances of the type will be handled according to the type info, so we don’t need to define metadata mapping for each instance. This is similar to how JsonSerializer works, which also allows you to defined IJsonTypeInfo<T> metadata per type. The use of generis allows us to have strongly-typed mapping and avoid reflection and boxing overhead and allocations. This is a huge contrast to the existing ODataMessageWriter which requires you map each entity instance into a weakly-typed ODataResource object. To demonstrate the difference, here’s what it would take to write the same payload using ODataMessageWriter:

var messageWriterSettings = new ODataMessageWriterSettings
{
    ODataUri = odataUri,
};

using var outputStream = Console.OpenStandardOutput();
var messageWriter = new ODataMessageWriter(
    new ODataResponseMessage(outputStream),
    messageWriterSettings,
    model);

var writer = await messageWriter.CreateODataResourceSetWriterAsync();
await writer.WriteStartAsync(new ODataResourceSet()
{ 
    TypeName = "Collection(ODataDemo.Product)"
});

foreach (var product in products)
{
    var resource = new ODataResource
    {
        TypeName = "ODataDemo.Product",
        Properties = new[]
        {
            new ODataProperty { Name = "ID", Value = product.ID },
            new ODataProperty { Name = "Name", Value = product.Name },
            new ODataProperty { Name = "Description", Value = product.Description },
            new ODataProperty { Name = "InStock", Value = product.IsAvailable },
            new ODataProperty { Name = "Price", Value = product.Price }
        }
    };

    await writer.WriteStartAsync(resource);
    await writer.WriteEndAsync();
}

await writer.WriteEndAsync();
await writer.FlushAsync();

As mentioned, when using ODataMessageWriter, we have to create a mapping from CLR object to OData for each instance via intermediate ODataResourceSet, ODataResource , ODataProperty and ODataValue objects. For each instance of the Product class in the collection, we create a new ODataResource instance, and for each property a new ODataProperty instance. Each property value is implicitly converted into an ODataValue wrapper object. All values are also stored into object references, which means value types like int , DateTimeOffset and decimal are boxed.  We also call async methods in a tight loop, even though most of the time the writer does not perform async I/O operations since it uses an internal memory buffer to reduce I/O overhead. All these allocations contribute to significant performance overhead of the existing serializer. We could use techniques like resource pooling, use of ValueTask, and optimizations to reduce the overhead, but there are limits to how far we can go with this, it also introduces more complexities, significant breaking changes, and opportunities for subtle bugs. There are also other internal sources of overhead that are not visible in the public API. Creating a new serializer from the ground up gives us an opportunity to rethink the existing architecture and implementation and eliminate overhead more aggressively. Let’s look at a simple benchmark to get a sense of the difference.

Performance comparison with existing serializer

Here are the results of running a simple benchmark using BenchmarkDotNet on .NET 10 preview, comparing ODataSerializer and ODataMessageWriter on the task of writing 500 product entries to an in-memory stream.

Method Mean Error StdDev Gen0 Gen1 Allocated
WriteWithODataSerializer 321.4 us 8.48 us 24.87 us 0.4883 2417 B
WriteWithODataMessageWriter 1,628.0 us 32.47 us 30.37 us 195.3125 15.6250 847118 B
WriteWithJsonSerializer 148.3 us 2.89 us 2.71 us 504 B

Based on this benchmark, ODataSerializer is 5x faster than ODataMessageWriter and allocates 350x less memory. While this single benchmark is not sufficient to get wholistic view of the performance of the various libraries across different real-world scenarios, it helps to demonstrate the kind of performance gains we are able to unlock by adopting a new architecture and dropping some unnecessary overhead from ODataMessageWriter. We could not get these types of gains without a major overhaul or a rewrite.

I also included JsonSerializer in the benchmark to get a frame of reference. It’s 2.1.x faster than ODataSerializer and allocated 4.8x less memory. This is not direct 1:1 comparison since the JsonSerializer does not produce the same output as the other two writers in this example (it does wrap the response in an object with a value field, it does not generate the context URL, etc.). However, we still use it as a baseline and are committed to reducing the gap between JsonSerializer and ODataSerializer in future iterations.

Here’s the full benchmark code as reference:

using BenchmarkDotNet.Attributes;
using Microsoft.OData;
using Microsoft.OData.Edm;
using Microsoft.OData.Edm.Csdl;
using Microsoft.OData.Serializer;
using Microsoft.OData.UriParser;
using System.Text.Json;
using System.Xml;

namespace ODataSerializerSample.Benchmarks;

[MemoryDiagnoser]
public class Benchmarks
{
    private static readonly IEdmModel Model = LoadEdmModel();
    private readonly ODataUri uri = new ODataUriParser(
        Model,
        new Uri("http://localhost/odata"),
        new Uri("Products", UriKind.Relative))
      .ParseUri();


    List<Product> data = GenerateData(500);

    ODataMessageWriterSettings writerSettings;
    ODataSerializerOptions serializerOptions;

    public MemoryStream stream;

    [GlobalSetup]
    public void Setup()
    {
        stream = new MemoryStream();
        writerSettings = new() { ODataUri = uri };

        serializerOptions = new ODataSerializerOptions();
        serializerOptions.AddTypeInfo<Product>(new ODataTypeInfo<Product>()
        {
            EdmTypeName = "ODataDemo.Product",
            Properties = [
                new ODataPropertyInfo<Product, int, DefaultState>()
                {
                    Name = "ID",
                    GetValue = (product, state) => product.ID
                },
                new ODataPropertyInfo<Product, string, DefaultState>()
                {
                    Name = "Name",
                    GetValue = (product, state) => product.Name
                },
                new ODataPropertyInfo<Product, string, DefaultState>()
                {
                    Name = "Description",
                    GetValue = (product, state) => product.Description
                },
                new ODataPropertyInfo<Product, bool, DefaultState>()
                {
                    Name = "InStock",
                    GetValue = (product, state) => product.IsAvailable
                },
                new ODataPropertyInfo<Product, decimal, DefaultState>()
                {
                    Name = "Price",
                    GetValue = (product, state) => product.Price
                }
            ]
        });
    }

    [Benchmark]
    public async Task WriteWithODataSerializer()
    {
        stream.Position = 0;
        await ODataSerializer.WriteAsync(data, stream, uri, Model, serializerOptions);
    }

    [Benchmark]
    public async Task WriteWithODataMessageWriter()
    {
        stream.Position = 0;
        var messageWriter = new ODataMessageWriter(
            new ODataResponseMessage(stream),
            writerSettings,
            Model);

        var writer = await messageWriter.CreateODataResourceSetWriterAsync();
        await writer.WriteStartAsync(new ODataResourceSet()
        {
            TypeName = "Collection(ODataDemo.Product)"
        });

        foreach (var product in data)
        {
            var resource = new ODataResource
            {
                TypeName = "ODataDemo.Product",
                Properties = new[]
                {
                    new ODataProperty { Name = "ID", Value = product.ID },
                    new ODataProperty { Name = "Name", Value = product.Name },
                    new ODataProperty { Name = "Description", Value = product.Description },
                    new ODataProperty { Name = "InStock", Value = product.IsAvailable },
                    new ODataProperty { Name = "Price", Value = product.Price }
                }
            };

            await writer.WriteStartAsync(resource);
            await writer.WriteEndAsync();
        }

        await writer.WriteEndAsync();
        await writer.FlushAsync();
    }

    [Benchmark]
    public async Task WriteWithJsonSerializer()
    {
        stream.Position = 0;
        await JsonSerializer.SerializeAsync(stream, data);
    }

    private static List<Product> GenerateData(int count) =>
        Enumerable.Range(1, count)
            .Select(id => new Product
            {
                ID = id,
                Name = "Laptop",
                Description = "A high-performance laptop.",
                IsAvailable = count % 2 == 0,
                Price = 999.99m
            })
            .ToList();

    private static IEdmModel LoadEdmModel()
    {
        var csdlSchema =
        """
        <edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
          <edmx:DataServices>
            <Schema Namespace="ODataDemo" xmlns="http://docs.oasis-open.org/odata/ns/edm">
              <EntityType Name="Product">
                <Key>
                  <PropertyRef Name="ID"/>
                </Key>
                <Property Name="ID" Type="Edm.Int32" Nullable="false"/>
                <Property Name="Name" Type="Edm.String"/>
                <Property Name="Description" Type="Edm.String"/>
                <Property Name="InStock" Type="Edm.Boolean"/>
                <Property Name="Price" Type="Edm.Decimal"/>
              </EntityType>
              <EntityContainer Name="DemoService">
                <EntitySet Name="Products" EntityType="ODataDemo.Product"/>
              </EntityContainer>
            </Schema>
         </edmx:DataServices>
        </edmx:Edmx>
        """;

        var xmlReader = XmlReader.Create(new StringReader(csdlSchema));
        IEdmModel model = CsdlReader.Parse(xmlReader);

        return model;
    }

    class Product
    {
        public int ID { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
        public bool IsAvailable { get; set; }
        public decimal Price { get; set; }
    }
}

Features and flexibility

One of the key advantages of the existing ODataMessageWriter architecture is that it provides a lot of flexibility that makes it easy to support different OData payload kinds and features. Instances of the same type are not always meant to be handled the same way. For example, you can omit or include properties dynamically based on the $select and $expand options of an OData query, control which control information such @count or custom annotations are displayed, handle polymorphic payloads, delta payloads, support for stream or text writer-based properties, etc. The new ODataSerializer does not yet have full feature parity or flexibility of the existing writer, but the ODataTypeInfo does not provide some flexibility in control how data is serialized. In this section we’ll explore a few examples.

Control information

ODataTypeInfo<T> exposes properties to extract control information such as @odata.count, @odata.nextLink, @odata.etag.

The following example uses the GetCount, and GetEtag properties to retrieve and include @odata.count to the products collection and @odata.etag to the product entities:

var options = new ODataSerializerOptions();
options.AddTypeInfo<List<Product>>(new()
{
    GetCount = (products, state) => products.Count,
});

options.AddTypeInfo<Product>(new()
{
   GetEtag = (product, state) => $"W/\"{product.Version}\"",
   Properties = [
        new ODataPropertyInfo<Product, int, DefaultState>()
        {
            Name = "ID",
            GetValue = (product, state) => product.ID
        },
        new ODataPropertyInfo<Product, string, DefaultState>()
        {
            Name = "Name",
            GetValue = (product, state) => product.Name
        },
        new ODataPropertyInfo<Product, string, DefaultState>()
        {
            Name = "Description",
            GetValue = (product, state) => product.Description
        },
        new ODataPropertyInfo<Product, bool, DefaultState>()
        {
            Name = "InStock",
            GetValue = (product, state) => product.IsAvailable
        },
        new ODataPropertyInfo<Product, decimal, DefaultState>()
        {
            Name = "Price",
            GetValue = (product, state) => product.Price
        }
    ]
});

await ODataSerializer.WriteAsync(products, stream, uri, model, options);

Here’s the output:

{
  "@odata.context": "http://localhost/odata/$metadata#Products",
  "@odata.count": 2,
  "value": [
    {
      "@odata.etag": "W/\"1\"",
      "ID": 1,
      "Name": "Laptop",
      "Description": "A high-performance laptop.",
      "InStock": false,
      "Price": 999.99
    },
    {
      "@odata.etag": "W/\"1\"",
      "ID": 2,
      "Name": "Laptop",
      "Description": "A high-performance laptop.",
      "InStock": true,
      "Price": 999.99
    }
  ]
}

In some cases, you may also control where the position of the control information relative to the value. For example, if we want @odata.count to be written after the array value, we can use the CountPosition property and set its value to AnnotationPosition.PostValue (the other options are AnnotationPosition.PreValue and AnnotationPosition.Auto):

options.AddTypeInfo<List<Product>>(new()
{
    GetCount = (products, state) => products.Count,
    CountPosition = AnnotationPosition.PostValue,
});

Here’s the output:

{
  "@odata.context": "http://localhost/odata/$metadata#Products",
  "value": [
    {
      "@odata.etag": "W/\"1\"",
      "ID": 1,
      "Name": "Laptop",
      "Description": "A high-performance laptop.",
      "InStock": false,
      "Price": 999.99
    },
    {
      "@odata.etag": "W/\"1\"",
      "ID": 2,
      "Name": "Laptop",
      "Description": "A high-performance laptop.",
      "InStock": true,
      "Price": 999.99
    }
  ],
  "@odata.count": 2
}

Writing values

GetValue and ShouldSkip hooks

We’ve already seen that the GetValue property of the type info is used to extract a property’s value from an instance and serialize it. However, sometimes you may not want to write the value for a given instance. For example you may want to skip properties with null values, or properties that are not part of the $select query option. You can use the ShouldSkip property to conditionally skip writing properties. In the following example, we use it to skip descriptions with null or empty values:

List<Product> products = [
    new Product { ID = 1, Name = "Laptop", Description = "A high-end laptop", IsAvailable = true, Price = 1500.00m, Version = 1 },
    new Product { ID = 2, Name = "Smartphone", Description = "", IsAvailable = false, Price = 800.00m, Version = 1 }
];

var options = new ODataSerializerOptions();

options.AddTypeInfo<Product>(new()
{
   Properties = [
        /* other properties omitted for brevity */
        new ODataPropertyInfo<Product, string, DefaultState>()
        {
            Name = "Description",
            GetValue = (product, state) => product.Description,
            ShouldSkip = (product, state) => string.IsNullOrEmpty(product.Description)
        }
    ]
});

await ODataSerializer.WriteAsync(products, stream, uri, model, options);

Here’s the output:

{
  "@odata.context": "http://localhost/odata/$metadata#Products",
  "value": [
    {
      "ID": 1,
      "Name": "Laptop",
      "Description": "A high-end laptop",
      "InStock": true,
      "Price": 1500
    },
    {
      "ID": 2,
      "Name": "Smartphone",
      "InStock": false,
      "Price": 800
    }
  ]
}

In some cases, the serializer might call the GetValue hook multiple times. Therefore, you should ensure the method’s logic is simple and cheap to call.

WriteValue hook

Besides the GetValue hook, ODataTypeInfo<T> also exposes other APIs for writing values. The WriteValue property is an alternative that gives you a reference to a writer you can write directly to.  Here’s how we could use it to conditionally write the Description property only when it’s not null or empty:

List<Product> products = [
    new Product { ID = 1, Name = "Laptop", Description = "A high-end laptop", IsAvailable = true, Price = 1500.00m, Version = 1 },
    new Product { ID = 2, Name = "Smartphone", Description = "", IsAvailable = false, Price = 800.00m, Version = 1 }
];

var options = new ODataSerializerOptions();

options.AddTypeInfo<Product>(new()
{
   Properties = [
        /* other roperties omitted for brevity */
        new ODataPropertyInfo<Product, string>()
        {
            Name = "Description",
            WriteValue = (product, writer, state) =>
            {
                if (!string.IsNullOrEmpty(product.Description))
                {
                    return writer.WriteValue(product.Description, state);
                }

                return true;
            }
        }
    ]
});

await ODataSerializer.WriteAsync(products, stream, uri, model, options);

Here’s the output:

{
  "@odata.context": "http://localhost/odata/$metadata#Products",
  "value": [
    {
      "ID": 1,
      "Name": "Laptop",
      "Description": "A high-end laptop",
      "InStock": true,
      "Price": 1500
    },
    {
      "ID": 2,
      "Name": "Smartphone",
      "InStock": false,
      "Price": 800
    }
  ]
}

The WriteValue delegate should call writer.WriteValue() to write the value. If this method is not called, neither the property name nor its value will be written. The WriteValue hook returns a boolean. This is used by the serializer to determine whether the value was written completely. In general, you should not worry about this, you should either return the result of calling writer.WriteValue(), or you should return true. The serializer might call this delegate multiple times if the value to be written is large and it needs to flush intermittently. This means that you should keep the logic simple and safe to call multiple times. If you have more complex logic, you can consider the async writer scenarios which we’ll cover later.

PropertySelector for sparse dynamic property selection

Let’s say you have a scenario where your entity data is stored in a dictionary, or JsonElement or some other dynamic container. The dictionary only contains the properties that need to be written, properties which are defined on the entity but not written are excluded. We could define an ODataTypeInfo<T> where each property info is defined as follows:

new ODataPropertyInfo<Dictionary<string, object>>
{
    Name = "PropertyName",
    WriteValue = (productData, state) =>
    {
        if (productData.TryGetValue("PropertyName", out object? value))
        {
            return state.WriteValue("PropertyName", value);
        }
        
        return true;
    }
}

However, it would be inefficient for the writer to iterate through each defined property, check if it’s in the dictionary, then write its value. There could be 100 defined properties and only 10 are in the dictionary. The PropertySelector addresses this issue by allowing you to specify a collection or enumerator that provides only the set of properties that need to be written.

 var data = new[]
 {
     new Entity()
     {
         Etag = "W/\"1\"",
         Data = new Dictionary<string, object>
         {
             { "ID", 1 },
             { "Name", "Laptop" }
         },
     },
     new Entity()
     {
         Etag = "W/\"2\"",
         Data = new Dictionary<string, object>
         {
             { "ID", 2 },
             { "Price", 800.00m }
         },
     }
 };

 var options = new ODataSerializerOptions();

 options.AddTypeInfo<Entity>(new()
 {
     GetEtag = (entity, state) => entity.Etag,
     PropertySelector = new ODataPropertyEnumerableSelector<Entity, KeyValuePair<string, object>>()
     {
         GetProperties = (entity, state) => entity.Data,
         WriteProperty = (entity, property, writer, state) => writer.WriteProperty(property.Key, property.Value, state)
     },
 });

 await ODataSerializer.WriteAsync(data, stream, uri, model, options);

class Entity
{
    public string Etag { get; set; }
    public Dictionary<string, object> Data { get; set; }
}

Here’s the output:

{
  "@odata.context": "http://localhost/odata/$metadata#Products",
  "value": [
    {
      "@odata.etag": "W/\"1\"",
      "ID": 1,
      "Name": "Laptop"
    },
    {
      "@odata.etag": "W/\"2\"",
      "ID": 2,
      "Price": 800
    }
  ]
}

The GetProperties property of the PropertySelector returns a collection of properties as an IEnumerable<TProperty> where TProperty is the type that represents each property, in this case it’s a KeyValuePair<string, object> since we’re dealing with a Dictionary<string, object>. The WriteProperty hook is called to write each property. It receives a writer instance that can write the property name and value.

We can also pass a strongly-typed enumerator that reduce the overhead from boxing if the enumerator is a struct

var data = new[]
{
    new Entity()
    {
        Etag = "W/\"1\"",
        Data = new Dictionary<string, object>
        {
            { "ID", 1 },
            { "Name", "Laptop" }
        },
    },
    new Entity()
    {
        Etag = "W/\"2\"",
        Data = new Dictionary<string, object>
        {
            { "ID", 2 },
            { "Price", 800.00m }
        },
    }
};

var options = new ODataSerializerOptions();

options.AddTypeInfo<Entity>(new()
{
    GetEtag = (entity, state) => entity.Etag,
    PropertySelector = new ODataPropertyEnumeratorSelector<Entity, Dictionary<string, object>.Enumerator, KeyValuePair<string, object>>()
    {
        GetPropertiesEnumerator = (entity, state) => entity.Data.GetEnumerator(),
        WriteProperty = (entity, property, writer, state) => writer.WriteProperty(property.Key, property.Value, state)
    },
});

await ODataSerializer.WriteAsync(data, stream, uri, model, options);

In this case, we use the ODataPropertyEnumeratorSelector and specify the type of the enumerator explicitly as the Dictionary<string, object>.Enumerator. We also use the GetPropertiesEnumerator property to retrieve the enumerator. This approach may reduce allocations from boxing the enumerator in cases where the entire collection can fit in the internal memory buffer.

Open types and dynamic properties

In OData, open types are entity or complex types that let you add arbitrary properties which are not defined in the schema. These extra properties are usually referred to as dynamic properties or open properties. If you have an open type, you can specify a collection that will be used to retrieve open properties using the GetOpenProperties property:

var data = new[]
{
    new OpenProduct
    {
        ID = 1,
        Name = "Laptop",
        Price = 1500.00m,
        OpenProperties = new Dictionary<string, object>
        {
            { "Color", "Silver" },
            { "Weight", 2.5 }
        }
    },
    new OpenProduct
    {
        ID = 2,
        Name = "Smartphone",
        Price = 800.00m,
        OpenProperties = new Dictionary<string, object>
        {
            { "Color", "Black" },
            { "BatteryLife", "10 hours" }
        }
    }
};

var options = new ODataSerializerOptions();

options.AddTypeInfo<OpenProduct>(new()
{
    Properties = [
        new ODataPropertyInfo<OpenProduct, int, DefaultState>()
        {
            Name = "ID",
            GetValue = (product, state) => product.ID
        },
        new ODataPropertyInfo<OpenProduct, string, DefaultState>()
        {
            Name = "Name",
            GetValue = (product, state) => product.Name
        },
        new ODataPropertyInfo<OpenProduct, decimal, DefaultState>()
        {
            Name = "Price",
            GetValue = (product, state) => product.Price
        }
    ],
    GetOpenProperties = (product, state) => product.OpenProperties
});

await ODataSerializer.WriteAsync(data, stream, uri, model, options);

class OpenProduct
{
    public int ID { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public Dictionary<string, object>? OpenProperties { get; set; }
}

Here’s the output:

{
  "@odata.context": "http://localhost/odata/$metadata#Products",
  "value": [
    {
      "ID": 1,
      "Name": "Laptop",
      "Price": 1500,
      "Color": "Silver",
      "Weight": 2.5
    },
    {
      "ID": 2,
      "Name": "Smartphone",
      "Price": 800,
      "Color": "Black",
      "BatteryLife": "10 hours"
    }
  ]
}

The GetOpenProperties hook should return a collection that implements IEnumerable<KeyValuePair<string, object>>. In a future iteration, we may provide a more strongly-typed option for specifying open properties.

Writing values asynchronously and streaming sources

So far, all the hooks we have seen on the ODataTypeInfo<T> are synchronous. Using synchronous methods allows us to avoid async/await overhead in the hot path since most writes happen in the in-memory buffer. The serializer keeps track of the buffer size and when it notices the buffer getting full, it writes the contents of the buffer to the output stream and clears the buffer. Since writing to the output stream might perform an I/O operation, this is done asynchronously to avoid blocking I/O. This is similar to how JsonSerializer works. The serializer also automatically detects large strings or byte arrays and writes them one chunk at a time to avoid excessive growth of the internal buffer, which could cause memory issues. As part of this mechanism, the serializer might visit the same object or property multiple times, that is why you should keep all the hook implementations, simple, fast and safe to call multiple times. That is also how WriteXXX properties return a boolean value and why you must return true if you don’t invoke the writer.

However, there are scenarios that are more complex and need more control over how the value is written. For example, we have cases where values are too large to efficiently in memory, so they are read from a Stream, TextWriter,IAsyncEnumerable, PipeReader or other similar source. The existing ODataMessageWriter provides APIs that give you a Stream or TextWriter that you can write to in a streaming fashion. In the new ODataSerializer, we provide a similar capability through a WriteAsync writer hook that provides a writer instance that can perform asynchronous writes.

To demonstrate how it works, let’s assume we have a BlogPost entity type that has  Contents field containing the text content of the post. Since this content could be large, the server opts to read the content from a stream instead of storing it in a string field. The following sample code shows we could feed that to the ODataSerializer:

var csdlSchema =
"""
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
  <edmx:DataServices>
    <Schema Namespace="ODataStreamingDemo" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityType Name="BlogPost">
        <Key>
          <PropertyRef Name="ID"/>
        </Key>
        <Property Name="ID" Type="Edm.Int32" Nullable="false"/>
        <Property Name="Title" Type="Edm.String"/>
        <Property Name="Content" Type="Edm.String"/>
      </EntityType>
      <EntityContainer Name="DemoService">
        <EntitySet Name="Posts" EntityType="ODataStreamingDemo.BlogPost"/>
      </EntityContainer>
    </Schema>
 </edmx:DataServices>
</edmx:Edmx>
""";

var xmlReader = XmlReader.Create(new StringReader(csdlSchema));
IEdmModel model = CsdlReader.Parse(xmlReader);


var uri = new ODataUriParser(
    model,
    new Uri("http://localhost/odata"),
    new Uri("Posts(1)", UriKind.Relative))
  .ParseUri();

// Simulate content stream
var contentStream = new MemoryStream();
var contentWriter = new StreamWriter(contentStream, leaveOpen: true);
contentWriter.Write(string.Concat(Enumerable.Range(1, 1000).Select(i => $"This is the blog post line {i}\n")));
contentWriter.Flush();
contentStream.Position = 0;

var post = new BlogPost(contentStream)
{
    ID = 1,
    Title = "Announcing ODataSerializer"
};

var options = new ODataSerializerOptions();
options.AddTypeInfo<BlogPost>(new()
{
    Properties = [
        new ODataPropertyInfo<BlogPost, int, DefaultState>()
        {
            Name = "ID",
            GetValue = (post, state) => post.ID
        },
        new ODataPropertyInfo<BlogPost, string, DefaultState>()
        {
            Name = "Title",
            GetValue = (post, state) => post.Title
        },
        new ODataPropertyInfo<BlogPost, DefaultState>()
        {
            Name = "Content",
            // This hook allows us to perform async writes and streaming
            WriteValueAsync = async (post, writer, state) =>
            {
                // Retrieve content stream
                using var contentStream = await post.GetContentStreamAsync();
                using var streamReader = new StreamReader(contentStream);
                char[] buffer = ArrayPool<char>.Shared.Rent(4096);

                // Read the content stream in chunks and write
                // to the OData writer one chunk at a time
                // to avoid buffering the entire content in memory.
                int charsRead = 0;
                while (true)
                {
                    charsRead = await streamReader.ReadAsync(buffer);
                    if (charsRead == 0)
                    {
                        break;
                    }

                    writer.WriteStringSegment(buffer.AsSpan(0, charsRead), isFinalBlock: false, state);

                    // Manually flush to avoid excessive growth of the internal buffer.
                    if (writer.ShouldFlush(state))
                    {
                        await writer.FlushAsync(state);
                    }
                }

                // Signal the writer that we have reached the end of the string.
                writer.WriteStringSegment([], isFinalBlock: true, state);
                ArrayPool<char>.Shared.Return(buffer);
            }
        }
    ]
});

await ODataSerializer.WriteAsync(post, outputStream, uri, model, options);

class BlogPost(Stream content)
{
    public int ID { get; set; }
    public string Title { get; set; }

    public Task<Stream> GetContentStreamAsync()
    {
        // This is a placeholder for an actual implementation that retrieves the content stream.
        return Task.FromResult(content);
    }
}

Here’s the output (content truncated due to length):

{
  "@odata.context": "http://localhost/odata/$metadata#Posts/$entity",
  "ID": 1,
  "Title": "Announcing ODataSerializer",
  "Content": "This is the blog post line 1\nThis is the blog post line 2\n......This is the blog post line 999\nThis is the blog post line 1000\n"
}

In this example, we use the WriteValueAsync hook to asynchronously read text content from a stream and write it one chunk at a time into the value of the Content property. We use the writer.WriteStringSegment() method to write the string in chunks. The isFinalBlock parameter tells the writer whether we have written the last chunk. We should set it to true on the last call to WriteStringSegment and we should not call the method after we have set isFinalBlock to true. If we were writing base-64 encoded binary data instead of text, we should use the writer.WriteBinarySegment() method. These APIs allow us to control how much data we write and flush intermittently to clear the internal buffer to avoid performance issues related to unbounded buffer growth. The writer.ShouldFlush() method tells when it’s a good time to flush. The writer passed to this hook also exposes other APIs like asynchronous writer.WriteValueAsync<T>() method which asynchronously writes an entire value. This version will automatically flush the contents to the underlying stream if the internal buffer gets full. This can be used to write any type of value, not just strings or byte arrays. It also exposes a synchronous writer.WriteValue<T>() method which writes a value synchronously, this writes the entire value to the buffer without flushing. It’s suitable for writing small values with bounded lengths, like integers, booleans and dates. It’s not suitable for writing strings or byte arrays that can have variable lengths.

The WriteValueAsync delegate is only called once per property per entity and is expected to completely write the value in a single call. For this reason, it can accommodate more complex logic that is not safe to call twice. In the example above we’re reading the contents from an in-memory stream, so there’s no real benefit of using this API. But in a production setting, the contents could be streamed from the network, and in that case being able to stream large amount of data from the network directly into a string value could lead more efficient use of memory.

POCO support and OData attributes

We’ve already demonstrated that the ODataSerializer can support serializing POCO (Plain Old CLR Object) classes out of the box. We have also demonstrated the user of the [ODataType] and [ODataPropertyName] attributes which help map classes and properties to their counterparts in an IEdmModel. In this section we’re going to cover a few more attributes available in the preview that can be used to customize the serialization of plain classes.

[ODataIgnore] attribute

By default, ODataSerializer will ignore properties which do not map to a property of the corresponding type in the IEdmModel. The [ODataIgnore] attribute is useful when want to ignore a property which would have been otherwise written by the serializer, for example if it has the same name as a property in the IEdmModel.

var csdlSchema =
"""
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
  <edmx:DataServices>
    <Schema Namespace="ODataDemo" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityType Name="User">
        <Key>
          <PropertyRef Name="ID"/>
        </Key>
        <Property Name="ID" Type="Edm.Int32" Nullable="false"/>
        <Property Name="Name" Type="Edm.String"/>
        <Property Name="Email" Type="Edm.String"/>
      </EntityType>
      <EntityContainer Name="DemoService">
        <EntitySet Name="users" EntityType="ODataDemo.User"/>
      </EntityContainer>
    </Schema>
 </edmx:DataServices>
</edmx:Edmx>
""";

var xmlReader = XmlReader.Create(new StringReader(csdlSchema));
IEdmModel model = CsdlReader.Parse(xmlReader);


var uri = new ODataUriParser(
    model,
    new Uri("http://localhost/odata"),
    new Uri("users", UriKind.Relative))
  .ParseUri();

var users = new[]
{
    new User { ID = 1, Name = "Alice", Email = "alice@example.com" },
    new User { ID = 2, Name = "Bob", Email = "bob@example.com" }
};

var options = new ODataSerializerOptions();

await ODataSerializer.WriteAsync(users, stream, uri, model, options);

[ODataType("ODataDemo.User")]
class User
{
    public int ID { get; set; }
    public string Name { get; set; }

    [ODataIgnore]
    public string Email { get; set; }
}

Here’s the output:

{
  "@odata.context": "http://localhost/odata/$metadata#users",
  "value": [
    {
      "ID": 1,
      "Name": "Alice"
    },
    {
      "ID": 2,
      "Name": "Bob"
    }
  ]
}

In this example, the Email property is always ignored despite being defined in the schema.

[ODataOpenProperties] attribute

If the class represents an open type, you can apply the [ODataOpenProperties] attribute on a property that returns the collection of open/dynamic properties.

var csdlSchema =
"""
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
  <edmx:DataServices>
    <Schema Namespace="ODataDemo" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityType Name="Product" OpenType="true">
        <Key>
          <PropertyRef Name="ID"/>
        </Key>
        <Property Name="ID" Type="Edm.Int32" Nullable="false"/>
        <Property Name="Name" Type="Edm.String"/>
        <Property Name="Price" Type="Edm.Decimal"/>
      </EntityType>
      <EntityContainer Name="DemoService">
        <EntitySet Name="Products" EntityType="ODataDemo.Product"/>
      </EntityContainer>
    </Schema>
 </edmx:DataServices>
</edmx:Edmx>
""";
var xmlReader = XmlReader.Create(new StringReader(csdlSchema));
IEdmModel model = CsdlReader.Parse(xmlReader);

var uri = new ODataUriParser(
    model,
    new Uri("http://localhost/odata"),
    new Uri("Products", UriKind.Relative))
  .ParseUri();

var data = new[]
{
    new OpenProduct
    {
        ID = 1,
        Name = "Laptop",
        Price = 1500.00m,
        OpenProperties = new Dictionary<string, object>
        {
            { "Color", "Silver" },
            { "Weight", 2.5 }
        }
    },
    new OpenProduct
    {
        ID = 2,
        Name = "Smartphone",
        Price = 800.00m,
        OpenProperties = new Dictionary<string, object>
        {
            { "Color", "Black" },
            { "BatteryLife", "10 hours" }
        }
    }
};

var options = new ODataSerializerOptions();

await ODataSerializer.WriteAsync(data, stream, uri, model, options);

[ODataType("ODataDemo.Product")]
class OpenProduct
{
    public int ID { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }

    [ODataOpenProperties]
    public Dictionary<string, object>? OpenProperties { get; set; }
}

Here’s the output:

{
  "@odata.context": "http://localhost/odata/$metadata#Products",
  "value": [
    {
      "ID": 1,
      "Name": "Laptop",
      "Price": 1500,
      "Color": "Silver",
      "Weight": 2.5
    },
    {
      "ID": 2,
      "Name": "Smartphone",
      "Price": 800,
      "Color": "Black",
      "BatteryLife": "10 hours"
    }
  ]
}

[ODataValueWriter] attribute

The [ODataValueWriter] attribute allows us to specify custom logic for serializing a property value. This can be useful if the property type is not properly handled by the serializer by default, or the property is handled differently from one instance to another, or we need to handle the property in special way that’s different from how the serializer would have handled it by default. The [ODataValueWriter] expects as parameter a custom class that implements a method that will execute the logic for writing the value. In the current preview release, that class must inherit from the built-in ODataAsyncPropertyWriter abstract class, which requires you to implement a WriteValueAsync method. In the following example, we revisit the blog post demo where we wrote the value from a stream, but this time use the [ODataValueWriter] to implement the same behaviour.

var csdlSchema =
"""
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
  <edmx:DataServices>
    <Schema Namespace="ODataStreamingDemo" xmlns="http://docs.oasis-open.org/odata/ns/edm">
      <EntityType Name="BlogPost">
        <Key>
          <PropertyRef Name="ID"/>
        </Key>
        <Property Name="ID" Type="Edm.Int32" Nullable="false"/>
        <Property Name="Title" Type="Edm.String"/>
        <Property Name="Content" Type="Edm.String"/>
      </EntityType>
      <EntityContainer Name="DemoService">
        <EntitySet Name="Posts" EntityType="ODataStreamingDemo.BlogPost"/>
      </EntityContainer>
    </Schema>
 </edmx:DataServices>
</edmx:Edmx>
""";

var xmlReader = XmlReader.Create(new StringReader(csdlSchema));
IEdmModel model = CsdlReader.Parse(xmlReader);


var uri = new ODataUriParser(
    model,
    new Uri("http://localhost/odata"),
    new Uri("Posts(1)", UriKind.Relative))
  .ParseUri();

// Simulate content stream
var contentStream = new MemoryStream();
var contentWriter = new StreamWriter(contentStream, leaveOpen: true);
contentWriter.Write(string.Concat(Enumerable.Range(1, 1000).Select(i => $"This is the blog post line {i}\n")));
contentWriter.Flush();
contentStream.Position = 0;

var post = new BlogPost(contentStream)
{
    ID = 1,
    Title = "Announcing ODataSerializer"
};

var options = new ODataSerializerOptions();

await ODataSerializer.WriteAsync(post, outputStream, uri, model, options);

[ODataType("ODataStreamingDemo.BlogPost")]
class BlogPost(Stream content)
{
    public int ID { get; set; }
    public string Title { get; set; }

    [ODataValueWriter(typeof(StreamTextContentWriter))]
    public string Content { get; set; }

    public Task<Stream> GetContentStreamAsync()
    {
        // This is a placeholder for an actual implementation that retrieves the content stream.
        return Task.FromResult(content);
    }
}

class StreamTextContentWriter : ODataAsyncPropertyWriter<BlogPost, string, DefaultState>
{
    public override async ValueTask WriteValueAsync(
        BlogPost post,
        string propertyValue,
        IStreamValueWriter<DefaultState> writer,
        ODataWriterState<DefaultState> state)
    {
        // Retrieve content stream
        using var contentStream = await post.GetContentStreamAsync();
        using var streamReader = new StreamReader(contentStream);
        char[] buffer = ArrayPool<char>.Shared.Rent(4096);

        // Read the content stream in chunks and write
        // to the OData writer one chunk at a time
        // to avoid buffering the entire content in memory.
        int charsRead = 0;
        while (true)
        {
            charsRead = await streamReader.ReadAsync(buffer);
            if (charsRead == 0)
            {
                break;
            }

            writer.WriteStringSegment(buffer.AsSpan(0, charsRead), isFinalBlock: false, state);

            // Manually flush to avoid unintend growth of the internal buffer.
            if (writer.ShouldFlush(state))
            {
                await writer.FlushAsync(state);
            }
        }

        // Signal the writer that we have reached the end of the string.
        writer.WriteStringSegment([], isFinalBlock: true, state);
        ArrayPool<char>.Shared.Return(buffer);
    }
}

Here’s the output (truncated due to content length):

{
  "@odata.context": "http://localhost/odata/$metadata#Posts/$entity",
  "ID": 1,
  "Title": "Announcing ODataSerializer",
  "Content": "This is the blog post line 1\nThis is the blog post line 2\n......This is the blog post line 997\nThis is the blog post line 998\nThis is the blog post line 999\nThis is the blog post line 1000\n"
}

In this example, we defined a custom StreamTextContentWriter class that inherits from ODataAsyncPropertyWriter<BlogPost, string, DefaultState> class. The first generic parameter to ODataAsyncPropertyWriter is the entity type, in this case BlogPost, the second type parameter is the property type, string in this case. We’ll cover the DefaultState later. The StreamTextContentWriter class overrides the WriteValueAsync and implements logic to read the blog content from a stream and write to the writer one chunk at a time. In this case the actual value of the BlogPost.Content property is not used, and instead we retrieve the value from the BlogPost.GetContentStreamAsync method. However, we still added the property to the class because the [ODataValueWriter] attribute can only be applied to a property, not a method. This might change in a future iteration of the serializer.

Serializer state

By you now, you have most likely noticed that we there’s this recurring state parameter that is passed to every parameter and every writer method. This is an instance of the ODataWriterState type. This variable contains the current state of the serializer and other contextual information that is necessary for the serializer to function properly. It is passed to every hook or writer method as the last parameter. In most cases you, don’t need to worry about it and you should just pass it along. In some scenarios, it may prove useful and can give access to useful contextual data. For example, the state.GetCurrentPropertyInfo() returns the ODataPropertyInfo of the current property being serialized, if available. This could be useful if you’re reusing the same writer logic for multiple properties and need a way to tell which property is currently being processed. You can also pass custom data to the state that is specific to your own use case.

Custom state

We can store custom data in the ODataWriteState type that we can retrieve from the state.CustomState property. Unlike the ODataSerializerOptions which is intended to store service-wide configuration that is shared by all requests, the ODataWriterState can store data that is specific to a single request or a single call to ODataSerializer.WriterAsync. The ODataWriterState is generic, defined as ODataWriterState<TCustomState>, and allows us to access the custom state in a strongly-typed manner. This can be useful if we want to store custom state as a struct that we don’t want to allocate to the heap for every request. This design is experimental, and it might change in the future based on feedback.

You can store arbitrary data in the custom state. One use case is to inject custom dependencies that is required by the custom logic in your custom GetValue, WriteValue or WriterValueAsync implementations. To demonstrate this use case, let’s revisit the blog post example. Let’s say that instead of the Stream content being part of the BlogPost type, it is retrieved by a dedicated IStreamContentService that is retrieved from a service provider. Since this IStreamContentService is external to the BlogPost type, how would we retrieve it from the ODataPropertyInfo.WriteValueAsync hook? The following code demonstrates a naive approach that does not use the state:

IStreamContentService streamContentService = GetStreamContentService();

var options = new ODataSerializerOptions();
options.AddTypeInfo<BlogPost>(new()
{
    Properties = [
        new ODataPropertyInfo<BlogPost, string, DefaultState>()
        {
            Name = "Content",
            WriteValueAsync = async (post, writer, state) => {
                using var stream = await streamContentService.GetContentStream(post.ID);
                // ...
            }
        }
    ],
});

In this example the, WriteValueAsync hook attempts to refernece the streamContentService that was defined in the outer scope. This has many problems. First, this would only work for cases where streamContentService is a singleton or at least has the same lifetime as the ODataSerializerOptions. It would not work for dependencies which are not available at the time the ODataSerializerOptions instance is created. Second, by referencing the streamContentService variable defined in the outer scope, the lambda function becomes a closure that will lead to heap allocations every time it’s called.

The custom state addresses such problem by allowing you to pass custom data to the ODataSerializer.WriteAsync method and pass this data back to your custom hooks via the state.CustomState property. Let’s demonstrate this in action by refactoring the blog post example retrieve content from an external IStreamContentService.

var post = new BlogPostV2
{
    ID = 1,
    Title = "Announcing ODataSerializer"
};

// Simulate content stream service retrieval,
// in prod, this could be injected via DI.
var contentService = new SampleStreamContentService();

// Since we're using custom state, we have to specify the type
// to the serializer options.
var options = new ODataSerializerOptions<CustomState>();

// create custom state for each call to WriteAsync
var customState = new CustomState(contentService);
await ODataSerializer.WriteAsync(post, outputStream, uri, model, options, customState);

[ODataType("ODataStreamingDemo.BlogPost")]
class BlogPostV2
{
    public int ID { get; set; }
    public string Title { get; set; }

    [ODataValueWriter(typeof(StreamTextContentWriterV2))]
    public string Content { get; set; }
}

class StreamTextContentWriterV2 : ODataAsyncPropertyWriter<BlogPostV2, string, CustomState>
{
    public override async ValueTask WriteValueAsync(
        BlogPostV2 post,
        string propertyValue,
        IStreamValueWriter<CustomState> writer,
        ODataWriterState<CustomState> state)
    {
        // Retrieve content stream from the content stream service
        using var contentStream = await state.CustomState.ContentService.GetContentStream(post.ID);

        using var streamReader = new StreamReader(contentStream);
        //  Same writing logic as before, omitted here for brevity...
    }
}

// Define the custom state type
readonly record struct CustomState(IStreamContentService ContentService);

interface IStreamContentService
{
    Task<Stream> GetContentStream(int postId);
}

class SampleStreamContentService : IStreamContentService
{
    public Task<Stream> GetContentStream(int postId)
    {
        // This is a placeholder for an actual implementation that retrieves the content stream.
        var stream = new MemoryStream();
        var writer = new StreamWriter(stream);
        writer.Write(string.Concat(Enumerable.Range(1, 1000).Select(i => $"Blog post {postId} content line {i}\n")));
        writer.Flush();
        stream.Position = 0;
        return Task.FromResult<Stream>(stream);
    }
}

Here’s the output:

{
  "@odata.context": "http://localhost/odata/$metadata#Posts/$entity",
  "ID": 1,
  "Title": "Announcing ODataSerializer",
  "Content": "Blog post 1 content line 1\nBlog post 1 content line 2\n......Blog post 1 content line 999\nBlog post 1 content line 1000\n"
}

In this example, we define the custom state type as a struct called CustomState which stores a reference to the IStreamContentService. Now when we instantiate the serializer options, we have to use the generic version and specify the custom state type as ODataSerializerOptions<CustomState>. We also have to use specify this type to other serializer APIs, for example ODataAsyncPropertyValueWriter<BlogPost, string, CustomState>. The CustomState type replaces all instances where we used DefaultState. We also create an instance of custom state object with custom data and pass it as the last argument to ODataSerializer.WriteAsync. If the custom state only stores singleton references that do not change from one request to another, then we could also create it once and pass the same instance to all calls to ODataSerializer.WriteAsync.

In our custom StreamTextContentWriterV2.WriteValueAsync method, we can retrieve the custom state from state.CustomState and get the reference to the content service: state.CustomState.ContentService.GetContentStreamAsync().

DefaultState

Now we can finally explain what DefaultState is. This simply a built-in placeholder empty type that is used when you don’t define your own custom state type. In some places it can be omitted, for example you don’t have to specify the DefaultState when creating an instance of ODataSerializerOptions. But in some cases, where there are multiple variations of the same type with the same name, like ODataPropertyInfo, you do have to specify DefaultState. We may change this in a future iteration so that you don’t have to specify DefaultState when you have not defined custom state.

Concerns about the custom state design

Through our investigation of usage scenarios of the library, we understand that there’s need to provide a way for customer to specify custom data that they can access in custom writer logic. This is also evident by similar open issues related to JsonSerializer (see this and this). But there are multiple approaches we could consider. For example, the custom data could be an untyped object that you could assign any value and use casts to retrieve the underlying value. It could also be a Dictionary<string, object> where you could store arbitrary key-value pairs. This approach could even allow different library authors to extend the serializer by adding their own custom state with a custom unique key that is unique to their library. The approach we’ve taken in this version of the serializer is to make the custom state strongly typed. This is experimental and we’re looking forward to your feedback before we can commit to it in the official release.

The advantage of the generic, strongly-typed approach is it promotes clarity and correctness. It’s easy to tell what data is available in the custom state, what its type is, whether it’s nullable, read-only, writable, etc. We can detect some issues related to incorrect use at compile time. If we want to create a custom instance for each request, but do not want to allocate it on the heap, we can also define it as a struct and it will not be boxed.

However, there are usability concerns related to this design. The simple one is that it leads to passing an extra generic parameter to almost all APIs of the serializer. Since the compiler ensures that we don’t forget to pass this generic parameter where it’s needed, we can overlook this. The bigger concern is that it makes it harder for library authors to extend the serializer with custom features because they don’t know ahead of time which type to use the custom type. In this case, the custom library authors would have to make their APIs generic and accept a TCustomState parameter. This is a challenge if the library author also wants to inject their own custom data in the custom state since they don’t know what the shape of the TCustomState is. They could resolve this by specify an interface constraint to the TCustomState generic parameter where the interface would declare the custom properties that the library needs for its custom logic. Then the consumer of the library would have to define a custom state that implements this interface and any other interface required by another library that it consumes.

// --- Defined in consumer application ---

var options = new ODataSerializerOptions<CustomState>();

// initialize library extensions
options.LibAInitOptions();
options.LibBInitOptions();

var customState = new CustomState();
// Allow each library to initialize custom data
InitializeCustomState(ref customState);
customState.AppSpecificData = ...;

await ODataSerializer.WriteAsync(data, output, uri, model, options, customState);

public static void InitializeCustomState(ref CustomState customState)
{
    // Allow each library to initialize custom data
    LibAInitCustomState(ref customState);
    LibBInitCustomState(ref customState);
}


class CustomState : ILibACustomState, ILibBCustomState
{
    public bool AppSpecificData { get; set; }
    public bool LibACustomData { get; set; }
    public bool LibBCustomData { get; set; }
}


// --- Defined in Library A ---
public static void LibAInitOptions<TCustomState>(this ODataSerializerOptions<TCustomState> options)
    where TCustomState : ILibACustomState
{
    options.AddTypeInfo<LibAEntity>(new()
    {
        Properties = [
            new ODataPropertyInfo<LibAEntity, TCustomState>()
            {
                Name = "PropertyA1",
                WriteValue = (entity, writer, state) =>
                {
                    var customData = state.CustomState.LibACustomData;
                    // Do something with customData
                    return true;
                }
            },
        ]
    });

}

public static void LibAInitCustomState<TCustomState>(ref TCustomState state)
    where TCustomState : ILibACustomState
{
    state.LibACustomData = true; // initialize state data
}

public interface ILibACustomState
{
    bool LibACustomData { get; set; }
}

public class LibAEntity
{

}


// --- Defined in Library B ---
public static void LibBInitOptions<TCustomState>(this ODataSerializerOptions<TCustomState> options)
    where TCustomState : ILibBCustomState
{
    options.AddTypeInfo<LibBEntity>(new()
    {
        Properties = [
            new ODataPropertyInfo<LibBEntity, TCustomState>()
            {
                Name = "PropertyA1",
                WriteValue = (entity, writer, state) =>
                {
                    var customData = state.CustomState.LibBCustomData;
                    // Do something with customData
                    return true;
                }
            },
        ]
    });
}

public static void LibBInitCustomState<TCustomState>(ref TCustomState state)
    where TCustomState : ILibBCustomState
{
    state.LibBCustomData = true; // initialize state data
}

public interface ILibBCustomState
{
    bool LibBCustomData { get; set; }
}

public class LibBEntity
{

}

We don’t have many known cases of third-party library building directly on top of the core serializer, so this may not turn out to be huge blocker in practice. If you have built functionality on top of the existing ODataMessageWriter, we would like to hear your views.

Why not use JsonSerializer

In earlier attempts at building a new serializer, we considered building on top of JsonSerializer to leverage its already robust and performant architecture as well as allow users to reuse their custom JsonConverters or IJsonTypeInfos with the ODataSerializer. However, we hit dead ends with this approach and instead opted to building our own serializer using lower-level primitives like Utf8JsonWriter. The key issue is that we wanted to strike a balance between offering control, performance, flexibility while still making it easy for users to write payloads that are OData-compliant. Our goal is to reach functional parity with the existing ODataMessageWriter, not with JsonSerializer. JsonSerializer did also not provide the level of flexibility we need, for example it was important for our existing users that provide a memory-efficient way of streaming property values inline, or access to custom state, etc.  features not yet available in the current version of JsonSerializer. The OData serializer is also not a JSON only serializer. Some payloads are expected to be serialized in non-JSON formats. For example a $value endpoint like `https://services.odata.org/V4/TripPinServiceRW/People(‘russellwhyte’)/UserName/$value` returns a raw string and not a JSON value. Similarly the $metadata endpoint often returns the schema in XML format by default, unless JSON is request. For this reason, we do not expose the underlying JSON writer, that way ODataSerializer can make OData-specific decisions about what kind of payload or value can be written where.

Limitations and way forward

Since this is a preview release, there are many limitations and potential bugs. The public API is not stable, you should expect drastic changes to the API and functionality before the final release. Also, as earlier mention, we do not plan to continue supporting the standalone release of Microsoft.OData.Serializer once it has been shipped as part of the Microsoft.OData.Core 9.x library. Also note that Microsoft.OData.Core 9.x will only support .NET 10 and later. While the current Microsoft.OData.Serializer preview release supports .NET 8 and later, once the official release lands in Microsoft.OData.Core, only .NET 10+ will be supported.

The current preview also only covers the writer. We do also intend to include a reader, but it will be shipped later than the writer. We still do not have a timeline in place yet.

The ODataSerializer does not yet have feature parity with ODataMessageWriter, and we do not guarantee that it will have feature parity by the time of the official release. The official release will indicate that stability of the API, but we will continue to add features in a non-breaking way to the ODataSerializer. We will ship both ODataMessageWriter and ODataSerializer side by side in different namespaces. We expect that users will gradually migrate to or adopt ODataSerializer as their use cases are supported.

Here is a non-exhaustive list of ODataSerializer‘s known limitations:

  • It only supports serializing resource and resource payloads. It does not support writing primitive value endpoints, $value, $metadata, etc.
  • It does not support writing delta resources, aggregation responses, etc.
  • The context URL does not always get written correctly, especially in requests with complex queries like nested $select and $expand query options
  • Built-in POCO serialization only supports classes, it does not support structs.
  • It is not support in other OData libraries like Microsoft.OData.Client and Microsoft.AspNetCore.OData.
  • It does not perform the kind of validations that ODataMessageWriter does. For example, it does not check for duplicate properties or whether the type of value being written matches the expected schema type. Some of the missing validations are by design, and some may be included (perhaps optionally) in a future iteration.
  • etc.

Conclusion

This is only the beginning of the journey for the new serializer. This is an ongoing effort to improve performance and usability. We encourage you to test the library in your test environments and test projects and share feedback with us. You can report issues, questions, suggestions or any other form of feedback on our GitHub issues page, discussions forum and the comments on this blog post. We’re looking forward to hearing your views.

To see the serializer in action on relatively complex scenario that covers most of the scenarios discussed here, check out the FileService sample project and its tests.

Category
ODataODL

Author

Clément Habinshuti
Senior Software Engineer

3 comments

Sort by :