API-compatible data type change

February 28th 2025 Serialization ASP.NET Core .NET

A recent business requirement demanded an unexpected change to a web API endpoint. A numeric field in the request body had to accept a set of predefined string values in addition to numeric values. I managed to implement it in a way which wasn't a breaking change for the existing clients of this endpoint.

Initially, the property in the request body was of a numeric type:

public class Weather
{
    [Required]
    public double? Temperature { get; set; }
}

It was nullable to correctly report an error when the field was missing in the request instead of treating it as a 0.

The clients were expected to serialize the value as a number:

{
  "temperature": 20.0
}

But the deserialization worked even if it was serialized as a string:

{
  "temperature": "20.0"
}

To be able to also accept some predefined string values in addition to the numeric ones, I had to change the property to a string:

public class Weather
{
    [Required]
    [NumericOrOneOf(["Cold", "Hot"])]
    public string? Temperature { get; set; }
}

I implemented a custom data annotation attribute to validate the input values:

public class NumericOrOneOf(string[] validValues)
    : ValidationAttribute(() =>
        $"The {{0}} field must be numeric or one of: {string.Join(", ", validValues)}."
    )
{
    public override bool IsValid(object? value)
    {
        return validValues.Contains(value)
            || double.TryParse(
                value?.ToString() ?? string.Empty,
                CultureInfo.InvariantCulture,
                out var _
            );
    }
}

It accepts all numeric values and any other strings, passed in as a constructor parameter.

With this change, the endpoint accepted the custom strings, listed in the attribute:

{
  "temperature": "Cold"
}

And it still accepted any numeric value serialized as a string:

{
  "temperature": "20.0"
}

However, it rejected numeric values serialized as a number:

{
  "temperature": 20.0
}

The client got the following 400 response:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "weather": ["The weather field is required."],
    "$.temperature": [
      "The JSON value could not be converted to System.String. Path: $.temperature | LineNumber: 1 | BytePositionInLine: 21."
    ]
  },
  "traceId": "00-2d2a4d6333fcfca8aac275ed9ed27a8a-f2d967ce7731e3e2-00"
}

If we kept it like this, we would have broken any existing endpoint clients, which wasn't an option. We would have to create a new endpoint and deprecate the old one.

Fortunately, I found a way to accept numeric values serialized as a number as well. I implemented a custom JSON converter:

public class NumericStringFromNumberJsonConverter : JsonConverter<string>
{
    public override string? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options
    )
    {
        return reader.TokenType switch
        {
            JsonTokenType.Number
                => reader.GetDouble().ToString(CultureInfo.InvariantCulture),
            JsonTokenType.String => reader.GetString(),
            _
                => throw new JsonException(
                    $"The JSON value could not be converted to {typeToConvert}."
                ),
        };
    }

    public override void Write(
        Utf8JsonWriter writer,
        string value,
        JsonSerializerOptions options
    )
    {
        writer.WriteStringValue(value);
    }
}

Now the following request was valid again:

{
  "temperature": 20.0
}

This meant that all existing clients would still work without any changes, so we didn't have to create a new endpoint.

You can find a sample project in my GitHub repository. You can follow individual commits to see the transition from the original state over the intermediate state with a breaking change to the final state without a breaking change. An integration test shows which inputs are valid at each step.

API compatibility at the REST service level doesn't always match API compatibility at .NET class level. In this particular case, I managed to keep API compatibility, although there was no way to keep the .NET class backward compatible. But since the .NET class is only used on the server, it's backward compatibility wasn't all that important. What really mattered was, not breaking the REST service clients.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License